# 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. `). - 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 " 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 " 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 " 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. # # 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= 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 " 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. # # 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 " 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. # # 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 " 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/.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 " 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.