# 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. # # 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/.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. # # 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).