aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-30 20:38:04 +0200
committerDanilo M. <danix@danix.xyz>2026-09-30 20:38:04 +0200
commit15be1778b0a918b093594c1e2b5653033e897939 (patch)
tree7b22aa829571e053d719c8af29349ae0649dc025
parentd8da556ea1b9686ae8dd2ec68fe37864c247c5af (diff)
downloaddesktop-assistant-15be1778b0a918b093594c1e2b5653033e897939.tar.gz
desktop-assistant-15be1778b0a918b093594c1e2b5653033e897939.zip
Plan local memory implementation
Eleven tasks: the local-memory CLI with its self-check, detached commit and push, install, assistant tools and prompt block, llamachat memory module, config key, tool loop and local-only UI gate, the private server repo, guardrails against cloud agents, and the live measurement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-09-30-local-memory.md1424
1 files changed, 1424 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-30-local-memory.md b/docs/superpowers/plans/2026-09-30-local-memory.md
new file mode 100644
index 0000000..4c5966d
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-30-local-memory.md
@@ -0,0 +1,1424 @@
+# Local Memory Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Facts about the user, kept in topic files under `~/.local/share/local-memory/`, offered to local models by both the desktop assistant and llamachat through one `local-memory` CLI, versioned in a private git repo.
+
+**Architecture:** A single stdlib Python script, `local-memory`, is the only code that touches the memory files: it prints the memory block for the system prompt and implements `recall`/`remember`/`forget`, with `flock` locking, atomic writes and a detached commit plus push after each write. The desktop assistant calls it through three tool TOML files and one prompt line; llamachat calls it through `subprocess` from a new `memory.py` module, only while the current model's provider is local.
+
+**Tech Stack:** Python 3 stdlib (`fcntl`, `tempfile`, `subprocess`), git, util-linux `flock`, `gitctl` for the server repo. llamachat: PySide6 (already there).
+
+**Spec:** `docs/superpowers/specs/2026-09-30-local-memory-design.md`
+
+**Repos touched:**
+- this repo, `desktop-assistant` (Tasks 1-4): commit style is a plain imperative subject, no prefix.
+- `../local-chat` (Tasks 5-8): commit style is conventional (`feat: ...`). Has no AGENTS.md; keeps a `CHANGELOG.md` with an `## [Unreleased]` / `### Added` section.
+- outside both (Tasks 9-10): every step there is outward-facing, confirm with the user before running it.
+
+**Rules that apply throughout:** commits are GPG-signed (global rule) in both code repos; ask the user before each commit; no em dashes in any text; never put real personal facts in tests or docs (use `example.org`, "likes puzzles", "name is Example").
+
+## Deviation from the spec
+
+- llamachat's config is flat keys (`search_enabled`, `skills_enabled`), not tables, so the spec's `[memory] enabled` becomes `memory_enabled = false`, resolving to off when `local-memory` is not on `PATH` (same rule as `skills_enabled` with a missing directory).
+
+## File map
+
+| File | Repo | Change |
+|------|------|--------|
+| `local-memory` | desktop-assistant | create: the CLI and its `--test` |
+| `examples/tools/recall.toml`, `remember.toml`, `forget.toml` | desktop-assistant | create |
+| `assistant.py` | desktop-assistant | modify: `import shutil`, `MEMORY`, `memory()`, system prompt, startup log |
+| `AGENTS.md` | desktop-assistant | modify: status line + "Memory" finding |
+| `llamachat/memory.py` | local-chat | create: schemas, CLI calls, tool message |
+| `llamachat/config.py` | local-chat | modify: `memory_enabled` |
+| `llamachat/backend.py` | local-chat | modify: `MemoryConfig`, loop wiring |
+| `llamachat/ui.py` | local-chat | modify: locality gate, system block, worker arg, switch warning |
+| `test_llamachat.py` | local-chat | modify: new tests, fakes accept `memory_cfg` |
+| `README.md`, `CHANGELOG.md` | local-chat | modify |
+
+---
+
+### Task 1: `local-memory` store commands
+
+**Files:**
+- Create: `local-memory` (repo root, executable)
+
+- [ ] **Step 1: Write the file with the self-check and stub commands**
+
+Create `local-memory` with the header, the self-check, and commands that do nothing yet, so the check fails for the right reason:
+
+```python
+#!/usr/bin/env python3
+# local-memory: facts about the user for local models.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 2 as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+# Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Facts about the user for local models, shared by every local client
+(desktop-assistant, llamachat). The only code that touches the files.
+
+Usage: local-memory prompt | recall TOPIC | remember TOPIC FACT | forget TOPIC TEXT
+ local-memory --test
+Files: $XDG_DATA_HOME/local-memory/<topic>.md, one "- YYYY-MM-DD: fact" per line.
+"""
+
+import contextlib
+import datetime
+import fcntl
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+DIR = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
+ "local-memory")
+CORE_CAP = 1500 # core.md goes into every prompt
+SLUG = re.compile(r"[a-z0-9-]{1,32}")
+
+
+def path(d, topic):
+ raise NotImplementedError
+
+
+def read(p):
+ raise NotImplementedError
+
+
+def prompt(d):
+ raise NotImplementedError
+
+
+def recall(d, topic):
+ raise NotImplementedError
+
+
+def remember(d, topic, fact, today=None):
+ raise NotImplementedError
+
+
+def forget(d, topic, text):
+ raise NotImplementedError
+
+
+def test():
+ import threading
+ day = "2026-09-30"
+ with tempfile.TemporaryDirectory() as d:
+ out, changed = remember(d, "games", "dislikes turn-based RPGs", day)
+ assert changed and "- 2026-09-30: dislikes turn-based RPGs" in out, out
+ # Same fact after whitespace folding: not written twice.
+ assert remember(d, "games", "dislikes turn-based\nRPGs", day)[1] is False
+ # Contradiction round trip: remember shows the old fact, forget drops it.
+ out, changed = remember(d, "games", "now likes turn-based RPGs", day)
+ assert changed and "dislikes turn-based RPGs" in out, out
+ out, changed = forget(d, "games", "DISLIKES")
+ assert changed and read(path(d, "games")) == ["- 2026-09-30: now likes turn-based RPGs"]
+ # forget with no match or several matches deletes nothing.
+ remember(d, "games", "plays on PC", day)
+ assert forget(d, "games", "chess")[1] is False
+ out, changed = forget(d, "games", "- 2026")
+ assert not changed and "Several" in out and len(read(path(d, "games"))) == 2
+ assert forget(d, "games", "")[1] is False
+ # A topic emptied by forget is gone from disk and from the index.
+ remember(d, "food", "likes pizza", day)
+ forget(d, "food", "pizza")
+ assert not os.path.exists(path(d, "food"))
+ # core has a cap and says where to go instead.
+ remember(d, "core", "name is Example", day)
+ try:
+ remember(d, "core", "x" * CORE_CAP, day)
+ assert False, "core cap not enforced"
+ except ValueError as e:
+ assert "topic" in str(e)
+ # Topics come from the model: a bad one is an error, never a path.
+ for bad in ("../x", "Games", "", "a" * 33, "core.md", "a/b"):
+ try:
+ path(d, bad)
+ assert False, bad
+ except ValueError:
+ pass
+ # The prompt block: core facts, index with counts, the rules.
+ p = prompt(d)
+ assert "name is Example" in p and "games (2)" in p, p
+ assert "core (" not in p and "food" not in p, p
+ assert "ask whether to remember" in p
+ out = recall(d, "music")
+ assert "No facts about music" in out and "games" in out, out
+ assert recall(d, "games").count("\n") == 1
+ # Concurrent writers: every fact lands, files stay private.
+ ts = [threading.Thread(target=remember, args=(d, "many", f"fact {i}", day))
+ for i in range(20)]
+ for t in ts:
+ t.start()
+ for t in ts:
+ t.join()
+ assert len(read(path(d, "many"))) == 20
+ assert os.stat(path(d, "many")).st_mode & 0o777 == 0o600
+
+
+def main(argv):
+ if argv == ["--test"]:
+ test()
+ print("ok")
+ return
+ sys.exit(__doc__)
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
+```
+
+Then: `chmod +x local-memory`
+
+- [ ] **Step 2: Run the self-check to see it fail**
+
+Run: `./local-memory --test`
+Expected: FAIL, traceback ending in `NotImplementedError` from `remember`.
+
+- [ ] **Step 3: Implement the store**
+
+Replace the six stubs (`path` to `forget`) with:
+
+```python
+RULES = """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."""
+
+
+def path(d, topic):
+ # The trust boundary: topics come from the model.
+ if not SLUG.fullmatch(topic):
+ raise ValueError(f"bad topic {topic!r}: use a short lowercase English word")
+ return os.path.join(d, topic + ".md")
+
+
+def read(p):
+ try:
+ with open(p) as f:
+ return [line.rstrip("\n") for line in f if line.strip()]
+ except FileNotFoundError:
+ return []
+
+
+def write(p, lines):
+ if not lines:
+ os.remove(p)
+ return
+ # mkstemp creates 0600; os.replace means a reader never sees half a file.
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p), prefix=".tmp")
+ with os.fdopen(fd, "w") as f:
+ f.writelines(line + "\n" for line in lines)
+ os.replace(tmp, p)
+
+
+@contextlib.contextmanager
+def lock(d):
+ # One writer at a time across clients (assistant, llamachat, a shell).
+ with open(os.path.join(d, ".lock"), "a") as f:
+ fcntl.flock(f, fcntl.LOCK_EX)
+ yield
+
+
+def topics(d):
+ return sorted(f[:-3] for f in os.listdir(d) if f.endswith(".md") and f != "core.md")
+
+
+def prompt(d):
+ core = read(path(d, "core"))
+ index = ", ".join(f"{t} ({len(read(path(d, t)))})" for t in topics(d)) or "none yet"
+ return "\n".join(["Memory about the user, kept across conversations.", "Core facts:",
+ *(core or ["(none yet)"]), f"Topics you can recall: {index}.", RULES])
+
+
+def recall(d, topic):
+ lines = read(path(d, topic))
+ if lines:
+ return "\n".join(lines)
+ return f"No facts about {topic}. Topics: {', '.join(topics(d)) or 'none yet'}."
+
+
+def remember(d, topic, fact, today=None):
+ fact = " ".join(fact.split()) # one line, whatever the model sent
+ if not fact:
+ raise ValueError("empty fact")
+ p = path(d, topic)
+ with lock(d):
+ lines = read(p)
+ if any(line.split(": ", 1)[-1] == fact for line in lines):
+ return f"Already remembered. {topic} holds:\n" + "\n".join(lines), False
+ lines.append(f"- {today or datetime.date.today().isoformat()}: {fact}")
+ if topic == "core" and len("\n".join(lines)) > CORE_CAP:
+ raise ValueError(f"core is full ({CORE_CAP} characters): save this under a topic instead")
+ write(p, lines)
+ # The whole topic, so the model sees a fact the new one contradicts.
+ return f"Saved. {topic} now holds:\n" + "\n".join(lines), True
+
+
+def forget(d, topic, text):
+ p = path(d, topic)
+ with lock(d):
+ lines = read(p)
+ hits = [line for line in lines if text.strip().lower() in line.lower()]
+ # Never a multi-line delete from one vague phrase.
+ if len(hits) != 1:
+ if not hits:
+ return f"Nothing in {topic} matches {text!r}.", False
+ return "Several facts match, nothing deleted. Say which:\n" + "\n".join(hits), False
+ lines.remove(hits[0])
+ write(p, lines)
+ return f"Forgot: {hits[0]}", True
+```
+
+- [ ] **Step 4: Run the self-check to see it pass**
+
+Run: `./local-memory --test`
+Expected: `ok`
+
+- [ ] **Step 5: Wire the commands into `main`**
+
+Replace `main` with:
+
+```python
+def main(argv):
+ if argv == ["--test"]:
+ test()
+ print("ok")
+ return
+ arity = {"prompt": 0, "recall": 1, "remember": 2, "forget": 2}
+ if not argv or arity.get(argv[0]) != len(argv) - 1:
+ sys.exit(__doc__)
+ cmd, args = argv[0], argv[1:]
+ os.makedirs(DIR, mode=0o700, exist_ok=True)
+ try:
+ if cmd in ("prompt", "recall"):
+ print((prompt if cmd == "prompt" else recall)(DIR, *args))
+ return
+ out, changed = (remember if cmd == "remember" else forget)(DIR, *args)
+ except ValueError as e:
+ sys.exit(str(e)) # stderr, exit 1: callers show it as the error
+ print(out)
+```
+
+Check by hand in a throwaway data dir:
+
+```bash
+export XDG_DATA_HOME=$(mktemp -d)
+./local-memory remember games "likes puzzles"; ./local-memory prompt; ./local-memory recall ../x; echo "exit $?"
+```
+
+Expected: `Saved. games now holds:` plus the dated line; a prompt block listing `games (1)`; `bad topic '../x': ...` and `exit 1`. Then `unset XDG_DATA_HOME`.
+
+- [ ] **Step 6: Commit (ask the user first)**
+
+```bash
+git add local-memory
+git commit -m "Add local-memory, the shared store for facts about the user"
+```
+
+---
+
+### Task 2: commit and push after every write
+
+**Files:**
+- Modify: `local-memory`
+
+- [ ] **Step 1: Add the git check to `test()`**
+
+Append to the end of `test()`:
+
+```python
+ # Commit and push: a temp repo with a local bare remote.
+ with tempfile.TemporaryDirectory() as t:
+ d, bare, hooks = (os.path.join(t, n) for n in ("mem", "bare.git", "hooks"))
+ os.makedirs(d)
+ os.makedirs(hooks)
+
+ def git(*a, cwd=d):
+ return subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True,
+ text=True).stdout.strip()
+
+ git("init", "-q", "--bare", bare)
+ git("init", "-q")
+ for k, v in (("user.name", "test"), ("user.email", "test@example.org"),
+ ("commit.gpgsign", "false"), ("core.hooksPath", hooks)):
+ git("config", k, v)
+ git("remote", "add", "origin", bare)
+ with open(os.path.join(d, ".gitignore"), "w") as f:
+ f.write(GITIGNORE)
+ remember(d, "games", "likes puzzles", day)
+ sync(d, "remember games").wait()
+ assert git("log", "--all", "--format=%s", cwd=bare) == "remember games"
+ assert git("status", "--porcelain") == "" # .lock and friends ignored
+ assert not os.path.exists(os.path.join(d, ".git-error"))
+ sync(d, "nothing new").wait() # nothing to commit is fine
+ assert not os.path.exists(os.path.join(d, ".git-error"))
+ # A failed push leaves .git-error; the file itself was saved anyway.
+ git("remote", "set-url", "origin", os.path.join(t, "missing.git"))
+ remember(d, "games", "plays on PC", day)
+ sync(d, "remember games").wait()
+ assert os.path.exists(os.path.join(d, ".git-error"))
+ assert len(read(path(d, "games"))) == 2
+ assert sync(t, "not a repo") is None
+```
+
+- [ ] **Step 2: Run to see it fail**
+
+Run: `./local-memory --test`
+Expected: FAIL with `NameError: name 'GITIGNORE' is not defined`.
+
+- [ ] **Step 3: Implement `sync`**
+
+Add after `RULES`:
+
+```python
+GITIGNORE = ".lock\n.gitlock\n.git-error\n.tmp*\n"
+# Serialized by its own lock, outside the file lock: a slow push never blocks
+# the next write. Nothing to commit still pushes, flushing an earlier failure.
+GIT = ('{ git add -A && { git diff --cached --quiet || git commit -q -m "$1"; }'
+ ' && git push -q origin HEAD; } 2>.git-error && rm -f .git-error')
+
+
+def sync(d, message):
+ # Detached, with no pipe of the caller's: a tool call must not wait on
+ # the network or a PIN prompt (the push key is on a smartcard).
+ if not os.path.isdir(os.path.join(d, ".git")):
+ return None
+ return subprocess.Popen(["flock", ".gitlock", "sh", "-c", GIT, "sh", message], cwd=d,
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, start_new_session=True)
+```
+
+In `main`, after `print(out)`, add:
+
+```python
+ if changed:
+ sync(DIR, f"{cmd} {args[0]}")
+```
+
+- [ ] **Step 4: Run to see it pass**
+
+Run: `./local-memory --test`
+Expected: `ok`
+
+- [ ] **Step 5: Check that a caller is not held by the push**
+
+```bash
+export XDG_DATA_HOME=$(mktemp -d); mkdir -p $XDG_DATA_HOME/local-memory && cd $XDG_DATA_HOME/local-memory
+git init -q && git config commit.gpgsign false && git config core.hooksPath "$(mktemp -d)"
+git remote add origin /nonexistent/slow.git
+cd - >/dev/null
+time (./local-memory remember games "likes puzzles" | cat)
+cat $XDG_DATA_HOME/local-memory/.git-error; unset XDG_DATA_HOME
+```
+
+Expected: `real` well under 0.2 s, and `.git-error` holding git's "does not appear to be a git repository" message. `| cat` is the point: it proves the detached worker does not keep the caller's stdout pipe open (the assistant's `run_tool` waits for EOF).
+
+- [ ] **Step 6: Commit (ask first)**
+
+```bash
+git add local-memory
+git commit -m "Commit and push local-memory after every write, detached"
+```
+
+---
+
+### Task 3: install the CLI
+
+**Files:** none in a repo.
+
+- [ ] **Step 1: Confirm with the user, then link it**
+
+```bash
+cd ~/Programming/GIT/desktop-assistant && ln -s "$PWD/local-memory" ~/bin/local-memory
+command -v local-memory && local-memory --test
+```
+
+Expected: `~/bin/local-memory` (expanded), then `ok`.
+
+- [ ] **Step 2: Check the Hyprland session sees it**
+
+The assistant is started from Hyprland autostart, whose `PATH` may differ from the shell's:
+
+```bash
+tr '\0' '\n' < /proc/$(pgrep -f 'python3 .*assistant.py' | head -1)/environ | grep '^PATH='
+```
+
+Expected: a `PATH` containing the expanded `~/bin`. If it does not, stop and tell the user: the fix belongs in the hypr-theme repo's environment, not here.
+
+---
+
+### Task 4: desktop assistant integration
+
+**Files:**
+- Create: `examples/tools/recall.toml`, `examples/tools/remember.toml`, `examples/tools/forget.toml`
+- Modify: `assistant.py` (imports near line 23-42, constants near line 120-124, `reply()` near line 556-560, `main()` line 641)
+- Modify: `AGENTS.md`
+
+- [ ] **Step 1: The three tool files**
+
+These descriptions are shared word for word with llamachat's `memory.py` (Task 5). Change both together.
+
+`examples/tools/recall.toml`:
+
+```toml
+description = "Read the saved facts about the user on one topic. Call it when a question touches a topic listed in the memory index."
+command = ["local-memory", "recall", "{topic}"]
+timeout = 5
+
+[params.topic]
+type = "string"
+description = "A topic name from the memory index, e.g. games."
+```
+
+`examples/tools/remember.toml`:
+
+```toml
+description = "Save a lasting fact about the user: a preference, a habit, a fact about their life. Only when the user asked you to remember it, or said yes when you offered."
+command = ["local-memory", "remember", "{topic}", "{fact}"]
+timeout = 5
+
+[params.topic]
+type = "string"
+description = "A short lowercase English word, e.g. games, food. Reuse a topic from the memory index when one fits; core is for basics like name, languages, city."
+
+[params.fact]
+type = "string"
+description = "The fact in one short sentence, as the user stated it."
+```
+
+`examples/tools/forget.toml`:
+
+```toml
+description = "Delete one saved fact about the user, when they ask you to forget it or a new fact contradicts it."
+command = ["local-memory", "forget", "{topic}", "{text}"]
+timeout = 5
+
+[params.topic]
+type = "string"
+description = "The topic the fact is saved under."
+
+[params.text]
+type = "string"
+description = "A few words from the fact to delete."
+```
+
+- [ ] **Step 2: Check they load**
+
+Run: `python3 -c "import assistant as a; t = a.load_tools('examples/tools'); print(sorted(k for k in t if k in ('recall','remember','forget')))"`
+Expected: `['forget', 'recall', 'remember']`
+
+(Importing `assistant` reads the user's config and tools dir at module level; that is fine, it starts nothing.)
+
+- [ ] **Step 3: Memory block in the system prompt**
+
+Add `import shutil` in the import block, between `import re` and `import signal` (alphabetical).
+
+After `TOOL_SCHEMA = schema(TOOLS) or None` add:
+
+```python
+MEMORY = shutil.which("local-memory")
+
+
+def memory():
+ # The rules and core facts come from the CLI, so llamachat sends the same
+ # block. ponytail: one ~30 ms process per question, cache it if that shows.
+ if not MEMORY:
+ return ""
+ try:
+ return subprocess.run([MEMORY, "prompt"], capture_output=True, text=True,
+ timeout=5).stdout.strip()
+ except (OSError, subprocess.TimeoutExpired):
+ return ""
+```
+
+In `reply()`, replace:
+
+```python
+ msgs = ([{"role": "system", "content": f"{CFG['system']} It is {now}."}]
+```
+
+with:
+
+```python
+ mem = memory()
+ msgs = ([{"role": "system", "content": f"{CFG['system']} It is {now}."
+ + (f"\n\n{mem}" if mem else "")}]
+```
+
+The block goes after `CFG["system"]`, so a custom `system` in `config.toml` keeps it.
+
+In `main()`, just before `log(f"ready, mic is {'on' if prev else 'off'}")`, add:
+
+```python
+ log(f"memory: {MEMORY or 'off, local-memory not on PATH'}")
+```
+
+- [ ] **Step 4: Self-check still passes**
+
+Run: `python3 assistant.py --test`
+Expected: the same success output as before the change.
+
+- [ ] **Step 5: Install the tool files for the user (ask first)**
+
+```bash
+cp examples/tools/{recall,remember,forget}.toml ~/.config/desktop-assistant/tools/
+./assistant.sh restart && sleep 20 && grep 'memory:' $XDG_RUNTIME_DIR/desktop-assistant.log | tail -1
+```
+
+Expected: `memory: ` followed by the expanded `~/bin/local-memory`.
+
+- [ ] **Step 6: AGENTS.md**
+
+In the status paragraph after the "Tools (2026-09-26 ...)" paragraph, add:
+
+```markdown
+Memory (2026-09-30): facts about the user in topic files under
+`~/.local/share/local-memory/`, read and written only through the
+`local-memory` CLI in this repo (symlinked into `~/bin`), shared with
+llamachat, local models only. Spec:
+`docs/superpowers/specs/2026-09-30-local-memory-design.md`.
+```
+
+Under "Next steps", add a bullet:
+
+```markdown
+- **Memory:** run the live measurement (plan Task 11) and record it under
+ Findings; drop the offer rule if passing remarks trigger offers.
+```
+
+- [ ] **Step 7: Commit (ask first)**
+
+```bash
+git add assistant.py examples/tools/recall.toml examples/tools/remember.toml examples/tools/forget.toml AGENTS.md
+git commit -m "Give the assistant local memory: prompt block and recall/remember/forget tools"
+```
+
+---
+
+### Task 5: llamachat `memory.py`
+
+All paths in Tasks 5-8 are relative to `../local-chat`. Run tests there with `./test_llamachat.py` (every `test_*` function is called from the list at the bottom of the file; a new test must be added to that list).
+
+**Files:**
+- Create: `llamachat/memory.py`
+- Modify: `test_llamachat.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test_llamachat.py` (near `test_skill_tool_message`):
+
+```python
+def test_memory_module():
+ from llamachat import memory, skills
+
+ names = [s["function"]["name"] for s in memory.TOOL_SCHEMAS]
+ assert names == ["recall", "remember", "forget"], names
+ assert memory.TOOL_SCHEMAS[1]["function"]["parameters"]["required"] == ["topic", "fact"]
+
+ with tempfile.TemporaryDirectory() as tmp:
+ fake = Path(tmp) / "local-memory"
+ fake.write_text(
+ '#!/bin/sh\n'
+ 'if [ "$2" = "../x" ]; then echo "bad topic" >&2; exit 1; fi\n'
+ 'echo "$@"\n'
+ )
+ fake.chmod(0o755)
+ saved = memory.COMMAND
+ memory.COMMAND = str(fake)
+ try:
+ assert memory.prompt() == "prompt"
+ out = memory.call("remember", json.dumps({"topic": "games", "fact": "likes puzzles"}))
+ assert out == "remember games likes puzzles", out
+ assert memory.call("recall", json.dumps({"topic": "../x"})) == "error: bad topic"
+ assert memory.call("recall", "{not json").startswith("error:")
+ assert memory.call("drop_table", "{}").startswith("error:")
+ # A missing argument becomes an empty one, like the assistant does.
+ assert memory.call("recall", "{}") == "recall"
+ finally:
+ memory.COMMAND = saved
+ memory.COMMAND = str(Path(tmp) / "missing")
+ try:
+ assert memory.prompt() == ""
+ assert memory.call("recall", '{"topic": "games"}').startswith("error:")
+ finally:
+ memory.COMMAND = saved
+
+ plain = memory.tool_message("call_1", "x")
+ assert plain == {"role": "tool", "tool_call_id": "call_1", "content": "x"}
+ assert skills.NO_MORE_TOOLS in memory.tool_message("call_1", "x", last=True)["content"]
+ print("ok memory module")
+```
+
+Add `test_memory_module()` to the call list at the bottom, after `test_skill_tool_message()`.
+
+- [ ] **Step 2: Run to see it fail**
+
+Run: `./test_llamachat.py`
+Expected: FAIL with `ImportError: cannot import name 'memory'`.
+
+- [ ] **Step 3: Write `llamachat/memory.py`**
+
+```python
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# llamachat - a small native chat client for a local llama.cpp router
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+"""Memory about the user, through the local-memory CLI.
+
+The CLI lives in the desktop-assistant repo and is the only code that
+touches the memory files; the voice assistant calls the same commands, so
+both clients send the same rules and see the same facts. Local models
+only: the caller decides that, this module never looks at providers.
+
+No Qt here: this is called from the streaming worker thread.
+"""
+
+import json
+import shutil
+import subprocess
+
+from . import skills
+
+COMMAND = "local-memory"
+TIMEOUT = 5
+
+# Shared word for word with desktop-assistant's examples/tools/*.toml.
+_TOOLS = {
+ "recall": (
+ "Read the saved facts about the user on one topic. Call it when a "
+ "question touches a topic listed in the memory index.",
+ {"topic": "A topic name from the memory index, e.g. games."},
+ ),
+ "remember": (
+ "Save a lasting fact about the user: a preference, a habit, a fact "
+ "about their life. Only when the user asked you to remember it, or "
+ "said yes when you offered.",
+ {
+ "topic": "A short lowercase English word, e.g. games, food. Reuse "
+ "a topic from the memory index when one fits; core is for basics "
+ "like name, languages, city.",
+ "fact": "The fact in one short sentence, as the user stated it.",
+ },
+ ),
+ "forget": (
+ "Delete one saved fact about the user, when they ask you to forget "
+ "it or a new fact contradicts it.",
+ {
+ "topic": "The topic the fact is saved under.",
+ "text": "A few words from the fact to delete.",
+ },
+ ),
+}
+
+TOOL_SCHEMAS = [
+ {
+ "type": "function",
+ "function": {
+ "name": name,
+ "description": description,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ p: {"type": "string", "description": d} for p, d in params.items()
+ },
+ "required": list(params),
+ },
+ },
+ }
+ for name, (description, params) in _TOOLS.items()
+]
+NAMES = frozenset(_TOOLS)
+
+
+def available() -> bool:
+ return shutil.which(COMMAND) is not None
+
+
+def _run(*args: str) -> str:
+ try:
+ p = subprocess.run(
+ [COMMAND, *args], capture_output=True, text=True, timeout=TIMEOUT
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return f"error: {exc}"
+ if p.returncode:
+ return "error: " + (p.stderr.strip() or f"exit status {p.returncode}")
+ return p.stdout.strip()
+
+
+def prompt() -> str:
+ """The memory block for the system prompt, or "" when it cannot be had."""
+ out = _run("prompt")
+ return "" if out.startswith("error:") else out
+
+
+def call(name: str, arguments: str) -> str:
+ """Run one memory tool call; every failure is the tool's answer."""
+ try:
+ args = json.loads(arguments or "{}")
+ except json.JSONDecodeError:
+ args = None
+ if name not in NAMES or not isinstance(args, dict):
+ return "error: malformed memory request"
+ params = _TOOLS[name][1]
+ return _run(name, *("" if args.get(p) is None else str(args[p]) for p in params))
+
+
+def tool_message(call_id: str, content: str, last: bool = False) -> dict:
+ """The `role: tool` message carrying a memory result back to the model."""
+ if last:
+ content = f"{content}\n\n{skills.NO_MORE_TOOLS}"
+ return {"role": "tool", "tool_call_id": call_id, "content": content}
+```
+
+- [ ] **Step 4: Run to see it pass**
+
+Run: `./test_llamachat.py`
+Expected: `ok memory module` among the lines, ending with `all checks passed`.
+
+- [ ] **Step 5: Commit (ask first)**
+
+```bash
+git add llamachat/memory.py test_llamachat.py
+git commit -m "feat: memory module calling the shared local-memory CLI"
+```
+
+---
+
+### Task 6: llamachat config key
+
+**Files:**
+- Modify: `llamachat/config.py` (DEFAULTS near line 63, dataclass near line 102, resolve near line 140, constructor near line 176, template near line 252)
+- Modify: `test_llamachat.py`, `README.md`
+
+- [ ] **Step 1: Write the failing test**
+
+Add after `test_skills_config`:
+
+```python
+def test_memory_config():
+ from llamachat import memory
+
+ assert config.DEFAULTS["memory_enabled"] is False
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "config.toml"
+ path.write_text("memory_enabled = true\n")
+ saved = memory.COMMAND
+ try:
+ memory.COMMAND = "sh" # on PATH everywhere
+ assert config.load(path).memory_enabled is True
+ # A flag with no CLI behind it resolves to off.
+ memory.COMMAND = str(Path(tmp) / "missing")
+ assert config.load(path).memory_enabled is False
+ finally:
+ memory.COMMAND = saved
+ written = config.write_default(Path(tmp) / "fresh.toml")
+ assert "memory_enabled = false" in written.read_text()
+ print("ok memory config")
+```
+
+Add `test_memory_config()` to the call list after `test_memory_module()`.
+
+- [ ] **Step 2: Run to see it fail**
+
+Run: `./test_llamachat.py`
+Expected: FAIL with `KeyError: 'memory_enabled'`.
+
+- [ ] **Step 3: Implement**
+
+`llamachat/config.py`, add the import next to the providers one:
+
+```python
+from . import memory
+from . import providers as providers_mod
+```
+
+In `DEFAULTS`, after `"skills_dir": "~/.agents/skills",`:
+
+```python
+ # Memory about the user through the local-memory CLI (desktop-assistant
+ # repo), offered to local models only. Off by default; a flag with no
+ # CLI on PATH resolves to off.
+ "memory_enabled": False,
+```
+
+In the `Config` dataclass, after `skills_dir: Path`:
+
+```python
+ memory_enabled: bool
+```
+
+After `skills_enabled = bool(values["skills_enabled"]) and skills_dir.is_dir()`:
+
+```python
+ memory_enabled = bool(values["memory_enabled"]) and memory.available()
+```
+
+In the constructor call, after `skills_dir=skills_dir,`:
+
+```python
+ memory_enabled=memory_enabled,
+```
+
+In `write_default`'s template, after the `skills_dir` line:
+
+```python
+ '\n'
+ '# Memory about the user, kept by the local-memory CLI and offered to\n'
+ '# local models only. Off unless local-memory is on PATH.\n'
+ f'memory_enabled = {str(DEFAULTS["memory_enabled"]).lower()}\n'
+```
+
+- [ ] **Step 4: Run to see it pass**
+
+Run: `./test_llamachat.py`
+Expected: `ok memory config`, ending with `all checks passed`.
+
+- [ ] **Step 5: README**
+
+In the sample config block, after `skills_dir = "~/.agents/skills"`, add:
+
+```toml
+
+# Memory about the user through the local-memory CLI (desktop-assistant
+# repo), offered to local models only. Off unless local-memory is on PATH.
+memory_enabled = false
+```
+
+- [ ] **Step 6: Commit (ask first)**
+
+```bash
+git add llamachat/config.py test_llamachat.py README.md
+git commit -m "feat: memory_enabled config key"
+```
+
+---
+
+### Task 7: llamachat backend loop
+
+**Files:**
+- Modify: `llamachat/backend.py` (dataclasses near line 85-101, `stream_chat` near line 347-392, `_tool_schemas` and `_run_tool` near line 475-512)
+- Modify: `test_llamachat.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Add after `test_skill_tool_round`:
+
+```python
+def test_memory_tool_round():
+ from llamachat import memory
+
+ with tempfile.TemporaryDirectory() as tmp:
+ fake = Path(tmp) / "local-memory"
+ fake.write_text('#!/bin/sh\necho "$@"\n')
+ fake.chmod(0o755)
+ saved = memory.COMMAND
+ memory.COMMAND = str(fake)
+ try:
+ client = backend.Client("http://x")
+ seen, sent_tools = [], []
+
+ def scripted(model, messages, tools):
+ sent_tools.append(tools)
+ seen.append([dict(m) for m in messages])
+ if len(seen) == 1:
+ yield (
+ "tool_calls",
+ json.dumps([{
+ "index": 0, "id": "call_7",
+ "function": {
+ "name": "remember",
+ "arguments": json.dumps(
+ {"topic": "games", "fact": "likes puzzles"}
+ ),
+ },
+ }]),
+ )
+ yield ("tool_finish", "")
+ else:
+ yield ("content", "saved")
+
+ client._stream_once = scripted
+ cfg = backend.MemoryConfig(enabled=True, max_searches=1)
+ out = list(client.stream_chat(
+ "m", [{"role": "user", "content": "hi"}], memory_cfg=cfg
+ ))
+ finally:
+ memory.COMMAND = saved
+
+ assert ("content", "saved") in out, out
+ assert sent_tools[0] == memory.TOOL_SCHEMAS, sent_tools[0]
+ assert sent_tools[-1] is None
+ tool_msg = next(m for m in seen[1] if m["role"] == "tool")
+ assert tool_msg["tool_call_id"] == "call_7"
+ assert tool_msg["content"].startswith("remember games likes puzzles")
+
+ # Off or absent: no memory tools offered.
+ client = backend.Client("http://x")
+ offered = []
+
+ def plain(model, messages, tools):
+ offered.append(tools)
+ yield ("content", "hi")
+
+ client._stream_once = plain
+ list(client.stream_chat("m", [], memory_cfg=backend.MemoryConfig(enabled=False)))
+ list(client.stream_chat("m", []))
+ assert offered == [None, None], offered
+ print("ok memory tool round")
+```
+
+Add `test_memory_tool_round()` to the call list after `test_skill_tool_round()`.
+
+- [ ] **Step 2: Run to see it fail**
+
+Run: `./test_llamachat.py`
+Expected: FAIL with `AttributeError: module 'llamachat.backend' has no attribute 'MemoryConfig'`.
+
+- [ ] **Step 3: Implement**
+
+Import, next to the existing `from . import ...` lines in `backend.py`:
+
+```python
+from . import memory
+```
+
+After the `SkillsConfig` dataclass:
+
+```python
+@dataclass
+class MemoryConfig:
+ """What stream_chat needs to offer and run the memory tools.
+
+ Only ever built for a local model: the UI decides, per request.
+ """
+ enabled: bool = False
+ max_searches: int = 2 # tool rounds per turn, same cap as search
+```
+
+`stream_chat` signature: add a parameter after `skills_cfg`:
+
+```python
+ memory_cfg: "MemoryConfig | None" = None,
+```
+
+In the body, replace:
+
+```python
+ search_on = search_cfg is not None and search_cfg.enabled
+ skills_on = skills_cfg is not None and skills_cfg.enabled
+ if not (search_on or skills_on):
+```
+
+with:
+
+```python
+ search_on = search_cfg is not None and search_cfg.enabled
+ skills_on = skills_cfg is not None and skills_cfg.enabled
+ memory_on = memory_cfg is not None and memory_cfg.enabled
+ if not (search_on or skills_on or memory_on):
+```
+
+Replace:
+
+```python
+ cap = search_cfg.max_searches if search_on else skills_cfg.max_searches
+```
+
+with:
+
+```python
+ cap = (
+ search_cfg.max_searches if search_on
+ else skills_cfg.max_searches if skills_on
+ else memory_cfg.max_searches
+ )
+```
+
+Replace `tools = None if last_round else self._tool_schemas(search_cfg, skills_cfg)` with:
+
+```python
+ tools = (
+ None if last_round
+ else self._tool_schemas(search_cfg, skills_cfg, memory_cfg)
+ )
+```
+
+Replace the `_run_tool` call inside the loop:
+
+```python
+ yield from self._run_tool(
+ convo, call, search_cfg, skills_cfg, final
+ )
+```
+
+with:
+
+```python
+ yield from self._run_tool(
+ convo, call, search_cfg, skills_cfg, final, memory_cfg
+ )
+```
+
+`_tool_schemas`: new signature `def _tool_schemas(self, search_cfg, skills_cfg, memory_cfg=None):` and before `return tools or None`:
+
+```python
+ if memory_cfg is not None and memory_cfg.enabled:
+ tools.extend(memory.TOOL_SCHEMAS)
+```
+
+`_run_tool`: new signature `def _run_tool(self, convo, call, search_cfg, skills_cfg, last=False, memory_cfg=None):` and before the "A tool call for a name that exists nowhere" comment:
+
+```python
+ if (
+ memory_cfg is not None and memory_cfg.enabled
+ and name in memory.NAMES
+ ):
+ convo.append(
+ memory.tool_message(
+ call["id"], memory.call(name, call["arguments"]), last
+ )
+ )
+ return
+```
+
+- [ ] **Step 4: Run to see it pass**
+
+Run: `./test_llamachat.py`
+Expected: `ok memory tool round`, ending with `all checks passed`.
+
+- [ ] **Step 5: Commit (ask first)**
+
+```bash
+git add llamachat/backend.py test_llamachat.py
+git commit -m "feat: offer and run the memory tools in the tool loop"
+```
+
+---
+
+### Task 8: llamachat UI, local models only
+
+**Files:**
+- Modify: `llamachat/ui.py` (imports line 41, `StreamWorker` near line 290-330, `__init__` near line 562, `_on_model_changed` near line 1124, `new_session` near line 1196, `_system_messages` near line 1488, `_skills_config` near line 1552, `_start_stream` near line 1673, `open_session` near line 2076)
+- Modify: `test_llamachat.py` (fakes near lines 2332 and 2352), `CHANGELOG.md`
+
+- [ ] **Step 1: Write the failing test**
+
+Same window setup as `test_oneshot_skill_persists_until_new`. The cloud provider has no `api_key`, so `models.is_billable` is False and `_on_model_changed` opens no model-settings dialog (a modal would hang the offscreen test).
+
+```python
+def test_memory_local_only():
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat import backend as _backend
+ from llamachat import config as _config
+ from llamachat import memory
+ from llamachat import models as _models
+ from llamachat import providers as _providers
+ from llamachat.ui import ChatWindow
+
+ app = QApplication.instance() or QApplication([])
+
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp = Path(tmp)
+ cfg = _config.load(tmp / "config.toml")
+ cfg.prompts_dir = tmp / "prompts"
+ cfg.db_path = tmp / "t.db"
+ cfg.memory_enabled = True
+ cfg.providers["cloud"] = _providers.Provider("cloud", "http://cloud.example.org")
+ history = db.History(cfg.db_path)
+ window = ChatWindow(
+ cfg, history,
+ _backend.MultiClient(cfg.providers, cfg.request_timeout),
+ _config.parse_presets(cfg.presets_path),
+ _models.ModelStore(cfg.models_path),
+ )
+ saved = memory.prompt
+ memory.prompt = lambda: "MEMORY BLOCK"
+ try:
+ local, cloud = "gemma", "cloud:big-model"
+
+ window.current_model = lambda: local
+ assert window._memory_on()
+ assert "MEMORY BLOCK" in window._system_messages()[0]["content"]
+ assert window._memory_config() == _backend.MemoryConfig(
+ enabled=True, max_searches=cfg.max_searches
+ )
+
+ window.current_model = lambda: cloud
+ assert not window._memory_on()
+ assert all("MEMORY BLOCK" not in m["content"] for m in window._system_messages())
+ assert window._memory_config() is None
+
+ # Local to cloud mid-conversation warns, once per conversation.
+ window.session_id = history.create_session("chat", local)
+ window.status_label.setText("")
+ window._on_model_changed("")
+ assert "remembered facts" in window.status_label.text()
+ window.status_label.setText("")
+ window.current_model = lambda: local
+ window._on_model_changed("")
+ window.current_model = lambda: cloud
+ window._on_model_changed("")
+ assert window.status_label.text() == ""
+ # A new conversation warns again.
+ window.new_session()
+ window.session_id = history.create_session("chat", local)
+ window._on_model_changed("")
+ assert "remembered facts" in window.status_label.text()
+
+ cfg.memory_enabled = False
+ window.current_model = lambda: local
+ assert not window._memory_on()
+ finally:
+ memory.prompt = saved
+ window.close()
+ history.close()
+ print("ok memory local only")
+```
+
+If `providers.split("gemma", cfg.providers)` does not resolve to `local` in this setup (check with a one-line print before asserting), use whatever bare model id the loaded config treats as local; do not change `split`.
+
+Add `test_memory_local_only()` to the call list after `test_memory_tool_round()`.
+
+- [ ] **Step 2: Run to see it fail**
+
+Run: `./test_llamachat.py`
+Expected: FAIL with `AttributeError: ... has no attribute '_memory_on'`.
+
+- [ ] **Step 3: Implement**
+
+Import (line 41):
+
+```python
+from . import backend, memory, models as models_mod, prompts, skills
+```
+
+`StreamWorker.__init__`: add `memory_cfg: backend.MemoryConfig | None = None,` after `skills_cfg`, and `self.memory_cfg = memory_cfg` after `self.skills_cfg = skills_cfg`. In `run()`, replace:
+
+```python
+ stream = client.stream_chat(
+ wire, self.messages, self.search_cfg, self.skills_cfg
+ )
+```
+
+with:
+
+```python
+ stream = client.stream_chat(
+ wire, self.messages, self.search_cfg, self.skills_cfg,
+ memory_cfg=self.memory_cfg,
+ )
+```
+
+In `__init__`, right after `self.session_id: int | None = None` (line 562):
+
+```python
+ self._memory_warned = False
+```
+
+In `new_session`, after `self.session_id = None`, and in `open_session`, after `self.session_id = session_id`:
+
+```python
+ self._memory_warned = False
+```
+
+Next to `_skills_config`:
+
+```python
+ def _memory_on(self) -> bool:
+ """Memory goes to local models only, decided per request."""
+ if not self.cfg.memory_enabled:
+ return False
+ name, _ = providers_mod.split(self.current_model(), self.cfg.providers)
+ provider = self.cfg.providers.get(name)
+ return bool(provider and provider.is_local)
+
+ def _memory_config(self) -> backend.MemoryConfig | None:
+ """What the stream worker needs to offer the memory tools."""
+ if not self._memory_on():
+ return None
+ return backend.MemoryConfig(enabled=True, max_searches=self.cfg.max_searches)
+```
+
+`_system_messages`, before `text = "\n\n".join(...)`:
+
+```python
+ if self._memory_on():
+ parts.append(memory.prompt())
+```
+
+`_start_stream`, the worker construction becomes:
+
+```python
+ self.worker = StreamWorker(
+ self.client, model, messages,
+ self._search_config(), self._skills_config(), self._memory_config(),
+ )
+```
+
+`_on_model_changed`, at the end:
+
+```python
+ # Tool results are never stored, but replies can quote remembered
+ # facts, and the whole history goes to whichever model answers next.
+ if (
+ self.cfg.memory_enabled and self.session_id is not None
+ and not self._memory_on() and not self._memory_warned
+ ):
+ self._memory_warned = True
+ self.show_status(
+ "Earlier replies may mention remembered facts; "
+ "this cloud model will receive them.",
+ error=True,
+ )
+```
+
+Update the two test fakes (near lines 2332 and 2352):
+
+```python
+ def stream_chat(self, wire, messages, search_cfg=None, skills_cfg=None, memory_cfg=None):
+```
+
+- [ ] **Step 4: Run to see it pass**
+
+Run: `./test_llamachat.py`
+Expected: `ok memory local only`, ending with `all checks passed`.
+
+- [ ] **Step 5: CHANGELOG**
+
+Under `## [Unreleased]` / `### Added`:
+
+```markdown
+- Memory about the user, shared with the desktop voice assistant through
+ the `local-memory` CLI: a memory block in the system prompt and
+ `recall`/`remember`/`forget` tools, for local models only. A switch to a
+ cloud model mid-conversation warns once. `memory_enabled`, off by default.
+```
+
+- [ ] **Step 6: Commit (ask first)**
+
+```bash
+git add llamachat/ui.py test_llamachat.py CHANGELOG.md
+git commit -m "feat: memory for local models only, warn on a switch to cloud"
+```
+
+- [ ] **Step 7: Turn it on for the user (ask first)**
+
+Set `memory_enabled = true` in the user's llamachat `config.toml` (path: `grep -n "config.toml" llamachat/config.py` shows how it is found, normally `~/.config/llamachat/config.toml`), start llamachat, ask the local model "Ricordati che mi piacciono i giochi di strategia." Expected: a `remember` call, `~/.local/share/local-memory/games.md` gains the line.
+
+---
+
+### Task 9: the private memory repo
+
+Every step is outward-facing: show the user each command and confirm before running it.
+
+- [ ] **Step 1: Create the repo on the server**
+
+```bash
+gitctl repo create local-memory --private
+```
+
+Expected: gitolite stanza diff, confirmation prompt, then the bare repo built. No cgit phase.
+
+- [ ] **Step 2: Init the directory**
+
+```bash
+mkdir -p -m 0700 ~/.local/share/local-memory && cd ~/.local/share/local-memory
+git init -q
+printf '.lock\n.gitlock\n.git-error\n.tmp*\n' > .gitignore
+mkdir -p ~/.config/git/no-hooks
+git config commit.gpgsign false # user decision 2026-09-30, this repo only
+git config core.hooksPath ~/.config/git/no-hooks # personal data is this repo's content
+gitctl repo add-remote local-memory
+git add -A && git commit -q -m "Start local memory"
+git push -q -u origin HEAD
+```
+
+The `.gitignore` content must match `GITIGNORE` in `local-memory`.
+
+Expected: `git log --format='%h %G? %s'` shows `N` (unsigned) for "Start local memory"; `git status --porcelain` is empty.
+
+- [ ] **Step 3: End to end**
+
+```bash
+local-memory remember core "name is Example"
+sleep 5; git -C ~/.local/share/local-memory log --format=%s -1; ls ~/.local/share/local-memory/.git-error 2>&1
+local-memory forget core "Example"
+```
+
+Expected: `remember core`, then "No such file" for `.git-error`. The forget removes the test fact and pushes too.
+
+---
+
+### Task 10: keep cloud agents out
+
+Outside both repos; confirm each with the user.
+
+- [ ] **Step 1: Global AGENTS.md**
+
+In `~/.config/opencode/AGENTS.md`, under "### Tooling and infrastructure", add:
+
+```markdown
+- Never read `~/.local/share/local-memory/`: it holds facts about the
+ user for local models only (the `local-memory` CLI in desktop-assistant).
+ Cloud-model agents stay out, even when asked about memory in general.
+ (ALWAYS)
+```
+
+Tell the user the exact line, per that file's own rules.
+
+- [ ] **Step 2: Claude Code deny rule**
+
+Use the `update-config` skill to add to `~/.claude/settings.json` under `permissions.deny`:
+
+```json
+"Read(~/.local/share/local-memory/**)",
+"Bash(local-memory:*)"
+```
+
+It is a guardrail, not a wall: other Bash commands can still read the files.
+
+---
+
+### Task 11: live measurement
+
+Against the live model through the assistant's code path (as in the earlier "Tools, live test" findings: a throwaway script that builds the same messages as `reply()` and posts them to `http://localhost:8181/v1/chat/completions` with the tool schema, 10 runs each). Throwaway script in the session scratchpad, never in the repo. Use a scratch `XDG_DATA_HOME` with a few invented facts, never the user's real memory.
+
+- [ ] **Step 1: Run the five cases, 10 runs each**
+
+1. a lasting preference ("Non mi piacciono i giochi di ruolo a turni."): offers to save, no `remember` call;
+2. a passing remark ("Oggi sono stanco dei giochi di ruolo."): no offer;
+3. unrelated questions (time, weather): no offer, 0/10 expected;
+4. a question on a saved topic ("Che gioco mi consigli?") with `games (2)` in the index: calls `recall` with `games`;
+5. "Ricordati che adesso mi piacciono i giochi di ruolo a turni." with the opposite fact saved: `remember`, then `forget` of the old fact.
+
+- [ ] **Step 2: Record and decide**
+
+Add a "### Memory, live test (date)" section to AGENTS.md Findings with the counts. If case 2 or 3 misfires, ask the user whether to drop the offer sentence from `RULES` (explicit "remember that" only). Commit AGENTS.md (ask first).