aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-30 20:31:53 +0200
committerDanilo M. <danix@danix.xyz>2026-09-30 20:31:53 +0200
commitd8da556ea1b9686ae8dd2ec68fe37864c247c5af (patch)
tree5ef417d42e5521f5daba0c9020d5e4bdca4340f7
parent2497055f6315e3dd2af7422cba3dde51db5c7d82 (diff)
downloaddesktop-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.md196
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.