diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-30 20:38:04 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-30 20:38:04 +0200 |
| commit | 15be1778b0a918b093594c1e2b5653033e897939 (patch) | |
| tree | 7b22aa829571e053d719c8af29349ae0649dc025 | |
| parent | d8da556ea1b9686ae8dd2ec68fe37864c247c5af (diff) | |
| download | desktop-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.md | 1424 |
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). |
