# Text assistant daemon: design Date: 2026-10-16. Status: implemented (2026-10-16), live-checked. Supersedes `2026-09-26-overlay-design.md` (the voice overlay protocol). ## Goal Turn desktop-assistant from a push-to-talk voice assistant into a local text assistant daemon, the single brain behind a Luma-style conversation canvas in the quickshell desktop (see the quickshell repo, `docs/superpowers/specs/2026-10-16-assistant-canvas-design.md`). The canvas is only a view: the daemon owns the model, the tools and the history. Voice is dropped for now, not abandoned. The last voice commit is tagged `voice-v1` before any deletion, and future voice work starts from the tag rather than from dormant code behind a flag. The canvas takes llamachat's place on SUPER+Home while it is tested. llamachat keeps running, unbound, and its future is undecided. ## Out of scope for v1 - Voice in any form (STT, TTS, mic button, levels). - A model picker. The daemon keeps using whichever model the router has loaded, so it never swaps a model out under another client. - Attachments, cloud providers, skills: llamachat features that may be ported later. - Model-written conversation titles. - Importing llamachat's existing history. Its schema stores no tool calls. ## What is deleted whisper start/stop, `stt`, `transcribe`, Kokoro and `speaker`, `arecord`, `aplay`, the `amixer events` loop and `mic_on`, `user_levels`, `level_emitter`, `envelope`, `pcm_level`, `scale`, the presentation gate (`presenting`, `notify_ignored`), `announcement`, `split_sentences`, `lang_of`, `latin`, and the `[overlay]` config table. Requirements drop to a running llama-server, Python 3 (stdlib, plus whatever the user's tools need) and `curl`/`jq` for the example tools. ## What is kept `serve` and `emit`, the multi-client JSON-lines socket at `$XDG_RUNTIME_DIR/desktop-assistant.sock` (mode 0600). `ask` streaming, `run_tool`, `collect_calls`, the TOML tools and the example tools. `llm_model()` with its never-swap rule, `context_size()`. The history rule: tool calls are kept and tool results replaced by a placeholder, with the measurements already recorded in `reply()`. The date stamp on the system prompt and the question. The last 10 turns are sent to the model. Config merging, `--test`, `assistant.sh`. ## What changes - **Thinking.** `chat_template_kwargs.enable_thinking` becomes true. `delta.reasoning_content` is streamed as its own `think` event, so the canvas can collapse it. Reasoning is not stored and not sent back to the model. It is the config key `thinking` (default true): measured live, reasoning added about 21 s before the first token of a two-line answer. - **System prompt.** The default is rewritten for text: markdown is allowed, there is no instruction to answer in short spoken sentences. - **Input.** The mic loop is replaced by a reader thread per client. Each line a client writes is one JSON command. `main()` serves the socket, opens the database and waits for SIGTERM. - **Turns.** A command `ask` starts `reply()` on a thread, exactly as a button release did. A global `turn` counter still marks stale replies. - **Errors.** llama-server unreachable, an HTTP error or a malformed stream emit `error` and return the state to idle, instead of killing the thread. Every exit path of `main()` and of a reply thread logs a line. This closes the old debt of the assistant dying with nothing in the log. ## Protocol JSON lines both ways on the existing socket. Every connected client sees every outbound event, so a canvas reopened mid-reply catches up. A client stalled for 2 s on a send is dropped. Inbound: | Command | Effect | | --- | --- | | `{"type":"ask","text":"..."}` | starts a turn in the current conversation | | `{"type":"stop"}` | bumps `turn`: the stream stops, a tool already running finishes and its result is dropped | | `{"type":"new"}` | starts an empty current conversation (the old one is already saved) | | `{"type":"list"}` | answers `conversations` | | `{"type":"open","id":N}` | answers `conversation`; N becomes current | | `{"type":"search","q":"..."}` | answers `results` | | `{"type":"delete","id":N}` | deletes N, answers `conversations`; deleting the current one acts as `new` | Ids in `open` and `delete` must be integers; a bad id, a non-string `text` or `q`, or a command that raises answers `error` (`bad id`, `bad text`, `bad query`, ` failed`) and leaves the conversation untouched. Other errors: `bad command` (not JSON or no `type`), `unknown command `, `no such conversation`, `line too long` (over 1 MB without a newline), and llama-server's own message for an HTTP error or an error chunk. An `ask` with empty text is ignored. An `ask` while a reply is streaming stops the old reply first. An unknown or malformed line is logged and answered with `error`; it never closes the connection. Outbound: | Event | Fields | | --- | --- | | `hello` | `state`, `model`, `ctx_used`, `ctx_max`, `id`, `turns` (the current conversation), `pending` (`{user, reply, think, tools}` of a reply in flight, or null) | | `state` | `state`: `idle`, `thinking`, `tool` (with `tool`) | | `user` | `text`: the question, echoed so every client draws it | | `think` | `delta` | | `token` | `delta` | | `tool` | `name`, `done` (false when it starts, true when it returns) | | `done` | `id`: the conversation the turn was saved in; `stopped`: true for a reply that was stopped | | `error` | `message`; `turn`: true when the error ended the reply in flight, absent for a command error | | `ctx` | `used`, `max` | | `conversations` | `items`: `[{id, title, updated}]`, newest first | | `conversation` | `id`, `turns`: `[{user, reply, tools}]` | | `results` | `items`: `[{id, title, snippet}]`, best first | `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. ## Storage SQLite with FTS5, stdlib `sqlite3`. Verified on the system interpreter: SQLite 3.53.4, and `tokenize='unicode61 remove_diacritics 2'` matches `citta` against `città`, which matters for Italian. One file, `~/.local/share/desktop-assistant/history.db` (`$XDG_DATA_HOME` honoured), directory 0700, file 0600, WAL mode. It is personal data at rest and never goes in a repository. ```sql CREATE TABLE conversations(id INTEGER PRIMARY KEY, title TEXT, created INTEGER, updated INTEGER); CREATE TABLE turns(id INTEGER PRIMARY KEY, conv INTEGER REFERENCES conversations ON DELETE CASCADE, seq INTEGER, user TEXT, reply TEXT, tools TEXT, -- JSON list of tool names, for display messages TEXT); -- JSON: what the model gets for this turn CREATE VIRTUAL TABLE turns_fts USING fts5(user, reply, content='turns', content_rowid='id', tokenize='unicode61 remove_diacritics 2'); ``` Triggers on `turns` keep `turns_fts` in sync for insert and delete; `PRAGMA foreign_keys=ON` makes the conversation delete cascade, and the delete trigger cleans the index. - A conversation row is created at its first `done`, so an empty `new` leaves nothing behind. Its title is the first six words of the first question. - One `turns` row is inserted per `done`, and `updated` is bumped. A crash loses at most the reply in flight. - `open` loads every turn's `messages` as the model history (a reply sends the last 10) and all turns' `user`, `reply`, `tools` for the canvas. Tool results come back as the stored placeholder. - `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. Snippets mark matches with `**`. Results are ranked by `bm25` and grouped to one row per conversation, with `snippet()` from its best turn. - One connection guarded by a lock: the reply threads and the client reader threads all go through it. Only the daemon touches the file. - An index on `turns(conv, seq)`; `db_open` chmods the directory and file even when they already exist. ## assistant.sh The overlay handling goes: `start`, `stop`, `restart` and `status` cover the daemon only. `status` reports the process, the socket and the log's tail, and exits 1 when the daemon is down. ## Testing `python3 assistant.py --test`, extended: - a real temporary Unix socket, in both directions; - a fake LLM stream (a local HTTP server on an ephemeral port) that sends reasoning, content and a tool call, checking the `think`, `token`, `tool`, `done` order on the socket; - `stop` in the middle of the stream: no further `token`, the partial turn not saved; - the schema against `:memory:`: two conversations, a search with an accent-free query finding an accented word, a search containing `"` and `*`, a delete that removes the conversation's rows from `turns_fts`. Manual check with `socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/desktop-assistant.sock` against the real router before the canvas exists. ## Docs README and AGENTS.md are rewritten around "local text assistant daemon". The overlay spec gets a superseded line at the top pointing here. The local-memory spec and plan are unaffected and still target this repo.