diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-26 07:59:30 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-26 07:59:30 +0200 |
| commit | 7decd3e052003eb67c4f73126d96d4a5200d0c3c (patch) | |
| tree | 365aef29d08892a6e4ae369d5a1999602daf510a | |
| parent | 5af82eae05bd8d5666f4f9750adab87e0c645755 (diff) | |
| download | desktop-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>
| -rw-r--r-- | docs/superpowers/plans/2026-09-26-tools-and-config.md | 1016 |
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. |
