diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 12:18:10 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 12:18:10 +0200 |
| commit | 2f2a3d201889240baff43c0822fa5cc6e4da0cb1 (patch) | |
| tree | ca020958e9c184db93de3e3b12c40769bde1b80f | |
| parent | db1b252ff0921bce9d8a3b6783480ae0424d02c6 (diff) | |
| download | desktop-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.md | 1574 |
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 +``` |
