From a27df8738ea05adb4c8c0dc229a1c5f78d5a42c2 Mon Sep 17 00:00:00 2001 From: Bruno Campidelli Date: Thu, 17 Sep 2026 01:57:42 -0400 Subject: [PATCH] feat(memory): a coding turn from two weeks ago can still be found, after the conversation forgot it Every finished Code-screen turn joins an append-only SQLite/FTS5 history index (`chimera/memory/history.py`, `/history.db`): the message, the answer, the files the turn read or edited (off the same fold the replay endpoint shows), the tools, when, and whether it ran tainted. The session file trims at a user boundary and this never does. Written by the turn's finishing code through `redact`, never by the model; the LIKE degradation the memory store documents; the same tokenizer and function-word list; scoped by the same project key. `recall_history` (read-only, in the run-together set) prints dated excerpts with the files each turn edited or read, scoped to the current project unless asked for every project, with a window in days; a tainted turn is labelled in-line the way a tainted memory is on recall. Deleting a conversation or a project deletes its rows. An index that will not write logs and the turn goes on. Ten dictionaries for the Capabilities screen; the usage guide says which store is which in ten languages, translations restamped. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + apps/desktop/src/lib/i18n.tsx | 20 + chimera/api/code_api.py | 43 +- chimera/core/agent.py | 2 + chimera/memory/history.py | 331 +++++++++++++++ chimera/tools/builtin.py | 11 + chimera/tools/history.py | 157 +++++++ docs/i18n/de/usage.md | 12 +- docs/i18n/es/usage.md | 11 +- docs/i18n/fr/usage.md | 11 +- docs/i18n/it/usage.md | 11 +- docs/i18n/ja/usage.md | 6 +- docs/i18n/pl/usage.md | 10 +- docs/i18n/pt/usage.md | 11 +- docs/i18n/ru/usage.md | 10 +- docs/i18n/zh/usage.md | 6 +- docs/usage.md | 9 + ...n_from_two_weeks_ago_can_still_be_found.py | 397 ++++++++++++++++++ tests/test_document_args_match_the_tools.py | 5 + 19 files changed, 1054 insertions(+), 11 deletions(-) create mode 100644 chimera/memory/history.py create mode 100644 chimera/tools/history.py create mode 100644 tests/test_a_turn_from_two_weeks_ago_can_still_be_found.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1596f1..789a8efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **A coding turn from two weeks ago can still be found, after the conversation itself forgot it: every finished turn joins an SQLite/FTS5 history index, and `recall_history` searches it.** Item 5 of the list audited on 2026-09-16. A code session keeps the model's message list and trims it at a user boundary — about thirty tool-using turns — so a question about the turn that fixed the login function a fortnight ago was a question the session file could no longer answer, and the memory store was the wrong place to look: a memory is a fact the agent chose to keep, not a record of what was asked. Now `chimera/memory/history.py` keeps one row per completed turn — the message, the answer, the files the turn read or edited (read off the same fold the replay endpoint shows), the tools, when, and whether the turn ran tainted — in `/history.db`, FTS5 with the `LIKE` degradation the memory store already documents, through the same tokenizer and function-word list, scoped by project with the same key. Written by the turn's finishing code and never by the model, through `redact` (a pasted credential is not stored in clear), and never trimmed. The `recall_history` tool (read-only; in the set a step may run together) prints dated excerpts with the files each turn edited or read, scoped to the current project unless asked for every project, with a window in days; a turn that ran on untrusted content is labelled in-line the way a tainted memory is on recall. Deleting a conversation — one, or a whole project — deletes its rows, because the screen says the conversation is gone. An index that will not write logs and the turn goes on. Ten dictionaries for the Capabilities screen; the usage guide says which store is which in ten languages. + - **The agent's browser draws on the Code screen: after every browser action, the viewport appears under the turn, with the page's address beside it.** Item 2 of the list audited on 2026-09-16 (the "cockpit"). The model reads a page as text — the tag tree `render_elements` prints — and until now the person read the same text, or watched a second Chromium window beside the app with `CHIMERA_BROWSER_HEADLESS` off; neither was watching the agent open the site and type the dates. Measured before wiring (2026-09-17, viewport 1280×720): a JPEG at quality 55 is 13 KB for example.com, 62 KB for a GitHub repo page, 96 KB for a Wikipedia article, 24–106 ms to capture — a PNG is 18/103/232 KB and up to 224 ms. So: one frame per browser action, never on a timer. `BrowserDriver.frame()` (the Playwright driver captures the viewport as JPEG 55), `BrowserTool.on_frame` (set by `assemble_registry` only when the request has a screen — a headless run never asks the driver for a picture, pinned by a test that counts), a `browser` frame on the turn's stream (`action`, `url`, `title`, `width`, `height`, base64 `jpeg`, `n`), and `BrowserView` under the exchange: the last frame — where the browser is, not where it was — with the URL as text, because a picture of a page is a claim about where the browser is and the address is the part a person can check. Frames are never written to the run log (replay is for words a dropped connection lost, not for redrawing a page that has moved on) and never replayed on reopen. A capture that fails is logged and skipped, never a word in the observation the model reads. Nothing in the panel is interactive: the person is watching, not driving. Ten dictionaries. Sabotage-verified on the wire: with the dispatcher's `browser` branch removed, the frame test fails. diff --git a/apps/desktop/src/lib/i18n.tsx b/apps/desktop/src/lib/i18n.tsx index 6b5d2cd2..12bb5fa7 100644 --- a/apps/desktop/src/lib/i18n.tsx +++ b/apps/desktop/src/lib/i18n.tsx @@ -96,6 +96,8 @@ const en: Dict = { "What a background job started by run_shell(background=true) is doing: running, finished (with its exit code), cancelled or lost — and the tail of its output. Without a job_id, lists every job.", "tools.desc.job_cancel": "Stop a background job started by run_shell(background=true): kills the command and everything it started. A job that already ended is reported as it is.", + "tools.desc.recall_history": + "Search what was asked and answered in this project's earlier coding conversations — turns the current conversation no longer holds. Returns dated excerpts with the files each turn edited or read. This is the conversation history, not the memory store.", "tools.desc.http_get": "Fetch a URL with an HTTP GET and return status + body text.", "tools.desc.execute_code": @@ -1442,6 +1444,8 @@ const pt: Dict = { "O que um job em segundo plano iniciado por run_shell(background=true) está fazendo: rodando, terminado (com o código de saída), cancelado ou perdido — e o fim da saída dele. Sem job_id, lista todos os jobs.", "tools.desc.job_cancel": "Para um job em segundo plano iniciado por run_shell(background=true): mata o comando e tudo que ele iniciou. Um job que já terminou é reportado como está.", + "tools.desc.recall_history": + "Busca o que foi perguntado e respondido nas conversas de código anteriores deste projeto — turnos que a conversa atual já não guarda. Devolve trechos datados com os arquivos que cada turno editou ou leu. É o histórico da conversa, não o armazenamento de memória.", "tools.desc.http_get": "Busca uma URL com um HTTP GET e devolve o status + o texto do corpo.", "tools.desc.execute_code": @@ -2837,6 +2841,8 @@ const es: Dict = { "Qué hace un trabajo en segundo plano iniciado por run_shell(background=true): en ejecución, terminado (con su código de salida), cancelado o perdido — y el final de su salida. Sin job_id, lista todos los trabajos.", "tools.desc.job_cancel": "Detiene un trabajo en segundo plano iniciado por run_shell(background=true): mata el comando y todo lo que inició. Un trabajo que ya terminó se informa tal como está.", + "tools.desc.recall_history": + "Busca lo que se preguntó y respondió en las conversaciones de código anteriores de este proyecto — turnos que la conversación actual ya no conserva. Devuelve extractos fechados con los archivos que cada turno editó o leyó. Es el historial de conversación, no el almacén de memoria.", "tools.desc.http_get": "Pide una URL con un HTTP GET y devuelve el estado + el texto del cuerpo.", "tools.desc.execute_code": @@ -4206,6 +4212,8 @@ const fr: Dict = { "Ce que fait une tâche de fond lancée par run_shell(background=true) : en cours, terminée (avec son code de sortie), annulée ou perdue — et la fin de sa sortie. Sans job_id, liste toutes les tâches.", "tools.desc.job_cancel": "Arrête une tâche de fond lancée par run_shell(background=true) : tue la commande et tout ce qu'elle a lancé. Une tâche déjà terminée est rapportée telle quelle.", + "tools.desc.recall_history": + "Recherche ce qui a été demandé et répondu dans les conversations de code précédentes de ce projet — des tours que la conversation actuelle ne garde plus. Renvoie des extraits datés avec les fichiers que chaque tour a modifiés ou lus. C'est l'historique de conversation, pas la mémoire.", "tools.desc.http_get": "Récupère une URL par un HTTP GET et renvoie le statut + le texte du corps.", "tools.desc.execute_code": @@ -5585,6 +5593,8 @@ const de: Dict = { "Was ein mit run_shell(background=true) gestarteter Hintergrundjob tut: läuft, beendet (mit Exit-Code), abgebrochen oder verloren — und das Ende seiner Ausgabe. Ohne job_id werden alle Jobs aufgelistet.", "tools.desc.job_cancel": "Stoppt einen mit run_shell(background=true) gestarteten Hintergrundjob: beendet den Befehl und alles, was er gestartet hat. Ein bereits beendeter Job wird so gemeldet, wie er ist.", + "tools.desc.recall_history": + "Durchsucht, was in früheren Code-Gesprächen dieses Projekts gefragt und beantwortet wurde — Züge, die das aktuelle Gespräch nicht mehr enthält. Liefert datierte Auszüge mit den Dateien, die jeder Zug bearbeitet oder gelesen hat. Das ist der Gesprächsverlauf, nicht der Gedächtnisspeicher.", "tools.desc.http_get": "Ruft eine URL per HTTP GET ab und gibt Status + Body-Text zurück.", "tools.desc.execute_code": @@ -6955,6 +6965,8 @@ const zh: Dict = { "由 run_shell(background=true) 启动的后台任务在做什么:运行中、已完成(含退出码)、已取消或已丢失——以及其输出的末尾。不带 job_id 时列出所有任务。", "tools.desc.job_cancel": "停止由 run_shell(background=true) 启动的后台任务:终止该命令及其启动的一切。已结束的任务按原样报告。", + "tools.desc.recall_history": + "搜索本项目早先编码对话中问过和答过的内容——当前对话已不再保留的轮次。返回带日期的摘录,以及每轮编辑或读取的文件。这是对话历史,不是记忆库。", "tools.desc.http_get": "用 HTTP GET 抓取一个 URL,返回状态码和正文文本。", "tools.desc.execute_code": "运行一段 Python 3 代码并返回它的 stdout/stderr。它在所配置的沙箱里运行,而默认的沙箱就是这台机器本身 —— 不是隔离环境。", @@ -8263,6 +8275,8 @@ const ja: Dict = { "run_shell(background=true) で開始したバックグラウンドジョブの状態:実行中、終了(終了コード付き)、キャンセル済み、または喪失 — と出力の末尾。job_id を省くと全ジョブを一覧します。", "tools.desc.job_cancel": "run_shell(background=true) で開始したバックグラウンドジョブを停止します:コマンドとそれが起動したすべてを終了します。すでに終わったジョブはそのまま報告します。", + "tools.desc.recall_history": + "このプロジェクトの以前のコーディング会話で尋ねられ答えられた内容を検索します — 現在の会話がもう保持していないターンです。各ターンが編集または読み取ったファイルとともに、日付付きの抜粋を返します。これは会話履歴であり、記憶ストアではありません。", "tools.desc.http_get": "URL を HTTP GET で取得し、ステータスと本文テキストを返します。", "tools.desc.execute_code": @@ -9581,6 +9595,8 @@ const it: Dict = { "Cosa sta facendo un job in background avviato da run_shell(background=true): in esecuzione, terminato (con il codice di uscita), annullato o perso — e la coda del suo output. Senza job_id, elenca tutti i job.", "tools.desc.job_cancel": "Ferma un job in background avviato da run_shell(background=true): uccide il comando e tutto ciò che ha avviato. Un job già terminato viene riportato così com'è.", + "tools.desc.recall_history": + "Cerca cosa è stato chiesto e risposto nelle conversazioni di codice precedenti di questo progetto — turni che la conversazione attuale non conserva più. Restituisce estratti datati con i file che ogni turno ha modificato o letto. È la cronologia della conversazione, non l'archivio della memoria.", "tools.desc.http_get": "Richiede una URL con un HTTP GET e restituisce lo stato + il testo del corpo.", "tools.desc.execute_code": @@ -10952,6 +10968,8 @@ const pl: Dict = { "Co robi zadanie w tle uruchomione przez run_shell(background=true): działa, zakończone (z kodem wyjścia), anulowane lub utracone — oraz końcówka jego wyjścia. Bez job_id wypisuje wszystkie zadania.", "tools.desc.job_cancel": "Zatrzymuje zadanie w tle uruchomione przez run_shell(background=true): zabija polecenie i wszystko, co uruchomiło. Zadanie już zakończone jest raportowane takie, jakie jest.", + "tools.desc.recall_history": + "Przeszukuje, o co pytano i co odpowiedziano we wcześniejszych rozmowach o kodzie w tym projekcie — tury, których bieżąca rozmowa już nie przechowuje. Zwraca datowane fragmenty z plikami, które każda tura edytowała lub czytała. To historia rozmowy, nie magazyn pamięci.", "tools.desc.http_get": "Pobiera URL przez HTTP GET i zwraca status + tekst treści.", "tools.desc.execute_code": @@ -12316,6 +12334,8 @@ const ru: Dict = { "Что делает фоновая задача, запущенная через run_shell(background=true): выполняется, завершена (с кодом выхода), отменена или потеряна — и конец её вывода. Без job_id выводит все задачи.", "tools.desc.job_cancel": "Останавливает фоновую задачу, запущенную через run_shell(background=true): убивает команду и всё, что она запустила. Уже завершённая задача сообщается как есть.", + "tools.desc.recall_history": + "Ищет, о чём спрашивали и что отвечали в прежних беседах о коде этого проекта — ходы, которых текущая беседа уже не хранит. Возвращает датированные выдержки с файлами, которые каждый ход редактировал или читал. Это история беседы, а не хранилище памяти.", "tools.desc.http_get": "Запрашивает URL через HTTP GET и возвращает статус и текст тела ответа.", "tools.desc.execute_code": diff --git a/chimera/api/code_api.py b/chimera/api/code_api.py index 332b6005..16a94252 100644 --- a/chimera/api/code_api.py +++ b/chimera/api/code_api.py @@ -1019,6 +1019,7 @@ def register_code_api( from chimera.core.jobs import jobs_for from chimera.core.redact import redact from chimera.interface.session import recall_facts + from chimera.memory.history import files_of_exchange, history_for from chimera.memory.models import project_key # What the injected `memory` IS, so a later turn can tell "the owner changed the backend" from @@ -1033,6 +1034,11 @@ def register_code_api( live: Callable[[], Settings] = live_settings or (lambda: settings) store = CodeSessionStore(settings.home / "code_sessions") + # The index of finished turns (`chimera.memory.history`), one per home, shared with the + # `recall_history` tool every registry mounts. The session file is what a conversation is + # RESUMED from and trims itself accordingly; this is what a person's question about a turn + # from two weeks ago is answered from, and it never trims. + history = history_for(settings.home) # Beside the conversations, not inside them: a project you have added but not yet worked in # has no conversation to hang off, which is the whole reason the list cannot be derived. projects = CodeProjectRegistry(settings.home / "code_projects.json") @@ -1423,6 +1429,30 @@ def _verify_and_finish(payload: dict[str, Any]) -> None: store.save(session) except OSError as exc: # noqa: BLE001 — a failed record must not fail the turn _log.debug("could not store the turn receipt: %s", exc) + # The turn joins the conversation history index — the record that outlives the + # session's own trimming, so "what did we do about the login page two weeks + # ago?" has somewhere to look. Written by this code and not by the model, after + # the transcript is saved, and read off the same fold the replay endpoint shows + # (so the files the index says a turn touched are the files the screen shows + # it touching). A record that will not write must not fail a turn that was + # already paid for; the index logs and the turn goes on. + try: + from chimera.api.code_replay import exchanges_from_messages + + exchanges = exchanges_from_messages(session.to_dict()["messages"]) + history.record( + turn_id=turn_id, + session_id=session_id, + project=project_key(ws), + asked=message, + answered=str(payload.get("answer") or ""), + files=files_of_exchange(exchanges[-1]) if exchanges else [], + edited=list(edited), + tools=[str(t) for t in (payload.get("tool_names") or [])], + tainted=bool(payload.get("tainted")), + ) + except Exception as exc: # noqa: BLE001 — a failed record must not fail the turn + _log.debug("could not index the turn in the history: %s", exc) emit("done", payload) # Same swap the chat turn uses, under the same per-session lock: hand the agent the @@ -1932,9 +1962,13 @@ def delete_code_session(session_id: str) -> dict[str, bool]: """Forget a conversation. An unknown id is ``{ok: false}`` with a 200, not a 404 — that is exactly the state a second click on Clear hits, and it is not an error.""" try: - return {"ok": store.delete(session_id)} + gone = store.delete(session_id) except ValueError: return {"ok": False} + # Its rows in the history index go with it: the screen says the conversation is gone, and + # an index that still answered questions about it would make that a lie. + history.forget_session(session_id) + return {"ok": gone} @app.delete("/api/code/projects", dependencies=[guard], response_model=DeletedCountOut) def delete_code_project(workspace: str) -> dict[str, int]: @@ -1945,7 +1979,12 @@ def delete_code_project(workspace: str) -> dict[str, int]: mean the transcripts — and the count comes back so the screen can say how many went rather than reporting a success with no size. """ - return {"deleted": store.delete_project(workspace)} + # The ids first, then the files, then the index: the index is keyed by session id, and + # the list is the only place the workspace-to-id mapping exists. + ids = [str(m["id"]) for m in store.list_meta() if m["workspace"] == workspace] + deleted = store.delete_project(workspace) + history.forget_sessions(ids) + return {"deleted": deleted} # The registered projects live at `/workspaces`, NOT at `/projects`, and the distance is # deliberate. `DELETE /api/code/projects` above already means "delete every conversation filed diff --git a/chimera/core/agent.py b/chimera/core/agent.py index bb14cb40..67c32b44 100644 --- a/chimera/core/agent.py +++ b/chimera/core/agent.py @@ -43,6 +43,8 @@ "scrape", "extract", "map", # Reads a job's record and log; `job_cancel` kills and is not here. "job_status", + # Reads the index of finished turns; writes nothing. + "recall_history", } ) #: How many of a step's calls run at once. Four is a fetch batch, not a fan-out: a model asks for a diff --git a/chimera/memory/history.py b/chimera/memory/history.py new file mode 100644 index 00000000..b23094d7 --- /dev/null +++ b/chimera/memory/history.py @@ -0,0 +1,331 @@ +"""What was asked and answered in every coding conversation — kept after the conversation forgets. + +A code session (:class:`chimera.core.code_session.CodeSessionStore`) keeps the model's own message +list, trimmed to ``DEFAULT_MAX_MESSAGES`` at a ``user`` boundary — about thirty tool-using turns. +That is the right bound for a transcript that is re-sent to a provider on every turn, and the wrong +bound for a question like *"what did we decide about the login function two weeks ago?"*: the turn +that decided it is the first one trimming drops, and a search over the session files finds nothing, +not because it was never said but because the file no longer holds it. + +So this is an append-only index of **completed turns**: the person's message, the answer, the files +the turn read or edited, when it happened — one row per turn, in one SQLite file under the home, +with an FTS5 index (and the same ``LIKE`` degradation :mod:`chimera.memory.sqlite_store` documents +when FTS5 is not compiled in). Scoped by project the way memory is (:func:`project_key`), and +searched through the same tokenizer and the same function-word list, so *"o que é isso?"* recalls +nothing here for the reason it recalls nothing there. + +It is not the memory store. A memory is a fact the agent chose to keep; a history row is a record of +a turn that happened, whether or not anything in it was worth keeping — and it is written by the +turn's own finishing code, never by the model. ``recall_history`` says which of the two it searched. + +Three things are deliberate: + +* **Written through ``redact``**, as the session file is and for the same reason: a credential the + person pasted into the chat outlives the session here, and an index of one's own conversations + must not be the place a key is found in clear. +* **A turn that ran tainted stays marked.** Its answer may carry text an untrusted page put there, + and recalled two weeks later it would read as the agent's own conclusion. The row keeps the + flag, and the tool prints it in-line the way a tainted memory is labelled on recall. +* **Deleting a conversation deletes its rows.** ``Clear`` on the screen says the conversation is + gone; an index that still answered questions about it would make that a lie. +""" + +from __future__ import annotations + +import sqlite3 +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from chimera.core.redact import redact +from chimera.memory.models import EVERY_PROJECT +from chimera.telemetry import get_logger + +_log = get_logger("memory.history") + +#: The file under the home. One per home: every project's turns, scoped by the ``project`` column. +HISTORY_FILE = "history.db" + +#: How much of a message or an answer is kept. A turn's message can carry an attached document's +#: whole text, and an answer can be long; the index is for finding the turn, and fifty thousand +#: characters of either is more than any excerpt will show. +TEXT_CAP = 50_000 + +#: The columns, in the one order every SELECT and INSERT uses. +_COLUMNS = "turn_id, session_id, project, asked_at, tainted, asked, answered, files, edited, tools" + + +@dataclass(frozen=True) +class HistoryHit: + """One recorded turn, as the search returns it.""" + + turn_id: str + session_id: str + project: str + asked_at: float + tainted: bool + asked: str + answered: str + files: list[str] + edited: list[str] + tools: list[str] + + +def _split(raw: Any) -> list[str]: + return [p for p in str(raw or "").split("\n") if p] + + +def _join(items: list[str]) -> str: + # Newline-separated, because a path can contain a space and a newline is the one character a + # path the tools accept never does. Deduplicated in order: the same file read three times is + # one file the turn touched. + seen: list[str] = [] + for item in items: + text = str(item).replace("\n", " ").strip() + if text and text not in seen: + seen.append(text) + return "\n".join(seen) + + +class HistoryIndex: + """The append-only index of completed coding turns, in SQLite under ``home``.""" + + def __init__(self, home: Path) -> None: + self.path = Path(home) / HISTORY_FILE + self.path.parent.mkdir(parents=True, exist_ok=True) + # One connection shared by the turn that records and the tool that searches, which run on + # different threads: the request's worker thread writes, the agent's own thread reads. A + # lock around every statement is what makes that sharing safe; `check_same_thread` only + # forbids it. + self._conn = sqlite3.connect(str(self.path), check_same_thread=False) + self._lock = threading.Lock() + self._fts = self._init_schema() + + # -- schema ------------------------------------------------------------------------------ + + def _init_schema(self) -> bool: + with self._lock: + try: + self._conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS turns USING fts5(" + "turn_id UNINDEXED, session_id UNINDEXED, project UNINDEXED, " + "asked_at UNINDEXED, tainted UNINDEXED, asked, answered, files, " + "edited UNINDEXED, tools UNINDEXED)" + ) + self._conn.commit() + return True + except sqlite3.OperationalError: # FTS5 not compiled in — a plain table and LIKE + self._conn.execute( + "CREATE TABLE IF NOT EXISTS turns (" + "turn_id TEXT PRIMARY KEY, session_id TEXT, project TEXT, asked_at REAL, " + "tainted INTEGER, asked TEXT, answered TEXT, files TEXT, edited TEXT, " + "tools TEXT)" + ) + self._conn.commit() + return False + + @property + def full_text(self) -> bool: + """Whether FTS5 is doing the searching (``False`` means the ``LIKE`` fallback).""" + return self._fts + + # -- writing ----------------------------------------------------------------------------- + + def record( + self, + *, + turn_id: str, + session_id: str, + project: str | None, + asked: str, + answered: str, + files: list[str] | None = None, + edited: list[str] | None = None, + tools: list[str] | None = None, + tainted: bool = False, + asked_at: float | None = None, + ) -> None: + """Keep one completed turn. Recording the same ``turn_id`` again replaces the row. + + Never raises on the turn's behalf: a record ABOUT a turn that already happened and was + already paid for must not be able to fail the turn, so a database that will not take the + row is logged and the turn goes on. (The caller catches too; this is the second net.) + """ + row = ( + turn_id, + session_id, + project or "", + float(asked_at if asked_at is not None else time.time()), + 1 if tainted else 0, + redact(asked[:TEXT_CAP]), + redact(answered[:TEXT_CAP]), + _join(files or []), + _join(edited or []), + _join(tools or []), + ) + try: + with self._lock: + self._conn.execute("DELETE FROM turns WHERE turn_id = ?", (turn_id,)) + self._conn.execute( + f"INSERT INTO turns ({_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", row + ) + self._conn.commit() + except sqlite3.Error as exc: + _log.warning("history: turn %s not recorded: %s", turn_id, exc) + + def forget_session(self, session_id: str) -> int: + """Drop every turn of one conversation. Returns how many rows went.""" + with self._lock: + cursor = self._conn.execute("DELETE FROM turns WHERE session_id = ?", (session_id,)) + self._conn.commit() + return int(cursor.rowcount or 0) + + def forget_sessions(self, session_ids: list[str]) -> int: + return sum(self.forget_session(sid) for sid in session_ids) + + # -- reading ----------------------------------------------------------------------------- + + def search( + self, + query: str, + *, + project: str | None = EVERY_PROJECT, + k: int = 5, + since: float | None = None, + ) -> list[HistoryHit]: + """The turns whose message, answer or files match ``query``, best match first. + + ``project`` scopes the CANDIDATES in SQL, for the reason the memory store gives: a LIMIT + applied before the filter returns k rows of other projects while this project's sit below + the cut. :data:`EVERY_PROJECT` searches every project; a path searches that one; ``None`` + (a turn with no folder) searches the rows recorded with none. + + ``since`` is an epoch: only turns asked at or after it. ``k`` is clamped to 1..50. + """ + from chimera.memory.tokens import informative, tokens + + terms = sorted(informative(tokens(query))) + if not terms: + return [] + k = max(1, min(int(k), 50)) + + scope, scope_params = self._scope(project) + if since is not None: + scope += " AND asked_at >= ?" + scope_params.append(float(since)) + + rows: list[tuple[Any, ...]] = [] + if self._fts: + # Each term quoted: a phrase of one token, tokenized by FTS5 exactly as the content + # was, so `login_user` in a query reaches `login_user` in a file name. Column-filtered + # to the three text columns, so a tool name or a session id never matches a word. + match = "{asked answered files} : (" + " OR ".join( + '"' + term.replace('"', '""') + '"' for term in terms + ) + ")" + try: + with self._lock: + rows = self._conn.execute( + f"SELECT {_COLUMNS} FROM turns WHERE turns MATCH ?{scope} " + "ORDER BY rank, asked_at DESC LIMIT ?", + (match, *scope_params, k), + ).fetchall() + except sqlite3.OperationalError: + rows = [] # a query FTS5 will not parse — LIKE below still answers + # Nothing from FTS5 falls through to LIKE for the reason the memory store gives: its + # tokenizer keeps a run of Han as one token while `tokens` splits it per character, + # and a substring match is the one that can still find it. + if not rows: + clause = " OR ".join( + "(lower(asked) LIKE ? OR lower(answered) LIKE ? OR lower(files) LIKE ?)" + for _ in terms + ) + params: list[object] = [] + for term in terms: + params.extend([f"%{term}%"] * 3) + params.extend(scope_params) + params.append(k) + with self._lock: + rows = self._conn.execute( + f"SELECT {_COLUMNS} FROM turns WHERE ({clause}){scope} " + "ORDER BY asked_at DESC LIMIT ?", + params, + ).fetchall() + return [self._to_hit(row) for row in rows] + + def recent(self, *, project: str | None = EVERY_PROJECT, k: int = 5) -> list[HistoryHit]: + """The newest turns, no query — what the tool lists when asked for nothing in particular.""" + scope, scope_params = self._scope(project) + with self._lock: + rows = self._conn.execute( + f"SELECT {_COLUMNS} FROM turns WHERE 1=1{scope} ORDER BY asked_at DESC LIMIT ?", + (*scope_params, max(1, min(int(k), 50))), + ).fetchall() + return [self._to_hit(row) for row in rows] + + def count(self, *, project: str | None = EVERY_PROJECT) -> int: + scope, scope_params = self._scope(project) + with self._lock: + row = self._conn.execute( + f"SELECT COUNT(*) FROM turns WHERE 1=1{scope}", scope_params + ).fetchone() + return int(row[0]) if row else 0 + + def __len__(self) -> int: + return self.count() + + @staticmethod + def _scope(project: str | None) -> tuple[str, list[object]]: + if project == EVERY_PROJECT: + return "", [] + return " AND project = ?", [project or ""] + + @staticmethod + def _to_hit(row: tuple[Any, ...]) -> HistoryHit: + return HistoryHit( + turn_id=str(row[0]), + session_id=str(row[1]), + project=str(row[2] or ""), + asked_at=float(row[3] or 0.0), + tainted=bool(int(row[4] or 0)), + asked=str(row[5] or ""), + answered=str(row[6] or ""), + files=_split(row[7]), + edited=_split(row[8]), + tools=_split(row[9]), + ) + + +def files_of_exchange(exchange: dict[str, Any]) -> list[str]: + """The paths a folded exchange's tool calls named — a ``path`` argument, in call order. + + Read off the same fold the replay endpoint shows (:func:`chimera.api.code_replay. + exchanges_from_messages`), so the files the index says a turn touched are the files the screen + shows it touching. Only ``path``: the one argument name every file tool shares. + """ + out: list[str] = [] + for call in exchange.get("tools") or []: + if not isinstance(call, dict): + continue + arguments = call.get("arguments") + path = arguments.get("path") if isinstance(arguments, dict) else None + if isinstance(path, str) and path.strip(): + out.append(path.strip()) + return out + + +_INDEXES: dict[str, HistoryIndex] = {} +_INDEXES_LOCK = threading.Lock() + + +def history_for(home: Path) -> HistoryIndex: + """One index per home per process — the turn that records and the tool that searches share + the connection, the same way the job registry is shared.""" + key = str(Path(home).resolve()) + with _INDEXES_LOCK: + index = _INDEXES.get(key) + if index is None: + index = HistoryIndex(Path(home)) + _INDEXES[key] = index + return index diff --git a/chimera/tools/builtin.py b/chimera/tools/builtin.py index 103e17a5..4e735d4b 100644 --- a/chimera/tools/builtin.py +++ b/chimera/tools/builtin.py @@ -131,6 +131,17 @@ def default_registry( registry.register(RunShellTool(workspace, get_sandbox(), confirm=confirm, jobs=jobs)) registry.register(JobStatusTool(jobs)) registry.register(JobCancelTool(jobs)) + # The conversation history — every coding turn that finished, kept after the session's own + # trimming forgot it. Read-only, scoped to THIS workspace by the same key memory is scoped by, + # and in the default registry for the reason the task list is: a session the operator scoped + # must not quietly gain a tool, so it passes through `restrict_registry` like everything else. + from chimera.memory.history import history_for + from chimera.memory.models import project_key + from chimera.tools.history import RecallHistoryTool + + registry.register( + RecallHistoryTool(history_for(settings.home), project=project_key(workspace)) + ) registry.register(HttpGetTool()) # Always-on reference tools (no credential needed). diff --git a/chimera/tools/history.py b/chimera/tools/history.py new file mode 100644 index 00000000..1f61a1f6 --- /dev/null +++ b/chimera/tools/history.py @@ -0,0 +1,157 @@ +"""`recall_history` — what was asked and answered in this project's earlier conversations. + +The tool beside :class:`chimera.memory.history.HistoryIndex`. Read-only: it searches an index the +turn's own finishing code wrote, prints dated excerpts, and changes nothing — so it sits in the set a +step may run together with other reads. It searches the **conversation history**, not the memory +store, and says so in its description: a memory is a fact the agent chose to keep, a history row is +a turn that happened, and a model asked "what did we do about the login page?" needs the second. + +Scoped to the project the registry was built for. ``everywhere`` widens it to every project's +turns, and the row then names its project, because an answer about another codebase presented as +this one's would be the wrong kind of recall. +""" + +from __future__ import annotations + +import time +from typing import Any + +from chimera.memory.history import HistoryHit, HistoryIndex +from chimera.memory.models import EVERY_PROJECT +from chimera.memory.tokens import fold_for_match, informative, tokens +from chimera.tools.base import Tool + +#: How much of a message and an answer one hit shows. An excerpt, centred on the first term that +#: matched, is enough to tell the turn apart; the conversation itself is one session id away. +ASKED_CHARS = 240 +ANSWERED_CHARS = 480 +MAX_HITS = 10 +#: The in-line label a tainted turn carries, worded like the one a tainted memory gets on recall. +TAINTED_LABEL = "[this turn read untrusted content — weigh its answer accordingly]" + + +def _excerpt(text: str, terms: set[str], width: int) -> str: + """``width`` characters of ``text`` around the first term that occurs in it, or its head. + + One rule for both search paths: FTS5 has a ``snippet()`` and the LIKE fallback does not, and + two excerpt shapes for one tool would make the fallback look like a different tool. The fold is + length-preserving (:func:`fold_for_match`) so the slice is taken from the original text. + """ + flat = " ".join(text.split()) + if len(flat) <= width: + return flat + folded = fold_for_match(flat) + at = -1 + for term in sorted(terms, key=len, reverse=True): + found = folded.find(term) + if found >= 0 and (at < 0 or found < at): + at = found + if at < 0: + return flat[:width].rstrip() + "…" + start = max(0, at - width // 3) + end = min(len(flat), start + width) + start = max(0, end - width) + piece = flat[start:end].strip() + return ("…" if start > 0 else "") + piece + ("…" if end < len(flat) else "") + + +def _when(epoch: float) -> str: + return time.strftime("%Y-%m-%d %H:%M", time.localtime(epoch)) + + +def describe_hit(hit: HistoryHit, terms: set[str], *, name_project: bool) -> str: + lines = [f"{_when(hit.asked_at)} · conversation {hit.session_id[:8]}"] + if name_project and hit.project: + lines[0] += f" · project {hit.project}" + if hit.tainted: + lines.append(TAINTED_LABEL) + lines.append("asked: " + _excerpt(hit.asked, terms, ASKED_CHARS)) + if hit.answered.strip(): + lines.append("answered: " + _excerpt(hit.answered, terms, ANSWERED_CHARS)) + if hit.edited: + lines.append("edited: " + ", ".join(hit.edited[:12])) + read_only = [f for f in hit.files if f not in hit.edited] + if read_only: + lines.append("read: " + ", ".join(read_only[:12])) + return "\n".join(lines) + + +class RecallHistoryTool(Tool): + name = "recall_history" + description = ( + "Search what was asked and answered in this project's earlier coding conversations — " + "turns the current conversation no longer holds. Returns dated excerpts with the files " + "each turn edited or read. This is the conversation history, not the memory store." + ) + parameters = { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Words to look for in the questions, answers and file names.", + }, + "days": { + "type": "integer", + "description": "Only turns from the last N days. Omit for all time.", + }, + "k": { + "type": "integer", + "description": f"How many turns to return (1-{MAX_HITS}, default 5).", + }, + "everywhere": { + "type": "boolean", + "description": "Search every project's conversations, not only this one's.", + }, + }, + "required": ["query"], + } + + def __init__(self, index: HistoryIndex, *, project: str | None) -> None: + self._index = index + self._project = project + + def run(self, **kwargs: Any) -> str: + query = str(kwargs.get("query") or "").strip() + if not query: + return "error: query is required" + terms = informative(tokens(query)) + if not terms: + return ( + "error: the query has no searchable words (only function words) — name a file, a " + "function or a subject" + ) + try: + k = max(1, min(int(kwargs.get("k") or 5), MAX_HITS)) + except (TypeError, ValueError): + k = 5 + since: float | None = None + days = kwargs.get("days") + if days is not None: + try: + since = time.time() - max(0, int(days)) * 86_400 + except (TypeError, ValueError): + return "error: days must be a whole number" + everywhere = bool(kwargs.get("everywhere")) + scope = EVERY_PROJECT if everywhere else self._project + + hits = self._index.search(query, project=scope, k=k, since=since) + if not hits: + if self._index.count(project=scope) == 0: + where = "any project" if everywhere else "this project" + return ( + f"no conversation history recorded for {where} yet — turns are indexed as " + "they finish, so there is nothing before the first completed turn" + ) + window = f" in the last {int(days)} days" if days is not None else "" + where = "" if everywhere else " in this project" + return f"no earlier turn{where}{window} matches {query!r}" + head = ( + f"{len(hits)} earlier turn{'s' if len(hits) != 1 else ''} match {query!r}" + + (" across every project" if everywhere else " in this project") + + " (best match first; the conversation id opens the full exchange):" + ) + body = [ + f"{i}. " + describe_hit(hit, terms, name_project=everywhere).replace("\n", "\n ") + for i, hit in enumerate(hits, 1) + ] + return "\n\n".join([head, *body]) diff --git a/docs/i18n/de/usage.md b/docs/i18n/de/usage.md index cfee157c..07e029ab 100644 --- a/docs/i18n/de/usage.md +++ b/docs/i18n/de/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — Nutzungsleitfaden @@ -769,6 +769,16 @@ Die **Graph-Schicht** extrahiert `(Quelle, Relation, Ziel)`-Tripel aus den Erinnerungen (`PassaPro uses Supabase`, `Alex prefers TypeScript`), sodass Fakten nach Entität abgerufen werden können, nicht nur nach Schlüsselwort. +**Der Gesprächsverlauf ist ein eigener Speicher.** Jeder Code-Zug, der auf dem Code-Bildschirm +endet, wird indexiert — die Nachricht, die Antwort, die gelesenen oder bearbeiteten Dateien, der +Zeitpunkt — in `/history.db` (SQLite, FTS5 wenn Ihr Python es mitbringt), und bleibt dort, +nachdem das Transkript des Gesprächs seine ältesten Züge gekürzt hat. Der Agent durchsucht ihn mit +dem Werkzeug `recall_history` („was haben wir vor zwei Wochen zur Login-Funktion entschieden?"), +begrenzt auf das aktuelle Projekt, sofern nicht alle Projekte verlangt werden. Ein Zug, der auf +nicht vertrauenswürdigem Inhalt lief, wird beim Abruf gekennzeichnet, ein eingefügter +Zugangsschlüssel wird vor dem Schreiben geschwärzt, und das Löschen eines Gesprächs löscht seine +Zeilen. + ### `cron` — geplante Jobs & Event-SOPs ```bash diff --git a/docs/i18n/es/usage.md b/docs/i18n/es/usage.md index 58898205..a9eaf856 100644 --- a/docs/i18n/es/usage.md +++ b/docs/i18n/es/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — Guía de uso @@ -683,6 +683,15 @@ La **capa de grafo** extrae tripletas `(source, relation, target)` de tus memori (`PassaPro uses Supabase`, `Alex prefers TypeScript`), así los hechos se pueden recuperar por entidad, no solo por palabra clave. +**El historial de conversaciones es otro almacén.** Cada turno de código que termina en la +pantalla Code se indexa — el mensaje, la respuesta, los archivos que leyó o editó, cuándo — en +`/history.db` (SQLite, FTS5 cuando tu Python lo tiene), y sigue ahí después de que la propia +transcripción de la conversación recorta sus turnos más antiguos. El agente lo busca con la +herramienta `recall_history` ("¿qué decidimos sobre la función de login hace dos semanas?"), +acotado al proyecto actual salvo que pida todos los proyectos. Un turno que corrió sobre contenido +no confiable sale etiquetado al recuperarlo, una credencial pegada se redacta antes de escribirse, +y borrar una conversación borra sus filas. + ### `cron` — trabajos programados y SOPs de eventos ```bash diff --git a/docs/i18n/fr/usage.md b/docs/i18n/fr/usage.md index b3e7bf6d..b6e2e80c 100644 --- a/docs/i18n/fr/usage.md +++ b/docs/i18n/fr/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — Guide d'utilisation @@ -702,6 +702,15 @@ La **couche graphe** extrait des triplets `(source, relation, cible)` de vos mé (`PassaPro uses Supabase`, `Alex prefers TypeScript`), pour que les faits puissent être rappelés par entité, pas seulement par mot-clé. +**L'historique des conversations est un autre dépôt.** Chaque tour de code qui se termine sur +l'écran Code est indexé — le message, la réponse, les fichiers lus ou modifiés, la date — dans +`/history.db` (SQLite, FTS5 quand votre Python en dispose), et il y reste après que la +transcription de la conversation a coupé ses tours les plus anciens. L'agent y cherche avec l'outil +`recall_history` (« qu'avons-nous décidé sur la fonction de login il y a deux semaines ? »), limité +au projet courant sauf demande pour tous les projets. Un tour exécuté sur du contenu non fiable est +étiqueté au rappel, un identifiant collé est caviardé avant l'écriture, et supprimer une +conversation supprime ses lignes. + ### `cron` — tâches planifiées & SOP événementiels ```bash diff --git a/docs/i18n/it/usage.md b/docs/i18n/it/usage.md index 7aebd7f1..4c5138c8 100644 --- a/docs/i18n/it/usage.md +++ b/docs/i18n/it/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — Guida all'uso @@ -698,6 +698,15 @@ Il **livello a grafo** estrae triple `(fonte, relazione, target)` dalle tue memo (`PassaPro uses Supabase`, `Alex prefers TypeScript`), così i fatti possono essere richiamati per entità, non solo per parola chiave. +**La cronologia delle conversazioni è un archivio a parte.** Ogni turno di codice che termina +nella schermata Code viene indicizzato — il messaggio, la risposta, i file letti o modificati, +quando — in `/history.db` (SQLite, FTS5 quando il tuo Python lo ha), e resta lì dopo che la +trascrizione della conversazione ha tagliato i turni più vecchi. L'agente lo cerca con lo strumento +`recall_history` ("cosa abbiamo deciso sulla funzione di login due settimane fa?"), limitato al +progetto corrente salvo richiesta per tutti i progetti. Un turno eseguito su contenuto non +attendibile viene etichettato al richiamo, una credenziale incollata viene oscurata prima della +scrittura, ed eliminare una conversazione elimina le sue righe. + ### `cron` — job pianificati & SOP di evento ```bash diff --git a/docs/i18n/ja/usage.md b/docs/i18n/ja/usage.md index 338e0c3e..7c2a2635 100644 --- a/docs/i18n/ja/usage.md +++ b/docs/i18n/ja/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — 利用ガイド @@ -673,6 +673,10 @@ uv run chimera memory prune --max 50 # keep the N highest-value memories (`PassaPro uses Supabase`、`Alex prefers TypeScript`)。そのため事実は、キーワードだけ でなくエンティティによっても想起できます。 +**会話履歴は別のストアです。** Code 画面で完了した各コーディングターン — メッセージ、回答、読み書きしたファイル、日時 — は +`/history.db`(SQLite、Python が対応していれば FTS5)に索引され、会話自身の記録が古いターンを切り詰めた後も残ります。エージェントは +`recall_history` ツールで検索します(「2 週間前にログイン関数について何を決めた?」)。指定がなければ現在のプロジェクトに限定されます。信頼できない内容の上で実行されたターンは呼び出し時にラベル付けされ、貼り付けられた資格情報は書き込み前に伏せられ、会話を削除するとその行も削除されます。 + ### `cron` — スケジュールされたジョブとイベントSOP ```bash diff --git a/docs/i18n/pl/usage.md b/docs/i18n/pl/usage.md index 644e1366..e62b6b4a 100644 --- a/docs/i18n/pl/usage.md +++ b/docs/i18n/pl/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — Przewodnik użytkowania @@ -683,6 +683,14 @@ niezawodność) — nie pojedynczej wskazówki. (`PassaPro uses Supabase`, `Alex prefers TypeScript`), więc fakty można przywoływać wg encji, nie tylko wg słowa kluczowego. +**Historia rozmów to osobny magazyn.** Każda tura kodowania zakończona na ekranie Code jest +indeksowana — wiadomość, odpowiedź, pliki odczytane lub edytowane, kiedy — w `/history.db` +(SQLite, FTS5 gdy Twój Python je ma) i zostaje tam po tym, jak zapis rozmowy przytnie najstarsze +tury. Agent przeszukuje ją narzędziem `recall_history` („co ustaliliśmy o funkcji logowania dwa +tygodnie temu?"), w obrębie bieżącego projektu, chyba że poprosi o wszystkie projekty. Tura +wykonana na niezaufanej treści jest oznaczana przy przywołaniu, wklejone poświadczenie jest +zaczerniane przed zapisem, a usunięcie rozmowy usuwa jej wiersze. + ### `cron` — zaplanowane zadania i SOP zdarzeniowe ```bash diff --git a/docs/i18n/pt/usage.md b/docs/i18n/pt/usage.md index cf41ab8c..13497f52 100644 --- a/docs/i18n/pt/usage.md +++ b/docs/i18n/pt/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — Guia de Uso @@ -694,6 +694,15 @@ A **camada de grafo** extrai triplas `(fonte, relação, alvo)` das suas memóri (`PassaPro uses Supabase`, `Alex prefers TypeScript`), então fatos podem ser recuperados por entidade, não só por palavra-chave. +**O histórico de conversas é outro armazenamento.** Todo turno de código que termina na tela +Code é indexado — a mensagem, a resposta, os arquivos que leu ou editou, quando — em +`/history.db` (SQLite, FTS5 quando o seu Python o tem), e fica lá depois que a própria +transcrição da conversa corta os turnos mais antigos. O agente busca nele com a ferramenta +`recall_history` ("o que decidimos sobre a função de login duas semanas atrás?"), restrita ao +projeto atual a menos que peça todos os projetos. Um turno que rodou sobre conteúdo não confiável +vem rotulado na recuperação, uma credencial colada é redigida antes de ser gravada, e apagar uma +conversa apaga as linhas dela. + ### `cron` — jobs agendados & SOPs de evento ```bash diff --git a/docs/i18n/ru/usage.md b/docs/i18n/ru/usage.md index 021db1d6..e3091bcb 100644 --- a/docs/i18n/ru/usage.md +++ b/docs/i18n/ru/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera — руководство по использованию @@ -670,6 +670,14 @@ uv run chimera memory prune --max 50 # keep the N highest-value memories Supabase`, `Alex предпочитает TypeScript`), поэтому факты можно вспоминать по сущности, а не только по ключевому слову. +**История бесед — отдельное хранилище.** Каждый ход кодирования, завершённый на экране Code, +индексируется — сообщение, ответ, прочитанные или изменённые файлы, время — в `/history.db` +(SQLite, FTS5, если ваш Python его поддерживает) и остаётся там после того, как расшифровка беседы +обрежет самые старые ходы. Агент ищет в ней инструментом `recall_history` («что мы решили о функции +входа две недели назад?»), в пределах текущего проекта, если не запрошены все проекты. Ход, +выполненный на недоверенном содержимом, помечается при вызове, вставленные учётные данные +затираются перед записью, а удаление беседы удаляет её строки. + ### `cron` — задания по расписанию и по событиям ```bash diff --git a/docs/i18n/zh/usage.md b/docs/i18n/zh/usage.md index b7417397..7319d435 100644 --- a/docs/i18n/zh/usage.md +++ b/docs/i18n/zh/usage.md @@ -1,5 +1,5 @@ --- -source_sha256: cc6df54e6126e8ea6a8d68dd11af8e2e797b03ed2fdfc7fac65194624d7ece94 +source_sha256: 51204191938970d932cfa23c8a1b15ce45647f8fe7335793a525d5fa3a078bf0 --- # Chimera —— 使用指南 @@ -614,6 +614,10 @@ uv run chimera memory prune --max 50 # keep the N highest-value memories (例如 `PassaPro uses Supabase`、`Alex prefers TypeScript`),因此事实不仅能按关键词召回, 也能按实体召回。 +**对话历史是另一个存储。** 在 Code 界面上完成的每一轮编码都会被索引——消息、回答、读取或编辑的文件、时间——存入 +`/history.db`(SQLite,Python 支持时使用 FTS5),并在对话自身的记录裁掉最早的轮次之后仍然保留。代理通过 +`recall_history` 工具搜索它("两周前我们对登录函数做了什么决定?"),默认限定当前项目,除非要求搜索所有项目。在不可信内容上运行的轮次在召回时会被标注,粘贴的凭据在写入前会被脱敏,删除一段对话会删除它的行。 + ### `cron` —— 定时任务与事件 SOP ```bash diff --git a/docs/usage.md b/docs/usage.md index 3944dfed..3d88eb9c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -649,6 +649,15 @@ The **graph layer** extracts `(source, relation, target)` triples from your memo (`PassaPro uses Supabase`, `Alex prefers TypeScript`), so facts can be recalled by entity, not only by keyword. +**Conversation history is a different store.** Every coding turn that finishes on the Code +screen is indexed — the message, the answer, the files it read or edited, when — in +`/history.db` (SQLite, FTS5 when your Python has it), and it stays there after the +conversation's own transcript trims its oldest turns. The agent searches it with the +`recall_history` tool ("what did we decide about the login function two weeks ago?"), scoped to +the current project unless asked for every project. A turn that ran on untrusted content is +labelled on recall, a pasted credential is redacted before it is written, and deleting a +conversation deletes its rows. + ### `cron` — scheduled jobs & event SOPs ```bash diff --git a/tests/test_a_turn_from_two_weeks_ago_can_still_be_found.py b/tests/test_a_turn_from_two_weeks_ago_can_still_be_found.py new file mode 100644 index 00000000..53d3351c --- /dev/null +++ b/tests/test_a_turn_from_two_weeks_ago_can_still_be_found.py @@ -0,0 +1,397 @@ +"""A coding turn from two weeks ago can still be found, after the conversation itself forgot it. + +Item 5 of the list audited on 2026-09-16. A code session keeps the model's message list and trims it +at a user boundary (~30 tool-using turns), so a question about the turn that fixed the login page a +fortnight ago is a question the session file can no longer answer — and the memory store is the +wrong place to look, because a memory is a fact the agent chose to keep, not a record of what was +asked. `chimera.memory.history` is the append-only SQLite/FTS5 index every finished turn joins, and +`recall_history` is the tool that searches it. + +What is pinned: a turn is found by its message, its answer and the files it touched; the search is +scoped to the project unless asked otherwise; a window in days narrows it; the same turn recorded +twice is one row; a pasted credential is not stored in clear; a turn that ran tainted says so on +recall; the LIKE fallback answers the same questions; through the app the turn is indexed with the +files its tool calls named, survives the loss of the session FILE, and is forgotten when the +conversation is deleted — one conversation, or a whole project; an index that will not write does +not fail the turn; and the tool is in the read-only set a step may run together. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from chimera.config import Settings +from chimera.core.agent import PARALLEL_READ_TOOLS, AgentResult +from chimera.interface import ChatSession +from chimera.memory.history import HistoryIndex, files_of_exchange, history_for +from chimera.memory.models import EVERY_PROJECT +from chimera.tools.history import TAINTED_LABEL, RecallHistoryTool + +DAY = 86_400 + + +def _index(tmp_path: Path) -> HistoryIndex: + return HistoryIndex(tmp_path / "home") + + +def _seed(index: HistoryIndex, *, now: float | None = None) -> None: + now = time.time() if now is None else now + index.record( + turn_id="t-login", + session_id="sess-a", + project="/proj/one", + asked="fix the login function, it accepts any password", + answered="`login_user` in src/auth/login.py now checks the hash before the session is made.", + files=["src/auth/login.py", "src/auth/session.py"], + edited=["src/auth/login.py"], + tools=["read_file", "edit_file"], + asked_at=now - 14 * DAY, + ) + index.record( + turn_id="t-tests", + session_id="sess-a", + project="/proj/one", + asked="now the tests for it", + answered="added tests/test_login.py; 3 pass", + files=["tests/test_login.py"], + edited=["tests/test_login.py"], + asked_at=now - 13 * DAY, + ) + index.record( + turn_id="t-css", + session_id="sess-b", + project="/proj/two", + asked="the login page looks off on mobile", + answered="the flex container wrapped; fixed in login.css", + files=["web/login.css"], + asked_at=now - 1 * DAY, + ) + + +# ------------------------------------------------------------------ the index + + +def test_a_turn_is_found_by_its_message_its_answer_and_its_files(tmp_path: Path) -> None: + index = _index(tmp_path) + _seed(index) + assert index.full_text, "this Python has no FTS5 — the fallback test below covers that build" + + by_message = index.search("any password", project="/proj/one") + assert [h.turn_id for h in by_message] == ["t-login"] + by_answer = index.search("hash", project="/proj/one") + assert [h.turn_id for h in by_answer] == ["t-login"] + # Terms are OR-ed and ranked, as the memory store does: `test_login.py` is three tokens, one + # of which (`login`) the other turn shares, so both come back and the file's own turn is first. + by_file = index.search("test_login.py", project="/proj/one") + assert [h.turn_id for h in by_file] == ["t-tests", "t-login"] + # Folded like memory is: `função` reaches nothing here, `login` reaches both turns. + assert {h.turn_id for h in index.search("função de login", project="/proj/one")} == { + "t-login", + "t-tests", + } + hit = by_message[0] + assert hit.files == ["src/auth/login.py", "src/auth/session.py"] + assert hit.edited == ["src/auth/login.py"] + assert hit.tools == ["read_file", "edit_file"] + assert hit.session_id == "sess-a" + + +def test_the_search_is_scoped_to_the_project_unless_asked_otherwise(tmp_path: Path) -> None: + index = _index(tmp_path) + _seed(index) + assert {h.turn_id for h in index.search("login", project="/proj/one")} == {"t-login", "t-tests"} + assert {h.turn_id for h in index.search("login", project="/proj/two")} == {"t-css"} + assert {h.turn_id for h in index.search("login", project=EVERY_PROJECT)} == { + "t-login", + "t-tests", + "t-css", + } + assert index.search("login", project="/proj/none") == [] + assert index.count(project="/proj/one") == 2 and len(index) == 3 + + +def test_a_window_in_days_narrows_it_and_a_function_word_query_finds_nothing(tmp_path: Path) -> None: + index = _index(tmp_path) + _seed(index) + week = time.time() - 7 * DAY + assert [h.turn_id for h in index.search("login", project=EVERY_PROJECT, since=week)] == ["t-css"] + assert index.search("o que é isso?", project=EVERY_PROJECT) == [] + assert index.search(" ", project=EVERY_PROJECT) == [] + + +def test_the_same_turn_recorded_twice_is_one_row_and_a_forgotten_session_is_gone( + tmp_path: Path, +) -> None: + index = _index(tmp_path) + _seed(index) + index.record( + turn_id="t-login", session_id="sess-a", project="/proj/one", + asked="fix the login function, it accepts any password", answered="second write", + ) + assert len(index) == 3 + assert index.search("password", project="/proj/one")[0].answered == "second write" + assert index.forget_session("sess-a") == 2 + assert len(index) == 1 and index.search("login", project="/proj/one") == [] + assert index.forget_session("sess-a") == 0 + + +def test_a_pasted_credential_is_not_stored_in_clear(tmp_path: Path) -> None: + index = _index(tmp_path) + key = "sk-" + "A" * 24 + index.record( + turn_id="t", session_id="s", project="/p", + asked=f"use this key: {key}", answered=f"set OPENAI_API_KEY={key} and it worked", + ) + raw = (tmp_path / "home" / "history.db").read_bytes() + assert key.encode() not in raw + hit = index.search("key worked", project="/p")[0] + assert key not in hit.asked and key not in hit.answered + assert "[redacted" in hit.asked + + +def test_a_turn_that_ran_tainted_says_so_on_recall(tmp_path: Path) -> None: + index = _index(tmp_path) + index.record( + turn_id="t", session_id="s", project="/p", tainted=True, + asked="summarise that page about deploys", answered="it says to run the deploy script", + ) + tool = RecallHistoryTool(index, project="/p") + out = tool.run(query="deploys") + assert TAINTED_LABEL in out + index.record( + turn_id="t2", session_id="s", project="/p", + asked="rename the deploy script", answered="renamed", + ) + assert TAINTED_LABEL not in RecallHistoryTool(index, project="/p").run(query="rename") + + +def test_the_like_fallback_answers_the_same_questions(tmp_path: Path) -> None: + """The build without FTS5: the same table, searched by substring, same scoping.""" + index = _index(tmp_path) + _seed(index) + index._fts = False # noqa: SLF001 — the degradation, forced on a build that has FTS5 + assert [h.turn_id for h in index.search("any password", project="/proj/one")] == ["t-login"] + assert index.search("test_login", project="/proj/one")[0].turn_id == "t-tests" + assert {h.turn_id for h in index.search("login", project=EVERY_PROJECT)} == { + "t-login", "t-tests", "t-css", + } + assert index.search("login", project="/proj/none") == [] + assert [h.turn_id for h in index.search("login", project=EVERY_PROJECT, since=time.time() - 7 * DAY)] == [ + "t-css" + ] + + +def test_files_are_read_off_the_folded_exchange() -> None: + exchange = { + "you": "q", + "answer": "a", + "tools": [ + {"name": "read_file", "arguments": {"path": "a.py"}}, + {"name": "grep", "arguments": {"pattern": "x"}}, + {"name": "edit_file", "arguments": {"path": " b.py "}}, + {"name": "read_file", "arguments": {"path": "a.py"}}, + {"name": "odd", "arguments": {"path": 3}}, + "not a dict", + ], + } + assert files_of_exchange(exchange) == ["a.py", "b.py", "a.py"] + assert files_of_exchange({}) == [] + + +# ------------------------------------------------------------------ the tool + + +def test_the_tool_prints_dated_excerpts_with_the_files_and_says_what_it_did_not_find( + tmp_path: Path, +) -> None: + index = _index(tmp_path) + tool = RecallHistoryTool(index, project="/proj/one") + assert tool.run(query="") == "error: query is required" + assert "nothing before the first completed turn" in tool.run(query="login") + + _seed(index) + out = tool.run(query="login function") + assert out.startswith("2 earlier turns match 'login function' in this project") + first = out.split("\n\n")[1] + assert first.startswith("1. ") and "conversation sess-a" in first + assert time.strftime("%Y-%m-%d", time.localtime(time.time() - 14 * DAY)) in first + assert "asked: fix the login function" in first + assert "answered: `login_user`" in first + assert "edited: src/auth/login.py" in first + assert "read: src/auth/session.py" in first # read, not edited: the two are told apart + assert "project" not in first # this project's — naming it would be noise + + assert tool.run(query="mobile") == "no earlier turn in this project matches 'mobile'" + assert tool.run(query="login", days=3) == "no earlier turn in this project in the last 3 days matches 'login'" + wide = tool.run(query="mobile", everywhere=True) + assert "across every project" in wide and "project /proj/two" in wide + assert "only function words" in tool.run(query="o que é isso") + assert tool.run(query="login", k=1).count("\n\n") == 1 # head + one hit + + +def test_recall_history_is_a_read_only_tool_a_step_may_run_together() -> None: + assert "recall_history" in PARALLEL_READ_TOOLS + + +# ------------------------------------------------------------------ through the app + + +class _ToolUsingAgent: + """An agent whose transcript names the files it read and edited, like a real coding turn.""" + + def __init__(self, *_a: Any, **_k: Any) -> None: + pass + + def run(self, task: str, **kw: Any) -> AgentResult: + on_edit = kw.get("on_edit") + if on_edit is not None: + on_edit("src/auth/login.py", "--- a\n+++ b\n") + history = list(kw.get("history") or []) + return AgentResult( + answer=f"done: {task}", + steps=2, + stopped_reason="final", + transcript=[ + *history, + {"role": "user", "content": task}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "type": "function", "function": { + "name": "read_file", "arguments": json.dumps({"path": "src/auth/login.py"}), + }}, + {"id": "c2", "type": "function", "function": { + "name": "edit_file", "arguments": json.dumps({"path": "src/auth/login.py"}), + }}, + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "def login(): ..."}, + {"role": "tool", "tool_call_id": "c2", "content": "edited"}, + {"role": "assistant", "content": f"done: {task}"}, + ], + tool_names=["read_file", "edit_file"], + model="test/model", + ) + + +def _client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[TestClient, Path]: + import chimera.core + from chimera.api import build_api_app + from chimera.config import get_settings + + home = tmp_path / "home" + monkeypatch.setenv("CHIMERA_HOME", str(home)) + get_settings.cache_clear() + monkeypatch.setattr(chimera.core, "Agent", _ToolUsingAgent, raising=True) + ws = tmp_path / "ws" + ws.mkdir(exist_ok=True) + settings = Settings(CHIMERA_HOME=str(home)) + app = build_api_app(lambda: ChatSession(_ToolUsingAgent()), workspace=ws, settings=settings) + return TestClient(app), home + + +def _frames(response: Any) -> dict[str, dict[str, Any]]: + event, out = "", {} + for line in response.text.splitlines(): + if line.startswith("event: "): + event = line[len("event: ") :] + elif line.startswith("data: "): + out[event] = json.loads(line[len("data: ") :]) + return out + + +def test_a_turn_through_the_app_is_indexed_with_its_files_and_outlives_the_session_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + client, home = _client(tmp_path, monkeypatch) + first = client.post("/api/code/turn", json={"message": "fix the login function"}) + assert first.status_code == 200 + frames = _frames(first) + assert "done" in frames + session_id = frames["session"]["session_id"] + second = client.post( + "/api/code/turn", json={"message": "and the logout too", "session_id": session_id} + ) + assert _frames(second)["done"]["answer"] == "done: and the logout too" + + index = history_for(home) + project = str((tmp_path / "ws").resolve()) + # Both turns edited login.py (the stub always does), so both match `login`; the turn that + # ASKED about the login function is the better match and comes first. + hits = index.search("login function", project=project) + assert [h.asked for h in hits] == ["fix the login function", "and the logout too"] + hit = hits[0] + assert hit.session_id == session_id + assert hit.asked == "fix the login function" + assert hit.answered == "done: fix the login function" + assert hit.files == ["src/auth/login.py"] + assert hit.edited == ["src/auth/login.py"] + assert hit.tools == ["read_file", "edit_file"] + assert hit.tainted is False + assert index.count(project=project) == 2 + + # The tool a LATER conversation in the same project mounts finds it — through the registry + # the app builds, not a tool constructed by hand. + from chimera.tools import default_registry + + tool = default_registry(tmp_path / "ws").get("recall_history") + assert tool is not None + out = tool.run(query="login function") + assert f"conversation {session_id[:8]}" in out and "edited: src/auth/login.py" in out + + # The index is not derived from the session file: lose the file and the turns remain. + (home / "code_sessions" / f"{session_id}.json").unlink() + assert index.count(project=project) == 2 + assert "fix the login function" in tool.run(query="login") + + +def test_deleting_a_conversation_or_a_project_forgets_its_turns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + client, home = _client(tmp_path, monkeypatch) + ws = str(tmp_path / "ws") + other = tmp_path / "other" + other.mkdir() + a = _frames(client.post("/api/code/turn", json={"message": "alpha one", "workspace": ws})) + b = _frames(client.post("/api/code/turn", json={"message": "beta one", "workspace": ws})) + c = _frames(client.post("/api/code/turn", json={"message": "gamma one", "workspace": str(other)})) + index = history_for(home) + assert len(index) == 3 + + assert client.delete(f"/api/code/sessions/{a['session']['session_id']}").json() == {"ok": True} + assert len(index) == 2 + assert index.search("alpha", project=EVERY_PROJECT) == [] + # A second click: the file is already gone, and so are the rows — not an error. + assert client.delete(f"/api/code/sessions/{a['session']['session_id']}").json() == {"ok": False} + + deleted = client.delete("/api/code/projects", params={"workspace": ws}).json() + assert deleted == {"deleted": 1} + assert len(index) == 1 + assert index.search("beta", project=EVERY_PROJECT) == [] + remaining = index.search("gamma", project=EVERY_PROJECT) + assert [h.session_id for h in remaining] == [c["session"]["session_id"]] + assert b["session"]["session_id"] != c["session"]["session_id"] + + +def test_an_index_that_will_not_write_does_not_fail_the_turn( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + client, home = _client(tmp_path, monkeypatch) + + def _refuse(self: Any, **_: Any) -> None: + raise RuntimeError("disk full") + + monkeypatch.setattr(HistoryIndex, "record", _refuse) + response = client.post("/api/code/turn", json={"message": "still answered"}) + assert response.status_code == 200 + frames = _frames(response) + assert frames["done"]["answer"] == "done: still answered" + assert "error" not in frames + assert len(history_for(home)) == 0 diff --git a/tests/test_document_args_match_the_tools.py b/tests/test_document_args_match_the_tools.py index 5ce49375..e2ff00e0 100644 --- a/tests/test_document_args_match_the_tools.py +++ b/tests/test_document_args_match_the_tools.py @@ -60,6 +60,11 @@ # which job. A flag and an id — both say which thing was acted on, neither carries a body. "background", "job_id", + # `recall_history`: a window in days, how many hits, and whether to leave this project. Three + # scalars that narrow a search; the search terms themselves are `query`, below. + "days", + "k", + "everywhere", "exclude", "fields", "format",