diff --git a/anton/chat.py b/anton/chat.py index ca87349c..7200094a 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -36,7 +36,7 @@ handle_setup_models, ) from anton.commands.ui import handle_explain, handle_theme, print_slash_help, make_completer -from anton.commands.ui import SKILLS_COMMANDS, THEME_COMMANDS, COMMANDS +from anton.commands.ui import SKILLS_COMMANDS, THEME_COMMANDS, SHARE_COMMANDS, COMMANDS from anton.utils.clipboard import ( ensure_clipboard, @@ -58,6 +58,7 @@ handle_skill_show, handle_skills_list, ) +from anton.commands.share import handle_share_export, handle_share_import, handle_share_status, handle_share_history from anton.tools import CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL from anton.utils.prompt import ( prompt_or_cancel, @@ -1011,11 +1012,19 @@ async def _chat_loop( global_memory_dir = Path.home() / ".anton" / "memory" project_memory_dir = settings.workspace_path / ".anton" / "memory" + from anton.core.memory.episodes import EpisodicMemory + + episodes_dir = settings.workspace_path / ".anton" / "episodes" + episodic = EpisodicMemory(episodes_dir, enabled=settings.episodic_memory) + if episodic.enabled: + episodic.start_session() + cortex = Cortex( global_hc=Hippocampus(global_memory_dir), project_hc=Hippocampus(project_memory_dir), mode=settings.memory_mode, llm_client=state["llm_client"], + episodic=episodic if episodic.enabled else None, ) # Reconsolidation: migrate legacy memory formats on first run @@ -1031,13 +1040,6 @@ async def _chat_loop( if cortex.needs_compaction(): asyncio.create_task(cortex.compact_all()) - from anton.core.memory.episodes import EpisodicMemory - - episodes_dir = settings.workspace_path / ".anton" / "episodes" - episodic = EpisodicMemory(episodes_dir, enabled=settings.episodic_memory) - if episodic.enabled: - episodic.start_session() - from anton.memory.history_store import HistoryStore history_store = HistoryStore(episodes_dir) @@ -1143,11 +1145,11 @@ def _bottom_toolbar(): mouse_support=False, bottom_toolbar=_bottom_toolbar, style=pt_style, - completer=make_completer([THEME_COMMANDS, SKILLS_COMMANDS, COMMANDS, MEMORY_COMMANDS]), + completer=make_completer([THEME_COMMANDS, SKILLS_COMMANDS, SHARE_COMMANDS, COMMANDS, MEMORY_COMMANDS]), complete_while_typing=True, ) - memory_manage = MemoryManage(console, settings, cortex, episodic=episodic) + memory_manage = MemoryManage(console, settings, cortex, episodic=episodic, history_store=history_store) try: while True: # Memory confirmation UX — show pending lessons before prompt @@ -1228,7 +1230,7 @@ def _bottom_toolbar(): # Detect dragged file paths early — a dragged absolute path like # "/Users/foo/bar.txt" starts with "/" and would otherwise be # mistaken for a slash command. - if message_content is None and stripped.startswith("/"): + if message_content is None and stripped.startswith("/") and not stripped.startswith("/share"): dropped_early = _parse_dropped_paths(stripped) if dropped_early: stripped = format_file_message(stripped, dropped_early, console) @@ -1279,7 +1281,7 @@ def _bottom_toolbar(): ) continue elif cmd == "/memory": - await memory_manage.handle(cmd=stripped) + await memory_manage.handle(cmd=stripped, session=session) continue elif cmd == "/connect": arg = parts[1].strip() if len(parts) > 1 else "" @@ -1347,6 +1349,46 @@ def _bottom_toolbar(): elif cmd == "/skills": handle_skills_list(console) continue + elif cmd == "/share": + sub_parts = parts[1].strip().split(maxsplit=1) if len(parts) > 1 else [] + sub = sub_parts[0] if sub_parts else "" + rest = sub_parts[1] if len(sub_parts) > 1 else "" + if sub == "export": + await handle_share_export( + console, + session, + workspace, + state["llm_client"], + episodic if episodic.enabled else None, + summary_only="--summary" in rest, + ) + elif sub == "import": + if not rest: + console.print("[anton.warning]Usage: /share import [/]") + console.print() + else: + session = await handle_share_import( + console, + session, + workspace, + settings, + state, + self_awareness, + cortex, + episodic if episodic.enabled else None, + history_store, + filepath=rest, + ) + current_session_id = session._session_id + elif sub == "status": + handle_share_status(console, session, workspace) + elif sub == "history": + handle_share_history(console, workspace) + else: + usage = " | ".join(c.command for c in SHARE_COMMANDS) + console.print(f"[anton.warning]Usage: {usage}[/]") + console.print() + continue elif cmd == "/resume": session, resumed_id = await handle_resume( console, diff --git a/anton/commands/session.py b/anton/commands/session.py index 7b4d1856..cdd9d8df 100644 --- a/anton/commands/session.py +++ b/anton/commands/session.py @@ -64,8 +64,9 @@ async def handle_resume( console.print() choices = [str(i) for i in range(1, len(sessions) + 1)] + ["q"] + choices_display = f"1-{len(sessions)}/q" if len(sessions) > 1 else "1/q" choice = await prompt_or_cancel( - "(anton) Select session (or q to cancel)", choices=choices, default="q" + "(anton) Select session (or q to cancel)", choices=choices, choices_display=choices_display, default="q" ) if choice is None or choice == "q": console.print() @@ -74,6 +75,24 @@ async def handle_resume( idx = int(choice) - 1 selected = sessions[idx] sid = selected["session_id"] + return await restore_session( + sid, console, settings, state, self_awareness, cortex, workspace, + session, episodic, history_store + ) + + +async def restore_session( + sid: str, + console: Console, + settings: AntonSettings, + state: dict, + self_awareness, + cortex: "Cortex | None", + workspace: "Workspace | None", + session: "ChatSession", + episodic: "EpisodicMemory | None" = None, + history_store: "HistoryStore | None" = None, +): history = history_store.load(sid) if history is None: @@ -106,7 +125,7 @@ async def handle_resume( console.print() console.print( - f"[anton.success]Resumed session from {selected['date']} ({selected['turns']} turns)[/]" + f"[anton.success]Resumed session from {sid} ({new_session._turn_count} turns)[/]" ) console.print() diff --git a/anton/commands/share.py b/anton/commands/share.py new file mode 100644 index 00000000..81974e7d --- /dev/null +++ b/anton/commands/share.py @@ -0,0 +1,529 @@ +"""Slash-command handlers for /share.""" +from __future__ import annotations + +import getpass +import json +import os +import re +import tempfile +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field +from rich.console import Console + +if TYPE_CHECKING: + from anton.config.settings import AntonSettings + from anton.core.llm.client import LLMClient + from anton.core.session import ChatSession + from anton.workspace import Workspace + + +class _SessionMeta(BaseModel): + title: str = Field( + description=( + "A 5-7 word title in lowercase-with-hyphens, suitable as a filename slug. " + "Example: 'pipeline-latency-root-cause-analysis'" + ) + ) + summary: str = Field( + description=( + "A distilled narrative of the analytical session: the goal, key discoveries, " + "any corrections or dead ends, and where the analysis currently stands. " + "Each distinct finding appears exactly once. 2-4 sentences." + ) + ) + + +def _format_history_for_llm(history: list[dict], max_messages: int = 20) -> str: + recent = history[-max_messages:] + lines = [] + for msg in recent: + role = msg.get("role", "") + content = msg.get("content", "") + if isinstance(content, list): + text_parts = [ + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ] + content = " ".join(text_parts) + lines.append(f"{role}: {str(content)[:400]}") + return "\n".join(lines) + +async def _generate_meta( + llm_client: LLMClient, + history: list[dict], + session_id: str, +) -> tuple[str, str]: + try: + conversation_text = _format_history_for_llm(history) + result = await llm_client.generate_object_code( + _SessionMeta, + system=( + "You are producing a portable context distillation of an analytical session. " + "For the summary: cover the goal, key discoveries, any corrections or dead ends, " + "and where the analysis currently stands. Every distinct finding should appear once. " + "No filler, no repetition, no omissions of meaningful conclusions." + ), + messages=[{ + "role": "user", + "content": f"Distill this analytical session:\n\n{conversation_text}", + }], + max_tokens=300, + ) + + return result.title, result.summary + except Exception: + return f"session-{session_id}", "" + + +async def handle_share_export( + console: Console, + session: "ChatSession", + workspace: "Workspace", + llm_client: "LLMClient", + episodic: "EpisodicMemory | None", + *, + summary_only: bool = False, +) -> None: + session_id = session._session_id + if not session_id: + console.print("[anton.warning]No active session to export.[/]") + console.print() + return + + if not episodic: + console.print("[anton.muted]Episodic memory not enabled — memory snapshot will be empty.[/]") + return + + history = [] if summary_only else [asdict(ep) for ep in episodic.get_conversation()] + + msg_count = len(session._history) + if not summary_only and msg_count > 100: + console.print( + f"[anton.warning]This session has {msg_count} messages. " + "Consider [bold]/share export --summary[/] for a lighter file.[/]" + ) + console.print() + if msg_count == 0: + console.print( + f"[anton.warning]This session conversation is empty. Nothing to export.[/]" + ) + return + + # memory snapshot + episodes = episodic.get_memory_usage() + session_born, project_accessed = [], [] + for e in episodes: + item = { + "content": e.content, + "kind": e.meta.get("kind", ""), + "topic": e.meta.get("topic", ""), + } + if e.role == "memory_write": + session_born.append(item) + if e.role == "memory_read": + project_accessed.append(item) + + # scratchpad cells + cells: list[dict] = [] + for pad_name, runtime in session._scratchpads.pads.items(): + for cell in runtime.cells: + cells.append({ + "pad": pad_name, + "code": cell.code, + "stdout": cell.stdout, + "stderr": cell.stderr, + "error": cell.error, + "description": cell.description, + }) + + console.print("[anton.muted]Generating session summary…[/]") + title, summary = await _generate_meta(llm_client, session._history, session_id) + + slug = title.lower().strip() + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"[\s_]+", "-", slug) + slug = re.sub(r"-+", "-", slug).strip("-") + slug = slug[:60] or "session" + + exported_at = datetime.now(timezone.utc).isoformat() + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + filename = f"{slug}_{timestamp}.anton" + + payload = { + "version": "0.1", + "exported_by": getpass.getuser(), + "exported_at": exported_at, + "session": { + "id": session_id, + "title": title, + "summary": summary, + "conversation_history": history, + }, + "memory": { + "session_born": session_born, + "project_accessed": project_accessed, + }, + "scratchpad": { + "cells": cells, + }, + } + + output_dir = workspace.base / ".anton" / "output" + output_dir.mkdir(parents=True, exist_ok=True) + dest = output_dir / filename + + tmp_fd, tmp_path = tempfile.mkstemp(dir=output_dir, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + os.replace(tmp_path, dest) + except Exception: + try: + os.unlink(tmp_path) + except Exception: + pass + raise + + console.print() + console.print("[bold][anton.cyan]Session exported[/][/]") + console.print(f" [bold]File:[/] {dest}") + console.print(f" [bold]Title:[/] {payload['session']['title']}") + if summary: + console.print(f" [bold]Summary:[/] {summary}") + console.print( + f" [bold]Memory:[/] {len(session_born)} session-born, " + f"{len(project_accessed)} project memories" + ) + console.print(f" [bold]Code:[/] {len(cells)} scratchpad cells") + if episodic and not session_born and not project_accessed: + console.print( + "[anton.muted] No project memories were delivered in this session.[/]" + ) + console.print() + + +# ── status ─────────────────────────────────────────────────────────────────── + + +def _find_import_record(output_dir: Path, session_id: str) -> dict | None: + """Return the .anton payload that was imported into session_id, or None.""" + if not output_dir.exists(): + return None + for p in output_dir.glob("*.anton"): + try: + data = json.loads(p.read_text(encoding="utf-8")) + if data.get("imported", {}).get("session_id") == session_id: + return data + except Exception: + continue + return None + +def handle_share_status( + console: Console, + session: "ChatSession", + workspace: "Workspace", +) -> None: + session_id = session._session_id + + console.print() + console.print("[bold]Shared session status[/]") + console.print() + + if not session_id: + console.print("[anton.muted] No active session.[/]") + console.print() + return + + output_dir = workspace.base / ".anton" / "output" + record = _find_import_record(output_dir, session_id) + + if not record: + console.print(f" [bold]Status:[/] Session is not imported") + return + + sess = record.get("session", {}) + imp = record.get("imported", {}) + console.print(f" [bold]Title:[/] {sess.get('title', '—')}") + console.print( + f" [bold]Exported by:[/] {record.get('exported_by', '?')} · " + f"{record.get('exported_at', '')[:10]}" + ) + if sess.get("summary"): + console.print(f" [bold]Summary:[/] {sess['summary']}") + console.print() + console.print( + f" [bold]Imported by:[/] {imp.get('user', '?')} · " + f"{imp.get('date', '')[:10]}" + ) + + console.print() + + from anton.core.datasources.data_vault import LocalDataVault + connections = LocalDataVault().list_connections() + connected_ds = {f"{c['engine']}_{c['name']}".lower() for c in connections} + + used_ds = set() + for entry in record.get("session", {}).get("conversation_history", []): + if not isinstance(entry, dict): + continue + for ds_name in (entry.get("meta") or {}).get("datasources") or []: + used_ds.add(ds_name) + + if used_ds: + console.print(" [bold]Data sources[/]") + for ds_name in used_ds: + if ds_name in connected_ds: + mark = "[green]✓[/]" + note = "connected" + else: + mark = "[yellow]![/]" + note = "[anton.warning]not connected[/]" + console.print(f" {mark} {ds_name} · {note}") + else: + console.print("[anton.muted] No data sources referenced in this session.[/]") + + console.print() + + +# ── history ────────────────────────────────────────────────────────────────── + + +def handle_share_history( + console: Console, + workspace: "Workspace", +) -> None: + output_dir = workspace.base / ".anton" / "output" + + console.print() + + files = [] + if output_dir.exists(): + files = sorted(output_dir.glob("*.anton"), key=lambda p: p.stat().st_mtime, reverse=True) + + if not files: + console.print("[anton.muted]No exported sessions found.[/]") + console.print() + return + + console.print(f"[bold]Exported sessions[/] ({len(files)} files)") + console.print() + + for p in files: + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception: + console.print(f" [anton.warning]{p.name}[/] — corrupted or unreadable") + console.print() + continue + + sess = data.get("session", {}) + imp = data.get("imported", {}) + + title = sess.get("title") or p.stem + summary = sess.get("summary", "") + + if imp: + date = imp.get("date", "")[:16].replace("T", " ") + who = imp.get("user", "?") + label = f"imported by {who} · {date}" + else: + date = data.get("exported_at", "")[:16].replace("T", " ") + who = data.get("exported_by", "?") + label = f"exported by {who} · {date}" + + console.print(f" [bold]{title}[/] [anton.muted]{label}[/]") + if summary: + short = summary[:120] + "…" if len(summary) > 120 else summary + console.print(f" {short}") + console.print(f" [anton.muted]→ {p}[/]") + console.print() + + +# ── import ──────────────────────────────────────────────────────────────────── + + +async def import_v0_1( + console: Console, + session: "ChatSession", + workspace: "Workspace", + settings: "AntonSettings", + state: dict, + self_awareness, + cortex: "Cortex | None", + episodic: "EpisodicMemory | None", + history_store: "HistoryStore | None", + payload: dict, + *, + source_path: Path, +) -> "ChatSession": + from anton.commands.session import restore_session + from anton.utils.prompt import prompt_or_cancel + + # warn if active session + if session._history: + console.print( + "[anton.warning]You have an active session in progress. " + "Importing will create a new session — your current work is preserved in history.[/]" + ) + console.print() + choice = await prompt_or_cancel( + "(anton) Continue?", + choices=["y", "n"], + choices_display="y/n", + default="n", + ) + if choice is None or choice != "y": + console.print() + return session + + raw_history = payload.get("session", {}).get("conversation_history", []) + + # fill episodic from export + if episodic and episodic.enabled: + episodic.start_session() + new_session_id = episodic._session_id if (episodic and episodic.enabled) else None + + from anton.core.memory.episodes import Episode + for ep_data in raw_history: + ep = Episode(**ep_data) + episodic.log(ep) + + # reconstruct API history and save to history_store + from anton.memory.history_store import HistoryStore + + api_history = HistoryStore.episodes_to_api_history(raw_history) + if history_store and new_session_id: + history_store.save(new_session_id, api_history) + + # stamp imported metadata and persist file to output/ + payload["imported"] = { + "user": getpass.getuser(), + "date": datetime.now(timezone.utc).isoformat(), + "session_id": new_session_id, + } + output_dir = workspace.base / ".anton" / "output" + output_dir.mkdir(parents=True, exist_ok=True) + dest = output_dir / source_path.name + dest.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + # resume session (closes old scratchpads, rebuild_session, loads _history) + new_session, _ = await restore_session( + new_session_id, console, settings, state, self_awareness, cortex, workspace, + session, episodic, history_store, + ) + + session_born = payload.get("memory", {}).get("session_born", []) + project_accessed = payload.get("memory", {}).get("project_accessed", []) + + # log memories to episodic + if episodic and episodic.enabled: + for m in session_born: + episodic.log_turn(0, "memory_write", m["content"], + kind=m.get("kind", ""), topic=m.get("topic", "")) + for m in project_accessed: + episodic.log_turn(0, "memory_read", m["content"], + kind=m.get("kind", ""), topic=m.get("topic", "")) + + # restore memories to cortex + if cortex: + for m in session_born + project_accessed: + kind = m.get("kind", "") + content = m.get("content", "") + topic = m.get("topic", "") + if kind in ("always", "never", "when"): + cortex.project_hc.encode_rule(content, kind=kind, source="import") + elif kind == "lesson": + cortex.project_hc.encode_lesson(content, topic=topic, source="import") + + # restore scratchpad cells + from anton.core.backends.base import Cell as _Cell # noqa: PLC0415 + cells_data = payload.get("scratchpad", {}).get("cells", []) + for cell_data in cells_data: + pad_name = cell_data.get("pad", "main") + pad = await new_session._scratchpads.get_or_create(pad_name) + pad.cells.append(_Cell( + code=cell_data.get("code", ""), + stdout=cell_data.get("stdout", ""), + stderr=cell_data.get("stderr", ""), + error=cell_data.get("error"), + description=cell_data.get("description", ""), + )) + + # print briefing + sess = payload.get("session", {}) + cells = payload.get("scratchpad", {}).get("cells", []) + + console.print() + console.print(f"[bold][anton.cyan]Imported: {sess.get('title', 'Session')}[/][/]") + console.print( + f" [bold]From:[/] {payload.get('exported_by', '?')} · " + f"{payload.get('exported_at', '')[:10]}" + ) + if sess.get("summary"): + console.print(f" [bold]Summary:[/] {sess['summary']}") + if new_session._turn_count: + console.print(f" [bold]Turns:[/] {new_session._turn_count}") + if session_born or project_accessed: + console.print( + f" [bold]Memory:[/] {len(session_born)} session-born, " + f"{len(project_accessed)} project memories" + ) + if cells: + console.print(f" [bold]Code:[/] {len(cells)} scratchpad cells") + console.print() + console.print("[anton.muted]Session restored. Continue where it left off.[/]") + console.print() + + return new_session + + +async def handle_share_import( + console: Console, + session: "ChatSession", + workspace: "Workspace", + settings: "AntonSettings", + state: dict, + self_awareness, + cortex: "Cortex | None", + episodic: "EpisodicMemory | None", + history_store: "HistoryStore | None", + filepath: str, +) -> "ChatSession": + + # parse & validate + path = Path(filepath).expanduser() + if not path.is_file(): + console.print(f"[anton.warning]File not found: {filepath}[/]") + console.print() + return session + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + console.print("[anton.warning]Could not read file — may be corrupted.[/]") + console.print() + return session + + version = payload.get("version") + + importers = { + "0.1": import_v0_1 + } + + if version not in importers: + console.print( + f"[anton.warning]Unsupported version: {version}. Supported versions: {list(importers.keys())}.[/]" + ) + console.print() + return session + + return await importers[version]( + console, session, workspace, settings, state, self_awareness, cortex, + episodic, history_store, payload, + source_path=path, + ) diff --git a/anton/commands/ui.py b/anton/commands/ui.py index 27543e59..5f332088 100644 --- a/anton/commands/ui.py +++ b/anton/commands/ui.py @@ -40,6 +40,7 @@ class Command: "Chat Tools", Command("/paste", "Attach an image from your clipboard"), Command("/resume", "Continue a previous session"), + Command("/share", "Share sessions: export / import / status / history"), Command("/publish", "Publish an HTML report to the web"), Command("/unpublish", "Remove a published report"), Command("/explain", "Show explainability details for the latest answer"), @@ -61,6 +62,14 @@ class Command: Command("/skill remove