# 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 `. 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. # # 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 " 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 " 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 " 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. # # 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 | 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 " 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 /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 /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 /ask.py '{"type":"list"}' conversations` and `/usr/bin/python3 /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":}' 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/.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. 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 " git push ```