From af48a7e28ac9c7b0dc8bcbcd16a59a32c67d3d85 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Apr 2026 16:09:38 +0300 Subject: [PATCH 01/36] don't create empty files fix resume choices --- anton/commands/session.py | 3 ++- anton/core/memory/episodes.py | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/anton/commands/session.py b/anton/commands/session.py index 7b4d1856..0110f6fc 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() diff --git a/anton/core/memory/episodes.py b/anton/core/memory/episodes.py index 54e067a2..df55213b 100644 --- a/anton/core/memory/episodes.py +++ b/anton/core/memory/episodes.py @@ -56,7 +56,6 @@ def start_session(self) -> str: self._session_id = now.strftime("%Y%m%d_%H%M%S") self._dir.mkdir(parents=True, exist_ok=True) self._file = self._dir / f"{self._session_id}.jsonl" - self._file.touch() return self._session_id def resume_session(self, session_id: str) -> str: @@ -64,8 +63,6 @@ def resume_session(self, session_id: str) -> str: self._session_id = session_id self._dir.mkdir(parents=True, exist_ok=True) self._file = self._dir / f"{self._session_id}.jsonl" - if not self._file.exists(): - self._file.touch() return self._session_id def log(self, episode: Episode) -> None: From 212739b854e3f420652514081cd367b5de4f204d Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Apr 2026 17:57:45 +0300 Subject: [PATCH 02/36] v0 --- anton/chat.py | 15 ++++++------ anton/core/memory/cortex.py | 35 +++++++++++++++++++++++++++ anton/core/memory/episodes.py | 45 ++++++++++++++++++++++++++++++++--- tests/test_episodes.py | 8 +++---- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 867e48eb..7c494d46 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -998,11 +998,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 @@ -1018,13 +1026,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) diff --git a/anton/core/memory/cortex.py b/anton/core/memory/cortex.py index 506cc7dd..e750e97d 100644 --- a/anton/core/memory/cortex.py +++ b/anton/core/memory/cortex.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: from anton.core.llm.client import LLMClient + from anton.core.memory.episodes import EpisodicMemory # ───────────────────────────────────────────────────────────────────────────── @@ -114,6 +115,7 @@ def __init__( project_hc: HippocampusProtocol, mode: str = "autopilot", llm_client: LLMClient | None = None, + episodic: EpisodicMemory | None = None, ) -> None: """Initialize the executive with two hippocampal stores. @@ -122,11 +124,14 @@ def __init__( project_hc: Memory store for project-specific memories mode: Memory mode — autopilot|copilot|off (encoding gate) llm_client: For LLM-assisted operations (profile extraction, compaction) + episodic: For logging memory_read/memory_write events per session """ self.global_hc = global_hc self.project_hc = project_hc self.mode = mode self._llm = llm_client + self._episodic = episodic + self._seen_texts: set[str] = set() self._turn_count = 0 # ~6000 chars ≈ ~1500 tokens — above this, use LLM to filter rules @@ -173,6 +178,9 @@ async def build_memory_context(self, user_message: str = "") -> str: ) if project_rules: sections.append(f"## Your Memory — Project Rules\n{project_rules}") + if self._episodic is not None: + for engram in self.project_hc.get_rules(): + self._log_read_engram(engram) # 4. Global lessons global_lessons = self.global_hc.recall_lessons(token_budget=1000) @@ -183,6 +191,9 @@ async def build_memory_context(self, user_message: str = "") -> str: project_lessons = self.project_hc.recall_lessons(token_budget=1000) if project_lessons: sections.append(f"## Your Memory — Project Lessons\n{project_lessons}") + if self._episodic is not None: + for engram in self.project_hc.get_lessons(): + self._log_read_engram(engram) # 6. Minds datasource context (auto-loaded if present) minds_topic = self.project_hc.recall_topic("minds-datasource") @@ -311,6 +322,8 @@ async def encode(self, engrams: list[Engram]) -> list[str]: confidence=engram.confidence, source=engram.source, ) + if engram.scope != "global": + self._log_write_engram(engram) actions.append(f"Encoded {engram.kind} rule: {engram.text}") elif engram.kind == "lesson": @@ -319,10 +332,32 @@ async def encode(self, engrams: list[Engram]) -> list[str]: topic=engram.topic, source=engram.source, ) + if engram.scope != "global": + self._log_write_engram(engram) actions.append(f"Encoded lesson: {engram.text}") return actions + def _log_write_engram(self, engram: Engram) -> None: + if self._episodic is None: + return + self._seen_texts.add(engram.text) + self._episodic.log_turn( + 0, "memory_write", engram.text, + kind=engram.kind or "lesson", + topic=engram.topic or "", + ) + + def _log_read_engram(self, engram: Engram) -> None: + if engram.text in self._seen_texts: + return + self._seen_texts.add(engram.text) + self._episodic.log_turn( + 0, "memory_read", engram.text, + kind=engram.kind or "lesson", + topic=engram.topic or "", + ) + def encoding_gate(self, engram: Engram) -> bool: """Whether this engram needs user confirmation before encoding. diff --git a/anton/core/memory/episodes.py b/anton/core/memory/episodes.py index df55213b..4de3d9fb 100644 --- a/anton/core/memory/episodes.py +++ b/anton/core/memory/episodes.py @@ -158,13 +158,16 @@ def recall( except Exception: continue - # Parse all episodes in this file for context lookups + # Parse all episodes in this file for context lookups. + # memory_read/memory_write are system events, not conversation turns. all_episodes: list[Episode] = [] for line in lines: if not line.strip(): continue try: - all_episodes.append(Episode(**json.loads(line))) + ep = Episode(**json.loads(line)) + if ep.role not in ("memory_read", "memory_write"): + all_episodes.append(ep) except Exception: continue @@ -215,7 +218,9 @@ def get_episodes(self) -> list[Episode]: try: for line in path.read_text(encoding="utf-8").splitlines(): if line.strip(): - result.append(Episode(**json.loads(line))) + ep = Episode(**json.loads(line)) + if ep.role not in ("memory_read", "memory_write"): + result.append(ep) except Exception: continue return result @@ -245,6 +250,40 @@ def recall_formatted( lines.append(f"[{ep.ts}] ({ep.role}) {ep.content[:max_len]}") return "\n".join(lines) + def get_memory_episodes( + self, + session_id: str, + roles: list[str], + ) -> list[Episode]: + """Return episodes for a session filtered by role, deduplicated by content. + + Used by /share export to collect the memory payload for a session. + """ + if not self._dir.is_dir(): + return [] + path = self._dir / f"{session_id}.jsonl" + if not path.is_file(): + return [] + result: list[Episode] = [] + seen: set[str] = set() + try: + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + ep = Episode(**json.loads(line)) + if ep.role not in roles: + continue + if ep.content in seen: + continue + seen.add(ep.content) + result.append(ep) + except Exception: + continue + except Exception: + pass + return result + def session_count(self) -> int: """Count the number of session files.""" if not self._dir.is_dir(): diff --git a/tests/test_episodes.py b/tests/test_episodes.py index 977a45c7..245303d2 100644 --- a/tests/test_episodes.py +++ b/tests/test_episodes.py @@ -25,6 +25,7 @@ def em(episodes_dir: Path) -> EpisodicMemory: class TestStartSession: def test_creates_file(self, em: EpisodicMemory, episodes_dir: Path): sid = em.start_session() + em.log_turn(1, "user", "hello") assert (episodes_dir / f"{sid}.jsonl").exists() def test_filename_format(self, em: EpisodicMemory): @@ -119,9 +120,8 @@ def test_noop_when_disabled(self, episodes_dir: Path): role="user", content="should not appear", )) - # File should exist but be empty (only created by start_session touch) path = episodes_dir / f"{sid}.jsonl" - assert path.read_text() == "" + assert not path.exists() class TestLogTurn: def test_convenience_method(self, em: EpisodicMemory, episodes_dir: Path): @@ -259,6 +259,7 @@ def test_includes_timestamps(self, em: EpisodicMemory): class TestSessionCount: def test_counts_files(self, em: EpisodicMemory): em.start_session() + em.log_turn(1, "user", "hello") # Create additional fake session files em._dir.mkdir(parents=True, exist_ok=True) (em._dir / "20260101_120000.jsonl").touch() @@ -275,9 +276,8 @@ def test_log_noop(self, episodes_dir: Path): em = EpisodicMemory(episodes_dir, enabled=False) em.start_session() em.log_turn(1, "user", "should not log") - # File should be empty path = em._file - assert path.read_text() == "" + assert not path.exists() def test_recall_empty(self, episodes_dir: Path): em = EpisodicMemory(episodes_dir, enabled=False) From e5c7ddc9e7c46044d8b0ae90b4331fc40a894130 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Apr 2026 18:14:50 +0300 Subject: [PATCH 03/36] v1 --- anton/core/memory/cortex.py | 120 +++++++++++++++++------------------- 1 file changed, 57 insertions(+), 63 deletions(-) diff --git a/anton/core/memory/cortex.py b/anton/core/memory/cortex.py index e750e97d..9541f1c9 100644 --- a/anton/core/memory/cortex.py +++ b/anton/core/memory/cortex.py @@ -162,25 +162,24 @@ async def build_memory_context(self, user_message: str = "") -> str: sections.append(f"## Your Memory — Identity\n{identity}") # 2. Global rules (with smart retrieval) - global_rules = self.global_hc.recall_rules() - if global_rules: - global_rules = await self._retrieve_relevant_rules( - global_rules, user_message - ) - if global_rules: - sections.append(f"## Your Memory — Global Rules\n{global_rules}") + global_engrams = self.global_hc.get_rules() + if global_engrams: + global_engrams = await self._retrieve_relevant_rules(global_engrams, user_message) + if global_engrams: + sections.append( + f"## Your Memory — Global Rules\n{self._format_rules_engrams(global_engrams)}" + ) # 3. Project rules (with smart retrieval) - project_rules = self.project_hc.recall_rules() - if project_rules: - project_rules = await self._retrieve_relevant_rules( - project_rules, user_message - ) - if project_rules: - sections.append(f"## Your Memory — Project Rules\n{project_rules}") - if self._episodic is not None: - for engram in self.project_hc.get_rules(): - self._log_read_engram(engram) + project_engrams = self.project_hc.get_rules() + if project_engrams: + project_engrams = await self._retrieve_relevant_rules(project_engrams, user_message) + if project_engrams: + sections.append( + f"## Your Memory — Project Rules\n{self._format_rules_engrams(project_engrams)}" + ) + for engram in project_engrams: + self._log_read_engram(engram) # 4. Global lessons global_lessons = self.global_hc.recall_lessons(token_budget=1000) @@ -205,8 +204,24 @@ async def build_memory_context(self, user_message: str = "") -> str: return "\n\n" + "\n\n".join(sections) - async def _retrieve_relevant_rules(self, all_rules: str, user_message: str) -> str: - """Filter rules to only those relevant to the current user message. + @staticmethod + def _format_rules_engrams(engrams: list[Engram]) -> str: + """Format rule engrams to section display format (## Always / Never / When).""" + by_kind: dict[str, list[Engram]] = {} + for e in engrams: + by_kind.setdefault((e.kind or "always").lower(), []).append(e) + parts: list[str] = [] + for section in ("always", "never", "when"): + items = by_kind.get(section, []) + if items: + parts.append(f"## {section.capitalize()}") + parts.extend(f"- {e.text}" for e in items) + return "\n".join(parts) + + async def _retrieve_relevant_rules( + self, engrams: list[Engram], user_message: str + ) -> list[Engram]: + """Filter rule engrams to those relevant to the current user message. Brain analog: dlPFC cue-dependent recall — the prefrontal cortex selects which memories to activate based on current goals, rather @@ -217,41 +232,21 @@ async def _retrieve_relevant_rules(self, all_rules: str, user_message: str) -> s If rules are under budget or no LLM is available, returns as-is. """ if not user_message or self._llm is None: - return all_rules - if len(all_rules) <= self._RULES_BUDGET_CHARS: - return all_rules - - # Split rules into mandatory (Always/Never) and filterable (When) - lines = all_rules.splitlines() - mandatory_lines: list[str] = [] - when_lines: list[str] = [] - current_section = "" - - for line in lines: - stripped = line.strip() - if stripped.startswith("## Always"): - current_section = "always" - mandatory_lines.append(line) - elif stripped.startswith("## Never"): - current_section = "never" - mandatory_lines.append(line) - elif stripped.startswith("## When"): - current_section = "when" - mandatory_lines.append(line) # keep the header - elif stripped.startswith("## ") or stripped.startswith("# "): - current_section = "" - mandatory_lines.append(line) - elif current_section == "when": - when_lines.append(line) - else: - mandatory_lines.append(line) + return engrams + + if len(self._format_rules_engrams(engrams)) <= self._RULES_BUDGET_CHARS: + return engrams + + mandatory = [e for e in engrams if (e.kind or "").lower() in ("always", "never")] + when_engrams = [e for e in engrams if (e.kind or "").lower() == "when"] + + if not when_engrams: + return engrams - # If When section is small, no need to filter - when_text = "\n".join(when_lines).strip() - if not when_text or len(when_text) < 1000: - return all_rules + when_text = "\n".join(f"- {e.text}" for e in when_engrams) + if len(when_text) < 1000: + return engrams - # Filter only the When rules try: response = await self._llm.code( system=self._RULES_RETRIEVAL_PROMPT, @@ -265,19 +260,18 @@ async def _retrieve_relevant_rules(self, all_rules: str, user_message: str) -> s ) result = response.content.strip() if result == "NONE": - filtered_when = "" - elif result: - filtered_when = result + return mandatory + if result: + returned = {line.lstrip("- ").strip() for line in result.splitlines() if line.strip()} + filtered_when = [e for e in when_engrams if e.text in returned] + if not filtered_when: + filtered_when = when_engrams else: - filtered_when = when_text + filtered_when = when_engrams except Exception: - filtered_when = when_text + filtered_when = when_engrams - # Reassemble: mandatory sections + filtered When rules - output = "\n".join(mandatory_lines) - if filtered_when: - output += "\n" + filtered_when - return output + return mandatory + filtered_when def get_scratchpad_context(self) -> str: """Retrieve procedural knowledge for scratchpad tool injection. @@ -349,7 +343,7 @@ def _log_write_engram(self, engram: Engram) -> None: ) def _log_read_engram(self, engram: Engram) -> None: - if engram.text in self._seen_texts: + if self._episodic is None or engram.text in self._seen_texts: return self._seen_texts.add(engram.text) self._episodic.log_turn( From 8fff87b6c7c1c893b39731e8c61318dcc96ffdd5 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 24 Apr 2026 16:54:05 +0300 Subject: [PATCH 04/36] export --- anton/chat.py | 18 +++ anton/commands/share.py | 204 ++++++++++++++++++++++++++++++++++ anton/commands/ui.py | 2 + anton/core/memory/episodes.py | 7 +- 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 anton/commands/share.py diff --git a/anton/chat.py b/anton/chat.py index 0aa27038..96ea7b5f 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -58,6 +58,7 @@ handle_skill_show, handle_skills_list, ) +from anton.commands.share import handle_share_export from anton.tools import CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL from anton.utils.prompt import ( prompt_or_cancel, @@ -1348,6 +1349,23 @@ 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, + ) + else: + console.print("[anton.warning]Usage: /share export [--summary][/]") + console.print() + continue elif cmd == "/resume": session, resumed_id = await handle_resume( console, diff --git a/anton/commands/share.py b/anton/commands/share.py new file mode 100644 index 00000000..73c76d35 --- /dev/null +++ b/anton/commands/share.py @@ -0,0 +1,204 @@ +"""Slash-command handlers for /share.""" +from __future__ import annotations + +import getpass +import json +import os +import re +import tempfile +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field +from rich.console import Console + +if TYPE_CHECKING: + from anton.core.llm.client import LLMClient + from anton.core.memory.episodes import EpisodicMemory + 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 2-3 sentence narrative of the analytical journey — " + "what was explored, key discoveries, and current state." + ) + ) + + +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) + + +def _slugify(title: str) -> str: + slug = title.lower().strip() + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"[\s_]+", "-", slug) + slug = re.sub(r"-+", "-", slug).strip("-") + return slug[:60] or "session" + + +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 summarizing an analytical session. Produce a filename-safe title slug and a concise summary.", + messages=[{ + "role": "user", + "content": f"Summarize this analytical session:\n\n{conversation_text}", + }], + max_tokens=300, + ) + return _slugify(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 list(session._history) + + 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() + + # memory snapshot + episodes = episodic.get_memory_usage( + session_id + ) + exportable = [e for e in episodes if e.meta.get("kind") != "profile"] + session_born = [ + { + "content": e.content, + "kind": e.meta.get("kind", ""), + "topic": e.meta.get("topic", ""), + } + for e in exportable if e.role == "memory_write" + ] + project_accessed = [ + { + "content": e.content, + "kind": e.meta.get("kind", ""), + "topic": e.meta.get("topic", ""), + } + for e in exportable if e.role == "memory_read" + ] + + # 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_slug, summary = await _generate_meta(llm_client, session._history, session_id) + + exported_at = datetime.now(timezone.utc).isoformat() + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d") + filename = f"{title_slug}_{timestamp}.anton" + + payload = { + "version": "0.1", + "exported_by": getpass.getuser(), + "exported_at": exported_at, + "session": { + "id": session_id, + "title": title_slug.replace("-", " ").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() diff --git a/anton/commands/ui.py b/anton/commands/ui.py index 27543e59..2e7c04a7 100644 --- a/anton/commands/ui.py +++ b/anton/commands/ui.py @@ -40,6 +40,8 @@ class Command: "Chat Tools", Command("/paste", "Attach an image from your clipboard"), Command("/resume", "Continue a previous session"), + Command("/share export", "Export current session as a portable .anton file"), + Command("/share export --summary", "Export distilled context only (no full 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"), diff --git a/anton/core/memory/episodes.py b/anton/core/memory/episodes.py index 4de3d9fb..ecbd0b6a 100644 --- a/anton/core/memory/episodes.py +++ b/anton/core/memory/episodes.py @@ -250,10 +250,9 @@ def recall_formatted( lines.append(f"[{ep.ts}] ({ep.role}) {ep.content[:max_len]}") return "\n".join(lines) - def get_memory_episodes( + def get_memory_usage( self, - session_id: str, - roles: list[str], + session_id: str ) -> list[Episode]: """Return episodes for a session filtered by role, deduplicated by content. @@ -272,7 +271,7 @@ def get_memory_episodes( continue try: ep = Episode(**json.loads(line)) - if ep.role not in roles: + if ep.role not in ["memory_write", "memory_read"]: continue if ep.content in seen: continue From 27d0d547aaa42560c30f9e6c0fe5eeb9ac778cfa Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 24 Apr 2026 16:58:25 +0300 Subject: [PATCH 05/36] export --- anton/commands/share.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/anton/commands/share.py b/anton/commands/share.py index 73c76d35..d5e4e132 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -28,8 +28,9 @@ class _SessionMeta(BaseModel): ) summary: str = Field( description=( - "A 2-3 sentence narrative of the analytical journey — " - "what was explored, key discoveries, and current state." + "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." ) ) @@ -68,10 +69,15 @@ async def _generate_meta( conversation_text = _format_history_for_llm(history) result = await llm_client.generate_object_code( _SessionMeta, - system="You are summarizing an analytical session. Produce a filename-safe title slug and a concise summary.", + 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"Summarize this analytical session:\n\n{conversation_text}", + "content": f"Distill this analytical session:\n\n{conversation_text}", }], max_tokens=300, ) From d1f08e28ec8a5825ebaff9ba5a4e312ffbfbadc7 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 24 Apr 2026 18:17:04 +0300 Subject: [PATCH 06/36] import --- anton/chat.py | 24 +++++- anton/commands/share.py | 187 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 96ea7b5f..e0d88fde 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -58,7 +58,7 @@ handle_skill_show, handle_skills_list, ) -from anton.commands.share import handle_share_export +from anton.commands.share import handle_share_export, handle_share_import from anton.tools import CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL from anton.utils.prompt import ( prompt_or_cancel, @@ -1362,8 +1362,28 @@ def _bottom_toolbar(): 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 else: - console.print("[anton.warning]Usage: /share export [--summary][/]") + console.print( + "[anton.warning]Usage: /share export [--summary] | /share import [/]" + ) console.print() continue elif cmd == "/resume": diff --git a/anton/commands/share.py b/anton/commands/share.py index d5e4e132..48a81198 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -7,12 +7,14 @@ import re import tempfile 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.memory.episodes import EpisodicMemory from anton.core.session import ChatSession @@ -208,3 +210,188 @@ async def handle_share_export( "[anton.muted] No project memories were delivered in this session.[/]" ) console.print() + + +# ── import ──────────────────────────────────────────────────────────────────── + + +def _content_to_str(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ] + return " ".join(p for p in parts if p) + return str(content) + + +def _replay_to_episodic(episodic: "EpisodicMemory", history: list[dict]) -> None: + turn = 0 + for msg in history: + role = msg.get("role", "") + content = _content_to_str(msg.get("content", "")) + if role == "user": + turn += 1 + if role in ("user", "assistant"): + episodic.log_turn(turn, role, content) + + +def _build_provenance_suffix(payload: dict) -> str: + sess = payload.get("session", {}) + title = sess.get("title", "") + exported_by = payload.get("exported_by", "unknown") + exported_at = payload.get("exported_at", "")[:10] + return ( + "## Note\n" + f'This session was imported from a .anton file: "{title}", ' + f"exported by {exported_by} on {exported_at}. " + "The full conversation history has been restored." + ) + + +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": + from anton.chat_session import rebuild_session + from anton.core.llm.prompt_builder import SystemPromptContext + from anton.utils.prompt import prompt_or_cancel + + # 1. 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") + if version != "0.1": + console.print( + f"[anton.warning]Unsupported version: {version}. Expected 0.1.[/]" + ) + console.print() + return session + + # 2. 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 + + # 3. start new episodic session + if episodic and episodic.enabled: + episodic.start_session() + + new_session_id = episodic._session_id if (episodic and episodic.enabled) else None + + # close old scratchpads + if session._scratchpads.list_pads(): + await session._scratchpads.close_all() + + # 4. create new session + new_session = rebuild_session( + settings=settings, + state=state, + self_awareness=self_awareness, + cortex=cortex, + workspace=workspace, + console=console, + episodic=episodic, + history_store=history_store, + session_id=new_session_id, + ) + + # 5. inject conversation history into LLM context + history = payload.get("session", {}).get("conversation_history", []) + new_session._history = list(history) + new_session._turn_count = sum(1 for m in history if m.get("role") == "user") + + if history_store and new_session_id: + history_store.save(new_session_id, history) + + # 6. replay to episodic file + if episodic and episodic.enabled: + _replay_to_episodic(episodic, history) + + # 7. log memories to episodic + session_born = payload.get("memory", {}).get("session_born", []) + project_accessed = payload.get("memory", {}).get("project_accessed", []) + + 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", ""), + ) + + # 8. inject provenance suffix + suffix = _build_provenance_suffix(payload) + new_session._system_prompt_context = SystemPromptContext( + runtime_context=new_session._system_prompt_context.runtime_context, + prefix=new_session._system_prompt_context.prefix, + output_context=new_session._system_prompt_context.output_context, + suffix=suffix, + ) + + # 9. print briefing + sess = payload.get("session", {}) + memory = payload.get("memory", {}) + cells = payload.get("scratchpad", {}).get("cells", []) + n_turns = sum(1 for m in history if m.get("role") == "user") + + 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 n_turns: + console.print(f" [bold]Turns:[/] {n_turns}") + 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 From 0782f4980a7edfcb8eb03abd00eba92bcfb265b2 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 24 Apr 2026 18:21:47 +0300 Subject: [PATCH 07/36] import --- anton/commands/share.py | 1 - 1 file changed, 1 deletion(-) diff --git a/anton/commands/share.py b/anton/commands/share.py index 48a81198..cff1397b 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -369,7 +369,6 @@ async def handle_share_import( # 9. print briefing sess = payload.get("session", {}) - memory = payload.get("memory", {}) cells = payload.get("scratchpad", {}).get("cells", []) n_turns = sum(1 for m in history if m.get("role") == "user") From 71e26340b86f2e8bfc98180de96cdc824efc2bda Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 16:26:18 +0300 Subject: [PATCH 08/36] fixes --- anton/chat.py | 2 +- anton/commands/share.py | 44 +++++++++++++++++------------------------ 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index e0d88fde..93a13113 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -1230,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) diff --git a/anton/commands/share.py b/anton/commands/share.py index cff1397b..f487a837 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -53,15 +53,6 @@ def _format_history_for_llm(history: list[dict], max_messages: int = 20) -> str: lines.append(f"{role}: {str(content)[:400]}") return "\n".join(lines) - -def _slugify(title: str) -> str: - slug = title.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"[\s_]+", "-", slug) - slug = re.sub(r"-+", "-", slug).strip("-") - return slug[:60] or "session" - - async def _generate_meta( llm_client: LLMClient, history: list[dict], @@ -83,7 +74,8 @@ async def _generate_meta( }], max_tokens=300, ) - return _slugify(result.title), result.summary + + return result.title, result.summary except Exception: return f"session-{session_id}", "" @@ -121,23 +113,17 @@ async def handle_share_export( episodes = episodic.get_memory_usage( session_id ) - exportable = [e for e in episodes if e.meta.get("kind") != "profile"] - session_born = [ - { + session_born, project_accessed = [], [] + for e in episodes: + item = { "content": e.content, "kind": e.meta.get("kind", ""), "topic": e.meta.get("topic", ""), } - for e in exportable if e.role == "memory_write" - ] - project_accessed = [ - { - "content": e.content, - "kind": e.meta.get("kind", ""), - "topic": e.meta.get("topic", ""), - } - for e in exportable if e.role == "memory_read" - ] + if e.role == "memory_write": + session_born.append(item) + if e.role == "memory_read": + project_accessed.append(item) # scratchpad cells cells: list[dict] = [] @@ -153,11 +139,17 @@ async def handle_share_export( }) console.print("[anton.muted]Generating session summary…[/]") - title_slug, summary = await _generate_meta(llm_client, session._history, session_id) + 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") - filename = f"{title_slug}_{timestamp}.anton" + filename = f"{slug}_{timestamp}.anton" payload = { "version": "0.1", @@ -165,7 +157,7 @@ async def handle_share_export( "exported_at": exported_at, "session": { "id": session_id, - "title": title_slug.replace("-", " ").title(), + "title": title, "summary": summary, "conversation_history": history, }, From 831182c04eb988bd85787a3da2d9594e07a2bc79 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 16:57:50 +0300 Subject: [PATCH 09/36] fix write scratchpad and memory --- anton/commands/share.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/anton/commands/share.py b/anton/commands/share.py index f487a837..71f14c71 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -350,6 +350,32 @@ async def handle_share_import( kind=m.get("kind", ""), topic=m.get("topic", ""), ) + # 7b. write session_born memories to project hippocampus + if cortex: + for m in session_born: + 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") + # profile kind: skip — never import personal memories + + # 7c. restore scratchpad cells into new session + 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", ""), + )) + # 8. inject provenance suffix suffix = _build_provenance_suffix(payload) new_session._system_prompt_context = SystemPromptContext( From 47f40849e3fd0d2c8789d425d12e76deec9868e9 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 17:03:50 +0300 Subject: [PATCH 10/36] support multi format --- anton/commands/share.py | 89 +++++--- tests/test_share.py | 482 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 538 insertions(+), 33 deletions(-) create mode 100644 tests/test_share.py diff --git a/anton/commands/share.py b/anton/commands/share.py index 71f14c71..c6a67650 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -244,44 +244,22 @@ def _build_provenance_suffix(payload: dict) -> str: ) -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, +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, ) -> "ChatSession": from anton.chat_session import rebuild_session from anton.core.llm.prompt_builder import SystemPromptContext from anton.utils.prompt import prompt_or_cancel - # 1. 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") - if version != "0.1": - console.print( - f"[anton.warning]Unsupported version: {version}. Expected 0.1.[/]" - ) - console.print() - return session - # 2. warn if active session if session._history: console.print( @@ -412,3 +390,48 @@ async def handle_share_import( 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": + + # 1. 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 + ) diff --git a/tests/test_share.py b/tests/test_share.py new file mode 100644 index 00000000..91484ee1 --- /dev/null +++ b/tests/test_share.py @@ -0,0 +1,482 @@ +"""Comprehensive export → import roundtrip tests for /share command. + +Flow under test: + 1. Build a session with conversation history, memory entries, scratchpad cells. + 2. Export via handle_share_export → .anton file. + 3. Verify .anton file structure and content. + 4. Import via handle_share_import → new session. + 5. Compare state before and after, noting expected differences. + +Expected differences after roundtrip: + - session._session_id : new ID (import always creates a fresh session) + - system_prompt_context: new session has provenance suffix; original had none +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from rich.console import Console + +from anton.commands.share import handle_share_export, handle_share_import +from anton.core.backends.base import Cell +from anton.core.backends.manager import ScratchpadManager +from anton.core.memory.episodes import EpisodicMemory +from anton.core.session import ChatSession, ChatSessionConfig +from tests.conftest import make_mock_llm + + +# ── fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture() +def console() -> Console: + return Console(quiet=True) + + +@pytest.fixture() +def workspace(tmp_path: Path): + return MagicMock(base=tmp_path) + + +# Canonical conversation used throughout the tests +HISTORY = [ + {"role": "user", "content": "What is the revenue breakdown by region?"}, + {"role": "assistant", "content": "Let me query that for you."}, + {"role": "user", "content": "Can you also show the YoY change?"}, + {"role": "assistant", "content": "Sure, here is the YoY comparison."}, +] + +SESSION_BORN_MEMORY = {"content": "Always use CTEs for readability", "kind": "lesson", "topic": "sql"} +PROJECT_MEMORY = {"content": "Never use SELECT * in production", "kind": "never", "topic": ""} +PROFILE_MEMORY = {"content": "User prefers camel-case", "kind": "profile", "topic": ""} +SCRATCHPAD_CELL = Cell(code="df.head()", stdout=" col1\n0 1\n", stderr="", error=None, description="Preview data") + + +def _build_exporter_session( + tmp_path: Path, + workspace, + *, + include_profile_memory: bool = False, +) -> tuple[ChatSession, EpisodicMemory, str]: + """Return (session, episodic, session_id) ready for export.""" + episodes_dir = tmp_path / "episodes" + episodic = EpisodicMemory(episodes_dir) + sid = episodic.start_session() + + # log memories that the export should pick up + episodic.log_turn(0, "memory_write", **SESSION_BORN_MEMORY) + episodic.log_turn(0, "memory_read", **PROJECT_MEMORY) + if include_profile_memory: + episodic.log_turn(0, "memory_write", **PROFILE_MEMORY) + + mock_llm = make_mock_llm() + session = ChatSession(ChatSessionConfig( + llm_client=mock_llm, + session_id=sid, + episodic=episodic, + workspace=workspace, + )) + session._history = list(HISTORY) + session._turn_count = sum(1 for m in HISTORY if m.get("role") == "user") + + # wire a fake scratchpad runtime with one cell + mock_runtime = MagicMock() + mock_runtime.cells = [SCRATCHPAD_CELL] + session._scratchpads._pads = {"main": mock_runtime} + + return session, episodic, sid + + +# ── export tests ────────────────────────────────────────────────────────────── + + +class TestShareExport: + async def test_creates_anton_file_in_output_dir(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("revenue-region-yoy", "Analyzed revenue by region. Found APAC leads YoY."))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + output_dir = tmp_path / ".anton" / "output" + files = list(output_dir.glob("*.anton")) + assert len(files) == 1 + assert files[0].name.startswith("revenue-region-yoy_") + + async def test_file_version_and_metadata(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("test-session", "Summary text."))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) + + assert payload["version"] == "0.1" + assert payload["exported_by"] # non-empty username + assert payload["exported_at"] # ISO timestamp + + async def test_conversation_history_preserved(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("test-session", ""))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) + assert payload["session"]["conversation_history"] == HISTORY + + async def test_session_born_memory_included(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("test-session", ""))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) + born = payload["memory"]["session_born"] + assert len(born) == 1 + assert born[0]["content"] == SESSION_BORN_MEMORY["content"] + assert born[0]["kind"] == SESSION_BORN_MEMORY["kind"] + assert born[0]["topic"] == SESSION_BORN_MEMORY["topic"] + + async def test_project_accessed_memory_included(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("test-session", ""))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) + accessed = payload["memory"]["project_accessed"] + assert len(accessed) == 1 + assert accessed[0]["content"] == PROJECT_MEMORY["content"] + assert accessed[0]["kind"] == PROJECT_MEMORY["kind"] + + async def test_scratchpad_cells_included(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("test-session", ""))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) + cells = payload["scratchpad"]["cells"] + assert len(cells) == 1 + assert cells[0]["pad"] == "main" + assert cells[0]["code"] == SCRATCHPAD_CELL.code + assert cells[0]["stdout"] == SCRATCHPAD_CELL.stdout + assert cells[0]["description"] == SCRATCHPAD_CELL.description + + async def test_summary_flag_empties_history(self, tmp_path, console, workspace): + session, episodic, _ = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("test-session", ""))): + await handle_share_export(console, session, workspace, mock_llm, episodic, + summary_only=True) + + payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) + # --summary: history omitted, memories still present + assert payload["session"]["conversation_history"] == [] + assert len(payload["memory"]["session_born"]) == 1 + + +# ── import tests ────────────────────────────────────────────────────────────── + + +def _make_anton_payload( + history: list[dict] | None = None, + session_born: list[dict] | None = None, + project_accessed: list[dict] | None = None, + cells: list[dict] | None = None, + version: str = "0.1", +) -> dict: + return { + "version": version, + "exported_by": "alice", + "exported_at": "2026-04-20T10:00:00+00:00", + "session": { + "id": "20260420_100000", + "title": "Revenue Region Analysis", + "summary": "Analyzed revenue by region. APAC leads YoY.", + "conversation_history": history if history is not None else list(HISTORY), + }, + "memory": { + "session_born": session_born if session_born is not None else [SESSION_BORN_MEMORY], + "project_accessed": project_accessed if project_accessed is not None else [PROJECT_MEMORY], + }, + "scratchpad": { + "cells": cells if cells is not None else [ + {"pad": "main", "code": "df.head()", "stdout": "...", "stderr": "", "error": None, "description": "preview"} + ], + }, + } + + +def _write_anton_file(tmp_path: Path, payload: dict) -> Path: + path = tmp_path / "import_test.anton" + path.write_text(json.dumps(payload, ensure_ascii=False)) + return path + + +async def _do_import( + tmp_path: Path, + console: Console, + workspace, + anton_file: Path, + *, + current_history: list[dict] | None = None, + cortex=None, +) -> tuple[ChatSession, EpisodicMemory]: + """Run handle_share_import and return (new_session, new_episodic). + + get_or_create is mocked so no real venv subprocess is started. + Pad runtimes are injected into session._scratchpads._pads so callers + can inspect restored cells via result._scratchpads._pads[name].cells. + """ + mock_llm = make_mock_llm() + + # episodic for the new session (recipient side) + new_episodic = EpisodicMemory(tmp_path / "new_episodes") + + # empty current session (no active history unless specified) + current_session = ChatSession(ChatSessionConfig( + llm_client=mock_llm, + workspace=workspace, + )) + if current_history: + current_session._history = list(current_history) + + # pre-build the session that rebuild_session will return. + # Uses the session_id already set by handle_share_import's start_session() call. + def _fake_rebuild(**kwargs): + return ChatSession(ChatSessionConfig( + llm_client=mock_llm, + session_id=kwargs.get("session_id"), + episodic=new_episodic, + workspace=workspace, + )) + + # Mock get_or_create so no real venv is started. + # The mock sets _pads[name] so callers can inspect cells afterward. + async def _mock_get_or_create(mgr_self, name): + if name not in mgr_self._pads: + rt = MagicMock() + rt.cells = [] + mgr_self._pads[name] = rt + return mgr_self._pads[name] + + with patch.object(ScratchpadManager, "get_or_create", _mock_get_or_create): + with patch("anton.chat_session.rebuild_session", side_effect=_fake_rebuild): + result = await handle_share_import( + console, + current_session, + workspace, + MagicMock(), # settings + {"llm_client": mock_llm}, # state + None, # self_awareness + cortex, + new_episodic, + None, # history_store + filepath=str(anton_file), + ) + + return result, new_episodic + + +class TestShareImport: + async def test_history_restored_in_llm_context(self, tmp_path, console, workspace): + path = _write_anton_file(tmp_path, _make_anton_payload()) + result, _ = await _do_import(tmp_path, console, workspace, path) + + assert result._history == HISTORY + assert result._turn_count == 2 # two user messages + + async def test_session_born_logged_as_memory_write(self, tmp_path, console, workspace): + path = _write_anton_file(tmp_path, _make_anton_payload()) + result, new_episodic = await _do_import(tmp_path, console, workspace, path) + + mem_eps = new_episodic.get_memory_usage(result._session_id) + writes = [e for e in mem_eps if e.role == "memory_write"] + + assert len(writes) == 1 + assert writes[0].content == SESSION_BORN_MEMORY["content"] + assert writes[0].meta["kind"] == SESSION_BORN_MEMORY["kind"] + assert writes[0].meta["topic"] == SESSION_BORN_MEMORY["topic"] + + async def test_project_accessed_logged_as_memory_read(self, tmp_path, console, workspace): + path = _write_anton_file(tmp_path, _make_anton_payload()) + result, new_episodic = await _do_import(tmp_path, console, workspace, path) + + mem_eps = new_episodic.get_memory_usage(result._session_id) + reads = [e for e in mem_eps if e.role == "memory_read"] + + assert len(reads) == 1 + assert reads[0].content == PROJECT_MEMORY["content"] + assert reads[0].meta["kind"] == PROJECT_MEMORY["kind"] + + async def test_conversation_replayed_to_episodic(self, tmp_path, console, workspace): + path = _write_anton_file(tmp_path, _make_anton_payload()) + result, new_episodic = await _do_import(tmp_path, console, workspace, path) + + # The episodic file should contain user and assistant turns from the replayed history + eps = new_episodic.get_episodes() # all non-memory episodes + roles = [e.role for e in eps] + assert "user" in roles + assert "assistant" in roles + + async def test_new_session_id_differs_from_original(self, tmp_path, console, workspace): + payload = _make_anton_payload() + original_sid = payload["session"]["id"] # "20260420_100000" + path = _write_anton_file(tmp_path, payload) + + result, _ = await _do_import(tmp_path, console, workspace, path) + + assert result._session_id != original_sid + + async def test_provenance_suffix_set(self, tmp_path, console, workspace): + path = _write_anton_file(tmp_path, _make_anton_payload()) + result, _ = await _do_import(tmp_path, console, workspace, path) + + suffix = result._system_prompt_context.suffix + assert "alice" in suffix + assert "2026-04-20" in suffix + assert "Revenue Region Analysis" in suffix + + async def test_scratchpad_cells_restored_in_runtime(self, tmp_path, console, workspace): + """Cells from the .anton file are restored into the new session's scratchpad.""" + path = _write_anton_file(tmp_path, _make_anton_payload()) + result, _ = await _do_import(tmp_path, console, workspace, path) + + assert "main" in result._scratchpads._pads + restored = result._scratchpads._pads["main"].cells + assert len(restored) == 1 + assert restored[0].code == "df.head()" + assert restored[0].stdout == "..." + + async def test_session_born_written_to_hippocampus(self, tmp_path, console, workspace): + """session_born memories with kind=lesson are written to cortex.project_hc.""" + path = _write_anton_file(tmp_path, _make_anton_payload()) + + mock_hc = MagicMock() + mock_cortex = MagicMock() + mock_cortex.project_hc = mock_hc + + result, _ = await _do_import(tmp_path, console, workspace, path, cortex=mock_cortex) + + # SESSION_BORN_MEMORY has kind="lesson" and topic="sql" + mock_hc.encode_lesson.assert_called_once_with( + SESSION_BORN_MEMORY["content"], + topic=SESSION_BORN_MEMORY["topic"], + source="import", + ) + mock_hc.encode_rule.assert_not_called() + + async def test_import_file_not_found(self, tmp_path, console, workspace): + mock_llm = make_mock_llm() + current_session = ChatSession(ChatSessionConfig(llm_client=mock_llm, workspace=workspace)) + + result = await handle_share_import( + console, current_session, workspace, + MagicMock(), {"llm_client": mock_llm}, None, None, None, None, + filepath=str(tmp_path / "nonexistent.anton"), + ) + + # Returns unchanged session + assert result is current_session + + async def test_import_wrong_version(self, tmp_path, console, workspace): + path = _write_anton_file(tmp_path, _make_anton_payload(version="9.9")) + mock_llm = make_mock_llm() + current_session = ChatSession(ChatSessionConfig(llm_client=mock_llm, workspace=workspace)) + + result = await handle_share_import( + console, current_session, workspace, + MagicMock(), {"llm_client": mock_llm}, None, None, None, None, + filepath=str(path), + ) + + assert result is current_session # unchanged + + +# ── roundtrip ───────────────────────────────────────────────────────────────── + + +class TestShareRoundtrip: + async def test_full_roundtrip(self, tmp_path, console, workspace): + """Export a live session, import it, compare state point by point.""" + + # ── 1. build exporter session ────────────────────────────────────── + session, episodic, original_sid = _build_exporter_session(tmp_path, workspace) + mock_llm = make_mock_llm() + + with patch("anton.commands.share._generate_meta", + AsyncMock(return_value=("revenue-region-yoy", "Analyzed revenue. APAC leads YoY."))): + await handle_share_export(console, session, workspace, mock_llm, episodic) + + output_dir = tmp_path / ".anton" / "output" + anton_file = next(output_dir.glob("*.anton")) + payload = json.loads(anton_file.read_text()) + + # ── 2. import into fresh session ─────────────────────────────────── + mock_hc = MagicMock() + mock_cortex = MagicMock() + mock_cortex.project_hc = mock_hc + + result, new_episodic = await _do_import( + tmp_path / "recipient", console, workspace, anton_file, + cortex=mock_cortex, + ) + + # ── 3. compare ───────────────────────────────────────────────────── + + # conversation history: EQUAL + assert result._history == HISTORY, "Conversation history must be fully preserved" + assert result._turn_count == 2 + + # session_id: comes from a fresh start_session(), not copied from the .anton file + # (the .anton file's session.id is the original exporter's ID) + # We verify the new session got its ID from the new episodic, not the payload + assert result._session_id == new_episodic._session_id + + # memory in new episodic: session_born → memory_write + mem_eps = new_episodic.get_memory_usage(result._session_id) + writes = [e for e in mem_eps if e.role == "memory_write"] + reads = [e for e in mem_eps if e.role == "memory_read"] + + assert len(writes) == 1 + assert writes[0].content == SESSION_BORN_MEMORY["content"] + + assert len(reads) == 1 + assert reads[0].content == PROJECT_MEMORY["content"] + + # scratchpad: one cell in .anton file, and it is restored into the runtime + assert len(payload["scratchpad"]["cells"]) == 1 + assert "main" in result._scratchpads._pads + assert len(result._scratchpads._pads["main"].cells) == 1 + + # hippocampus: session_born (kind=lesson) written to project_hc + mock_hc.encode_lesson.assert_called_once_with( + SESSION_BORN_MEMORY["content"], + topic=SESSION_BORN_MEMORY["topic"], + source="import", + ) + mock_hc.encode_rule.assert_not_called() + + # suffix: provenance present, original session had none + exported_by = payload["exported_by"] + assert exported_by not in (session._system_prompt_context.suffix or "") + assert exported_by in result._system_prompt_context.suffix + assert payload["session"]["title"] in result._system_prompt_context.suffix From 3ada6f06b58035a8a3591a1fedacdae88f7fea6f Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 17:08:51 +0300 Subject: [PATCH 11/36] less tests --- tests/test_share.py | 257 -------------------------------------------- 1 file changed, 257 deletions(-) diff --git a/tests/test_share.py b/tests/test_share.py index 91484ee1..44e6d928 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -91,145 +91,6 @@ def _build_exporter_session( return session, episodic, sid -# ── export tests ────────────────────────────────────────────────────────────── - - -class TestShareExport: - async def test_creates_anton_file_in_output_dir(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("revenue-region-yoy", "Analyzed revenue by region. Found APAC leads YoY."))): - await handle_share_export(console, session, workspace, mock_llm, episodic) - - output_dir = tmp_path / ".anton" / "output" - files = list(output_dir.glob("*.anton")) - assert len(files) == 1 - assert files[0].name.startswith("revenue-region-yoy_") - - async def test_file_version_and_metadata(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("test-session", "Summary text."))): - await handle_share_export(console, session, workspace, mock_llm, episodic) - - payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) - - assert payload["version"] == "0.1" - assert payload["exported_by"] # non-empty username - assert payload["exported_at"] # ISO timestamp - - async def test_conversation_history_preserved(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("test-session", ""))): - await handle_share_export(console, session, workspace, mock_llm, episodic) - - payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) - assert payload["session"]["conversation_history"] == HISTORY - - async def test_session_born_memory_included(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("test-session", ""))): - await handle_share_export(console, session, workspace, mock_llm, episodic) - - payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) - born = payload["memory"]["session_born"] - assert len(born) == 1 - assert born[0]["content"] == SESSION_BORN_MEMORY["content"] - assert born[0]["kind"] == SESSION_BORN_MEMORY["kind"] - assert born[0]["topic"] == SESSION_BORN_MEMORY["topic"] - - async def test_project_accessed_memory_included(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("test-session", ""))): - await handle_share_export(console, session, workspace, mock_llm, episodic) - - payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) - accessed = payload["memory"]["project_accessed"] - assert len(accessed) == 1 - assert accessed[0]["content"] == PROJECT_MEMORY["content"] - assert accessed[0]["kind"] == PROJECT_MEMORY["kind"] - - async def test_scratchpad_cells_included(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("test-session", ""))): - await handle_share_export(console, session, workspace, mock_llm, episodic) - - payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) - cells = payload["scratchpad"]["cells"] - assert len(cells) == 1 - assert cells[0]["pad"] == "main" - assert cells[0]["code"] == SCRATCHPAD_CELL.code - assert cells[0]["stdout"] == SCRATCHPAD_CELL.stdout - assert cells[0]["description"] == SCRATCHPAD_CELL.description - - async def test_summary_flag_empties_history(self, tmp_path, console, workspace): - session, episodic, _ = _build_exporter_session(tmp_path, workspace) - mock_llm = make_mock_llm() - - with patch("anton.commands.share._generate_meta", - AsyncMock(return_value=("test-session", ""))): - await handle_share_export(console, session, workspace, mock_llm, episodic, - summary_only=True) - - payload = json.loads(next((tmp_path / ".anton" / "output").glob("*.anton")).read_text()) - # --summary: history omitted, memories still present - assert payload["session"]["conversation_history"] == [] - assert len(payload["memory"]["session_born"]) == 1 - - -# ── import tests ────────────────────────────────────────────────────────────── - - -def _make_anton_payload( - history: list[dict] | None = None, - session_born: list[dict] | None = None, - project_accessed: list[dict] | None = None, - cells: list[dict] | None = None, - version: str = "0.1", -) -> dict: - return { - "version": version, - "exported_by": "alice", - "exported_at": "2026-04-20T10:00:00+00:00", - "session": { - "id": "20260420_100000", - "title": "Revenue Region Analysis", - "summary": "Analyzed revenue by region. APAC leads YoY.", - "conversation_history": history if history is not None else list(HISTORY), - }, - "memory": { - "session_born": session_born if session_born is not None else [SESSION_BORN_MEMORY], - "project_accessed": project_accessed if project_accessed is not None else [PROJECT_MEMORY], - }, - "scratchpad": { - "cells": cells if cells is not None else [ - {"pad": "main", "code": "df.head()", "stdout": "...", "stderr": "", "error": None, "description": "preview"} - ], - }, - } - - -def _write_anton_file(tmp_path: Path, payload: dict) -> Path: - path = tmp_path / "import_test.anton" - path.write_text(json.dumps(payload, ensure_ascii=False)) - return path - async def _do_import( tmp_path: Path, @@ -296,124 +157,6 @@ async def _mock_get_or_create(mgr_self, name): return result, new_episodic -class TestShareImport: - async def test_history_restored_in_llm_context(self, tmp_path, console, workspace): - path = _write_anton_file(tmp_path, _make_anton_payload()) - result, _ = await _do_import(tmp_path, console, workspace, path) - - assert result._history == HISTORY - assert result._turn_count == 2 # two user messages - - async def test_session_born_logged_as_memory_write(self, tmp_path, console, workspace): - path = _write_anton_file(tmp_path, _make_anton_payload()) - result, new_episodic = await _do_import(tmp_path, console, workspace, path) - - mem_eps = new_episodic.get_memory_usage(result._session_id) - writes = [e for e in mem_eps if e.role == "memory_write"] - - assert len(writes) == 1 - assert writes[0].content == SESSION_BORN_MEMORY["content"] - assert writes[0].meta["kind"] == SESSION_BORN_MEMORY["kind"] - assert writes[0].meta["topic"] == SESSION_BORN_MEMORY["topic"] - - async def test_project_accessed_logged_as_memory_read(self, tmp_path, console, workspace): - path = _write_anton_file(tmp_path, _make_anton_payload()) - result, new_episodic = await _do_import(tmp_path, console, workspace, path) - - mem_eps = new_episodic.get_memory_usage(result._session_id) - reads = [e for e in mem_eps if e.role == "memory_read"] - - assert len(reads) == 1 - assert reads[0].content == PROJECT_MEMORY["content"] - assert reads[0].meta["kind"] == PROJECT_MEMORY["kind"] - - async def test_conversation_replayed_to_episodic(self, tmp_path, console, workspace): - path = _write_anton_file(tmp_path, _make_anton_payload()) - result, new_episodic = await _do_import(tmp_path, console, workspace, path) - - # The episodic file should contain user and assistant turns from the replayed history - eps = new_episodic.get_episodes() # all non-memory episodes - roles = [e.role for e in eps] - assert "user" in roles - assert "assistant" in roles - - async def test_new_session_id_differs_from_original(self, tmp_path, console, workspace): - payload = _make_anton_payload() - original_sid = payload["session"]["id"] # "20260420_100000" - path = _write_anton_file(tmp_path, payload) - - result, _ = await _do_import(tmp_path, console, workspace, path) - - assert result._session_id != original_sid - - async def test_provenance_suffix_set(self, tmp_path, console, workspace): - path = _write_anton_file(tmp_path, _make_anton_payload()) - result, _ = await _do_import(tmp_path, console, workspace, path) - - suffix = result._system_prompt_context.suffix - assert "alice" in suffix - assert "2026-04-20" in suffix - assert "Revenue Region Analysis" in suffix - - async def test_scratchpad_cells_restored_in_runtime(self, tmp_path, console, workspace): - """Cells from the .anton file are restored into the new session's scratchpad.""" - path = _write_anton_file(tmp_path, _make_anton_payload()) - result, _ = await _do_import(tmp_path, console, workspace, path) - - assert "main" in result._scratchpads._pads - restored = result._scratchpads._pads["main"].cells - assert len(restored) == 1 - assert restored[0].code == "df.head()" - assert restored[0].stdout == "..." - - async def test_session_born_written_to_hippocampus(self, tmp_path, console, workspace): - """session_born memories with kind=lesson are written to cortex.project_hc.""" - path = _write_anton_file(tmp_path, _make_anton_payload()) - - mock_hc = MagicMock() - mock_cortex = MagicMock() - mock_cortex.project_hc = mock_hc - - result, _ = await _do_import(tmp_path, console, workspace, path, cortex=mock_cortex) - - # SESSION_BORN_MEMORY has kind="lesson" and topic="sql" - mock_hc.encode_lesson.assert_called_once_with( - SESSION_BORN_MEMORY["content"], - topic=SESSION_BORN_MEMORY["topic"], - source="import", - ) - mock_hc.encode_rule.assert_not_called() - - async def test_import_file_not_found(self, tmp_path, console, workspace): - mock_llm = make_mock_llm() - current_session = ChatSession(ChatSessionConfig(llm_client=mock_llm, workspace=workspace)) - - result = await handle_share_import( - console, current_session, workspace, - MagicMock(), {"llm_client": mock_llm}, None, None, None, None, - filepath=str(tmp_path / "nonexistent.anton"), - ) - - # Returns unchanged session - assert result is current_session - - async def test_import_wrong_version(self, tmp_path, console, workspace): - path = _write_anton_file(tmp_path, _make_anton_payload(version="9.9")) - mock_llm = make_mock_llm() - current_session = ChatSession(ChatSessionConfig(llm_client=mock_llm, workspace=workspace)) - - result = await handle_share_import( - console, current_session, workspace, - MagicMock(), {"llm_client": mock_llm}, None, None, None, None, - filepath=str(path), - ) - - assert result is current_session # unchanged - - -# ── roundtrip ───────────────────────────────────────────────────────────────── - - class TestShareRoundtrip: async def test_full_roundtrip(self, tmp_path, console, workspace): """Export a live session, import it, compare state point by point.""" From 3608ceab67d9ba7c6beffe0ec6013331bb01d094 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 17:52:42 +0300 Subject: [PATCH 12/36] remove inject provenance suffix --- anton/commands/share.py | 9 --------- tests/test_share.py | 6 ------ 2 files changed, 15 deletions(-) diff --git a/anton/commands/share.py b/anton/commands/share.py index c6a67650..12ad0f9f 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -354,15 +354,6 @@ async def import_v0_1( description=cell_data.get("description", ""), )) - # 8. inject provenance suffix - suffix = _build_provenance_suffix(payload) - new_session._system_prompt_context = SystemPromptContext( - runtime_context=new_session._system_prompt_context.runtime_context, - prefix=new_session._system_prompt_context.prefix, - output_context=new_session._system_prompt_context.output_context, - suffix=suffix, - ) - # 9. print briefing sess = payload.get("session", {}) cells = payload.get("scratchpad", {}).get("cells", []) diff --git a/tests/test_share.py b/tests/test_share.py index 44e6d928..7be38967 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -217,9 +217,3 @@ async def test_full_roundtrip(self, tmp_path, console, workspace): source="import", ) mock_hc.encode_rule.assert_not_called() - - # suffix: provenance present, original session had none - exported_by = payload["exported_by"] - assert exported_by not in (session._system_prompt_context.suffix or "") - assert exported_by in result._system_prompt_context.suffix - assert payload["session"]["title"] in result._system_prompt_context.suffix From 73496692b08123b7aef9fa0d568bb41eb37f9347 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 18:06:37 +0300 Subject: [PATCH 13/36] write imported file to output --- anton/commands/share.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/anton/commands/share.py b/anton/commands/share.py index 12ad0f9f..3f30ae23 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -231,19 +231,6 @@ def _replay_to_episodic(episodic: "EpisodicMemory", history: list[dict]) -> None episodic.log_turn(turn, role, content) -def _build_provenance_suffix(payload: dict) -> str: - sess = payload.get("session", {}) - title = sess.get("title", "") - exported_by = payload.get("exported_by", "unknown") - exported_at = payload.get("exported_at", "")[:10] - return ( - "## Note\n" - f'This session was imported from a .anton file: "{title}", ' - f"exported by {exported_by} on {exported_at}. " - "The full conversation history has been restored." - ) - - async def import_v0_1( console: Console, session: "ChatSession", @@ -255,9 +242,10 @@ async def import_v0_1( episodic: "EpisodicMemory | None", history_store: "HistoryStore | None", payload: dict, + *, + source_path: Path, ) -> "ChatSession": from anton.chat_session import rebuild_session - from anton.core.llm.prompt_builder import SystemPromptContext from anton.utils.prompt import prompt_or_cancel # 2. warn if active session @@ -283,6 +271,17 @@ async def import_v0_1( new_session_id = episodic._session_id if (episodic and episodic.enabled) else None + # 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") + # close old scratchpads if session._scratchpads.list_pads(): await session._scratchpads.close_all() @@ -424,5 +423,6 @@ async def handle_share_import( return await importers[version]( console, session, workspace, settings, state, self_awareness, cortex, - episodic, history_store, payload + episodic, history_store, payload, + source_path=path, ) From 9bd8a9a9f2bc951e73a9e5244253304d10dcdbff Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 18:15:24 +0300 Subject: [PATCH 14/36] share status --- anton/chat.py | 6 ++-- anton/commands/share.py | 69 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 93a13113..7839eb8b 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -58,7 +58,7 @@ handle_skill_show, handle_skills_list, ) -from anton.commands.share import handle_share_export, handle_share_import +from anton.commands.share import handle_share_export, handle_share_import, handle_share_status from anton.tools import CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL from anton.utils.prompt import ( prompt_or_cancel, @@ -1380,9 +1380,11 @@ def _bottom_toolbar(): filepath=rest, ) current_session_id = session._session_id + elif sub == "status": + handle_share_status(console, session, workspace) else: console.print( - "[anton.warning]Usage: /share export [--summary] | /share import [/]" + "[anton.warning]Usage: /share export [--summary] | /share import | /share status | /share history[/]" ) console.print() continue diff --git a/anton/commands/share.py b/anton/commands/share.py index 3f30ae23..ce394ee8 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -204,6 +204,75 @@ async def handle_share_export( 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() + + vault = session._data_vault + connections = vault.list_connections() if vault else [] + if connections: + console.print(f" [bold]Data sources[/] ({len(connections)} connected)") + for c in connections: + console.print(f" {c.get('name', '?')} · {c.get('engine', '?')}") + else: + console.print("[anton.muted] No data sources connected.[/]") + + console.print() + + # ── import ──────────────────────────────────────────────────────────────────── From b31348928aaf985ee1cd75704bef075a0f48bf66 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 18:22:52 +0300 Subject: [PATCH 15/36] share history --- anton/chat.py | 4 ++- anton/commands/share.py | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/anton/chat.py b/anton/chat.py index 7839eb8b..eaef5adc 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -58,7 +58,7 @@ handle_skill_show, handle_skills_list, ) -from anton.commands.share import handle_share_export, handle_share_import, handle_share_status +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, @@ -1382,6 +1382,8 @@ def _bottom_toolbar(): current_session_id = session._session_id elif sub == "status": handle_share_status(console, session, workspace) + elif sub == "history": + handle_share_history(console, workspace) else: console.print( "[anton.warning]Usage: /share export [--summary] | /share import | /share status | /share history[/]" diff --git a/anton/commands/share.py b/anton/commands/share.py index ce394ee8..e3a5c402 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -273,6 +273,60 @@ def handle_share_status( 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", "")[:10] + who = imp.get("user", "?") + label = f"imported by {who} · {date}" + else: + date = data.get("exported_at", "")[:10] + 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 ──────────────────────────────────────────────────────────────────── From 6af657d6d47f343296da8e1249f614be7ecb1f99 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 18:30:09 +0300 Subject: [PATCH 16/36] share history --- anton/commands/share.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anton/commands/share.py b/anton/commands/share.py index e3a5c402..fc2753eb 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -311,11 +311,11 @@ def handle_share_history( summary = sess.get("summary", "") if imp: - date = imp.get("date", "")[:10] + date = imp.get("date", "")[:16].replace("T", " ") who = imp.get("user", "?") label = f"imported by {who} · {date}" else: - date = data.get("exported_at", "")[:10] + date = data.get("exported_at", "")[:16].replace("T", " ") who = data.get("exported_by", "?") label = f"exported by {who} · {date}" From bd3a8a6bb15ce7d59d30df60d063ad42089ed955 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Apr 2026 19:19:03 +0300 Subject: [PATCH 17/36] autocomplete --- anton/chat.py | 9 ++++----- anton/commands/ui.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index eaef5adc..4b088039 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, @@ -1145,7 +1145,7 @@ 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, ) @@ -1385,9 +1385,8 @@ def _bottom_toolbar(): elif sub == "history": handle_share_history(console, workspace) else: - console.print( - "[anton.warning]Usage: /share export [--summary] | /share import | /share status | /share history[/]" - ) + usage = " | ".join(c.command for c in SHARE_COMMANDS) + console.print(f"[anton.warning]Usage: {usage}[/]") console.print() continue elif cmd == "/resume": diff --git a/anton/commands/ui.py b/anton/commands/ui.py index 2e7c04a7..5f332088 100644 --- a/anton/commands/ui.py +++ b/anton/commands/ui.py @@ -40,8 +40,7 @@ class Command: "Chat Tools", Command("/paste", "Attach an image from your clipboard"), Command("/resume", "Continue a previous session"), - Command("/share export", "Export current session as a portable .anton file"), - Command("/share export --summary", "Export distilled context only (no full history)"), + 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"), @@ -63,6 +62,14 @@ class Command: Command("/skill remove