summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-26-tools-and-config.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-26 07:59:30 +0200
committerDanilo M. <danix@danix.xyz>2026-09-26 07:59:30 +0200
commit7decd3e052003eb67c4f73126d96d4a5200d0c3c (patch)
tree365aef29d08892a6e4ae369d5a1999602daf510a /docs/superpowers/plans/2026-09-26-tools-and-config.md
parent5af82eae05bd8d5666f4f9750adab87e0c645755 (diff)
downloaddesktop-assistant-7decd3e052003eb67c4f73126d96d4a5200d0c3c.tar.gz
desktop-assistant-7decd3e052003eb67c4f73126d96d4a5200d0c3c.zip
Add implementation plan for tools and the config folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-09-26-tools-and-config.md')
-rw-r--r--docs/superpowers/plans/2026-09-26-tools-and-config.md1016
1 files changed, 1016 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-26-tools-and-config.md b/docs/superpowers/plans/2026-09-26-tools-and-config.md
new file mode 100644
index 0000000..3b7316c
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-26-tools-and-config.md
@@ -0,0 +1,1016 @@
+# Tools and Config Folder (Phase 1) 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:** Read-only tools (time, calendar, weather, web search) that the LLM can call, defined as shell-command TOML files in `~/.config/desktop-assistant/tools/`, plus a `config.toml` replacing the hard-coded constants.
+
+**Architecture:** Everything in the assistant stays in the single file `assistant.py`: config merge, tool loading, tool running (argv, no shell, process-group timeout), streamed `tool_calls` assembly and the tool round loop inside `reply()`. Example tools ship in `examples/` and are copied by the user into the config folder; their helper scripts are standalone (`agenda.py`, `weather.sh`, `search.sh`) and get personal values only from `[env]` in `config.toml`.
+
+**Tech Stack:** Python 3.12 stdlib (`tomllib`, `subprocess`, `urllib`), `python3-dateutil` (agenda only), bash + `curl` + `jq`, llama-server OpenAI API with `--jinja` tool calling.
+
+**Spec:** `docs/superpowers/specs/2026-09-26-tools-and-config-design.md`
+
+**Conventions (from AGENTS.md, apply to every task):**
+- No em dashes anywhere. No personal data in any committed file (no real city, instance URL, calendar or event names): use `example.org`, `/home/you`, generic event names.
+- Every new source file gets the GPLv2-only header used in `assistant.py` (copyright line `Copyright (C) 2026 Danilo M. <danix@danix.xyz>`).
+- Commits are GPG-signed (default config, never disable), end with the `Co-Authored-By` line, and are made only after the user approved committing this plan's work. Push after each commit with `git push`.
+- The self-check is `python3 assistant.py --test`, which must print `ok`.
+
+---
+
+## File structure
+
+| File | Status | Responsibility |
+|------|--------|----------------|
+| `assistant.py` | modify | config merge, tool load/run, tool loop, self-check |
+| `examples/config.toml` | create | every key with its default, commented |
+| `examples/tools/now.toml` | create | date/time tool |
+| `examples/tools/search.toml`, `search.sh` | create | SearXNG search |
+| `examples/tools/calendar.toml`, `agenda.py` | create | vdir calendar reader (named `agenda.py`, not `calendar.py`: a script named `calendar.py` shadows the stdlib `calendar` module that dateutil imports) |
+| `examples/tools/weather.toml`, `weather.sh` | create | OpenWeatherMap current + forecast |
+| `README.md`, `AGENTS.md` | modify | usage and status |
+
+---
+
+### Task 1: config.toml with built-in defaults
+
+**Files:**
+- Modify: `assistant.py` (imports, constants block lines 37-47, `mic_on`, `llm_model`, `ask`, `speaker`, `start_whisper`, `reply`, `main`, `test`)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `test()` in `assistant.py`, before `print("ok")`:
+
+```python
+ c = merge(DEFAULTS, {"card": "X", "whisper": {"port": 9},
+ "voice": {"it": {"blend": {"a": 1.0}}}, "env": {"K": "v"}})
+ assert c["card"] == "X" and c["whisper"] == {**DEFAULTS["whisper"], "port": 9}
+ assert c["voice"]["it"] == {"lang": "it", "blend": {"a": 1.0}} # blend replaced whole
+ assert c["voice"]["en"] == DEFAULTS["voice"]["en"] and c["env"] == {"K": "v"}
+ assert DEFAULTS["card"] == "Microphone" # defaults untouched
+ for bad in ({"whisper": {"prot": 1}}, {"cards": "X"}, {"whisper": 3}):
+ try:
+ merge(DEFAULTS, bad)
+ assert False, bad
+ except SystemExit as e:
+ assert "config:" in str(e)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 assistant.py --test`
+Expected: `NameError: name 'merge' is not defined`
+
+- [ ] **Step 3: Implement**
+
+Add `import tomllib` to the imports (alphabetical, after `import time`).
+
+Replace the constants block (from `CARD = "Microphone"` through `KLANG = {"it": "it", "en": "en-us"}`) with:
+
+```python
+CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
+ "desktop-assistant")
+RAW = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "desktop-assistant.raw")
+WAV = RAW[:-3] + "wav"
+DEFAULTS = {
+ "card": "Microphone", # ALSA card id of the USB mic
+ "llm": "http://localhost:8181/v1",
+ "system": ("You are a voice assistant. Your replies are spoken aloud, so answer "
+ "briefly in plain sentences: no markdown, no lists, no emoji. "
+ "Reply in the language the user speaks, Italian or English."),
+ "tool_rounds": 3,
+ "whisper": {"model": "/data/voice-models/whisper/ggml-large-v3-turbo-q5_0.bin",
+ "device": 0, "port": 8182},
+ "kokoro": {"model": "/data/voice-models/kokoro/kokoro-v1.0.onnx",
+ "voices": "/data/voice-models/kokoro/voices-v1.0.bin"},
+ "voice": {"it": {"lang": "it", "blend": {"if_sara": 0.8, "af_bella": 0.2}},
+ "en": {"lang": "en-us", "blend": {"af_heart": 1.0}}},
+ "env": {},
+}
+OPEN = {"env", "blend"} # 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
+
+
+CFG = load_config(os.path.join(CONFIG_DIR, "config.toml"))
+WHISPER = f"http://127.0.0.1:{CFG['whisper']['port']}"
+KLANG = {l: v["lang"] for l, v in CFG["voice"].items()}
+```
+
+Then replace the uses of the removed constants:
+
+- `mic_on()`: `["amixer", "-c", CARD, "cget", "numid=2"]` becomes `["amixer", "-c", CFG["card"], "cget", "numid=2"]`.
+- `main()`: `["stdbuf", "-oL", "amixer", "-c", CARD, "events"]` becomes `["stdbuf", "-oL", "amixer", "-c", CFG["card"], "events"]`.
+- `llm_model()`: `LLM + "/models"` becomes `CFG["llm"] + "/models"`.
+- `ask()`: `LLM + "/chat/completions"` becomes `CFG["llm"] + "/chat/completions"`.
+- `reply()`: `{"role": "system", "content": SYSTEM}` becomes `{"role": "system", "content": CFG["system"]}`.
+- `speaker()`: replace the `k = Kokoro(...)` line and the `voices = {...}` statement with:
+
+```python
+ k = Kokoro(CFG["kokoro"]["model"], CFG["kokoro"]["voices"])
+ # Each voice is a weighted blend of Kokoro style vectors.
+ voices = {l: sum(w * k.get_voice_style(n) for n, w in v["blend"].items())
+ for l, v in CFG["voice"].items()}
+```
+
+- `start_whisper()`: the `Popen` argv becomes:
+
+```python
+ p = subprocess.Popen(["whisper-server", "-m", CFG["whisper"]["model"],
+ "-dev", str(CFG["whisper"]["device"]),
+ "--host", "127.0.0.1", "--port", str(CFG["whisper"]["port"])],
+```
+
+(the remaining `Popen` keyword lines stay as they are).
+
+Check nothing still refers to the old names:
+
+Run: `grep -n -E '\b(CARD|LLM|SYSTEM|MODELS)\b' assistant.py`
+Expected: no output.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 assistant.py --test`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assistant.py
+git commit -m "Read settings from config.toml over built-in defaults
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 2: load and run tools
+
+**Files:**
+- Modify: `assistant.py` (imports, after `load_config`, `test`)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `test()` before `print("ok")`:
+
+```python
+ import tempfile
+ 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"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 assistant.py --test`
+Expected: `NameError: name 'load_tools' is not defined`
+
+- [ ] **Step 3: Implement**
+
+Add `import glob` to the imports (alphabetical, after `import ctypes`).
+
+After `load_config()` and before `CFG = ...`, add:
+
+```python
+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()]
+```
+
+After `CFG = ...`, `WHISPER = ...`, `KLANG = ...`, add:
+
+```python
+TOOLS_DIR = os.path.join(CONFIG_DIR, "tools")
+TOOLS = load_tools(TOOLS_DIR)
+TOOL_SCHEMA = schema(TOOLS) or None
+```
+
+After `split_sentences()`, add:
+
+```python
+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}")
+ return out.strip()[:2000]
+```
+
+Note: `d=TOOLS_DIR` as a default needs `TOOLS_DIR` defined above `run_tool`, which it is (module level, set after `CFG`).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 assistant.py --test`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assistant.py
+git commit -m "Load tools from the config folder and run them without a shell
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 3: tool round loop, streamed calls, announcements
+
+**Files:**
+- Modify: `assistant.py` (imports, `ask`, `reply`, new `collect_calls` and `announcement`, `test`)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `test()` before `print("ok")` (fragments are the shape llama-server streamed in the brainstorming probe):
+
+```python
+ 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": "{}"}}
+ tools = {"now": {}, "search": {"announce": {"it": ["Cerco."], "en": ["Searching."]}}}
+ assert announcement(tools, {0: {"name": "now"}}, "it") is None
+ assert announcement(tools, {0: {"name": "now"}, 1: {"name": "search"}}, "en") == "Searching."
+ assert announcement(tools, {0: {"name": "unknown"}}, "it") is None
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 assistant.py --test`
+Expected: `NameError: name 'collect_calls' is not defined`
+
+- [ ] **Step 3: Implement**
+
+Add `import random` to the imports (alphabetical, after `import queue`).
+
+After `run_tool()`, add:
+
+```python
+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 announcement(tools, calls, lang):
+ # Something to hear while a slow tool runs: there is no screen.
+ for c in calls.values():
+ phrases = tools.get(c["name"], {}).get("announce", {}).get(lang)
+ if phrases:
+ return random.choice(phrases)
+ return None
+```
+
+Replace the whole `ask()` function with:
+
+```python
+def ask(messages, calls, tools=None):
+ # Yields reply text as it streams; tool-call fragments go into calls.
+ body = {"model": llm_model(), "messages": messages, "stream": True,
+ "chat_template_kwargs": {"enable_thinking": False}}
+ if tools:
+ body["tools"] = tools
+ 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
+ choices = json.loads(line[6:])["choices"]
+ if not choices:
+ continue
+ delta = choices[0]["delta"]
+ collect_calls(calls, delta.get("tool_calls") or [])
+ if delta.get("content"):
+ yield delta["content"]
+```
+
+In `reply()`, replace everything from `history.append({"role": "user", "content": text})` to the end of the function with:
+
+```python
+ history.append({"role": "user", "content": text})
+ # ponytail: last 20 messages, summarise if long sessions need memory
+ msgs = [{"role": "system", "content": CFG["system"]}] + history[-20:]
+ t, said, voice, announced = time.perf_counter(), [], lang, False
+
+ def say(s):
+ nonlocal voice
+ if not said:
+ log(f"first sentence after {time.perf_counter() - t:.2f}s")
+ said.append(s)
+ voice = lang_of(s, voice)
+ q.put((re.sub(r"[*_#`]", "", s), voice))
+
+ for rnd in range(CFG["tool_rounds"] + 1):
+ calls, buf = {}, ""
+ # The last round offers no tools, which forces a spoken answer.
+ for delta in ask(msgs, calls, TOOL_SCHEMA if rnd < CFG["tool_rounds"] else None):
+ buf += delta
+ done, buf = split_sentences(buf)
+ for s in done:
+ say(s)
+ if buf.strip():
+ say(buf.strip())
+ if not calls:
+ break
+ msgs.append({"role": "assistant", "content": "", "tool_calls": [
+ {"id": c["id"], "type": "function",
+ "function": {"name": c["name"], "arguments": c["arguments"]}}
+ for c in calls.values()]})
+ phrase = None if announced else announcement(TOOLS, calls, lang)
+ if phrase:
+ # The question's language: the reply has no text yet to follow.
+ q.put((phrase, lang))
+ announced = True
+ for c in calls.values():
+ 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})
+ q.put(None)
+ # Tool calls stay inside the turn: history keeps only what was said.
+ history.append({"role": "assistant", "content": " ".join(said)})
+ log("assistant:", " ".join(said))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 assistant.py --test`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assistant.py
+git commit -m "Let the model call tools, with a spoken note while slow ones run
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 4: example config, now and search tools
+
+**Files:**
+- Create: `examples/config.toml`, `examples/tools/now.toml`, `examples/tools/search.toml`, `examples/tools/search.sh`
+
+- [ ] **Step 1: Create `examples/config.toml`**
+
+```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.
+
+# card = "Microphone" # ALSA card id of the mic
+# llm = "http://localhost:8181/v1" # llama-server, OpenAI API
+# system = "You are a voice assistant. ..."
+# tool_rounds = 3 # tool round trips per question
+
+# [whisper]
+# model = "/data/voice-models/whisper/ggml-large-v3-turbo-q5_0.bin"
+# device = 0 # -dev index, GPU devices only
+# port = 8182
+
+# [kokoro]
+# model = "/data/voice-models/kokoro/kokoro-v1.0.onnx"
+# voices = "/data/voice-models/kokoro/voices-v1.0.bin"
+
+# Style name = weight; a blend replaces the default blend whole.
+# [voice.it]
+# lang = "it"
+# blend = { if_sara = 0.8, af_bella = 0.2 }
+# [voice.en]
+# lang = "en-us"
+# blend = { af_heart = 1.0 }
+
+# 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: Create `examples/tools/now.toml`**
+
+```toml
+description = "Current local date and time."
+command = ["date", "+%A %d %B %Y, %H:%M"]
+```
+
+- [ ] **Step 3: Create `examples/tools/search.sh`** and make it executable
+
+```bash
+#!/bin/bash
+# desktop-assistant tool: web search through a SearXNG instance.
+# 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.
+#
+# search.sh QUERY: prints the top 5 results as "title: snippet".
+# Needs SEARXNG_URL (config.toml [env]) with the JSON format enabled.
+
+set -uo pipefail
+
+[ -n "${SEARXNG_URL:-}" ] || { echo "SEARXNG_URL is not set" >&2; exit 1; }
+[ -n "${1:-}" ] || { echo "empty query" >&2; exit 1; }
+
+curl -fsS --max-time 8 --get "$SEARXNG_URL/search" \
+ --data-urlencode "q=$1" --data-urlencode "format=json" \
+ | jq -r '.results[:5][] | "\(.title): \(.content // "")"' \
+ || { echo "search failed" >&2; exit 1; }
+```
+
+Run: `chmod +x examples/tools/search.sh`
+
+- [ ] **Step 4: Create `examples/tools/search.toml`**
+
+```toml
+description = "Search the web. Use it for facts, news, distances, prices, anything you are not sure about, instead of guessing."
+command = ["search.sh", "{query}"]
+timeout = 10
+announce.it = ["Cerco un attimo.", "Vediamo cosa trovo.", "Controllo."]
+announce.en = ["Let me look that up.", "Let me see what I can find.", "Checking."]
+
+[params.query]
+type = "string"
+description = "Search terms"
+```
+
+- [ ] **Step 5: Check the tools**
+
+Run (the instance URL is the local one from the user's config, not written to the repo): `SEARXNG_URL=<the user's instance> examples/tools/search.sh slackware | head -3`
+Expected: three `title: snippet` lines.
+
+Run: `examples/tools/search.sh x; echo "exit $?"`
+Expected: `SEARXNG_URL is not set` then `exit 1`.
+
+Run: `python3 -c "import tomllib; [tomllib.load(open(f'examples/tools/{n}.toml','rb')) for n in ('now','search')]; tomllib.load(open('examples/config.toml','rb')); print('toml ok')"`
+Expected: `toml ok`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add examples/config.toml examples/tools/now.toml examples/tools/search.toml examples/tools/search.sh
+git commit -m "Add example config and the now and search tools
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 5: calendar tool (agenda.py)
+
+**Files:**
+- Create: `examples/tools/agenda.py`, `examples/tools/calendar.toml`
+
+- [ ] **Step 1: Write `examples/tools/agenda.py` with its self-check first, `agenda()` stubbed**
+
+```python
+#!/usr/bin/env python3
+# desktop-assistant tool: events from a vdir (vdirsyncer's .ics folders).
+# 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.
+"""agenda.py [START] [DAYS]: events from START (YYYY-MM-DD, empty = today)
+for DAYS days (empty = 1), one line each. Reads every .ics under
+CALENDAR_DIR (default ~/.local/share/calendars). --test runs the self-check.
+
+Not named calendar.py: that would shadow the stdlib module dateutil imports.
+"""
+
+import datetime as dt
+import glob
+import os
+import re
+import sys
+import time
+
+from dateutil import rrule, tz
+
+
+def agenda(d, start, days):
+ return []
+
+
+def test():
+ import tempfile
+ os.environ["TZ"] = "Europe/Rome"
+ time.tzset()
+ cal = lambda body: f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"
+ files = {
+ "once.ics": cal("BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20261001T100000\r\n"
+ "SUMMARY:Dentist\\, checkup\r\nEND:VEVENT\r\n"),
+ "yearly.ics": cal("BEGIN:VEVENT\r\nUID:b\r\nDTSTART;VALUE=DATE:19900312\r\n"
+ "RRULE:FREQ=YEARLY\r\nSUMMARY:Birthday\r\n"
+ "BEGIN:VALARM\r\nACTION:EMAIL\r\nSUMMARY:not the event\r\n"
+ "END:VALARM\r\nEND:VEVENT\r\n"),
+ "weekly.ics": cal(
+ "BEGIN:VTIMEZONE\r\nTZID:Europe/Rome\r\nBEGIN:DAYLIGHT\r\nDTSTART:19700329T020000\r\n"
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n"
+ "BEGIN:VEVENT\r\nUID:c\r\nDTSTART;TZID=Europe/Rome:20260901T180000\r\n"
+ "RRULE:FREQ=WEEKLY;UNTIL=20261231T170000Z\r\n"
+ "EXDATE;TZID=Europe/Rome:20260908T180000\r\nSUMMARY:Gym\r\nEND:VEVENT\r\n"
+ "BEGIN:VEVENT\r\nUID:c\r\nRECURRENCE-ID;TZID=Europe/Rome:20260915T180000\r\n"
+ "DTSTART;TZID=Europe/Rome:20260916T190000\r\nSUMMARY:Gym moved\r\nEND:VEVENT\r\n"),
+ "utc.ics": cal("BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20261001T160000Z\r\n"
+ "SUMMARY:Call\r\nEND:VEVENT\r\n"),
+ }
+ with tempfile.TemporaryDirectory() as d:
+ os.mkdir(os.path.join(d, "cal"))
+ for name, text in files.items():
+ with open(os.path.join(d, "cal", name), "w", newline="") as f:
+ f.write(text)
+ assert agenda(d, "2026-09-01", 30) == [
+ "2026-09-01 18:00 Gym", "2026-09-16 19:00 Gym moved",
+ "2026-09-22 18:00 Gym", "2026-09-29 18:00 Gym"], agenda(d, "2026-09-01", 30)
+ assert agenda(d, "2026-03-10", 5) == ["2026-03-12 all day Birthday"]
+ assert agenda(d, "2026-10-01", 1) == [
+ "2026-10-01 10:00 Dentist, checkup", "2026-10-01 18:00 Call"] # 16Z = 18 local
+ assert agenda(d, "2026-10-02", 1) == []
+ print("ok")
+
+
+if __name__ == "__main__":
+ if sys.argv[1:] == ["--test"]:
+ test()
+ else:
+ a = sys.argv[1:] + ["", ""]
+ d = os.environ.get("CALENDAR_DIR") or os.path.expanduser("~/.local/share/calendars")
+ print("\n".join(agenda(d, a[0] or dt.date.today().isoformat(), int(a[1] or 1)))
+ or "no events")
+```
+
+Run: `chmod +x examples/tools/agenda.py`
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 examples/tools/agenda.py --test`
+Expected: `AssertionError` showing `[]` for the September range.
+
+- [ ] **Step 3: Implement**
+
+Replace the `agenda()` stub with:
+
+```python
+def unfold(text):
+ return re.sub(r"\r?\n[ \t]", "", text).splitlines()
+
+
+def events(text):
+ # The lines we need from each VEVENT; VTIMEZONE and VALARM are skipped,
+ # their RRULE and SUMMARY lines are not the event's.
+ ev, alarm = None, False
+ for line in unfold(text):
+ if line == "BEGIN:VEVENT":
+ ev = {"EXDATE": []}
+ elif line == "END:VEVENT":
+ yield ev
+ ev = None
+ elif line == "BEGIN:VALARM":
+ alarm = True
+ elif line == "END:VALARM":
+ alarm = False
+ elif ev is not None and not alarm:
+ name = re.split("[;:]", line, maxsplit=1)[0]
+ if name == "EXDATE":
+ ev["EXDATE"].append(line)
+ elif name in ("UID", "DTSTART", "RRULE", "RECURRENCE-ID", "SUMMARY"):
+ ev.setdefault(name, line)
+
+
+def local(params, value):
+ # ICS date or date-time to naive local time, and whether it is all-day.
+ if len(value) == 8:
+ return dt.datetime.strptime(value, "%Y%m%d"), True
+ t = dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S")
+ m = re.search(r"TZID=([^;:]+)", params)
+ zone = tz.UTC if value.endswith("Z") else tz.gettz(m.group(1)) if m else None
+ if zone:
+ t = t.replace(tzinfo=zone).astimezone(tz.tzlocal()).replace(tzinfo=None)
+ return t, False
+
+
+def when(line):
+ params, value = line.split(":", 1)
+ return local(params, value)
+
+
+def agenda(d, start, days):
+ # ponytail: naive local times, so a recurring event in a zone other than
+ # ours drifts by an hour across DST; multi-day events show on day one only.
+ lo = dt.datetime.fromisoformat(start)
+ hi = lo + dt.timedelta(days=days)
+ evs = []
+ for path in glob.glob(os.path.join(d, "**", "*.ics"), recursive=True):
+ with open(path, encoding="utf-8", errors="replace") as f:
+ evs += [e for e in events(f.read()) if "DTSTART" in e]
+ moved = {(e.get("UID"), when(e["RECURRENCE-ID"])[0]) for e in evs if "RECURRENCE-ID" in e}
+ out = []
+ for e in evs:
+ first, allday = when(e["DTSTART"])
+ if "RRULE" in e and "RECURRENCE-ID" not in e:
+ # dateutil rejects a UTC UNTIL next to a naive start: make it local.
+ rule = re.sub(r"UNTIL=(\d{8}T\d{6})Z",
+ lambda m: "UNTIL=" + local("", m.group(1) + "Z")[0].strftime("%Y%m%dT%H%M%S"),
+ e["RRULE"].split(":", 1)[1])
+ rs = rrule.rruleset()
+ rs.rrule(rrule.rrulestr(rule, dtstart=first))
+ for line in e["EXDATE"]:
+ params, values = line.split(":", 1)
+ for v in values.split(","):
+ rs.exdate(local(params, v)[0])
+ for uid, t in moved:
+ if uid == e.get("UID"):
+ rs.exdate(t)
+ occ = [t for t in rs.between(lo, hi, inc=True) if t < hi]
+ else:
+ occ = [first] if lo <= first < hi else []
+ summary = e.get("SUMMARY", "SUMMARY:(no title)").split(":", 1)[1]
+ summary = summary.replace("\\,", ",").replace("\\;", ";").replace("\\n", " ")
+ out += [(t, f"{t:%Y-%m-%d} {'all day' if allday else f'{t:%H:%M}'} {summary}")
+ for t in occ]
+ return [line for _, line in sorted(out)]
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 examples/tools/agenda.py --test`
+Expected: `ok`
+
+- [ ] **Step 5: Try it on the real calendars (output stays in the terminal, never in a file in the repo)**
+
+Run: `examples/tools/agenda.py "" 7 | wc -l; examples/tools/agenda.py "" 7 >/dev/null && echo exit-ok`
+Expected: a line count and `exit-ok`, no traceback. Show the user the actual listing and ask them to compare it with their calendar for the week.
+
+- [ ] **Step 6: Create `examples/tools/calendar.toml`**
+
+```toml
+description = "Events in the user's calendar for a range of days. Use it for questions about appointments, birthdays, what is planned."
+command = ["agenda.py", "{start}", "{days}"]
+timeout = 10
+
+[params.start]
+type = "string"
+description = "First day, YYYY-MM-DD. Empty for today."
+
+[params.days]
+type = "integer"
+description = "How many days, 1 for a single day. Empty for 1."
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add examples/tools/agenda.py examples/tools/calendar.toml
+git commit -m "Add calendar tool reading the vdirsyncer folder directly
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 6: weather tool
+
+**Files:**
+- Create: `examples/tools/weather.sh`, `examples/tools/weather.toml`
+
+- [ ] **Step 1: Create `examples/tools/weather.sh`** and make it executable
+
+```bash
+#!/bin/bash
+# desktop-assistant tool: weather from OpenWeatherMap.
+# 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.
+#
+# weather.sh [PLACE] [WHEN]: PLACE empty = home, WHEN now (default), today
+# or tomorrow. WEATHER_ENV names an env file with KEY, CITY, COUNTRY and
+# UNITS (the conky-theme-udt format). Home "now" reuses that project's
+# cache when it is under 30 minutes old, so it costs no request.
+# ponytail: wind is converted as metric (m/s to km/h); UNITS=imperial
+# would need mph instead.
+
+set -uo pipefail
+
+env_file="${WEATHER_ENV:-$HOME/.config/udt/weather.env}"
+[ -r "$env_file" ] || { echo "no weather env file at $env_file" >&2; exit 1; }
+# shellcheck source=/dev/null
+. "$env_file"
+[ -n "${KEY:-}" ] || { echo "KEY is empty in $env_file" >&2; exit 1; }
+
+place="${1:-}"
+when="${2:-}"
+q="${place:-$CITY,$COUNTRY}"
+
+get() {
+ curl -fsS --max-time 8 --get "https://api.openweathermap.org/data/2.5/$1" \
+ --data-urlencode "appid=$KEY" --data-urlencode "units=${UNITS:-metric}" \
+ --data-urlencode "q=$q" \
+ || { echo "weather request failed for $q" >&2; exit 1; }
+}
+
+case "${when:-now}" in
+now)
+ cache="${XDG_CACHE_HOME:-$HOME/.cache}/udt/weather.json"
+ if [ -z "$place" ] && [ -n "$(find "$cache" -mmin -30 2>/dev/null)" ]; then
+ json=$(cat "$cache")
+ else
+ json=$(get weather) || exit 1
+ fi
+ jq -r '"\(.name): \(.weather[0].description), \(.main.temp|round) degrees, feels like \(.main.feels_like|round), humidity \(.main.humidity)%, wind \(.wind.speed*3.6|round) km/h"' <<<"$json"
+ ;;
+today|tomorrow)
+ day=$(date -d "$when" +%F)
+ json=$(get forecast) || exit 1
+ jq -r --arg day "$day" '
+ .city.name as $c
+ | [.list[] | select((.dt | localtime | strftime("%Y-%m-%d")) == $day)]
+ | if length == 0 then "no forecast for \($c) on \($day)"
+ else "\($c) \($day): " + (map("\(.dt | localtime | strftime("%H:%M")) \(.weather[0].description) \(.main.temp|round) degrees") | join(", "))
+ end' <<<"$json"
+ ;;
+*)
+ echo "when must be now, today or tomorrow" >&2
+ exit 1
+ ;;
+esac
+```
+
+Run: `chmod +x examples/tools/weather.sh`
+
+- [ ] **Step 2: Create `examples/tools/weather.toml`**
+
+```toml
+description = "Weather now, or the forecast for today or tomorrow. Leave place empty for the user's home."
+command = ["weather.sh", "{place}", "{when}"]
+timeout = 12
+announce.it = ["Controllo il meteo.", "Vediamo che tempo fa."]
+announce.en = ["Checking the weather.", "Let me check the weather."]
+
+[params.place]
+type = "string"
+description = "City name, empty for home"
+
+[params.when]
+type = "string"
+description = "now, today or tomorrow. Empty for now."
+```
+
+- [ ] **Step 3: Check every branch against the live API**
+
+Each command prints one line, no error. Run them and show the user the output:
+
+```bash
+examples/tools/weather.sh # home now, from the conky cache when fresh
+examples/tools/weather.sh Rome # other place now
+examples/tools/weather.sh "" tomorrow # home forecast
+examples/tools/weather.sh Rome today
+examples/tools/weather.sh Nowhereville; echo "exit $?" # expect: request failed, exit 1
+examples/tools/weather.sh "" yesterday; echo "exit $?" # expect: when must be..., exit 1
+```
+
+Note: `today` late in the evening can print `no forecast for ...` because the free forecast starts at the next 3-hour slot. That is correct output, the model will say so.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add examples/tools/weather.sh examples/tools/weather.toml
+git commit -m "Add weather tool on OpenWeatherMap, reusing the conky cache for home
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 7: documentation
+
+**Files:**
+- Modify: `README.md`, `AGENTS.md`
+
+- [ ] **Step 1: README.md**
+
+Add after the `## Usage` section (before `## License`):
+
+````markdown
+## 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
+announce.it = ["Cerco un attimo."] # optional, said while it runs
+announce.en = ["Let me look that up."]
+
+[params.param]
+type = "string"
+description = "What the model should pass"
+```
+
+The example tools only read: `now` (date), `calendar` (`agenda.py` over
+a vdirsyncer folder, needs `python3-dateutil`), `weather`
+(OpenWeatherMap, `curl` and `jq`), `search` (a SearXNG instance with the
+JSON format enabled).
+````
+
+In the `## Requirements` list, add a line: `` - `curl` and `jq` for the weather and search example tools ``
+
+- [ ] **Step 2: AGENTS.md**
+
+- In the status paragraph at the top, after the sentence ending `Kokoro speaks it.`, add: `The model can call read-only tools (time, calendar, weather, search) defined in ~/.config/desktop-assistant/tools/; see docs/superpowers/specs/2026-09-26-tools-and-config-design.md.`
+- In "Next steps", replace the bullet `- **Tools** (time, weather, calendar) so the model stops guessing facts.` with `- **Phase 2 tools:** tools that write or modify (events, messages, desktop), own spec, after phase 1 has been used for a while.`
+- Add a findings subsection before `### TTS (2026-09-25)` with what the live test in Task 8 measured (tool-call latency, announcement behaviour, anything that misfired). Write it after Task 8, not before.
+
+- [ ] **Step 3: Commit** (after Task 8 filled the findings)
+
+```bash
+git add README.md AGENTS.md
+git commit -m "Document the config folder and the tools
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 8: live install and test with the user
+
+No repo changes; the config folder is the user's, never committed.
+
+- [ ] **Step 1: Install**
+
+```bash
+mkdir -p ~/.config/desktop-assistant
+cp examples/config.toml ~/.config/desktop-assistant/
+cp -r examples/tools ~/.config/desktop-assistant/
+```
+
+Then set, in `~/.config/desktop-assistant/config.toml` under `[env]`, the real `SEARXNG_URL` (from the user's searxng MCP config) and uncomment `WEATHER_ENV` pointing at the existing conky env file. Show the user the `[env]` block.
+
+- [ ] **Step 2: Restart the assistant**
+
+Stop the running instance, start `python3 -u assistant.py` in the background with its log teed to the scratchpad, watch the log with Monitor.
+
+- [ ] **Step 3: Ask the user to try, one question each**
+
+1. "Che ore sono?" (expect a `tool now(...)` log line, no announcement)
+2. "Cosa ho in calendario questa settimana?" (tool calendar, silent)
+3. "Che tempo fa?" (tool weather, home, announcement)
+4. "Domani piove a Roma?" (weather with place and when)
+5. "Quanto dista Roma da Milano?" or any fact question (search, announcement)
+6. "Chi era Garibaldi?" (no tool line)
+
+For each, the log shows the tool line with arguments and result; the user reports by ear whether the announcement and the answer sounded right. Watch for `aplay` underrun noise between the announcement and the answer (the stream stays open while the tool runs); if it is audible, note it for a follow-up.
+
+- [ ] **Step 4: Record findings** in AGENTS.md (Task 7 Step 2, last bullet) and commit Task 7.