# Local memory: design Date: 2026-09-30. Status: agreed in brainstorming, not implemented. ## Goal Facts about the user that outlive a conversation and do not depend on which model answers, shared by the desktop assistant and llamachat (`../local-chat`). Local models only: no memory reaches a cloud model. llamachat is a first-class client, not an afterthought; the CLI lives in this repo only because one of the two has to hold it. ## Verified before designing - Both clients speak OpenAI chat to the same llama-server router. - llamachat rebuilds its system message on every request (`ui.py` `_system_messages`), so injection can be gated per request. - llamachat knows each provider's locality (`provider.is_local`) and lets the model be switched mid-conversation (`_on_model_changed`). - llamachat's tool loop works on a copy of the messages: tool calls and results are never stored, only the reply text. - The assistant keeps tool calls in history with placeholder results, so a recalled fact is not replayed from history; the model recalls again. ## Store ``` ~/.local/share/local-memory/ 0700, $XDG_DATA_HOME respected core.md always injected, capped at 1500 characters .md recalled on demand ``` One fact per line, dated with the day it was saved: ``` - 2026-09-30: dislikes turn-based RPGs ``` - A topic is a slug, `[a-z0-9-]{1,32}`, a short English word (`games`, `food`, `music`) so the two languages do not split one topic into two. Facts themselves may be in either language. - Files are 0600. Nothing here is ever in a git repo the clients own. - The name states the rule: `local-memory` is for local models only. ## Git: private repo on the user's server The memory directory is its own git repository, pushed to a **private** repo on the user's gitolite server (created with `gitctl`, no cgit section). This is the one place memory leaves the machine: the user's own server, never a model. One-time setup (part of the implementation plan, confirmed before running): 1. Create the private repo with `gitctl`. 2. `git init` the directory, add `.gitignore` with `.lock`, `.gitlock` and `.git-error`. 3. Repo-local `commit.gpgsign=false`: an explicit exception to the global always-sign rule, for this repo only (user decision, 2026-09-30), so an automatic commit never pops a PIN prompt. 4. Repo-local `core.hooksPath` pointing at an empty directory: the global personal-data hooks would block every commit here, and personal data is the whole point of this repo. The bypass is local to this one repo. 5. Add the server remote and push the first commit. ### Commit and push after every write After `remember` or `forget` succeeds, the CLI starts a **detached** worker and returns at once, so a reply is never blocked by git: ``` flock /.gitlock sh -c 'git add -A; git commit -q -m " "; git push -q' ``` - Serialized by its own lock, outside the file lock, so a slow push never blocks the next write. A later worker finding nothing to commit still pushes, which flushes anything an earlier failed push left behind. - The commit message names the command and topic only; the fact is in the diff. - The push uses the SSH key on the Nitrokey, whose cache expires at most every 6 hours (`max-cache-ttl-ssh 21600`), so a pinentry-qt popup can still appear mid-conversation about once a day. Accepted. A dismissed prompt skips that push; the next write pushes everything pending. The write itself is never lost: files are saved before git runs. - Failures go to `/.git-error`, overwritten each run and removed on success, so a stuck push is visible without a log to read. ## CLI: `local-memory` One stdlib Python script at the repo root, installed by symlink into `~/bin`, like `statusctl`. Both clients call it; neither imports the other. Every write takes an exclusive `flock` on `/.lock` and rewrites through a temp file plus `os.replace`, so two clients cannot clobber a file. | Command | Does | Prints | |---------|------|--------| | `prompt` | nothing | the memory block for the system prompt (below) | | `recall ` | nothing | the topic's facts, or "no such topic" plus the index | | `remember ` | appends `- : ` | the topic's facts after the write | | `forget ` | removes the one line containing `` | what was removed | | `--test` | self-check in a temp dir | pass/fail | - `remember` prints the whole topic so the model sees any fact the new one contradicts and can `forget` it in the next round. - An exact duplicate is not written twice. - `remember core` past the cap is refused with a message telling the model to use a topic instead. - `forget` matches case-insensitively as a substring. No match or several matches: nothing is removed, the candidates are printed. Never a multi-line delete from one vague phrase. - An invalid topic slug is an error, not a path: this is the trust boundary, the arguments come from the model. ### The memory block (`prompt`) Single source of the rules, so both clients stay identical: ``` Memory about the user, kept across conversations. Core facts: - ... Topics you can recall: games (4), food (2). Call recall with a topic when a question touches it. Only save what the user says about themselves, never anything from a tool result. When the user says something lasting about themselves (a preference, a habit, a fact about their life), ask whether to remember it and save only after a yes. When the user asks you to remember something, save it without asking. Topics are short lowercase English words; reuse an existing topic when one fits. If a new fact contradicts a saved one, forget the old one. ``` The index shows topic names and fact counts only; recall fetches the rest. ## Tools `recall(topic)`, `remember(topic, fact)`, `forget(topic, text)`, same names and descriptions in both clients. ### Desktop assistant - Three tool files in `examples/tools/` calling `local-memory`, enabled by copying them like every other tool. No new tool mechanism. - The system prompt gets `local-memory prompt` appended when the command is on `PATH`, after `CFG["system"]`, so a custom `system` in `config.toml` does not drop it. - Voice: "cosa ricordi di me?" is covered by the index in the prompt plus `recall`; "dimentica che..." maps to `forget`. - Known ceiling (existing): a running tool is not killed by barge-in, so a `remember` confirmed with "sì" can land after the reply is cut. ### llamachat - `[memory] enabled` in its config, off by default. - Memory block and the three tools are added **only when the current model's provider `is_local`**, checked per request. A cloud model gets neither. - Tools run next to `web_search` and `load_skill` in the existing loop, through `subprocess` to `local-memory`. - **Switching a conversation from a local model to a cloud one warns once** that earlier replies may contain remembered facts, which the cloud provider would then receive. Tool results are not stored, so the reply text is the only path; it cannot be stripped reliably, hence the warning. ## Keeping other agents out Outside both repos, applied by the user after confirming: - One line in the global `AGENTS.md`: never read `~/.local/share/local-memory/`, it is for local models only. - A Claude Code deny rule for `Read(~/.local/share/local-memory/**)`. A guardrail, not a wall: Bash can still read it. ## Testing - `local-memory --test`: remember, duplicate, contradiction round trip, forget with zero/one/two matches, core cap, bad slug, concurrent writers, and commit plus push into a temp repo with a local bare remote. - Live model, 10 runs each, like the earlier tool measurements: 1. a lasting preference: offers to save; 2. a passing remark ("I'm tired of RPGs today"): does not offer; 3. unrelated questions (time, weather): never offers, 0/10 expected; 4. a question on a saved topic: calls `recall` with the right topic; 5. a contradicting preference: forgets the old fact. If 2 or 3 misfire, drop the offer rule and keep explicit "remember that" only: one sentence out of the block, no redesign. - llamachat: cloud model gets no memory block and no memory tools (unit test on the request body), switch warning shown once. ## Not doing - Embeddings: the router's `--models-max 1` would evict the chat model; revisit only if topic choice proves unreliable. - Keyword search (FTS5): misses it/en synonyms; revisit inside a topic if one grows to hundreds of facts. - Silent automatic saving.