aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 13:31:42 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 13:31:42 +0200
commit460cd4456790092ea42a8848bb0ea517a1174fca (patch)
tree41d91debdce4ff52d1334f56091dfe812b009f85
parent0185271050ff66d7ce14aa83a990035fc62e73e4 (diff)
downloaddesktop-assistant-460cd4456790092ea42a8848bb0ea517a1174fca.tar.gz
desktop-assistant-460cd4456790092ea42a8848bb0ea517a1174fca.zip
Final review: startup probe, error messages, refusal exit, docs
A startup probe that failed with anything but OSError (llama-server not in router mode, an empty model list) crashed the daemon at boot, against its own comment; it is caught like the per-request failures now. llama-server's reasons reach the client: an HTTP error carries its body and an error chunk mid-stream its message, where the client used to see 'choices'. A daemon that refuses to start, or fails before serving, exits 1 instead of 0. assistant.sh no longer mistakes a --test run for the daemon. AGENTS.md said every section from Hardware to Kokoro was voice-only, which included findings the daemon still applies; it now names the voice ones. It also claimed a stopped reply emits nothing after done, while a late ctx can. The spec lists every error message, the example config no longer shows a non-default temperature as the default, and the updates tool stops telling the model not to read lists aloud. The spec is marked implemented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md15
-rw-r--r--assistant.py28
-rwxr-xr-xassistant.sh2
-rw-r--r--docs/superpowers/specs/2026-10-16-text-daemon-design.md8
-rw-r--r--examples/config.toml2
-rwxr-xr-xexamples/tools/updates.py4
-rw-r--r--examples/tools/updates.toml2
7 files changed, 44 insertions, 17 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 45d6e31..e25ec1e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,9 +10,14 @@ 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.
+button, whisper large-v3-turbo, Kokoro. These sections describe only that
+version, and are kept because a return to voice starts from them:
+"Hardware", "whisper.cpp", and under Findings "Models", "STT backends",
+"Live mic with whisper-stream", both "Push-to-talk" sections,
+"large-v3-turbo", "The loop", "Overlay", "Barge-in", "Wake word trial",
+"TTS" and "Kokoro-82M". The rest still describes the daemon: the tool,
+context-size and tool-call-history findings are rules `assistant.py`
+applies today.
Tools (2026-09-26): read-only shell-command TOML files in
`~/.config/desktop-assistant/tools/`; `config.toml` there overrides the
@@ -35,7 +40,9 @@ socket (ask) ──> llama-server (LLM, tools) ──> socket (think, token, too
- **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`.
+ bumps the turn under it, so a stopped reply cannot send a token or tool
+ event after its `done`. A late `ctx` can still arrive: `ask()` emits it
+ outside the lock, and its numbers are true regardless.
- **One daemon:** `serve()` probe-connects first and refuses to start over a socket that answers; `main()` removes the socket only if it served it.
- **Commands** are type-checked before they run (ids must be `int`: SQLite would match `"1"` to row 1 while the Python comparison with the current id would not) and run under a try, so a failing command answers `error` instead of killing the client's reader.
- **Client sockets** have a 2 s timeout, not non-blocking mode: a large
diff --git a/assistant.py b/assistant.py
index 937700a..db527a5 100644
--- a/assistant.py
+++ b/assistant.py
@@ -488,11 +488,20 @@ def ask(messages, calls, tools=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:
+ try:
+ r = urllib.request.urlopen(req, timeout=300)
+ except urllib.error.HTTPError as e:
+ # The body says why (a full context, a bad request); the status alone does not.
+ raise RuntimeError(f"llama-server {e.code}: "
+ f"{e.read().decode(errors='replace')[:300]}") from None
+ with r:
for line in r:
if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]":
continue
chunk = json.loads(line[6:])
+ if "error" in chunk: # a failure mid-stream arrives as a chunk
+ err = chunk["error"]
+ raise RuntimeError(err.get("message", str(err)) if isinstance(err, dict) else str(err))
if chunk.get("usage"):
# How full the model's context is: history, tool results and
# tool definitions all count.
@@ -626,7 +635,7 @@ def main():
DB = db_open(DB_PATH)
try:
context_size(llm_model()) # so the first hello has it
- except OSError:
+ except Exception:
pass # llama-server down: asked again per request
serve()
served = True
@@ -635,6 +644,8 @@ def main():
signal.pause()
except (KeyboardInterrupt, SystemExit) as e:
log(f"exit: {type(e).__name__} {e}".rstrip())
+ if not served:
+ raise SystemExit(1) # refused or failed before serving: not a clean stop
except BaseException:
log("crashed:\n" + traceback.format_exc().rstrip())
raise
@@ -744,12 +755,13 @@ def test():
self.wfile.write(json.dumps(body).encode())
def do_POST(self):
- self.rfile.read(int(self.headers["Content-Length"]))
+ script["body"] = json.loads(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}]})
+ self.wfile.write(delta.encode() if isinstance(delta, str) else
+ b"data: " + json.dumps({"choices": [{"delta": delta}]})
.encode() + b"\n\n")
self.wfile.flush()
time.sleep(script["delay"])
@@ -801,12 +813,20 @@ def test():
("state", "thinking"), ("token", "It is "), ("token", "noon."),
("done", None)], got
assert until("state")[-1]["state"] == "idle"
+ assert script["body"]["chat_template_kwargs"]["enable_thinking"] is True
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)"}
+ # An error chunk mid-stream reaches the client with its message.
+ script["replies"] = [['data: {"error": {"message": "context full"}}\n\n']]
+ CFG["thinking"] = False
+ send(type="ask", text="too much")
+ assert until("error")[-1]["message"] == "context full"
+ assert script["body"]["chat_template_kwargs"]["enable_thinking"] is False
+ CFG["thinking"] = True
# Stop mid-stream: no token after done, nothing saved.
script["replies"] = [[{"content": f"w{i} "} for i in range(50)]]
script["delay"] = 0.02
diff --git a/assistant.sh b/assistant.sh
index 315829e..30216dd 100755
--- a/assistant.sh
+++ b/assistant.sh
@@ -27,7 +27,7 @@ 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'
+pat='^\S*python3 (-u )?\S*assistant\.py$' # not a --test run
kill_wait() {
pkill -u "$USER" -f "$1" || return 0
diff --git a/docs/superpowers/specs/2026-10-16-text-daemon-design.md b/docs/superpowers/specs/2026-10-16-text-daemon-design.md
index 40a73c8..58d0240 100644
--- a/docs/superpowers/specs/2026-10-16-text-daemon-design.md
+++ b/docs/superpowers/specs/2026-10-16-text-daemon-design.md
@@ -1,6 +1,6 @@
# Text assistant daemon: design
-Date: 2026-10-16. Status: approved in brainstorming, not implemented.
+Date: 2026-10-16. Status: implemented (2026-10-16), live-checked.
Supersedes `2026-09-26-overlay-design.md` (the voice overlay protocol).
## Goal
@@ -85,7 +85,7 @@ Inbound:
| `{"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`, `<type> failed`) and leaves the conversation untouched.
+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`, `<type> failed`) and leaves the conversation untouched. Other errors: `bad command` (not JSON or no `type`), `unknown command <type>`, `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
@@ -141,7 +141,7 @@ delete trigger cleans the index.
question.
- One `turns` row is inserted per `done`, and `updated` is bumped. A crash
loses at most the reply in flight.
-- `open` loads the last 10 turns' `messages` as the model history and all
+- `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
@@ -161,7 +161,7 @@ tail, and exits 1 when the daemon is down.
`python3 assistant.py --test`, extended:
-- the existing socketpair check, now in both directions;
+- 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;
diff --git a/examples/config.toml b/examples/config.toml
index 94bc1e6..85fd846 100644
--- a/examples/config.toml
+++ b/examples/config.toml
@@ -6,7 +6,7 @@
# 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
+# temperature = 0.6 # example; the default is unset: the model preset's
# thinking = true # false skips the model's reasoning (faster first word)
# Added to every tool's environment. Personal values live here, not in
diff --git a/examples/tools/updates.py b/examples/tools/updates.py
index 57cc61a..6308bef 100755
--- a/examples/tools/updates.py
+++ b/examples/tools/updates.py
@@ -102,7 +102,7 @@ def by_action(todo):
def summary(newest, applied, n, todo):
- # Spoken: counts only, names just for the short lists.
+ # Counts only, names just for the short lists.
if not todo:
return f"Up to date with the ChangeLog of {newest}."
by = by_action(todo)
@@ -117,7 +117,7 @@ def summary(newest, applied, n, todo):
for a, label in (("Added", "New"), ("Removed", "Removed")):
if by[a]:
out.append(f"{label}: " + ", ".join(by[a]) + ".")
- out.append("Offer a written recap (report = true); do not read package lists aloud.")
+ out.append("Offer a written recap (report = true); do not list every package.")
return "\n".join(out)
diff --git a/examples/tools/updates.toml b/examples/tools/updates.toml
index 86eac1d..3b05817 100644
--- a/examples/tools/updates.toml
+++ b/examples/tools/updates.toml
@@ -1,4 +1,4 @@
-description = "Pending Slackware -current upgrades: ChangeLog entries not yet installed. Summary gives counts (upgraded, rebuilt, new, removed, security fixes) and a pending kernel version bump; say the counts, never read package lists aloud. Can instead open a written markdown recap in the user's editor, or give one package's ChangeLog notes. Read-only."
+description = "Pending Slackware -current upgrades: ChangeLog entries not yet installed. Summary gives counts (upgraded, rebuilt, new, removed, security fixes) and a pending kernel version bump; give the counts, not whole package lists. Can instead open a written markdown recap in the user's editor, or give one package's ChangeLog notes. Read-only."
command = ["updates.py", "{package}", "{report}"]
timeout = 10