aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 12:18:10 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 12:18:10 +0200
commit2f2a3d201889240baff43c0822fa5cc6e4da0cb1 (patch)
treeca020958e9c184db93de3e3b12c40769bde1b80f
parentdb1b252ff0921bce9d8a3b6783480ae0424d02c6 (diff)
downloaddesktop-assistant-2f2a3d201889240baff43c0822fa5cc6e4da0cb1.tar.gz
desktop-assistant-2f2a3d201889240baff43c0822fa5cc6e4da0cb1.zip
Plan the text daemonvoice-v1
Six tasks: tag voice-v1 and strip voice, SQLite history, the two-way protocol and reply loop, assistant.sh, a live check against the router, docs. Every code block was assembled stage by stage into a scratch copy and its self-check run; the planning-time deviations from the spec are listed at the top and folded back into it by the docs task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-10-16-text-daemon.md1574
1 files changed, 1574 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-text-daemon.md b/docs/superpowers/plans/2026-10-16-text-daemon.md
new file mode 100644
index 0000000..e5e42ed
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-16-text-daemon.md
@@ -0,0 +1,1574 @@
+# Text Assistant Daemon 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:** Turn desktop-assistant from a push-to-talk voice assistant into a text daemon: questions in and streamed replies out on its Unix socket, history in SQLite with FTS5 search.
+
+**Architecture:** One stdlib Python file, `assistant.py`. The voice pipeline is deleted (kept at tag `voice-v1`). The existing JSON-lines socket becomes two-way: one reader thread per client hands commands to `handle()`. A reply thread per question streams `think`/`token`/`tool` events from llama-server through `say()`, which checks a turn counter under one lock so a stopped reply can never emit after its `done`. Finished turns go to `history.db`.
+
+**Tech Stack:** Python 3.12 stdlib (`sqlite3` with FTS5, `socket`, `threading`, `urllib`, `http.server` for the test's fake llama-server), bash for `assistant.sh`.
+
+**Spec:** `docs/superpowers/specs/2026-10-16-text-daemon-design.md`
+
+**Conventions:**
+- No em dashes. No personal data or home paths in committed files (gitleaks hooks enforce it). GPLv2-only header in every source file.
+- This repo's AGENTS.md says to ask before committing: get the user's go-ahead at each commit step. Commits are GPG-signed by default; end each message with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Work on master.
+- Self-check: `/usr/bin/python3 assistant.py --test` prints `ok` (the last line; log lines from the test's own replies come before it).
+- Never `pkill -f` with a pattern that matches the agent's own shell. Stop a daemon started for a check by the PID or job the harness owns.
+
+**Verified while planning:** every code block below was assembled into a scratch copy of the repo and run: the stage after Task 1, after Task 2 and after Task 3 each printed `ok`, Task 3's five times in a row. FTS5 checks done by hand on SQLite 3.53.4: `"citt"*` matches `città`; the queries `""""*`, `"*"*`, `"a""b"*`, `""*` return nothing and raise nothing; a conversation delete cascades into `turns` and fires the FTS delete trigger; `INSERT INTO turns_fts(turns_fts, rank) VALUES ('integrity-check', 1)` raises "database disk image is malformed" when the trigger is missing, which is what the self-check relies on. The user's live `~/.config/desktop-assistant/config.toml` sets only `temperature` and `[env]`, so it still loads after the voice keys are removed.
+
+**Deviations from the spec, decided while planning:**
+- `hello` also carries `pending` (`{user, reply, think, tools}` of a reply in flight, or null), so a client that connects mid-reply can draw it instead of receiving orphan tokens.
+- A stopped reply's `done` carries `"stopped": true`. It is emitted by whoever stops it (`stop`, `ask`, `new`, `open`, `delete` of the current conversation), not by the reply thread.
+- Search adds a prefix star to the quoted phrase (`"cit"*`), so results update as the user types a word.
+- Snippets mark matches with `**`, which the canvas renders as markdown bold.
+- Client sockets use a 2 s timeout instead of non-blocking mode: a large `hello` or `conversation` event would not fit a non-blocking send and would drop the client. A client stalled for 2 s is dropped.
+
+---
+
+## File structure
+
+| File | Status | Responsibility |
+| --- | --- | --- |
+| `assistant.py` | rewrite | config, tools, storage, socket protocol, reply loop, self-check |
+| `assistant.sh` | modify | start/stop/restart/status for the daemon only |
+| `examples/config.toml` | rewrite | the remaining keys |
+| `examples/tools/*.toml` | modify | drop the voice-only `announce` lines |
+| `README.md` | rewrite | text daemon |
+| `AGENTS.md` | modify | header, pipeline, findings, next steps |
+| `docs/superpowers/specs/2026-09-26-overlay-design.md` | modify | superseded line |
+| `docs/superpowers/specs/2026-10-16-text-daemon-design.md` | modify | the deviations above |
+
+---
+
+### Task 1: Tag the voice version and strip voice
+
+**Files:**
+- Modify: `assistant.py` (whole file)
+
+- [ ] **Step 1: Tag the last voice commit**
+
+```bash
+cd ~/Programming/GIT/desktop-assistant
+git status --short # expect: clean
+git tag -s voice-v1 -m "Last voice version: push-to-talk, whisper, Kokoro, overlay feed" HEAD
+git tag -v voice-v1 2>&1 | grep -c "Good signature" # expect: 1
+```
+
+- [ ] **Step 2: Replace `assistant.py` with the voice-free version**
+
+Everything voice goes: `mic_on`, `presenting`, `notify_ignored`, `latin`, `scale`, `pcm_level`, `envelope`, `transcribe`, `stt`, `IT_WORDS`/`EN_WORDS`, `lang_of`, `split_sentences`, `announcement`, `speaker`, `user_levels`, `level_emitter`, `die_with_parent`, `start_whisper`, `reply`, the `card`/`whisper`/`kokoro`/`voice`/`overlay` config keys and their tests. `hello` loses the overlay table. `main()` only serves the socket until Task 3 gives it something to do. The DB and storage imports arrive now so the header does not change again. Write the file with exactly this content:
+
+```python
+#!/usr/bin/env python3
+# desktop-assistant: local text assistant daemon.
+# 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.
+"""Questions arrive as JSON lines on a Unix socket, the loaded llama-server
+model answers with tools, replies stream back on the same socket, and every
+finished turn is kept in a SQLite history with full-text search.
+
+Run: python3 assistant.py (--test runs the self-check)
+"""
+
+import glob
+import json
+import os
+import re
+import signal
+import socket
+import sqlite3
+import subprocess
+import sys
+import threading
+import time
+import tomllib
+import traceback
+import urllib.parse
+import urllib.request
+
+CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
+ "desktop-assistant")
+DB_PATH = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
+ "desktop-assistant", "history.db")
+DEFAULTS = {
+ "llm": "http://localhost:8181/v1",
+ "system": ("You are a desktop assistant. Answer in the language the user writes, "
+ "Italian or English. Markdown is rendered: use lists, tables and fenced "
+ "code blocks when they help, and keep answers as short as the question "
+ "allows. Facts about the user's computer, devices, accounts, mail, "
+ "calendar or money come only from a tool: call one, and if none can "
+ "tell, say you do not know. Never guess them."),
+ "tool_rounds": 3,
+ "temperature": None, # None: the llama-server preset's
+ "env": {},
+}
+OPEN = {"env"} # free-form tables: replaced whole, not merged
+
+
+def merge(base, over, where=""):
+ # A typo must fail loudly, not fall back to the default in silence.
+ out = dict(base)
+ for k, v in over.items():
+ if k not in base:
+ raise SystemExit(f"config: unknown key {where}{k}")
+ if isinstance(base[k], dict) and k not in OPEN:
+ if not isinstance(v, dict):
+ raise SystemExit(f"config: {where}{k} must be a table")
+ v = merge(base[k], v, f"{where}{k}.")
+ out[k] = v
+ return out
+
+
+def load_config(path):
+ try:
+ with open(path, "rb") as f:
+ return merge(DEFAULTS, tomllib.load(f))
+ except FileNotFoundError:
+ return DEFAULTS
+
+
+def load_tools(d):
+ tools = {}
+ for path in sorted(glob.glob(os.path.join(d, "*.toml"))):
+ with open(path, "rb") as f:
+ t = tomllib.load(f)
+ if not isinstance(t.get("description"), str) or not t.get("command"):
+ raise SystemExit(f"{path}: needs description and command")
+ tools[os.path.basename(path)[:-5]] = t
+ return tools
+
+
+def schema(tools):
+ return [{"type": "function", "function": {
+ "name": name, "description": t["description"],
+ "parameters": {"type": "object", "properties": {
+ p: {"type": s.get("type", "string"), "description": s.get("description", "")}
+ for p, s in t.get("params", {}).items()}}}}
+ for name, t in tools.items()]
+
+
+CFG = load_config(os.path.join(CONFIG_DIR, "config.toml"))
+TOOLS_DIR = os.path.join(CONFIG_DIR, "tools")
+TOOLS = load_tools(TOOLS_DIR)
+TOOL_SCHEMA = schema(TOOLS) or None
+SOCK = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "desktop-assistant.sock")
+
+
+def log(*a):
+ print(time.strftime("%H:%M:%S"), *a, flush=True)
+
+
+# The canvas's feed: JSON lines on a Unix socket. With no client connected
+# every event is dropped.
+clients, clients_lock = [], threading.Lock()
+now_state = {"state": "idle"} # repeated in hello for a late client
+ctx = {"model": None, "max": 0} # context size of the model in use
+
+
+def emit(event):
+ if event["type"] == "state":
+ now_state.clear()
+ now_state.update({k: v for k, v in event.items() if k != "type"})
+ line = (json.dumps(event) + "\n").encode()
+ with clients_lock:
+ for c in clients[:]:
+ try:
+ c.sendall(line) # non-blocking: a stalled client errors
+ except OSError:
+ clients.remove(c)
+ c.close()
+
+
+def hello():
+ return {"type": "hello", "ctx_max": ctx["max"], **now_state}
+
+
+def serve(path=SOCK):
+ try:
+ os.unlink(path) # left over from a crash
+ except FileNotFoundError:
+ pass
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ old = os.umask(0o177) # 0600: only this user reads the stream
+ try:
+ srv.bind(path)
+ finally:
+ os.umask(old)
+ srv.listen()
+
+ def accept():
+ while True:
+ c, _ = srv.accept()
+ try:
+ c.sendall((json.dumps(hello()) + "\n").encode())
+ except OSError:
+ c.close()
+ continue
+ c.setblocking(False)
+ with clients_lock:
+ clients.append(c)
+ threading.Thread(target=accept, daemon=True).start()
+
+
+def context_size(model):
+ # The router can swap models, so ask again whenever the id changes.
+ if ctx["model"] != model:
+ url = f"{CFG['llm'].removesuffix('/v1')}/props?model={urllib.parse.quote(model)}"
+ try:
+ with urllib.request.urlopen(url, timeout=5) as r:
+ ctx["max"] = json.load(r)["default_generation_settings"]["n_ctx"]
+ except (OSError, KeyError, ValueError):
+ ctx["max"] = 0
+ ctx["model"] = model
+ return ctx["max"]
+
+
+def run_tool(tools, name, arguments, d=TOOLS_DIR):
+ # Whatever goes wrong becomes the tool's answer, so the model can say it
+ # could not find out; a tool never takes the assistant down.
+ t = tools.get(name)
+ if t is None:
+ return f"error: no tool named {name}"
+ try:
+ args = json.loads(arguments or "{}")
+ except json.JSONDecodeError:
+ args = None
+ if not isinstance(args, dict):
+ return "error: arguments are not valid JSON"
+ vals = {p: "" if args.get(p) is None else str(args[p]) for p in t.get("params", {})}
+ # No shell: each {name} fills in inside one argv element.
+ argv = [re.sub(r"\{(\w+)\}", lambda m: vals.get(m.group(1), ""), a) for a in t["command"]]
+ if "/" not in argv[0] and os.path.exists(os.path.join(d, argv[0])):
+ argv[0] = os.path.join(d, argv[0])
+ env = {**os.environ, **{k: str(v) for k, v in CFG["env"].items()}}
+ try:
+ # Own process group: a timeout kills the script's children too (curl),
+ # which would otherwise keep the pipes open and block us.
+ p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
+ env=env, start_new_session=True)
+ except OSError as e:
+ return f"error: cannot run {argv[0]}: {e.strerror}"
+ try:
+ out, err = p.communicate(timeout=t.get("timeout", 10))
+ except subprocess.TimeoutExpired:
+ os.killpg(p.pid, signal.SIGKILL)
+ p.communicate()
+ return "error: timed out"
+ if p.returncode:
+ last = err.strip().splitlines()[-1:]
+ return ("error: " + (last[0] if last else f"exit status {p.returncode}"))[:2000]
+ return out.strip()[:2000]
+
+
+def collect_calls(calls, fragments):
+ # A streamed tool call arrives in pieces keyed by index: id and name in
+ # the first, the JSON arguments spread over the rest.
+ for f in fragments:
+ c = calls.setdefault(f["index"], {"id": "", "name": "", "arguments": ""})
+ fn = f.get("function") or {}
+ c["id"] = f.get("id") or c["id"]
+ c["name"] = fn.get("name") or c["name"]
+ c["arguments"] += fn.get("arguments") or ""
+
+
+def llm_model():
+ # The router keeps one model resident; asking for another would swap it
+ # out under other clients, so use whatever is not unloaded.
+ with urllib.request.urlopen(CFG["llm"] + "/models") as r:
+ models = json.load(r)["data"]
+ return next((m["id"] for m in models if m["status"]["value"] != "unloaded"),
+ models[0]["id"])
+
+
+def ask(messages, calls, tools=None):
+ # Yields reply text as it streams; tool-call fragments go into calls.
+ model = llm_model()
+ context_size(model)
+ body = {"model": model, "messages": messages, "stream": True,
+ "stream_options": {"include_usage": True},
+ "chat_template_kwargs": {"enable_thinking": False}}
+ if tools:
+ body["tools"] = tools
+ if CFG["temperature"] is not None:
+ body["temperature"] = CFG["temperature"]
+ req = urllib.request.Request(CFG["llm"] + "/chat/completions", json.dumps(body).encode(),
+ {"Content-Type": "application/json"})
+ with urllib.request.urlopen(req) as r:
+ for line in r:
+ if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]":
+ continue
+ chunk = json.loads(line[6:])
+ if chunk.get("usage"):
+ # How full the model's context is: history, tool results and
+ # tool definitions all count.
+ used = chunk["usage"]["prompt_tokens"]
+ log(f"context: {used} prompt tokens")
+ emit({"type": "ctx", "used": used, "max": ctx["max"]})
+ choices = chunk["choices"]
+ if not choices:
+ continue
+ delta = choices[0]["delta"]
+ collect_calls(calls, delta.get("tool_calls") or [])
+ if delta.get("content"):
+ yield delta["content"]
+
+
+
+def main():
+ # SIGTERM runs the finally below, so the socket is not left behind.
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
+ try:
+ context_size(llm_model()) # so the first hello has it
+ except OSError:
+ pass # llama-server down: asked again per request
+ serve()
+ log(f"ready on {SOCK}")
+ try:
+ while True:
+ signal.pause()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ if os.path.exists(SOCK):
+ os.remove(SOCK)
+
+
+def test():
+ import tempfile
+
+ c = merge(DEFAULTS, {"tool_rounds": 1, "env": {"K": "v"}})
+ assert c["tool_rounds"] == 1 and c["env"] == {"K": "v"}
+ assert DEFAULTS["tool_rounds"] == 3 # defaults untouched
+ for bad in ({"card": "X"}, {"whisper": {"port": 1}}): # voice-era keys are gone
+ try:
+ merge(DEFAULTS, bad)
+ assert False, bad
+ except SystemExit as e:
+ assert "config:" in str(e)
+ with tempfile.TemporaryDirectory() as d:
+ with open(os.path.join(d, "echo.sh"), "w") as f:
+ f.write('#!/bin/sh\nprintf "%s|" "$@"\n')
+ os.chmod(os.path.join(d, "echo.sh"), 0o755)
+ with open(os.path.join(d, "now.toml"), "w") as f:
+ f.write('description = "d"\ncommand = ["date"]\n'
+ '[params.x]\ntype = "string"\ndescription = "px"\n')
+ assert load_tools(d) == {"now": {"description": "d", "command": ["date"],
+ "params": {"x": {"type": "string", "description": "px"}}}}
+ assert schema(load_tools(d)) == [{"type": "function", "function": {
+ "name": "now", "description": "d", "parameters": {"type": "object", "properties": {
+ "x": {"type": "string", "description": "px"}}}}}]
+ tools = {"echo": {"command": ["echo.sh", "{a}", "x{b}y"], "params": {"a": {}, "b": {}}},
+ "slow": {"command": ["sh", "-c", "sleep 5; true"], "timeout": 0.3},
+ "fail": {"command": ["sh", "-c", "echo oops >&2; exit 3"]},
+ "gone": {"command": ["no-such-binary-here"]}}
+ # A value is one argv element, never a shell command or a second argument.
+ assert run_tool(tools, "echo", '{"a": "x; rm -rf ~", "b": 2}', d) == "x; rm -rf ~|x2y|"
+ assert run_tool(tools, "echo", "{}", d) == "|xy|"
+ t0 = time.time()
+ assert run_tool(tools, "slow", "{}", d) == "error: timed out"
+ assert time.time() - t0 < 2 # the grandchild sleep was killed too
+ assert run_tool(tools, "fail", "{}", d) == "error: oops"
+ assert run_tool(tools, "gone", "{}", d).startswith("error: cannot run")
+ assert run_tool(tools, "nope", "{}", d) == "error: no tool named nope"
+ assert run_tool(tools, "echo", "{bad", d) == "error: arguments are not valid JSON"
+ assert run_tool(tools, "echo", "[1]", d) == "error: arguments are not valid JSON"
+ calls = {}
+ collect_calls(calls, [{"index": 0, "id": "c1", "type": "function",
+ "function": {"name": "weather", "arguments": "{"}}])
+ for piece in ['"place', '":', '"', "Rome", '"', "}"]:
+ collect_calls(calls, [{"index": 0, "function": {"arguments": piece}}])
+ collect_calls(calls, [{"index": 1, "id": "c2", "function": {"name": "now", "arguments": "{}"}}])
+ assert calls == {0: {"id": "c1", "name": "weather", "arguments": '{"place":"Rome"}'},
+ 1: {"id": "c2", "name": "now", "arguments": "{}"}}
+
+ a, b = socket.socketpair()
+ a.setblocking(False)
+ clients.append(a)
+ emit({"type": "ctx", "used": 1, "max": 2})
+ assert json.loads(b.recv(4096)) == {"type": "ctx", "used": 1, "max": 2}
+ emit({"type": "state", "state": "tool", "tool": "search"})
+ assert now_state == {"state": "tool", "tool": "search"}
+ b.recv(4096)
+ b.close()
+ emit({"type": "state", "state": "idle"}) # the dead client is dropped
+ emit({"type": "state", "state": "idle"})
+ assert a not in clients and now_state == {"state": "idle"}
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "s.sock")
+ serve(path)
+ assert os.stat(path).st_mode & 0o777 == 0o600
+ c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ c.connect(path)
+ f = c.makefile()
+ first = json.loads(f.readline())
+ assert first == {"type": "hello", "ctx_max": ctx["max"], "state": "idle"}
+ c.close()
+ print("ok")
+
+
+if __name__ == "__main__":
+ test() if sys.argv[1:] == ["--test"] else main()
+```
+
+- [ ] **Step 3: Run the self-check**
+
+Run: `/usr/bin/python3 assistant.py --test`
+Expected: `ok`
+
+- [ ] **Step 4: Confirm nothing voice is left**
+
+Run: `grep -nE "whisper|kokoro|arecord|aplay|amixer|numpy|overlay|presenting|speaker" assistant.py`
+Expected: no output.
+
+- [ ] **Step 5: Commit (ask first)**
+
+```bash
+git add assistant.py
+git commit -m "Drop the voice pipeline
+
+The assistant becomes a text daemon behind the quickshell canvas. STT,
+TTS, the mic button loop, the presentation gate, the audio levels and the
+overlay config table go; the socket, the tool loop, the never-swap model
+rule and the config merge stay. The voice version is tagged voice-v1, so
+later voice work starts from the tag instead of from dormant code.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push && git push origin voice-v1
+```
+
+---
+
+### Task 2: History storage
+
+**Files:**
+- Modify: `assistant.py` (new block after `log()`, test block in `test()`)
+
+- [ ] **Step 1: Write the failing test**
+
+In `test()`, insert this block right after the `collect_calls` assertion (`assert calls == {0: ...}`) and before `a, b = socket.socketpair()`:
+
+```python
+ # Storage.
+ db = db_open(":memory:")
+ a = save_turn(db, None, "che tempo fa in città oggi pomeriggio?", "Piove.", ["weather"],
+ [{"role": "user", "content": "q"}])
+ assert save_turn(db, a, "e domani?", "Sole.", [], [{"role": "user", "content": "q2"}]) == a
+ b = save_turn(db, None, "how do I list files", "Use `ls`.", [], [])
+ assert [x["id"] for x in list_convs(db)] == [b, a]
+ assert list_convs(db)[1]["title"] == "che tempo fa in città oggi" # first six words
+ hist, shown = load_conv(db, a)
+ assert hist == [[{"role": "user", "content": "q"}], [{"role": "user", "content": "q2"}]]
+ assert shown == [{"user": "che tempo fa in città oggi pomeriggio?", "reply": "Piove.",
+ "tools": ["weather"]},
+ {"user": "e domani?", "reply": "Sole.", "tools": []}]
+ assert load_conv(db, 999) is None
+ hits = search(db, "citta") # no accent typed, accented word found
+ assert [h["id"] for h in hits] == [a] and "**città**" in hits[0]["snippet"]
+ assert [h["id"] for h in search(db, "cit")] == [a] # prefix, as you type
+ assert [h["id"] for h in search(db, "piove")] == [a] # replies are indexed
+ for q in ('"', "*", 'a"b', "NOT", "x OR y", "ls)", ""): # FTS syntax is text
+ search(db, q)
+ delete_conv(db, a)
+ assert load_conv(db, a) is None and search(db, "citta") == []
+ # Raises if the index still holds the deleted turns: the cascade must
+ # have fired the delete trigger.
+ db.execute("INSERT INTO turns_fts(turns_fts, rank) VALUES ('integrity-check', 1)")
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "sub", "h.db")
+ db_open(path).close()
+ assert os.stat(os.path.dirname(path)).st_mode & 0o777 == 0o700
+ assert os.stat(path).st_mode & 0o777 == 0o600
+```
+
+- [ ] **Step 2: Run it to make sure it fails**
+
+Run: `/usr/bin/python3 assistant.py --test`
+Expected: `NameError: name 'db_open' is not defined`
+
+- [ ] **Step 3: Implement the storage**
+
+Insert this block after `log()` and before the `# The canvas's feed` comment:
+
+```python
+# History: one SQLite file, written only by this process. turns.messages is
+# what the model is sent for that turn; user, reply and tools are what the
+# canvas draws. The FTS index follows turns through triggers, and a
+# conversation delete cascades into turns and so into the index.
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS conversations(id INTEGER PRIMARY KEY, title TEXT,
+ created INTEGER, updated INTEGER);
+CREATE TABLE IF NOT EXISTS turns(id INTEGER PRIMARY KEY,
+ conv INTEGER REFERENCES conversations ON DELETE CASCADE,
+ seq INTEGER, user TEXT, reply TEXT, tools TEXT, messages TEXT);
+CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(user, reply, content='turns',
+ content_rowid='id', tokenize='unicode61 remove_diacritics 2');
+CREATE TRIGGER IF NOT EXISTS turns_ai AFTER INSERT ON turns BEGIN
+ INSERT INTO turns_fts(rowid, user, reply) VALUES (new.id, new.user, new.reply);
+END;
+CREATE TRIGGER IF NOT EXISTS turns_ad AFTER DELETE ON turns BEGIN
+ INSERT INTO turns_fts(turns_fts, rowid, user, reply)
+ VALUES ('delete', old.id, old.user, old.reply);
+END;
+"""
+
+
+def db_open(path):
+ if path != ":memory:":
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
+ old = os.umask(0o177) # 0600, and the WAL files inherit it
+ try:
+ conn = sqlite3.connect(path, check_same_thread=False)
+ finally:
+ os.umask(old)
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ conn.executescript(SCHEMA)
+ return conn
+
+
+def save_turn(conn, conv, user, reply, tools, messages):
+ # Returns the conversation id: the row is made at its first finished turn,
+ # so a New that is never used leaves nothing behind.
+ now = int(time.time())
+ with conn:
+ if conv is None:
+ conv = conn.execute("INSERT INTO conversations(title, created, updated) "
+ "VALUES (?, ?, ?)",
+ (" ".join(user.split()[:6]), now, now)).lastrowid
+ else:
+ conn.execute("UPDATE conversations SET updated = ? WHERE id = ?", (now, conv))
+ seq = conn.execute("SELECT COALESCE(MAX(seq), 0) + 1 FROM turns WHERE conv = ?",
+ (conv,)).fetchone()[0]
+ conn.execute("INSERT INTO turns(conv, seq, user, reply, tools, messages) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (conv, seq, user, reply, json.dumps(tools), json.dumps(messages)))
+ return conv
+
+
+def list_convs(conn):
+ return [{"id": i, "title": t, "updated": u} for i, t, u in conn.execute(
+ "SELECT id, title, updated FROM conversations ORDER BY updated DESC, id DESC")]
+
+
+def load_conv(conn, conv):
+ # (model history, shown turns), or None for an id that is not there.
+ rows = conn.execute("SELECT user, reply, tools, messages FROM turns WHERE conv = ? "
+ "ORDER BY seq", (conv,)).fetchall()
+ if not rows:
+ return None
+ return ([json.loads(m) for *_, m in rows],
+ [{"user": u, "reply": r, "tools": json.loads(t)} for u, r, t, _ in rows])
+
+
+def search(conn, q):
+ # The query is one quoted phrase with a prefix star on its last word, so
+ # FTS syntax typed by the user is matched as text and cannot widen it.
+ # ponytail: rank per turn, first hit per conversation; 30 results.
+ q = q.strip()
+ if not q:
+ return []
+ seen, out = set(), []
+ for conv, title, snip in conn.execute(
+ "SELECT t.conv, c.title, snippet(turns_fts, -1, '**', '**', '…', 12) "
+ "FROM turns_fts JOIN turns t ON t.id = turns_fts.rowid "
+ "JOIN conversations c ON c.id = t.conv "
+ "WHERE turns_fts MATCH ? ORDER BY rank LIMIT 300",
+ ('"' + q.replace('"', '""') + '"*',)):
+ if conv not in seen:
+ seen.add(conv)
+ out.append({"id": conv, "title": title, "snippet": snip})
+ return out[:30]
+
+
+def delete_conv(conn, conv):
+ with conn:
+ conn.execute("DELETE FROM conversations WHERE id = ?", (conv,))
+```
+
+- [ ] **Step 4: Run the self-check**
+
+Run: `/usr/bin/python3 assistant.py --test`
+Expected: `ok`
+
+- [ ] **Step 5: Commit (ask first)**
+
+```bash
+git add assistant.py
+git commit -m "Keep conversations in SQLite with full-text search
+
+One file under XDG_DATA_HOME, 0700 directory and 0600 file. turns.messages
+holds what the model is sent for the turn, user/reply/tools what the canvas
+draws. FTS5 with remove_diacritics finds città from citta. The query is
+quoted as one phrase so FTS syntax typed by the user is matched as text,
+and the self-check's integrity-check proves a conversation delete cascades
+into the index.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 3: Two-way protocol and the text reply loop
+
+**Files:**
+- Modify: `assistant.py` (socket section, `llm_model`, `ask`, new `say`/`reply`/`answer`, `main`, `test()`)
+
+- [ ] **Step 1: Write the failing test**
+
+In `test()`, add `import http.server` as the first line, above `import tempfile`. Then replace everything from `a, b = socket.socketpair()` down to, not including, the final `print("ok")` with this block:
+
+```python
+ # Protocol, against a fake llama-server.
+ global DB, TOOLS, TOOL_SCHEMA
+ script = {"replies": [], "delay": 0}
+
+ class Fake(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_GET(self):
+ body = ({"data": [{"id": "m", "status": {"value": "loaded"}}]}
+ if self.path.endswith("/models")
+ else {"default_generation_settings": {"n_ctx": 4096}})
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(json.dumps(body).encode())
+
+ def do_POST(self):
+ self.rfile.read(int(self.headers["Content-Length"]))
+ self.send_response(200)
+ self.end_headers()
+ try:
+ for delta in script["replies"].pop(0):
+ self.wfile.write(b"data: " + json.dumps({"choices": [{"delta": delta}]})
+ .encode() + b"\n\n")
+ self.wfile.flush()
+ time.sleep(script["delay"])
+ self.wfile.write(b'data: {"choices": [], "usage": {"prompt_tokens": 42}}\n\n'
+ b"data: [DONE]\n\n")
+ except BrokenPipeError: # a stopped reply hangs up mid-stream
+ pass
+
+ srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fake)
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ CFG["llm"] = f"http://127.0.0.1:{srv.server_address[1]}/v1"
+ DB = db_open(":memory:")
+ TOOLS = {"now": {"description": "d", "command": ["sh", "-c", "echo noon"]}}
+ TOOL_SCHEMA = schema(TOOLS)
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "s.sock")
+ serve(path)
+ assert os.stat(path).st_mode & 0o777 == 0o600
+ cl = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ cl.connect(path)
+ cl.settimeout(5)
+ f = cl.makefile("rb")
+
+ def send(**cmd):
+ cl.sendall((json.dumps(cmd) + "\n").encode())
+
+ def until(kind):
+ seen = []
+ while True:
+ e = json.loads(f.readline())
+ seen.append(e)
+ if e["type"] == kind:
+ return seen
+
+ h = json.loads(f.readline())
+ assert h["type"] == "hello" and h["state"] == "idle" and h["turns"] == []
+ assert h["pending"] is None and "ctx_max" in h
+ # Thinking, a tool round, then the answer.
+ script["replies"] = [
+ [{"reasoning_content": "hmm"},
+ {"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "now",
+ "arguments": "{}"}}]}],
+ [{"content": "It is "}, {"content": "noon."}]]
+ send(type="ask", text="what time is it")
+ got = [(e["type"], e.get("delta") or e.get("name") or e.get("state") or e.get("text"))
+ for e in until("done") if e["type"] != "ctx"]
+ assert got == [("user", "what time is it"), ("state", "thinking"), ("think", "hmm"),
+ ("tool", "now"), ("state", "tool"), ("tool", "now"),
+ ("state", "thinking"), ("token", "It is "), ("token", "noon."),
+ ("done", None)], got
+ assert until("state")[-1]["state"] == "idle"
+ conv = cur["id"]
+ assert load_conv(DB, conv)[1] == [{"user": "what time is it", "reply": "It is noon.",
+ "tools": ["now"]}]
+ # The tool result went to the model but history keeps a placeholder.
+ assert load_conv(DB, conv)[0][0][2] == {"role": "tool", "tool_call_id": "c1",
+ "content": "(shown to the user at the time)"}
+ # Stop mid-stream: no token after done, nothing saved.
+ script["replies"] = [[{"content": f"w{i} "} for i in range(50)]]
+ script["delay"] = 0.02
+ send(type="ask", text="long one")
+ until("token")
+ send(type="stop")
+ seen = until("done")
+ assert seen[-1].get("stopped") is True
+ time.sleep(0.3) # the reply thread notices and quits
+ send(type="list")
+ late = until("conversations")
+ assert not [e for e in late if e["type"] == "token"], late
+ assert len(load_conv(DB, conv)[1]) == 1
+ script["delay"] = 0
+ # History browsing.
+ send(type="new")
+ assert until("conversation")[-1] == {"type": "conversation", "id": None, "turns": []}
+ send(type="list")
+ assert [x["id"] for x in until("conversations")[-1]["items"]] == [conv]
+ send(type="search", q="tim")
+ assert until("results")[-1]["items"][0]["id"] == conv
+ send(type="open", id=conv)
+ assert until("conversation")[-1]["turns"][0]["reply"] == "It is noon."
+ send(type="open", id=999)
+ assert until("error")[-1]["message"] == "no such conversation"
+ cl.sendall(b"not json\n")
+ assert until("error")[-1]["message"] == "bad command"
+ send(type="delete", id=conv)
+ seen = until("conversations")
+ assert {"type": "conversation", "id": None, "turns": []} in seen # was current
+ assert seen[-1]["items"] == []
+ # llama-server down: an error, not a dead thread.
+ CFG["llm"] = "http://127.0.0.1:9/v1"
+ send(type="ask", text="anyone?")
+ assert until("error")[-1]["type"] == "error"
+ assert until("state")[-1]["state"] == "idle" and cur["pending"] is None
+ # A second client gets hello with the conversation so far.
+ c2 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ c2.connect(path)
+ assert json.loads(c2.makefile("rb").readline())["type"] == "hello"
+ c2.close()
+ cl.close()
+ srv.shutdown()
+```
+
+- [ ] **Step 2: Run it to make sure it fails**
+
+Run: `/usr/bin/python3 assistant.py --test`
+Expected: an `AssertionError` on `h["turns"] == []` or a `KeyError: 'turns'` (the old `hello` has no conversation).
+
+- [ ] **Step 3: Replace the socket section**
+
+Replace everything from the `# The canvas's feed` comment through the end of `serve()` with:
+
+```python
+# The canvas's feed: JSON lines on a Unix socket, both ways. Every client
+# gets every event. A client that stalls for 2 s is dropped rather than
+# holding up the others.
+clients, clients_lock = [], threading.Lock()
+now_state = {"state": "idle"} # repeated in hello for a late client
+ctx = {"model": None, "max": 0, "used": 0}
+# One lock for the conversation, the turn counter and the database. Emits
+# that depend on the turn happen under it, so a stopped reply cannot send a
+# token after its done.
+lock = threading.RLock()
+cur = {"id": None, "history": [], "turns": [], "pending": None}
+turn = 0 # bumped by every ask, stop, new and open: older replies are stale
+DB = None
+
+
+def emit(event):
+ if event["type"] == "state":
+ now_state.clear()
+ now_state.update({k: v for k, v in event.items() if k != "type"})
+ line = (json.dumps(event) + "\n").encode()
+ with clients_lock:
+ for c in clients[:]:
+ try:
+ c.sendall(line)
+ except OSError: # gone, or stalled past its timeout
+ clients.remove(c)
+ c.close()
+
+
+def hello():
+ return {"type": "hello", **now_state, "model": ctx["model"], "ctx_used": ctx["used"],
+ "ctx_max": ctx["max"], "id": cur["id"], "turns": cur["turns"],
+ "pending": cur["pending"]}
+
+
+def drop(c):
+ with clients_lock:
+ if c in clients:
+ clients.remove(c)
+ c.close()
+
+
+def reader(c):
+ # A timed-out recv is only an idle client; the socket stays usable.
+ buf = b""
+ while True:
+ try:
+ data = c.recv(65536)
+ except TimeoutError:
+ continue
+ except OSError:
+ break
+ if not data:
+ break
+ buf += data
+ *lines, buf = buf.split(b"\n")
+ for line in lines:
+ if line.strip():
+ handle(line)
+ drop(c)
+
+
+def serve(path=SOCK):
+ try:
+ os.unlink(path) # left over from a crash
+ except FileNotFoundError:
+ pass
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ old = os.umask(0o177) # 0600: only this user talks to it
+ try:
+ srv.bind(path)
+ finally:
+ os.umask(old)
+ srv.listen()
+
+ def accept():
+ while True:
+ c, _ = srv.accept()
+ c.settimeout(2)
+ # Under the lock: no event can fall between hello and the client
+ # joining the list.
+ with lock:
+ try:
+ c.sendall((json.dumps(hello()) + "\n").encode())
+ except OSError:
+ c.close()
+ continue
+ with clients_lock:
+ clients.append(c)
+ threading.Thread(target=reader, args=(c,), daemon=True).start()
+ threading.Thread(target=accept, daemon=True).start()
+
+
+def interrupt():
+ # Caller holds lock. A reply in flight ends now: done marks it stopped,
+ # its partial text is not saved, its thread notices the turn and returns.
+ global turn
+ turn += 1
+ if cur["pending"] is not None:
+ cur["pending"] = None
+ emit({"type": "done", "id": cur["id"], "stopped": True})
+ emit({"type": "state", "state": "idle"})
+
+
+def start_new():
+ interrupt()
+ cur.update(id=None, history=[], turns=[])
+
+
+def handle(raw):
+ try:
+ cmd = json.loads(raw)
+ kind = cmd["type"]
+ except (ValueError, TypeError, KeyError):
+ log(f"bad command: {raw[:200]!r}")
+ emit({"type": "error", "message": "bad command"})
+ return
+ with lock:
+ if kind == "ask":
+ text = str(cmd.get("text", "")).strip()
+ if text:
+ interrupt()
+ threading.Thread(target=reply, args=(turn, text), daemon=True).start()
+ elif kind == "stop":
+ interrupt()
+ elif kind == "new":
+ start_new()
+ emit({"type": "conversation", "id": None, "turns": []})
+ elif kind == "list":
+ emit({"type": "conversations", "items": list_convs(DB)})
+ elif kind == "open":
+ got = load_conv(DB, cmd.get("id"))
+ if got is None:
+ emit({"type": "error", "message": "no such conversation"})
+ return
+ interrupt()
+ cur.update(id=cmd["id"], history=got[0], turns=got[1])
+ emit({"type": "conversation", "id": cur["id"], "turns": cur["turns"]})
+ elif kind == "search":
+ emit({"type": "results", "items": search(DB, str(cmd.get("q", "")))})
+ elif kind == "delete":
+ delete_conv(DB, cmd.get("id"))
+ if cmd.get("id") == cur["id"]:
+ start_new()
+ emit({"type": "conversation", "id": None, "turns": []})
+ emit({"type": "conversations", "items": list_convs(DB)})
+ else:
+ log(f"unknown command: {kind!r}")
+ emit({"type": "error", "message": f"unknown command {kind}"})
+```
+
+- [ ] **Step 4: Replace `llm_model()` and `ask()`, add the reply loop**
+
+`llm_model()` gains a timeout. `ask()` turns thinking on and yields `(kind, text)` pairs. Replace both functions with this block, which also adds `say()`, `reply()` and `answer()` after them:
+
+```python
+def llm_model():
+ # The router keeps one model resident; asking for another would swap it
+ # out under other clients, so use whatever is not unloaded.
+ with urllib.request.urlopen(CFG["llm"] + "/models", timeout=10) as r:
+ models = json.load(r)["data"]
+ return next((m["id"] for m in models if m["status"]["value"] != "unloaded"),
+ models[0]["id"])
+
+
+def ask(messages, calls, tools=None):
+ # Yields ("think", text) and ("token", text) as they stream; tool-call
+ # fragments go into calls.
+ model = llm_model()
+ context_size(model)
+ body = {"model": model, "messages": messages, "stream": True,
+ "stream_options": {"include_usage": True},
+ "chat_template_kwargs": {"enable_thinking": True}}
+ if tools:
+ body["tools"] = tools
+ if CFG["temperature"] is not None:
+ body["temperature"] = CFG["temperature"]
+ req = urllib.request.Request(CFG["llm"] + "/chat/completions", json.dumps(body).encode(),
+ {"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=300) as r:
+ for line in r:
+ if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]":
+ continue
+ chunk = json.loads(line[6:])
+ if chunk.get("usage"):
+ # How full the model's context is: history, tool results and
+ # tool definitions all count.
+ ctx["used"] = chunk["usage"]["prompt_tokens"]
+ emit({"type": "ctx", "used": ctx["used"], "max": ctx["max"]})
+ choices = chunk["choices"]
+ if not choices:
+ continue
+ delta = choices[0]["delta"]
+ collect_calls(calls, delta.get("tool_calls") or [])
+ if delta.get("reasoning_content"):
+ yield "think", delta["reasoning_content"]
+ if delta.get("content"):
+ yield "token", delta["content"]
+```
+
+```python
+def say(mine, kind, value, done=False):
+ # Sends one piece of the reply, unless the reply went stale; False tells
+ # the reply thread to give up.
+ with lock:
+ if turn != mine:
+ return False
+ p = cur["pending"]
+ if kind == "think":
+ p["think"] += value
+ emit({"type": "think", "delta": value})
+ elif kind == "token":
+ p["reply"] += value
+ emit({"type": "token", "delta": value})
+ else:
+ if not done:
+ p["tools"].append(value)
+ emit({"type": "tool", "name": value, "done": done})
+ emit({"type": "state", "state": "thinking"} if done
+ else {"type": "state", "state": "tool", "tool": value})
+ return True
+
+
+def reply(mine, text):
+ try:
+ answer(mine, text)
+ except Exception as e:
+ log("reply failed:\n" + traceback.format_exc().rstrip())
+ with lock:
+ if turn == mine:
+ cur["pending"] = None
+ emit({"type": "error", "message": str(e) or type(e).__name__})
+ emit({"type": "state", "state": "idle"})
+
+
+def answer(mine, text):
+ with lock:
+ if turn != mine:
+ return
+ history = list(cur["history"])
+ cur["pending"] = {"user": text, "reply": "", "think": "", "tools": []}
+ emit({"type": "user", "text": text})
+ emit({"type": "state", "state": "thinking"})
+ log(f"you: {text}")
+ question = {"role": "user", "content": text}
+ # The model has no clock: without the date it makes one up for tools.
+ # Also stamped on the question itself, which history does not keep: with
+ # the time only in the system prompt, "what time is it?" asked again an
+ # hour later copied the old answer from history. Stamping every message
+ # in history fixed that but lost tool arguments (account filter 0/6).
+ now = time.strftime("%A %d %B %Y, %H:%M")
+ # ponytail: last 10 turns, summarise if long sessions need memory
+ msgs = ([{"role": "system", "content": f"{CFG['system']} It is {now}."}]
+ + [m for past in history[-10:] for m in past]
+ + [{"role": "user", "content": f"[{now}] {text}"}])
+ first = len(msgs) # this turn's tool messages start here
+ said, used = "", []
+ for rnd in range(CFG["tool_rounds"] + 1):
+ calls, content = {}, ""
+ # The last round offers no tools, which forces an answer.
+ for kind, piece in ask(msgs, calls, TOOL_SCHEMA if rnd < CFG["tool_rounds"] else None):
+ if not say(mine, kind, piece):
+ log("interrupted")
+ return
+ if kind == "token":
+ content += piece
+ said += content
+ if not calls:
+ break
+ msgs.append({"role": "assistant", "content": content, "tool_calls": [
+ {"id": c["id"], "type": "function",
+ "function": {"name": c["name"], "arguments": c["arguments"]}}
+ for c in calls.values()]})
+ # ponytail: a stop does not kill a running tool, its result is dropped
+ for c in calls.values():
+ if not say(mine, "tool", c["name"]):
+ log("interrupted")
+ return
+ used.append(c["name"])
+ result = run_tool(TOOLS, c["name"], c["arguments"])
+ log(f"tool {c['name']}({c['arguments']}): {result[:120]!r}")
+ msgs.append({"role": "tool", "tool_call_id": c["id"], "content": result})
+ if not say(mine, "tool", c["name"], done=True):
+ log("interrupted")
+ return
+ # History keeps the tool calls: with only what was said, the model saw
+ # facts answered without tools and invented the next ones (no tool call
+ # 9/10 on a replayed session, 0/10 with the calls kept). Results are
+ # replaced by a placeholder, for context: same 10/10, and a follow-up on
+ # an old result calls the tool again (10/10). A stopped exchange is
+ # dropped whole, so user and assistant still alternate.
+ past = [{**m, "content": "(shown to the user at the time)"} if m["role"] == "tool" else m
+ for m in msgs[first:]]
+ entry = [question] + past + [{"role": "assistant", "content": said}]
+ with lock:
+ if turn != mine:
+ log("interrupted")
+ return
+ cur["id"] = save_turn(DB, cur["id"], text, said, used, entry)
+ cur["history"].append(entry)
+ cur["turns"].append({"user": text, "reply": said, "tools": used})
+ cur["pending"] = None
+ emit({"type": "done", "id": cur["id"]})
+ emit({"type": "state", "state": "idle"})
+ log("assistant:", said)
+```
+
+- [ ] **Step 5: Replace `main()`**
+
+```python
+def main():
+ global DB
+ # SIGTERM runs the finally below, so the socket is not left behind.
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
+ try:
+ DB = db_open(DB_PATH)
+ try:
+ context_size(llm_model()) # so the first hello has it
+ except OSError:
+ pass # llama-server down: asked again per request
+ serve()
+ log(f"ready on {SOCK}")
+ while True:
+ signal.pause()
+ except (KeyboardInterrupt, SystemExit) as e:
+ log(f"exit: {type(e).__name__}")
+ except BaseException:
+ log("crashed:\n" + traceback.format_exc().rstrip())
+ raise
+ finally:
+ if os.path.exists(SOCK):
+ os.remove(SOCK)
+```
+
+- [ ] **Step 6: Run the self-check**
+
+Run: `/usr/bin/python3 assistant.py --test`
+Expected: last line `ok`. A `reply failed:` traceback ending in `Connection refused` comes before it: that is the test's llama-server-down case, logged as it would be live.
+
+- [ ] **Step 7: Run it four more times**
+
+Run: `for i in 1 2 3 4; do /usr/bin/python3 assistant.py --test 2>&1 | tail -1; done`
+Expected: `ok` four times. The stop case depends on thread timing, and a flaky result here is a real race, not noise.
+
+- [ ] **Step 8: Commit (ask first)**
+
+```bash
+git add assistant.py
+git commit -m "Take questions on the socket and stream replies back
+
+Each client gets a reader thread; commands are ask, stop, new, list, open,
+search and delete. A reply streams think, token and tool events through
+say(), which checks the turn counter under the same lock that stop takes,
+so a stopped reply cannot send a token after its done. A finished turn is
+saved; a stopped one is not. hello carries the current conversation and
+any reply in flight, so a reconnecting canvas redraws both. Errors from
+llama-server become an error event and the daemon stays up; every exit
+path of main() logs a line, which the voice version's silent death lacked.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 4: assistant.sh for the daemon alone
+
+**Files:**
+- Modify: `assistant.sh` (whole file)
+
+- [ ] **Step 1: Replace `assistant.sh` with:**
+
+```bash
+#!/bin/bash
+# desktop-assistant: start, stop, restart or check the daemon.
+# 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.
+#
+# assistant.sh start|stop|restart|status
+#
+# stop: SIGTERM any running assistant.py, SIGKILL after 10 s. start: run it
+# in its own session, detached from the terminal, logging where the
+# Hyprland autostart does; does nothing for one already running. restart:
+# stop, then start. status: the process, the socket and the log's tail;
+# exit 1 when it is down.
+
+set -eu
+
+dir=$(cd "$(dirname "$0")" && pwd)
+log="${XDG_RUNTIME_DIR:?not in a login session}/desktop-assistant.log"
+pat='^\S*python3 (-u )?\S*assistant\.py'
+
+kill_wait() {
+ pkill -u "$USER" -f "$1" || return 0
+ for _ in $(seq 50); do
+ pgrep -u "$USER" -f "$1" >/dev/null || return 0
+ sleep 0.2
+ done
+ pkill -KILL -u "$USER" -f "$1" && sleep 0.5 || true
+}
+
+running() { pgrep -u "$USER" -f "$1" >/dev/null; }
+
+stop() {
+ kill_wait "$pat"
+}
+
+start() {
+ cd "$dir"
+ running "$pat" || setsid -f python3 -u "$dir/assistant.py" >>"$log" 2>&1 </dev/null
+ sleep 0.5
+ status
+}
+
+status() {
+ local pid
+ if pid=$(pgrep -u "$USER" -of "$pat"); then
+ echo "assistant: running"
+ ps -o pid=,etime=,rss= -p "$pid" | awk '{ printf " pid %s, up %s, %d MB\n", $1, $2, $3 / 1024 }'
+ else
+ echo "assistant: not running"
+ fi
+ [ -S "$XDG_RUNTIME_DIR/desktop-assistant.sock" ] && echo "socket: present" || echo "socket: missing"
+ echo "log: $log"
+ tail -n 5 "$log" 2>/dev/null | sed 's/^/ /'
+ [ -n "$pid" ]
+}
+
+case "${1:-}" in
+ start) start ;;
+ stop) stop ;;
+ restart) stop; start ;;
+ status) status ;;
+ *) echo "usage: $0 start|stop|restart|status" >&2; exit 2 ;;
+esac
+```
+
+- [ ] **Step 2: Check it**
+
+Run: `bash -n assistant.sh && ./assistant.sh status; echo "rc=$?"`
+Expected: `assistant: not running`, `socket: missing`, the log lines, `rc=1`.
+
+- [ ] **Step 3: Commit (ask first)**
+
+```bash
+git add assistant.sh
+git commit -m "assistant.sh: manage the daemon only
+
+The overlay is gone from the quickshell repo, and the canvas that replaces
+it lives in the desktop shell, which has its own lifecycle. status now
+reports the process, the socket and the log.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 5: Live check against the real router
+
+This wakes the resident model if it sleeps. Tell the user before starting.
+
+- [ ] **Step 1: Make sure no daemon already runs**
+
+Run: `pgrep -af 'python3 (-u )?\S*assistant\.py'`
+Expected: no output. If one runs, ask the user before stopping it.
+
+- [ ] **Step 2: Start the daemon as a harness-owned background command**
+
+Run with `run_in_background`: `/usr/bin/python3 -u assistant.py`
+Expected in its output: `ready on .../desktop-assistant.sock`.
+
+- [ ] **Step 3: Write a throwaway client**
+
+Write this to `ask.py` in the session's scratchpad directory (not the repo). It sends one command, prints each event with its time since sending, and stops at the event named last on the command line:
+
+```python
+import json, os, socket, sys, time
+s = socket.socket(socket.AF_UNIX)
+s.connect(os.environ["XDG_RUNTIME_DIR"] + "/desktop-assistant.sock")
+f = s.makefile("rb")
+f.readline() # hello
+t = time.time()
+s.sendall((sys.argv[1] + "\n").encode())
+for line in f:
+ e = json.loads(line)
+ print(f"{time.time() - t:6.2f} {line.decode().rstrip()[:150]}")
+ if e["type"] in (sys.argv[2], "error"):
+ break
+```
+
+- [ ] **Step 4: Ask a question that needs no tool, then one that does**
+
+Run: `/usr/bin/python3 <scratchpad>/ask.py '{"type":"ask","text":"Explain in two lines what a symlink is, with one shell example in a code block."}' done`
+
+Expected: `user`, `state thinking`, then `think` deltas if the preset thinks, `token` deltas containing a fenced block, `ctx`, `done`. Record: whether any `think` event arrived (Gemma 4 with `enable_thinking` true and the `reasoning-budget` preset), the time to the first `token`, the `ctx` numbers.
+
+Run: `/usr/bin/python3 <scratchpad>/ask.py '{"type":"ask","text":"What time is it?"}' done`
+
+Expected: `tool` `now` with `done` false, `state tool`, `tool` `now` with `done` true, then tokens with the time. Record the time to the first `token`.
+
+- [ ] **Step 5: Check history and search**
+
+Run: `/usr/bin/python3 <scratchpad>/ask.py '{"type":"list"}' conversations` and `/usr/bin/python3 <scratchpad>/ask.py '{"type":"search","q":"symlink"}' results`
+Expected: `conversations` with one item (both questions went to the same conversation), `results` with that id and a snippet containing `**symlink**` (or the word as the model wrote it).
+Run: `stat -c '%a %n' ~/.local/share/desktop-assistant ~/.local/share/desktop-assistant/history.db`
+Expected: `700` and `600`.
+
+- [ ] **Step 6: Stop the background daemon**
+
+Stop it through the harness (the background task), not with `pkill -f`. Expected in its output: `exit: SystemExit` or `exit: KeyboardInterrupt`, and the socket file gone.
+
+- [ ] **Step 7: Delete the test conversation**
+
+The live check left a conversation in the user's real history. Ask the user whether to keep it; if not, start the daemon again, run `ask.py '{"type":"delete","id":<id from Step 5>}' conversations`, then stop it.
+
+---
+
+### Task 6: Docs and examples
+
+**Files:**
+- Modify: `README.md`, `AGENTS.md`, `examples/config.toml`, `examples/tools/*.toml`, both specs
+
+- [ ] **Step 1: Replace `examples/config.toml` with:**
+
+```toml
+# desktop-assistant configuration. Copy to ~/.config/desktop-assistant/
+# and uncomment what you change; every key is optional and the values
+# shown are the built-in defaults. An unknown key stops the assistant
+# with its name, so a typo cannot fall back to a default in silence.
+
+# llm = "http://localhost:8181/v1" # llama-server, OpenAI API
+# system = "You are a desktop assistant. ..."
+# tool_rounds = 3 # tool round trips per question
+# temperature = 0.6 # per request; unset = the model preset's
+
+# Added to every tool's environment. Personal values live here, not in
+# the tool files.
+[env]
+# SEARXNG_URL = "http://searxng.example.org"
+# WEATHER_ENV = "/home/you/.config/udt/weather.env"
+# CALENDAR_DIR = "/home/you/.local/share/calendars"
+```
+
+- [ ] **Step 2: Drop the `announce` lines from the example tools**
+
+Run: `sed -i '/^announce\./d' examples/tools/*.toml && grep -l announce examples/tools/*.toml`
+Expected: no output. The user's own copies in `~/.config/desktop-assistant/tools/` keep theirs; the key is ignored, so nothing breaks.
+
+- [ ] **Step 3: Replace `README.md` with:**
+
+````markdown
+# desktop-assistant
+
+A local text assistant daemon. Questions arrive on a Unix socket, the
+model already loaded in a local llama-server answers, calling read-only
+tools when a question needs them, and the reply streams back token by
+token. Every conversation is kept in a local SQLite history with
+full-text search. The model and the history stay on the machine; only
+tools you enable (weather, web search) reach the network.
+
+The front end is the assistant canvas of the quickshell desktop
+(`desktop/assistant/` in the quickshell repo, on SUPER+Home). Anything
+that speaks JSON lines on a Unix socket can drive it.
+
+The voice version (push-to-talk, whisper, Kokoro) is the tag `voice-v1`.
+
+## Requirements
+
+- a running `llama-server` with an OpenAI-compatible API, router mode
+- Python 3 (stdlib only; SQLite with FTS5)
+- `curl` and `jq` for the weather and search example tools,
+ `python3-dateutil` for the calendar one
+
+## Usage
+
+```sh
+python3 assistant.py # run
+python3 assistant.py --test # self-check
+./assistant.sh start # detached
+./assistant.sh stop
+./assistant.sh restart
+./assistant.sh status
+```
+
+The daemon uses whichever model the router has loaded, so it never swaps
+a model out under another client.
+
+## Protocol
+
+JSON lines both ways on `$XDG_RUNTIME_DIR/desktop-assistant.sock` (mode
+0600). Every connected client sees every event.
+
+In: `ask {text}`, `stop`, `new`, `list`, `open {id}`, `search {q}`,
+`delete {id}`.
+
+Out: `hello` on connect (state, model, context use, the current
+conversation and any reply in flight), then `user`, `think`, `token`,
+`tool`, `done`, `state`, `ctx`, `error`, and the answers `conversations`,
+`conversation` and `results`. The full table is in
+`docs/superpowers/specs/2026-10-16-text-daemon-design.md`.
+
+Try it by hand:
+
+```sh
+socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/desktop-assistant.sock
+{"type":"ask","text":"What time is it?"}
+```
+
+## History
+
+`~/.local/share/desktop-assistant/history.db` (`$XDG_DATA_HOME`
+honoured), directory 0700, file 0600. The model is sent the last 10 turns
+of the open conversation. Tool results are stored as a placeholder, not
+their output. Reasoning is shown live and not stored.
+
+## Configuration and tools
+
+Everything is optional. Settings and tools live in
+`~/.config/desktop-assistant/` (or `$XDG_CONFIG_HOME/desktop-assistant/`):
+
+```sh
+mkdir -p ~/.config/desktop-assistant
+cp examples/config.toml ~/.config/desktop-assistant/
+cp -r examples/tools ~/.config/desktop-assistant/
+```
+
+- `config.toml`: every key is optional, the example lists the defaults.
+ `[env]` is added to every tool's environment; put instance URLs and
+ key file paths there.
+- `tools/<name>.toml`: one tool each, enabled by being there. A tool is a
+ command run without a shell, its stdout goes back to the model:
+
+```toml
+description = "What the tool does, for the model"
+command = ["script.sh", "{param}"] # looked up in tools/ first, then PATH
+timeout = 10
+
+[params.param]
+type = "string"
+description = "What the model should pass"
+```
+
+The example tools only read:
+
+- `now`: date and time
+- `calendar`: `agenda.py` over a vdirsyncer folder
+- `weather`: OpenWeatherMap, key file in the conky-theme-udt format
+- `search`: a SearXNG instance with the JSON format enabled
+- `calc`: `qalc` (arithmetic, units, currencies, date math)
+- `mail`: unread threads through `mailctl` searches, no tagging or sync
+- `playing`: the current track from `playerctl`, or `mpc`
+- `finance`: balances, budgets and spending from Firefly III's `firefly`
+ list commands
+- `bluetooth`: power, connected devices (battery when reported) and paired
+ ones, through `bluetoothctl show/devices/info`
+- `vms`: libvirt VMs and their state, or one VM's details, through `virsh`
+ read commands
+- `updates`: Slackware -current ChangeLog entries newer than the installed
+ packages: counts, security fixes, kernel bump, one package's notes, or a
+ full markdown recap opened in `MD_EDITOR` (default `xdg-open`); `CHANGELOG`
+ and `PKGDIR` in `[env]` override the mirror path and package database
+
+## License
+
+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. See [LICENSE](LICENSE).
+
+## Development Approach
+
+This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.
+
+All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.
+
+The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
+````
+
+- [ ] **Step 4: Rewrite the head of `AGENTS.md`**
+
+Replace everything from the first line down to, not including, `## Hardware` with:
+
+````markdown
+# desktop-assistant
+
+A local text assistant daemon: questions in and streamed replies out on a
+Unix socket, answered by the model already loaded in llama-server, with
+read-only tools and a SQLite history. Nothing leaves the machine except
+what an enabled tool fetches.
+
+Status (2026-10-16): **text daemon**. The front end is the quickshell
+canvas (`desktop/assistant/` in the quickshell repo, SUPER+Home). Spec:
+`docs/superpowers/specs/2026-10-16-text-daemon-design.md`.
+
+The voice version is the tag `voice-v1`: push-to-talk on the mic's mute
+button, whisper large-v3-turbo, Kokoro. The sections from "Hardware"
+through "Kokoro-82M" describe that version and its measurements. They are
+kept because a return to voice starts from them.
+
+Tools (2026-09-26): read-only shell-command TOML files in
+`~/.config/desktop-assistant/tools/`; `config.toml` there overrides the
+built-in settings. Examples in `examples/`. Spec:
+`docs/superpowers/specs/2026-09-26-tools-and-config-design.md`.
+
+Run: `python3 assistant.py` (`--test` for the self-check), or
+`./assistant.sh restart` to replace a running one detached from the
+terminal.
+
+---
+
+## Pipeline
+
+```
+socket (ask) ──> llama-server (LLM, tools) ──> socket (think, token, tool, done)
+ └─> history.db (SQLite, FTS5)
+```
+
+- **Concurrency:** one reader thread per client, one reply thread per
+ question. One lock guards the conversation, the turn counter and the
+ database. `say()` checks the turn under it before each emit, and stop
+ bumps the turn under it, so a stopped reply cannot emit after its `done`.
+- **Client sockets** have a 2 s timeout, not non-blocking mode: a large
+ `hello` would not fit a non-blocking send and would drop the client.
+- **Search** quotes the query as one FTS5 phrase with a prefix star. The
+ self-check proves typed FTS syntax (`"`, `*`, `NOT`, `OR`, `)`) is
+ matched as text and raises nothing.
+
+````
+
+- [ ] **Step 5: Add the text daemon's findings**
+
+Insert, directly under `## Findings`, a section `### Text daemon, live check (2026-10-16)` recording what Task 5 measured, as plain numbers: whether `think` events arrived and with which preset, the time from `user` to the first `token` for both questions, the `ctx` used/max, the tool round's event order, and the history file's permissions. Measured values only; if something was not measured, leave it out rather than estimating it.
+
+- [ ] **Step 6: Replace `## Next steps`**
+
+Replace the section's body (up to, not including, `## Package-side leftovers`) with:
+
+```markdown
+- **Canvas:** the quickshell side, `desktop/assistant/`, its own plan in
+ that repo.
+- **Local memory:** `docs/superpowers/plans/2026-09-30-local-memory.md`
+ still applies; its tasks 1-4 target this daemon, the voice-specific
+ bits of task 4 aside.
+- **llamachat ports**, if the canvas replaces llamachat for good:
+ attachments, skills, a model picker, model-written titles.
+- **Phase 2 tools:** tools that write or modify (events, messages,
+ desktop), with a review step, own spec.
+- **Voice:** start from `voice-v1`. The open items from that era (STT
+ latency, wake word, OpenVINO) are in the sections above.
+```
+
+- [ ] **Step 7: Mark the overlay spec superseded**
+
+In `docs/superpowers/specs/2026-09-26-overlay-design.md`, replace the `Date:` line with:
+
+```markdown
+Date: 2026-09-26. Status: **superseded** by
+`2026-10-16-text-daemon-design.md`; the overlay and the voice pipeline it
+drew were removed (tag `voice-v1`).
+```
+
+- [ ] **Step 8: Record the deviations in the daemon spec**
+
+In `docs/superpowers/specs/2026-10-16-text-daemon-design.md`:
+- in the outbound table, change the `hello` row's fields to `state`, `model`, `ctx_used`, `ctx_max`, `id`, `turns` (the current conversation), `pending` (`{user, reply, think, tools}` of a reply in flight, or null);
+- change the `done` row's fields to `id`: the conversation the turn was saved in; `stopped`: true for a reply that was stopped;
+- replace the sentence starting "`hello` is sent on connect." with: "`hello` is sent on connect. A stopped reply gets a `done` with `stopped: true` from whatever stopped it, and that partial turn is not saved.";
+- in Storage, replace the `search` bullet's first sentence with: "`search` wraps the user's text as an FTS5 phrase (double quotes doubled inside) with a prefix star, so FTS syntax in the query cannot break or widen it and a half-typed word still matches.", and add "Snippets mark matches with `**`."
+- in Protocol, after "Every connected client sees every outbound event", add: "A client stalled for 2 s on a send is dropped."
+
+- [ ] **Step 9: Check for em dashes and home paths**
+
+Run: `grep -nP "\x{2014}" README.md AGENTS.md examples/config.toml docs/superpowers/specs/2026-10-16-text-daemon-design.md; grep -rn "$HOME" README.md AGENTS.md examples docs`
+Expected: no output from either.
+
+- [ ] **Step 10: Commit (ask first)**
+
+```bash
+git add README.md AGENTS.md examples docs
+git commit -m "Docs: desktop-assistant is a text daemon now
+
+README and AGENTS.md describe the socket protocol and the history instead
+of the voice pipeline; the voice-era findings stay, marked as such, since
+a return to voice starts from them and from the voice-v1 tag. The overlay
+spec is marked superseded, the daemon spec records what changed while
+planning, and the example tools lose their spoken announcements.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```