summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-30-local-memory-design.md
blob: 1615634f7014d0f96511a01bd5c97deeeea49f90 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
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.