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",