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.
|