diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-30 20:31:53 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-30 20:31:53 +0200 |
| commit | d8da556ea1b9686ae8dd2ec68fe37864c247c5af (patch) | |
| tree | 5ef417d42e5521f5daba0c9020d5e4bdca4340f7 | |
| parent | 2497055f6315e3dd2af7422cba3dde51db5c7d82 (diff) | |
| download | desktop-assistant-d8da556ea1b9686ae8dd2ec68fe37864c247c5af.tar.gz desktop-assistant-d8da556ea1b9686ae8dd2ec68fe37864c247c5af.zip | |
Spec local memory shared with llamachat
Topic files under ~/.local/share/local-memory, a local-memory CLI both
clients call, recall/remember/forget tools offered to local models only,
offer-to-save plus explicit "remember that", and a private git repo on
the user's server with a detached commit and push after every write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/superpowers/specs/2026-09-30-local-memory-design.md | 196 |
1 files changed, 196 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-30-local-memory-design.md b/docs/superpowers/specs/2026-09-30-local-memory-design.md new file mode 100644 index 0000000..1615634 --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-local-memory-design.md @@ -0,0 +1,196 @@ +# 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 + <topic>.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 <dir>/.gitlock sh -c 'git add -A; git commit -q -m "<command> <topic>"; 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 `<dir>/.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 `<dir>/.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 <topic>` | nothing | the topic's facts, or "no such topic" plus the index | +| `remember <topic> <fact>` | appends `- <today>: <fact>` | the topic's facts after the write | +| `forget <topic> <text>` | removes the one line containing `<text>` | 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. |
