summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-26-tools-and-config.md
blob: 3b7316ca12c07944b4958603d9a0155f92c20954 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
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.