From 8ca0d452de81f45edc41c4b86cb24bf7aabd8c35 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 29 Apr 2026 12:23:03 +0300 Subject: [PATCH 1/3] remove turns instead of episodes --- anton/core/memory/episodes.py | 24 ++++++++++++--- anton/memory/manage.py | 57 ++++++++++++++++++----------------- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/anton/core/memory/episodes.py b/anton/core/memory/episodes.py index 194ab3fe..337c74dd 100644 --- a/anton/core/memory/episodes.py +++ b/anton/core/memory/episodes.py @@ -222,11 +222,27 @@ def get_conversation(self) -> list[Episode]: if ep.role not in ["memory_write", "memory_read"]: yield ep - def del_episode(self, session_id: str) -> bool: - path = self._dir / f"{session_id}.jsonl" - if not path.is_file(): + def del_episode_entry(self, turn: int) -> bool: + """Remove all episode lines for *turn* from the current session file.""" + if self._file is None or not self._file.is_file(): + return False + kept = [] + removed = 0 + for line in self._file.read_text(encoding="utf-8").splitlines(keepends=True): + if not line.strip(): + kept.append(line) + continue + try: + ep = Episode(**json.loads(line)) + if ep.turn == turn: + removed += 1 + continue + except Exception: + pass + kept.append(line) + if removed == 0: return False - path.unlink() + self._file.write_text("".join(kept), encoding="utf-8") return True def recall_formatted( diff --git a/anton/memory/manage.py b/anton/memory/manage.py index 9a234043..4462cab3 100644 --- a/anton/memory/manage.py +++ b/anton/memory/manage.py @@ -1,12 +1,10 @@ from __future__ import annotations -import os -import subprocess +from collections import defaultdict from pathlib import Path -from prompt_toolkit import PromptSession - from rich.console import Console +from rich.table import Table from anton.utils.prompt import prompt_or_cancel from anton.config.settings import AntonSettings @@ -36,7 +34,7 @@ Command("/memory identity edit ", "edit identity entry #n"), None, Command("/memory episodes", "show episodic sessions"), - Command("/memory episodes delete ", "delete session #n"), + Command("/memory episodes delete ", "delete turn #n"), None, 'Maintenance', Command("/memory vacuum", "deduplicate and compact"), @@ -292,45 +290,48 @@ async def episodes(self, action: str = None, num: str = None) -> None: self.console.print("[anton.warning]Episodic memory not initialized.[/]") return - items = dict(enumerate(self.episodic.get_episodes(), start=1)) + turns = defaultdict(dict) + for ep in self.episodic.get_conversation(): + turns[ep.turn][ep.role] = ep.content - if action is not None: - nums = list(items.keys()) + if not turns: + self.console.print("(no items)") + return - if not nums: - return self.console.print("Nothing to act on — no episodes.") + if action is not None: + nums = list(turns.keys()) if num is None: return self.console.print(f"Choose item to {action}: {min(nums)}-{max(nums)}") if num.isdigit(): num = int(num) - if num not in items: + if num not in turns: return self.console.print(f"Item {num} not found, choose number between {min(nums)} and {max(nums)}") if action == 'delete': - session_id = items[num].session - self.episodic.del_episode(session_id) - self.console.print("[anton.cyan]Deleted[/]") + if self.episodic.del_episode_entry(num): + self.console.print("[anton.cyan]Deleted[/]") return await self.episodes() else: return self.console.print(f"Unknown action: {action}") self._print_title("Episodic Memory") - max_shown_items = 50 - if len(items) > max_shown_items: - # keep only last items - items = dict(sorted(items.items())[-max_shown_items:]) - self.console.print(f"Only the last {max_shown_items} are shown:") - if len(items) == 0: - print("(no items)") - for i, item in items.items(): - content = item.content.replace('\n', ' ') - if len(content) > 100: - content = content[:97] + "..." - self.console.print(f" [dim]{i:>3}.[/] {content}") - - self.console.print(" [bold]/memory episodes delete [/] — delete session #n") + + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1)) + table.add_column("turn", justify="right", no_wrap=True) + table.add_column("question") + table.add_column("answer") + + for turn, data in turns.items(): + q = data["user"].replace('\n', ' ') + a = data["assistant"].replace('\n', ' ') + q_short = (q[:60] + "...") if len(q) > 63 else q + a_short = (a[:60] + "...") if len(a) > 63 else a + table.add_row(str(turn), q_short, a_short) + + self.console.print(table) + self.console.print(" [bold]/memory episodes delete [/] — delete turn #n") async def vacuum(self): toggle = await prompt_or_cancel( From 6c5b8c4be1bf2c24aac17910cafd3948721c5ae3 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 29 Apr 2026 16:07:11 +0300 Subject: [PATCH 2/3] update HistoryStore --- anton/chat.py | 4 +- anton/commands/share.py | 66 ++------------------------- anton/memory/history_store.py | 86 +++++++++++++++++++++++++++++++++++ anton/memory/manage.py | 12 ++++- 4 files changed, 102 insertions(+), 66 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 4b088039..4d4563dd 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -1149,7 +1149,7 @@ def _bottom_toolbar(): complete_while_typing=True, ) - memory_manage = MemoryManage(console, settings, cortex, episodic=episodic) + memory_manage = MemoryManage(console, settings, cortex, episodic=episodic, history_store=history_store, session=session) try: while True: # Memory confirmation UX — show pending lessons before prompt @@ -1281,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 "" diff --git a/anton/commands/share.py b/anton/commands/share.py index 1fc06c5b..89ceffc6 100644 --- a/anton/commands/share.py +++ b/anton/commands/share.py @@ -1,13 +1,11 @@ """Slash-command handlers for /share.""" from __future__ import annotations -import ast import getpass import json import os import re import tempfile -import uuid from dataclasses import asdict from datetime import datetime, timezone from pathlib import Path @@ -344,66 +342,6 @@ def handle_share_history( # ── import ──────────────────────────────────────────────────────────────────── -def _episodic_to_api_history(episodes: list[dict]) -> list[dict]: - """Convert episodic episode list to Anthropic API message format for HistoryStore. - - Processes episodes sequentially: - user -> {"role":"user","content":text} - tool_call -> {"role":"assistant","content":[tool_use block]} (generates id) - scratchpad -> skipped (content captured in tool_result) - tool_result -> {"role":"user","content":[tool_result block]} (uses id from preceding tool_call) - assistant -> {"role":"assistant","content":text} - """ - history: list[dict] = [] - i = 0 - while i < len(episodes): - ep = episodes[i] - role = ep.get("role", "") - - if role == "user": - history.append({"role": "user", "content": ep["content"]}) - i += 1 - - elif role == "tool_call": - tool_id = f"toolu_{uuid.uuid4().hex[:24]}" - tool_name = ep.get("meta", {}).get("tool", "unknown") - content_str = ep.get("content", "{}") - try: - tool_input = json.loads(content_str) - except Exception: - try: - tool_input = ast.literal_eval(content_str) - except Exception: - tool_input = {"raw": content_str} - - history.append({ - "role": "assistant", - "content": [{"type": "tool_use", "id": tool_id, "name": tool_name, "input": tool_input}], - }) - i += 1 - - # Skip optional scratchpad episode - if i < len(episodes) and episodes[i].get("role") == "scratchpad": - i += 1 - - # Consume matching tool_result - if i < len(episodes) and episodes[i].get("role") == "tool_result": - history.append({ - "role": "user", - "content": [{"type": "tool_result", "tool_use_id": tool_id, "content": episodes[i]["content"]}], - }) - i += 1 - - elif role == "assistant": - history.append({"role": "assistant", "content": ep["content"]}) - i += 1 - - else: - i += 1 - - return history - - async def import_v0_1( console: Console, session: "ChatSession", @@ -451,7 +389,9 @@ async def import_v0_1( episodic.log(ep) # reconstruct API history and save to history_store - api_history = _episodic_to_api_history(raw_history) + 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) diff --git a/anton/memory/history_store.py b/anton/memory/history_store.py index 2f68e551..321067ba 100644 --- a/anton/memory/history_store.py +++ b/anton/memory/history_store.py @@ -6,12 +6,19 @@ from __future__ import annotations +import ast import json import os import tempfile +import uuid from datetime import datetime, timezone from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from anton.core.memory.episodes import EpisodicMemory + class HistoryStore: """Persist and retrieve full chat history for session resume.""" @@ -19,6 +26,12 @@ class HistoryStore: def __init__(self, episodes_dir: Path) -> None: self._dir = episodes_dir + @classmethod + def from_episodes(cls, episodes_dir: Path): + ... + history = cls(episodes_dir) + return history + def save(self, session_id: str, history: list[dict]) -> None: """Atomically write history to ``{session_id}_history.json``. @@ -56,6 +69,79 @@ def load(self, session_id: str) -> list[dict] | None: except Exception: return None + @staticmethod + def episodes_to_api_history(episodes: list[dict]) -> list[dict]: + """Convert episodic episode list to Anthropic API message format for HistoryStore. + + Processes episodes sequentially: + user -> {"role":"user","content":text} + tool_call -> {"role":"assistant","content":[tool_use block]} (generates id) + scratchpad -> skipped (content captured in tool_result) + tool_result -> {"role":"user","content":[tool_result block]} (uses id from preceding tool_call) + assistant -> {"role":"assistant","content":text} + """ + history: list[dict] = [] + i = 0 + while i < len(episodes): + ep = episodes[i] + role = ep.get("role", "") + + if role == "user": + history.append({"role": "user", "content": ep["content"]}) + i += 1 + + elif role == "tool_call": + tool_id = f"toolu_{uuid.uuid4().hex[:24]}" + tool_name = ep.get("meta", {}).get("tool", "unknown") + content_str = ep.get("content", "{}") + try: + tool_input = json.loads(content_str) + except Exception: + try: + tool_input = ast.literal_eval(content_str) + except Exception: + tool_input = {"raw": content_str} + + history.append({ + "role": "assistant", + "content": [{"type": "tool_use", "id": tool_id, "name": tool_name, "input": tool_input}], + }) + i += 1 + + # Skip optional scratchpad episode + if i < len(episodes) and episodes[i].get("role") == "scratchpad": + i += 1 + + # Consume matching tool_result + if i < len(episodes) and episodes[i].get("role") == "tool_result": + history.append({ + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": tool_id, "content": episodes[i]["content"]}], + }) + i += 1 + + elif role == "assistant": + history.append({"role": "assistant", "content": ep["content"]}) + i += 1 + + else: + i += 1 + + return history + + def rebuild_from_episodic(self, episodic: "EpisodicMemory") -> list[dict]: + """Rebuild and persist API history from current episodic session. + + Reads episodes via get_conversation(), converts to API format, + saves to HistoryStore, and returns the result. + """ + from dataclasses import asdict + episodes = [asdict(ep) for ep in episodic.get_conversation()] + history = self.episodes_to_api_history(episodes) + if episodic._session_id: + self.save(episodic._session_id, history) + return history + def list_sessions(self, limit: int = 20) -> list[dict]: """List recent sessions with history, newest-first. diff --git a/anton/memory/manage.py b/anton/memory/manage.py index 4462cab3..678d3ccc 100644 --- a/anton/memory/manage.py +++ b/anton/memory/manage.py @@ -103,11 +103,15 @@ def __init__( settings: AntonSettings, cortex: "Cortex | None", episodic: "EpisodicMemory | None" = None, + history_store: "HistoryStore | None" = None, + session: "ChatSession | None" = None, ) -> None: self.console = console self.settings = settings self.cortex = cortex self.episodic = episodic + self.history_store = history_store + self.session = session self.SUBCOMMANDS: dict[str, object] = { "help": self.help, @@ -123,10 +127,12 @@ def __init__( # Entry point # ------------------------------------------------------------------ - async def handle(self, cmd: str) -> None: + async def handle(self, cmd: str, session) -> None: """Dispatch /memory [sub-command] or show the status dashboard.""" sub_cmd = cmd.removeprefix("/memory").strip() parts = sub_cmd.split() + # session might be changed + self.session = session if len(parts) == 0: return self.info() @@ -311,6 +317,10 @@ async def episodes(self, action: str = None, num: str = None) -> None: if action == 'delete': if self.episodic.del_episode_entry(num): + if self.history_store: + new_history = self.history_store.rebuild_from_episodic(self.episodic) + if self.session is not None: + self.session._history[:] = new_history self.console.print("[anton.cyan]Deleted[/]") return await self.episodes() else: From ebf3696794e594586ef896b036f81d2c86b2fd0c Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 29 Apr 2026 16:25:17 +0300 Subject: [PATCH 3/3] fixes --- anton/chat.py | 2 +- anton/core/memory/episodes.py | 20 ++++---------------- anton/memory/history_store.py | 10 ++-------- anton/memory/manage.py | 7 +++---- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 4d4563dd..7200094a 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -1149,7 +1149,7 @@ def _bottom_toolbar(): complete_while_typing=True, ) - memory_manage = MemoryManage(console, settings, cortex, episodic=episodic, history_store=history_store, session=session) + memory_manage = MemoryManage(console, settings, cortex, episodic=episodic, history_store=history_store) try: while True: # Memory confirmation UX — show pending lessons before prompt diff --git a/anton/core/memory/episodes.py b/anton/core/memory/episodes.py index 337c74dd..5bb1089d 100644 --- a/anton/core/memory/episodes.py +++ b/anton/core/memory/episodes.py @@ -47,6 +47,10 @@ def enabled(self) -> bool: def enabled(self, value: bool) -> None: self._enabled = value + @property + def session_id(self) -> str | None: + return self._session_id + def start_session(self) -> str: """Create a new JSONL file for this session and return the session ID.""" now = datetime.now(timezone.utc) @@ -200,22 +204,6 @@ def recall( return matches - def get_episodes(self) -> list[Episode]: - """Return all episodes across all sessions, newest-first.""" - if not self._dir.is_dir(): - return [] - result = [] - for path in sorted(self._dir.glob("*.jsonl"), reverse=True): - try: - for line in path.read_text(encoding="utf-8").splitlines(): - if line.strip(): - ep = Episode(**json.loads(line)) - if ep.role not in ("memory_read", "memory_write"): - result.append(ep) - except Exception: - continue - return result - def get_conversation(self) -> list[Episode]: """Yield conversation episodes for the current session (excludes memory entries).""" for ep in self.get_items(): diff --git a/anton/memory/history_store.py b/anton/memory/history_store.py index 321067ba..13c58efc 100644 --- a/anton/memory/history_store.py +++ b/anton/memory/history_store.py @@ -26,12 +26,6 @@ class HistoryStore: def __init__(self, episodes_dir: Path) -> None: self._dir = episodes_dir - @classmethod - def from_episodes(cls, episodes_dir: Path): - ... - history = cls(episodes_dir) - return history - def save(self, session_id: str, history: list[dict]) -> None: """Atomically write history to ``{session_id}_history.json``. @@ -138,8 +132,8 @@ def rebuild_from_episodic(self, episodic: "EpisodicMemory") -> list[dict]: from dataclasses import asdict episodes = [asdict(ep) for ep in episodic.get_conversation()] history = self.episodes_to_api_history(episodes) - if episodic._session_id: - self.save(episodic._session_id, history) + if episodic.session_id: + self.save(episodic.session_id, history) return history def list_sessions(self, limit: int = 20) -> list[dict]: diff --git a/anton/memory/manage.py b/anton/memory/manage.py index 678d3ccc..c854507a 100644 --- a/anton/memory/manage.py +++ b/anton/memory/manage.py @@ -104,14 +104,13 @@ def __init__( cortex: "Cortex | None", episodic: "EpisodicMemory | None" = None, history_store: "HistoryStore | None" = None, - session: "ChatSession | None" = None, ) -> None: self.console = console self.settings = settings self.cortex = cortex self.episodic = episodic self.history_store = history_store - self.session = session + self.session = None self.SUBCOMMANDS: dict[str, object] = { "help": self.help, @@ -334,8 +333,8 @@ async def episodes(self, action: str = None, num: str = None) -> None: table.add_column("answer") for turn, data in turns.items(): - q = data["user"].replace('\n', ' ') - a = data["assistant"].replace('\n', ' ') + q = data.get("user", "").replace('\n', ' ') + a = data.get("assistant", "").replace('\n', ' ') q_short = (q[:60] + "...") if len(q) > 63 else q a_short = (a[:60] + "...") if len(a) > 63 else a table.add_row(str(turn), q_short, a_short)