Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home>/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.


Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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。它在所配置的沙箱里运行,而默认的沙箱就是这台机器本身 —— 不是隔离环境。",
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
43 changes: 41 additions & 2 deletions chimera/api/code_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions chimera/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading