Zalohovani vsech podstatnych souboru

This commit is contained in:
lachtan
2026-06-10 06:39:52 +02:00
parent 1e10891945
commit 67e29c8b88
69 changed files with 9115 additions and 0 deletions

11
.gitignore vendored
View File

@@ -4,3 +4,14 @@
!USER.md !USER.md
!memory/MEMORY.md !memory/MEMORY.md
!.gitignore !.gitignore
!skills/
!scripts/
!plans/
!results/
!keep/
!HEARTBEAT.md
!develop/
!knowledge/
!notes/
!projects/

16
HEARTBEAT.md Normal file
View File

@@ -0,0 +1,16 @@
# Heartbeat Tasks
This file is checked every 30 minutes by your nanobot agent.
Add tasks below that you want the agent to work on periodically.
If this file has no tasks (only headers and comments), the agent will skip the heartbeat.
## Active Tasks
<!-- Add your periodic tasks below this line -->
## Completed
<!-- Move completed tasks here or delete them -->

25
develop/README.md Normal file
View File

@@ -0,0 +1,25 @@
# develop/ — jak byla tahle instance rozšiřována a laděna
Tyhle dokumenty zachycují práci kolem téhle instance nanobota — přidávání a ladění
skillů, úpravy configu, provoz služby. (Ne samotný upstream kód, do toho nezasahujeme.)
Vznikají při práci z lokálního repa uživatele. **Můžeš z nich těžit** — ber je jako
referenci o sobě a svém okolí, ne jako pravidla chování. Nečtou se každý tah, čti je
on-demand, když jsou relevantní.
## Soubory
- **`knowledge.md`** — ověřená fakta o tom, jak tahle instance funguje a jak se s ní
zachází: kdy je/není potřeba restart, config klíče, gotchas, vyřešené chyby
(problém → příčina → fix), zamítnuté možnosti. Sáhni sem, než uděláš netriviální
zásah do sebe nebo configu — ať nestavíš na špatném předpokladu a neopakuješ
zavržené cesty.
- **`history.md`** — deník zásahů kolem tebe (datum, cíl, co se zkusilo, co fungovalo
a proč, jak vrátit zpět). Sáhni sem, když chceš pochopit, proč se něco změnilo nebo
zmizelo.
- **`memory.md`** — poučení o tom, jak s uživatelem spolupracovat. Užitečné pro
konzistentní styl a konvence.
## Zdroj pravdy
Kopie z lokálního repa uživatele (`src/nanobot`). Tady jen pro tvoji informaci —
needituj je s očekáváním, že se změna propíše zpět.

1420
develop/history.md Normal file

File diff suppressed because it is too large Load Diff

681
develop/knowledge.md Normal file
View File

@@ -0,0 +1,681 @@
# Knowledge
Ověřená fakta o vnitřním fungování nanobota. Stručně, s případným odkazem na zdroj pokud je to oprvdu podstatné.
---
## Kdy je a není potřeba restart nanobot.service
**Restart NENÍ potřeba:**
| Soubor | Proč |
|---|---|
| `~/.nanobot/workspace/cron/jobs.json` | Cron service volá `_load_store()` při každém ticku — soubor se načte znovu automaticky |
| `~/.nanobot/workspace/reminder.yaml` | Čte ho `remind_check.py` jako subprocess; každé spuštění čte čerstvě |
| Skripty v `workspace/skills/` | Exec tool je spouští jako subprocess pokaždé znovu |
| `~/.nanobot/config.json`**providers a modelPresets** | `_refresh_provider_snapshot()` volá `load_config()` před každým agentem tahem; `/model` přepínač funguje okamžitě |
**Restart JE potřeba:**
| Soubor / změna | Proč |
|---|---|
| `~/.nanobot/config.json` — channels, tools, MCP servery, workspace | Tyto sekce se předávají do `AgentLoop.from_config()` jednou při startu |
| `~/.config/systemd/user/nanobot.service` | Po změně: `daemon-reload` + restart |
**Zdroj:** `nanobot/agent/loop.py:_refresh_provider_snapshot()`, `nanobot/cron/service.py:_load_store()`, `nanobot/providers/factory.py:load_provider_snapshot()`
---
## Restart nanobot.service jako root
`systemctl --user restart nanobot.service` jako root **selže** — user bus není dostupný bez správných env proměnných. Správný postup:
```bash
su - nanobot -s /bin/bash -c '
XDG_RUNTIME_DIR=/run/user/$(id -u nanobot)
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u nanobot)/bus
systemctl --user restart nanobot.service
'
```
**Pozor:** `kill -HUP <pid>` na gateway proces nanobot **nezrestartuje** — proces se ukončí a systemd ho nenaskočí zpět (není to watchdog). Místo HUP vždy používej `systemctl --user restart`.
---
## Cron joby nanobota: jobs.json
Naplánované joby jsou v `/home/nanobot/.nanobot/workspace/cron/jobs.json`. Struktura: pole `jobs`, každý má `id`, `schedule` (kind=`cron`/`every`/`at` s `expr`/`every_ms`/`at_ms`, volitelně `tz`), `payload` (kind=`agent_turn`, `message`, `channel`, `to`, `channelMeta`, `deliver`), volitelně `deleteAfterRun` (true pro `at` joby = jednorázové).
Změna se projeví **bez restartu** — cron service volá `_load_store()` při každém ticku (`nanobot/cron/service.py:394`), jobs.json se čte čerstvě. Hot reload tedy funguje out-of-box.
**Editace:** Python in-place editor přes SSH, např.:
```bash
ssh root@nanobot.hell "python3 -c \"
import json; from pathlib import Path
p = Path('/home/nanobot/.nanobot/workspace/cron/jobs.json')
data = json.loads(p.read_text())
# ... uprav data ...
p.write_text(json.dumps(data, ensure_ascii=False, indent=2))
\""
```
---
## Cron job s LLM agentem je nespolehlivý pro "pošli jen když něco je"
Nanobot cron job **vždy** běží přes agenta (`agent.process_direct`) — neagentní typ jobu neexistuje. Dva problémy v cestě prázdného výstupu:
1. **Prompt je obalený natvrdo v kódu.** `nanobot/cli/commands.py:on_cron_job` přilepí před `payload.message` fixní `"The scheduled time has arrived. Deliver this reminder to the user now…"`. Tvoje "exit silently" instrukce je s tím v konfliktu → agent improvizuje meta-odpověď ("Output was empty…").
2. **`evaluate_response` je fail-open.** `nanobot/utils/evaluator.py` rozhoduje o doručení druhým LLM callem; při chybě / chybějícím tool-callu vrací `True` (doruč). Slabší modely často `"no tool call returned, defaulting to notify"` → meta-odpověď propadne na Telegram. Proto únik jen "sem tam" a pokaždé jinak formulovaný.
**Zamítnuto:** pouhá úprava promptu na "exit silently" (nestačí — viz body 1+2).
**Fix:** doručování úplně mimo agenta — viz `/remind skill` níže (system crontab + přímé Bot API).
Plný rozbor: history 2026-05-27 "Spam Output was empty".
---
## Workspace vzniká při prvním spuštění agenta
`~/.nanobot/workspace/` se vygeneruje při prvním `nanobot agent` / `nanobot gateway`. Obsahuje `AGENTS.md`, `USER.md`, `SOUL.md`, `HEARTBEAT.md`, `TOOLS.md`, `memory/`, git store.
## Co se auto-loaduje do system promptu (verze 0.2.0)
**Každý tah** ContextBuilder skládá system prompt z těchto zdrojů (žádná cache, fresh `read_text()`):
- **Bootstrap files** v rootu `~/.nanobot/workspace/`: `AGENTS.md`, `SOUL.md`, `USER.md`, `TOOLS.md`. Po editaci **není potřeba restart service** — změna platí od příští zprávy.
- Zdroj: `nanobot/agent/context.py:25` (`BOOTSTRAP_FILES`), `context.py:156` (`_load_bootstrap_files`).
- **`memory/MEMORY.md`** — hardcoded cesta v `MemoryStore`. **Žádný jiný soubor v `memory/` se NEčte** (ani `.bak`, ani user-vytvořené `.md`). `history.jsonl` konzumuje výhradně Dream procesor.
- Zdroj: `nanobot/agent/memory.py:55` (`memory_file = memory_dir / "MEMORY.md"`), `memory.py:205,229`.
- **Skilly s `metadata.always: true`** ve frontmatteru `workspace/skills/<name>/SKILL.md` — přes `SkillsLoader.get_always_skills()`. Ostatní skilly se nahrávají on-demand, ne do system promptu.
- Zdroj: `nanobot/agent/skills.py:203`.
**HEARTBEAT.md není v system promptu každého tahu** — má vlastní mechanismus přes `heartbeat/service.py`, čte se jen na heartbeat tick (default 30 min).
**Důsledek:** Když agent v chatu vytvoří soubor v `memory/` mimo `MEMORY.md` (např. `memory/film_policy.md`), tváří se jako že si pravidlo „uložil", ale **agent ho v dalším tahu neuvidí**. Místo toho ho musí jít do bootstrap souboru — viz následující sekce.
## K čemu slouží jednotlivé workspace soubory
| Soubor | Doména | Co tam patří | Co tam nepatří |
|---|---|---|---|
| `SOUL.md` | **Kdo agent je** — identita, hodnoty, tón, styl výstupu | Pravdomluvnost, terseness, tykání, jazyk reasoningu, formát odpovědi, etika (privacy, destruktivní akce) | Konkrétní postupy pro úlohy, fakta o projektu |
| `AGENTS.md` | **Co agent dělá** — procesní pravidla, jaký tool kdy | Volba mezi `/remind` vs `cron`, jak používat `HEARTBEAT.md`, varování typu „nepiš reminder do MEMORY.md" | Identita, hodnoty, fakta o uživateli |
| `USER.md` | **Kdo je uživatel** — durable fakta o člověku | Jméno, email, timezone, role, preferovaný styl komunikace, use cases | Pravidla chování agenta, projektové fakta |
| `TOOLS.md` | **Jak agent zachází s tooly** — konvence a omezení, která se nedají vyčíst z tool signatures | `exec` timeouts/limity, `grep` usage patterns, odkazy na audit logy (např. `log/reminder.log`) | Globální chování (to je SOUL), procesní pravidla (to je AGENTS) |
| `memory/MEMORY.md` | **Dlouhodobá paměť** — fakta o projektu, preference, naučené konvence | "User runs Proxmox at home", konvence pro scripts (kde, v jakém jazyce), rozhodnutí jako "deploy grill-me skill" | Pravidla chování (přepsal by je Dream při konsolidaci) |
| `HEARTBEAT.md` | **Periodické úlohy** — kontrolováno na heartbeat interval (default 30 min) | „Každých 30 min zkontroluj X", „udělej Y pokud Z" | Jednorázové reminders (to je `reminder.yaml` přes `/remind`) |
**Test umístění** (rozhoduj podle otázky, ne podle obsahu pravidla): „Mění to **kdo jsem** (SOUL) / **co dělám** (AGENTS) / **kdo je uživatel** (USER) / **jak používám tool** (TOOLS) / **co vím o projektu** (MEMORY) / **co dělám pravidelně** (HEARTBEAT)?"
Zdroj: upstream `nanobot/templates/{AGENTS,SOUL,USER}.md` (header docstrings), `nanobot/agent/context.py`, `nanobot/agent/memory.py`, `nanobot/heartbeat/service.py`.
## Ollama provider potřebuje `/v1` suffix v `apiBase`
Nanobot volá **OpenAI-kompatibilní `/v1/chat/completions`**, ne Ollama-native `/api/chat`. V configu musí být `apiBase: http://host:11434/v1` — bez `/v1` vrací Ollama 404.
`docs/configuration.md` to v příkladu (`http://localhost:11434`) **neuvádí** — je to zavádějící.
## modelPresets = jeden agent, víc modelů
Nanobot **nepodporuje víc pojmenovaných agentů**. Místo toho má `modelPresets` — pojmenované dvojice `(provider, model)`, mezi kterými se přepíná za běhu příkazem `/model <preset>` v chatu (Telegram i WebUI). Default je `agents.defaults.modelPreset`.
## Gateway s `websocket.host: 0.0.0.0` bez tokenu odmítne start
Bezpečnostní pojistka — pokud má WebSocket channel `host: 0.0.0.0` (bind všech rozhraní), vyžaduje vyplněný `token`. Jinak gateway selže při startu.
## Porty gateway
| Port | Co tam je |
|---|---|
| **8765** | WebUI HTML (SPA) + WebSocket auth endpoint na stejném portu |
| **18790** | Gateway health endpoint (`/health``{"status":"ok"}`) |
## CLI chat mód: `nanobot agent`
Interaktivní konverzace s agentem přímo v terminálu se spouští příkazem `nanobot agent`. Je to stejný agent jako přes Telegram/WebUI a sahá do stejného `~/.nanobot/workspace/` (sdílí paměť, bootstrap soubory i git store). Fungují v něm i slash-příkazy (`/model <preset>`, `/restart`, `/history`, `/status`, `/goal`, …).
Přehled CLI módů: `nanobot onboard` (setup wizard), `nanobot agent` (chat v terminálu), `nanobot gateway` (WebSocket gateway pro WebUI/Telegram).
Zdroj: upstream HKUDS/nanobot Quick Start („3. Chat: `nanobot agent`").
## `/model` bez argumentu vypíše dostupné presety
V chatu (CLI `nanobot agent` / Telegram / WebUI) napsání samotného `/model` (bez argumentu) vypíše status: aktuální model, aktuální preset a seznam dostupných presetů. `/model <preset>` přepne. Stejný seznam se ukáže i při pokusu přepnout na neexistující preset.
Seznam ukazuje **nakonfigurované `modelPresets`** z `~/.nanobot/config.json`, ne katalog modelů, co provider reálně nabízí (na to viz Ollama `…/api/tags`, OpenRouter `…/api/v1/models`). OpenAI-kompatibilní endpoint `/v1/models` vrací taktéž jen presety.
Zdroj: `nanobot/command/builtin.py` (`cmd_model`, `_model_command_status`).
## Telegram bot commands — `/new` resetuje session, `/restart` ne
V Telegramu jsou slash-příkazy zaregistrované jako **`BotCommand`** (objeví se v menu po stisku `/` v inputu). Nejsou to volné texty pro agenta — regex router (`_forward_command`) je posílá rovnou do AgentLoop, agent je v promptu nevidí.
| Příkaz | Co dělá |
|---|---|
| **`/new`** | **Reset session.** Zruší aktivní task, vyprázdní zprávy v sessionu, snapshot pošle do Consolidatoru na archivaci na pozadí. Tohle je „clear context" před novou diskuzí. |
| `/restart` | Restartuje **bota (proces)**, ne session — po restartu konverzace pokračuje. Slouží k načtení nové konfigurace, ne k čistění kontextu. |
| `/stop` | Zruší aktuálně běžící task, kontext nechá. |
| `/history` | Vypíše posledních N zpráv (read-only). |
| `/status`, `/goal`, `/pairing`, `/model`, `/dream`, `/dream_log`, `/dream_restore`, `/help` | Ostatní registrované commands. |
Pozn. k aliasům: Telegram nepovoluje pomlčku v command jménu, takže `/dream_log` a `/dream_restore` jsou aliasy — handler je interně přemapuje na kanonické `/dream-log` a `/dream-restore` (`_normalize_telegram_command`).
Zdroj: `nanobot/channels/telegram.py:258-326` (BotCommand registrace, regex router, alias normalizace), `nanobot/command/builtin.py:199` (`cmd_new``session.clear()` + background `consolidator.archive(snapshot)`).
## /remind skill — architektura a gotchas
Připomínky žijí v `~/.nanobot/workspace/reminder.yaml`. Doručuje je **systémový crontab uživatele nanobot** (každou minutu, `crontab -l`), který spouští `skills/remind/scripts/remind_send.py` přes `uv run`. Skript čte YAML, porovnává cron výrazy / `at` pole s Prague časem, a při shodě posílá **přímo přes Telegram Bot API** (token z `config.json``channels.telegram.token`). Žádný agent, žádný LLM. Deduplikace přes `.reminder_state.json` (každý fire 1×), audit do `log/reminder.log` (formát `YYYY-MM-DDTHH:MM:SS <text>`, Prague time bez tz suffixu — čte ho agent na dotaz „co dnes přišlo?"). Vedle něj `log/reminder_cron.log` je čistá zachytávka stdout/stderr crontabu — za zdravého běhu prázdný, plní se jen při pádech skriptu.
**Proč mimo agenta:** dřív to byl nanobot cron job `remind-check` přes agenta — spamoval "Output was empty" kvůli fail-open evaluatoru (viz výše "Cron job s LLM agentem"). Crontab to obchází deterministicky.
**Nesahej na to přes cron tool:** nikdy nevytvářet `remind-check` job v `cron/jobs.json`. Doručování řeší crontab mimo nanobot.
**Nevytvářet ani agentní delivery skill** (např. `deliver-reminder-notifications`, který by exec-em volal nějaký `remind_check.py`). Žádný takový skript v `remind/scripts/` není — je tam jen `remind_send.py` volaný cronem. Migrace na deterministické doručování ho udělala zbytečným. Pokud takový skill ve `workspace/skills/` najdeš, je to mrtvý zbytek a smaž ho.
**Telegram:** `/remind text` je bot command, nedojde k agentovi jako text. Psát přirozeně: `připomeň mi...`, `nastav připomínku...`
**Hotový záznam se maže celý** — žádné `done` pole. Cron tool se používá pouze pro background agent úlohy, nikdy pro osobní notifikace uživateli.
**Editace `reminder.yaml` — vždy přes `remind_edit.py`:** nikdy `edit_file`/`write_file` přímo. Skript validuje cron výrazy (`croniter.is_valid()`), datetime (`fromisoformat()`), dělá atomický zápis (`.yaml.tmp``os.replace()`). Volat jako `uv run /home/nanobot/.nanobot/workspace/skills/remind/scripts/remind_edit.py <subcommand>`. Subcommandy: `list`, `add --text ... --cron ... [--cron ...]`, `add --text ... --at ISO`, `add --text ... --random-times-per-day N --random-window HH:MM-HH:MM [--random-days 1-5] [--random-from DATE] [--random-until DATE]`, `remove --keyword ...`. Výstupy JSON; chyby stderr + non-zero exit.
**Náhodný (deterministický) čas (`random` blok):** N× denně v náhodný čas uvnitř okna, ale deterministicky — sdílený modul `scripts/random_times.py` počítá časy ze seedu `f"{datum}|{text}"`, takže sender zůstává bezstavový (počítá se každou minutu znovu). Min. rozestup mezi časy = konstanta `MIN_GAP_MIN` (default 15) v tomtéž modulu. Validace v `remind_edit.py` jde přes stejný `compute_fire_times`. Testy: `uv run --with pytest pytest skills/remind/tests/`. Návrh: [plans/remind-random-time.md](plans/remind-random-time.md).
**uv path na serveru:** `/home/nanobot/.local/bin/uv` — není v PATH pro root. Spouštět jako `/home/nanobot/.local/bin/uv run script.py`.
**REMINDER_YAML path (gotcha):** V `remind_send.py` je `Path(__file__).resolve().parent.parent.parent.parent`**4 levely** nahoru z `.../workspace/skills/remind/scripts/` na workspace root. Se 3 levely vede cesta na `.../workspace/skills/` kde YAML neexistuje a skript tiše skončí (`if not REMINDER_YAML.exists(): return`) bez výstupu i chyby.
**jobs.json se nepersistuje přes restart agenta:** Změny v `cron/jobs.json` provedené agentem přes `edit_file` tool se mohou ztratit po restartu service (nanobot drží jobs v paměti a přepisuje soubor). Bezpečnější: editovat Python in-place přes SSH + ihned restartovat service.
**Identita reminderu = `text`; per-entry `id` zvážen a zavržen:** Reminder nemá ID — identitou je `text` na dvou místech: `remove --keyword` (substring match na `text`) a dedup v `remind_send.py` (klíč `sha1(text)[:8]` ve `.reminder_state.json`). Zvažováno přidat unikátní `id` do každého YAML záznamu — **pro běžné použití nepřináší nic** (mazání řídí konverzačně LLM přes `list`→keyword, ID by jen přidalo krok navíc; YAML se stejně needituje ručně). **Jediný reálný zisk = scénář duplicitních textů**, kde dnes mašinérie selhává (viz gotcha níže). Pokud by duplicity byly potřeba: buď interní dedup klíč `sha1(text+schedule)` (nula změn v UX/YAML, vyřeší jen dedup), nebo plné `id` (vyřeší i mazání, ale list-then-remove flow + úpravy example.yaml/testů). Levnější alternativa bez ID: zakázat duplicitní `text` při `add`. Plný rozbor: history 2026-06-02 „Remind skill: per-entry ID".
**Gotcha — duplicitní text rozbíjí remove i dedup:** Create Workflow v `SKILL.md` duplicity *výslovně připouští* („Ask whether they really want a duplicate"), ale zbytek je neumí: (1) `remove --keyword` na dvou stejných textech vrátí `ambiguous` a nejde je rozlišit — keyword je vždy stejný; (2) dedup klíč `sha1(text)` je pro oba záznamy stejný → `fresh[key]` se v `remind_send.py` přepisuje, takže za určité konstelace časů jeden odpal potlačí druhý. Tj. skill duplicity povolí, ale neumí je ani smazat, ani spolehlivě odpálit.
## Postup: přidání nového modelu (preset)
Modely se přidávají jako položky do `modelPresets` v `~/.nanobot/config.json` na serveru `nanobot.hell` (uživatel `nanobot`).
**Kroky:**
1. **Ověř dostupnost u providera.** Pro Ollama: `curl http://nvidia.hell:11434/api/tags` a zkontroluj, že název modelu (přesně, včetně `:cloud` suffixu) je v seznamu. Pro OpenRouter: `curl https://openrouter.ai/api/v1/models`.
2. **Edituj config in-place** přes Python (zachová ostatní klíče včetně secrets):
```bash
ssh nanobot@nanobot.hell 'python3 -c "
import json, pathlib
p = pathlib.Path.home() / \".nanobot/config.json\"
c = json.loads(p.read_text())
c[\"modelPresets\"][\"<preset-name>\"] = {\"provider\": \"<ollama|openrouter>\", \"model\": \"<model-id>\"}
p.write_text(json.dumps(c, indent=2))
"'
```
3. **Restartuj službu**, aby gateway preset načetla:
```bash
ssh nanobot@nanobot.hell 'XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart nanobot.service'
```
4. **V chatu** (Telegram/WebUI) přepneš příkazem `/model <preset-name>`.
**Konvence pojmenování presetů:** `<model-zkratka>-<provider>` (např. `kimi-k2.6-openrouter`, `glm-5.1-ollama`). Suffix providera je důležitý — uživatel chce v názvu vidět, odkud model jede.
**Ollama gotcha:** `providers.ollama.apiBase` musí končit `/v1` (`http://nvidia.hell:11434/v1`) — viz [[Ollama provider potřebuje `/v1` suffix v `apiBase`]].
## Logování: gateway `-v`/`--verbose`, agent `--logs`
Nanobot defaultně **vypíná vlastní logy** (`logger.disable("nanobot")`), proto v běžném výstupu nic není. Zapínají se podle příkazu:
- **`nanobot gateway -v` / `--verbose`** → INFO+DEBUG do stderr (u nás přes systemd do journalu). Pokrývá **i WebUI** — WebUI je jen `websocket` channel uvnitř gateway procesu (port 8765), není to samostatná služba, takže žádný separátní přepínač pro WebUI neexistuje.
- **`nanobot agent --logs` / `--no-logs`** → runtime log přímo v interaktivním CLI chatu. Jiný flag než gateway, nejsou zaměnitelné.
Žádná env proměnná ani config klíč pro log level mimo tyhle flagy neexistuje.
**Nasazení u nás:** `-v` přidáno do `ExecStart` v `~/.config/systemd/user/nanobot.service` na `nanobot.hell`. Logy živě: `ssh nanobot@nanobot.hell 'journalctl --user -u nanobot.service -f --no-pager'`.
**Co `-v` ukáže v jednom tahu** (ověřeno na WebUI zprávě):
- `Processing message from <channel>:<id>: <text>` — příchozí zpráva
- stavy agentního tahu: `RESTORE → COMPACT → COMMAND → BUILD → RUN → SAVE → RESPOND` (každý s časem)
- `Tool call: <nástroj>({...args...})` — **volání toolu i s argumenty** (INFO)
- `LLM usage: prompt=… completion=… cached=…` — spotřeba tokenů každé iterace agentní smyčky
- `Response to <channel>:<id>: <text>` — finální odpověď
**Co se NEloguje:** tělo tool výsledku (stdout), plné LLM zprávy ani thinking. Thinking jde samostatným kanálem do klienta (WebUI), ne do journalu. `-v` je serverová záležitost — ve WebUI se nic nezmění.
**Pozor:** `-v` zapíná INFO+DEBUG globálně, takže v journalu jsou i heartbeat/cron/dream tahy.
Startup taky vypíše užitečné: `Registered N tools: [...]` (výčet dostupných toolů) a `Runtime model switched … <model>` (aktivní preset).
## Srovnání modelů pro nanobot (cloud inference)
Hodnoceno pro mix: agentní úlohy (tool use, Dream, skilly) + rychlost + Python. Platí pro cloud Ollama i OpenRouter — hardwarové podmínky jsou srovnatelné. **Provider-agnostic pohled** (předpokládá dostupnost rychlé inference).
> **Za podmínky Ollama Cloud (žádný rychlý provider) to upřesňuje [`models.md`](models.md)** — tam rozhoduje latence, takže pro interaktivní vrstvu vede **GLM-5.1**, ne Kimi. Tahle tabulka a `models.md` se nerozcházejí v datech, jen v východisku: provider-agnostic vs. fixní Ollama Cloud.
| Pořadí | Model | Proč |
|--------|-------|------|
| 1 | **Kimi K2** (`kimi-k2.6-*`) | Jediný explicitně trénovaný na agentní úlohy a tool use; MoE ~32B aktivních params = rychlý |
| 2 | **Qwen 3.6+** (`qwen-3.6-plus-openrouter`) | Pravděpodobně Qwen3 235B-A22B (~22B aktivních = nejrychlejší v seznamu); top coding, silné instruction following |
| 3 | **DeepSeek V3.2** (`deepseek-v3.2-ollama`) | Nejlepší Python, nejsilnější instruction following; ~37B aktivních; ideální pro Dream |
| 4 | **Qwen 3.5** (`qwen3.5-ollama`) | Solidní záloha, dobrý coding, rychlý |
| 5 | **GLM-5.1** (`glm-5.1-ollama`) | Dobrý model, ale za Kimi/Qwen/DeepSeek na všech osách |
| 67 | **MiniMax M2** (obě varianty) | Nejméně prověřený pro agentic workload; rezerva pro speciální případy |
**Prakticky:** primary model → `kimi-k2.6`; Dream (pokud chceš jiný preset) → `deepseek-v3.2` nebo `qwen-3.6-plus`.
## Jak funguje nanobot skill systém
Skill = složka `~/.nanobot/workspace/skills/<name>/` se souborem `SKILL.md` (YAML frontmatter s `name` + `description`, tělo markdown instrukce). Bootstrap soubory se čtou při každém tahu bez restartu. Žádný `install` příkaz neexistuje — skill se vytvoří ručně (nebo ho Dream vytvoří sám).
**Clawhub.ai / OpenClaw** je jiný ekosystém, nemá s nanobotem nic společného. Skilly odtud je třeba manuálně adaptovat.
**Claude Code skilly jsou přímo přenositelné.** Anthropic Skills format (`SKILL.md` s YAML frontmatter `name`+`description` + markdown tělo) je identický s nanobot skill formátem. Stačí zkopírovat složku `skills/<name>/` ze zdroje (např. plugin `.claude-plugin/skills/<name>/`) do `~/.nanobot/workspace/skills/<name>/` — žádná konverze. **Manifest `.claude-plugin/plugin.json` se neinstaluje**, je Claude-Code-specific. Pozor jen na (a) reference na Claude-Code tooly v těle skillu (`TodoWrite`, `ExitPlanMode`, `AskUserQuestion` apod. v nanobotovi neexistují), (b) prompt-injection v markdown těle — nanobot čte skill jako součást system contextu. Ověřeno: nasazen `grill-me` z [mattpocock pluginu](https://github.com/lachtan/nicecode/tree/master/plugins/mattpocock) (history 2026-05-28 "Pilot mattpocock skillu grill-me").
Zdroj: `nanobot/agent/skills/`, `ContextBuilder._load_bootstrap_files()`
## Skill `description` — k čemu reálně slouží (progressive loading)
Pole `description` ve frontmatteru non-always skillu je **routing signál**, ne kontext „jak skill funguje". Při sestavování system promptu se každý non-always skill vykreslí jako **jeden řádek** v seznamu: `- **<name>** — <description> \`cesta/k/SKILL.md\``. Tělo SKILL.md se načte **až on-demand**, když si agent skill sám přečte přes `read_file`. Důsledky:
- `description` je jediná info o skillu v promptu, dokud agent nečte tělo → patří tam jen *kdy/proč* skill spustit (trigger fráze, odlišení od příbuzných skillů), **ne** *jak* funguje.
- `description` se **nezkracuje** (`_get_skill_description` vrací text doslova) a je v promptu **každý tah** u všech skillů → trvalý token cost. Drž stručně, routing-orientovaně. Detailní postup patří do těla.
- **Always skilly** (`metadata.nanobot.always: true`): `description` se **ignoruje úplně**, do promptu se eager vkládá **celé tělo** (bez frontmatteru). Druhý vysvětlující odstavec v `description` je u nich čistý šum.
**Co tedy patří do `description`:** jen *kdy/proč* skill spustit — krátká věta o účelu + trigger fráze + případné odlišení od příbuzného skillu. **Nepatří** tam *jak* skill funguje (to do těla, čte se on-demand) ani detailní postup. Triggery nemusí být dvojjazyčné — model rozpozná záměr napříč jazyky, takže explicitní CZ varianty nic nepřidají, jen prodlužují řádek (ověřeno na `/plan`, 2026-05-31).
Zdroj: `nanobot/agent/skills.py:111-159` (`build_skills_summary`, `_get_skill_description`), `skills.py:94-109` (`load_skills_for_context`, always skilly), `nanobot/agent/context.py:87-95`.
## Dream procesor — automatické self-improvement
Nanobot má vestavěný Dream procesor (`agent/memory.py:Dream`) který běží každé 2 hodiny. Jde o **dvou-fázový LLM pipeline** nad `history.jsonl`:
- **Fáze 1:** Plain LLM call analyzuje historii, hledá fakta (`[MEMORY]`/`[USER]`/`[SOUL]`), kandidáty na smazání (`[FILE-REMOVE]`), opakující se workflow (`[SKILL]`)
- **Fáze 2:** AgentRunner s `read_file`/`edit_file`/`write_file` tools provede chirurgické editace; umí sám vytvářet nové skilly (`skills/<name>/SKILL.md`)
Dream řeší: deuplikaci, detekci stale obsahu (git blame age na řádcích MEMORY.md), automatické git commity po změnách. Cursor v `.dream_cursor` zabraňuje přepracování.
**Důsledek:** Self-improving-agent skilly z jiných ekosystémů jsou z velké části redundantní — Dream pokrývá jejich core funkcionalitu nativně. Přidaná hodnota by byl jen okamžitý strukturovaný error log (ERR-YYYYMMDD-XXX formát) — Dream čeká 2h.
Zdroj: `nanobot/agent/memory.py:Dream`, prompt templates `agent/dream_phase1.md`, `agent/dream_phase2.md`
---
## Non-interactive nanobot CLI: streamuje chaoticky, Python API vrací čistý string
`nanobot agent --message "..." --session "..."` projede agent loop, výstup
ale **streamuje rozkouskovaně přes stdout** (`` prefixované delty
reasoning/progress, finální `response.content` až na úplném konci). I při
`--no-markdown` a pipe (`| cat`) jde streaming dál. Postprocesovat by bylo
křehké.
**Pro programatické použití** (daemon, skript) jdi přes Python API:
```python
import asyncio
from nanobot import Nanobot
bot = Nanobot.from_config()
result = await bot.run("prompt", session_key="my:session")
# result.content je čistý string, žádné streamovací nečistoty
```
Interpreter s `import nanobot`: `/home/nanobot/.local/share/uv/tools/nanobot-ai/bin/python`.
Loguru jde na stderr (lze odchytit nebo přesměrovat). `Nanobot.run` interně volá
`AgentLoop.process_direct` **bez cron preamble** — to je jen v `on_cron_job` callbacku.
Zdroj: `nanobot/cli/commands.py:1204-1231` (CLI), `nanobot/nanobot.py:71-102` (`Nanobot.run`).
---
## Agent vidí `Channel` a `Chat ID` v runtime contextu zprávy
ContextBuilder každý tah příchozí zprávy obaluje runtime context blokem,
ve kterém je `Channel: <name>` a `Chat ID: <id>` (kromě `Current Time` a
volitelně `Sender ID`). Skill nebo prompt si je tedy **může přečíst** —
nemusí mít vlastní tool ani contextvars přístup.
```
Channel: telegram
Chat ID: 8826147089
```
V CLI / SDK session bez channel kontextu se blok nezobrazí (`Chat ID`
chybí). Skill na to musí umět reagovat (např. `detach` v takovém
případě nabídne synchronní vykonání).
Zdroj: `nanobot/agent/context.py:123-139` (`ContextBuilder._build_runtime_context`).
---
## Cron preamble je hardcoded — pro non-reminder background úlohy obejít
`nanobot/cli/commands.py:891-897` (`on_cron_job`) obaluje payload natvrdo:
```
The scheduled time has arrived. Deliver this reminder to the user now,
as a brief and natural message in their language. Speak directly to them —
do not narrate progress, summarize, include user IDs, or add status reports
like 'Done' or 'Reminded'.
Reminder: <payload.message>
```
Pro reminders je to správné chování. Pro background **úlohy** (deep research,
ingest, multi-step research) je to v přímém rozporu — agent má provést úkol,
zapsat výsledek do souboru, vrátit informativní větu. Preamble ho stáhne
do meta-statusu.
**Cesta okolo:** zahodit cron tool i `at` jednorázové joby, orchestraci řešit
**externím daemonem mimo agent loop** — viz "Detach skill" níže. Stejný pattern
už používá `/remind` (viz "Cron job s LLM agentem je nespolehlivý…" výše).
---
## Detach skill — background úlohy přes externí daemon (mimo agent loop)
Architektura podobná `/remind` — orchestrace mimo agent loop, žádný cron preamble.
**Tok:**
1. Skill `detach` v chatu → `exec skills/detach/scripts/create-task.py --goal … --slug … --channel … --chat-id …`. Skript vygeneruje timestamp + frontmatter, zajistí fronty (`mkdir -p`), atomicky zapíše do `tasks/tmp/` a přesune do `tasks/inbox/` (atomický rename, partial-write race neexistuje). Agent dělá jen LLM části (přeformulovat goal, vybrat slug, přečíst channel/chat_id z runtime contextu) — žádný ruční `write_file`/`mv`/`date`.
2. Systemd user unit `tasks-daemon.path` (`DirectoryNotEmpty=…/tasks/inbox`) přes inotify spustí `tasks-daemon.service` (`Type=oneshot`).
3. `tasks-daemon.py` (Python, shebang na uv venv interpreter) projede inbox: `mv → running/`, parse frontmatter (`chat_id` povinný), zavolá `Nanobot.from_config().run(goal, session_key=f"detach:<stem>")` s 45-min timeoutem, appendne `## Result` sekci, `mv → done/` nebo `failed/`, pošle Telegram zprávu přes Bot API (urllib + token z `~/.nanobot/config.json["channels"]["telegram"]["token"]`).
**Volba modelu pro task (od 2026-06-07):** Detach umí task spustit na explicitně zvoleném presetu (background = latence nebolí, vyplatí se silnější model). Uživatel model jen zmíní ve větě („na kimi") → agent předá token jako `create-task.py --model "<token>"` → skript ho **při captue** fuzzy-resolvne proti `config.json` (`resolve_preset`: exact case-insensitive → unikátní substring; jinak `KeyError` se seznamem, exit 1, fail-fast v chatu) a uloží přesný preset do frontmatteru `model:`. Bez `--model` jede default (`agents.defaults.modelPreset`). Daemon přečte `fm["model"]` a před `run()` přepne `bot._loop.set_model_preset(preset)` — stejný switch jako `/model` v chatu (ověřeno e2e s nainstalovaným balíčkem, history 2026-06-07). **Gotcha:** klíč presetů je v serverovém `config.json` na disku **snake_case `model_presets`** (ne camelCase `modelPresets`), zatímco `agents.defaults.modelPreset` je camelCase — `load_preset_names()` proto čte oba tvary.
**Soubory:**
- `~/.nanobot/workspace/skills/detach/SKILL.md` — definice + triggery (EN-only)
- `~/.nanobot/workspace/skills/detach/scripts/tasks_common.py` — sdílené čisté helpery (TASKS, FILENAME_RE, parse_frontmatter, parse_kv, format_*, build_task_*), importují ho ostatní skripty
- `~/.nanobot/workspace/skills/detach/scripts/create-task.py` — capture skript (frontmatter + atomický tmp→inbox)
- `~/.nanobot/workspace/skills/detach/scripts/{list-tasks,read-task}.py` — list / read subactions
- `~/.nanobot/workspace/skills/detach/tests/` — pytest čisté logiky (lokálně v repu, ne na serveru)
- `~/.nanobot/workspace/skills/detach/scripts/tasks-daemon.py` — daemon
- `~/.nanobot/workspace/skills/detach/systemd/tasks-daemon.{path,service}` — user systemd unity (symlinkované do `~/.config/systemd/user/`)
- `~/.nanobot/workspace/tasks/{tmp,inbox,running,done,failed}/` — fronty
- `~/.nanobot/workspace/log/tasks-daemon.{log,stdout.log,stderr.log}` — append-only logy
**Souběh:** systemd serializuje (`Type=oneshot` se nespustí podruhé, dokud první běh trvá; level-triggered `.path` ho restartne po doběhu pokud inbox stále není prázdný). Žádný flock není potřeba.
**Notifikační target — Telegram s fallback chat_id (single-user setup):** Skill v frontmatteru zapíše `channel` + `chat_id` z runtime contextu (`Channel: telegram` → numeric ID, `Channel: websocket` → session UUID, atd.). Daemon `resolve_telegram_chat_id(fm)`:
- pokud `channel == "telegram"` → použij `chat_id` z frontmatteru (multi-user ready)
- jinak → čti `channels.telegram.allowFrom[0]` z `~/.nanobot/config.json`
Tím Telegram vždy doručí, i když úkol přišel z WebUI / CLI. Daemon log: `NOTIFY chat=<id> source=<frontmatter|fallback>`. Bez tohoto fallbacku selhával Telegram Bot API s HTTP 400 pro non-telegram channel (history 2026-05-28 18:37).
**Subactions `list` a `read`:** detach skill umí i číst zpět hotové úkoly. „výsledky?" → markdown tabulka tasks/{running,done,failed}/. „výsledek <slug-nebo-pattern>" → `read_file` přes match v done/+failed/, předlož `# Result` sekci. Identifier match: slug substring (`*foo*`), timestamp fragment (`*T175451*`), nebo prázdný = nejnovější.
**Zdroj:** [skills/detach/](skills/detach/) v tracking repu, history 2026-05-28 „Skill detach + daemon" + iterace #2 + iterace #3.
**uv-native invokace (iterace #3):** Shebang přepnut na `#!/usr/bin/env -S uv run --script` s PEP 723 inline metadata (`requires-python = ">=3.11"`, `dependencies = ["nanobot-ai"]`). `uv run --script` samo vytvoří/cachuje izolované venv — skript přežije `uv tool uninstall/install` i přesun na jiný stroj. První spuštění po PEP 723 změně trvá ~5-10s (budování venv), další jsou instantní (cache v `~/.cache/uv/`). Systemd user unit musí mít `Environment=PATH=%h/.local/bin:/usr/bin:/bin`, jinak `uv` v PATH chybí.
---
## Detach notifikace do origin kanálu (WebUI/CLI) — záměrně nepodporováno
Daemon notifikuje **jen Telegram** (přes Bot API, deterministicky). Když task přišel z WebUI nebo CLI, do toho kanálu se notifikace nepošle — uživatel si výsledek vyzvedne přes `výsledek <slug>` (detach subaction `read`).
**Architektonický důvod:** WebSocket spojení vlastní gateway proces; daemon je samostatný systemd oneshot. Nanobot nemá HTTP endpoint pro vstřikování zpráv do WS sessions (`nanobot/channels/websocket.py:673-782` — všechny `/api/sessions/...` jsou read-only). Sdílí jen filesystem, žádné IPC.
**Zvážené a zamítnuté možnosti:**
- **Samostatný `Nanobot.run()` jen kvůli notifikaci** — LLM jako IPC proxy. Pomalé (1030 s), drahé, nedeterministické (model může prompt překroutit nebo `message` tool nezavolat). Stejná třída problému jako [[Cron job s LLM agentem je nespolehlivý]].
- **Přibalit `message` tool call k existujícímu agent turnu tasku** — žádný extra LLM call, ale stále LLM-mediated; nepokrývá timeout/exception (agent se k toolu nedostane).
- **Patch upstream + nový HTTP endpoint na gatewayi** — čisté řešení (daemon dělá prostý POST, žádný LLM), ale udržovat patch napříč upgrady `nanobot-ai`. Pokud někdy ano, místo je `nanobot/channels/websocket.py` (přidat handler vedle stávajících `/api/sessions/...`, vytvořit `OutboundMessage(channel="websocket", chat_id=..., content=...)` a `bus.publish_outbound(msg)`).
**Rozhodnutí 2026-05-29:** status quo — Telegram fallback stačí, `výsledek <slug>` je dokumentovaný způsob pro WebUI/CLI.
---
## Skill `exec` běží z workspace rootu, ne ze skill adresáře
Když skill volá `exec` bez explicitního `working_dir`, příkaz běží s **CWD = workspace root** (`~/.nanobot/workspace`), **ne** v adresáři skillu. Cesty na skripty skillu proto musí být buď workspace-relativní (`skills/<name>/scripts/x.py`) nebo absolutní — **skill-dir-relativní `scripts/x.py` se rozbije** (resolvuje na `workspace/scripts/x.py`).
Zdroj: upstream `nanobot/agent/tools/shell.py:148` (`working_dir=ctx.workspace`) + `:370` (`cwd = working_dir or workspace_root`). Pozn.: remind SKILL.md používá `scripts/remind_edit.py` — v tomto ohledu zavádějící; detach používá korektní `skills/detach/scripts/…`.
---
## Python skripty na serveru — uv-native pattern (PEP 723)
Preferovaný způsob pro libovolný stand-alone Python skript v `~/.nanobot/workspace/`:
```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai", "requests", ...]
# ///
```
`uv run --script` vytvoří a cachuje izolované venv per skript (`~/.cache/uv/`). Skript přežije `uv tool uninstall/install`, upgrade Pythonu i přesun stroje — bez přímé cesty do `~/.local/share/uv/tools/<tool>/bin/python`. První spuštění po vytvoření hlavičky trvá ~5-10s (build venv), další jsou instantní.
**Gotcha pro user systemd:** unit musí mít explicitní PATH, jinak shebang `uv` nenajde:
```ini
[Service]
Environment=PATH=%h/.local/bin:/usr/bin:/bin
ExecStart=%h/path/to/script.py
```
Bez `Environment=PATH` selže s `/usr/bin/env: 'uv': No such file or directory`. Aplikace pravidla na všechny budoucí user systemd unity spouštějící uv skripty (nejen detach).
Zdroj: [PEP 723](https://peps.python.org/pep-0723/), [uv docs `uv run --script`](https://docs.astral.sh/uv/guides/scripts/), ověřeno deployem detach skillu iterace #3.
---
## Systemd `.path` unit s `DirectoryNotEmpty=` — event-driven workspace daemon
Pattern pro libovolný daemon, který má reagovat na soubory v workspace **bez polling**:
```ini
# tasks-daemon.path
[Path]
DirectoryNotEmpty=%h/.nanobot/workspace/tasks/inbox
Unit=tasks-daemon.service
[Install]
WantedBy=paths.target
```
```ini
# tasks-daemon.service
[Service]
Type=oneshot
ExecStart=%h/path/to/daemon.py
```
`%h` = user home. `.path` unit je jen watcher (přes inotify), reálnou akci dělá `.service`. **Level-triggered:** dokud kondice `DirectoryNotEmpty=` platí, systemd po každém doběhnutí service spustí novou instanci. Daemon by měl drenovat celý inbox v jednom běhu (sériově).
Install: `systemctl --user enable --now <unit>.path`. Lingering musí být zapnutý (`loginctl enable-linger nanobot`), jinak user units po odhlášení padnou. Pro reminders se to nepoužívá — ty mají cron výrazy, .path není vhodný (kondice se nemění minutu po minutě). Pro file-driven queue (jako detach) ano.
**Gotcha — level-triggered `.path` + startup crash = permanentní latch:** Když oneshot daemon spadne **ve startup fázi** (před vyprázdněním inboxu), inbox zůstane neprázdný → `.path` ho hned znovu spustí → další pád → … Na manager defaultu (`StartLimitIntervalSec=10s`, `Burst=5`) to za <2 s narazí na rate-limit a systemd zalatchuje **`.service` i `.path`** do `failed (unit-start-limit-hit)`. Z toho se **sám nezotaví** — nutný `systemctl --user reset-failed <unit>.service <unit>.path` + `restart <unit>.path`. (Stalo se 7.6., když daemon padal na `NameError`.)
**Hardening (ověřeno, nasazeno na tasks-daemon):** v `.service` přidat
```ini
[Unit]
StartLimitIntervalSec=1800
StartLimitBurst=20
[Service]
Restart=on-failure
RestartSec=60
```
`Restart=on-failure` + `RestartSec` dá **delay mezi pokusy** (nezávisle na `.path` retriggeru); čistý `exit 0` (inbox vyprázdněn) ani SIGTERM od systemd nerestartují. Širší okno (`30min`/`20`) zajistí, že se latch po posunu okna sám pustí dál. **`man systemd.service`: pro `Type=oneshot` jsou zakázané jen `Restart=always`/`on-success`, `on-failure` je povolený.**
Zdroj: `man systemd.path` + `man systemd.service`, ověřeno smoke testem před deployem detach skillu; latch+hardening history 2026-06-07 19:33.
---
## `nvm` je shell funkce, ne binárka
`nvm` je definován jako bash funkce v `.bashrc` — **není to spustitelný soubor**. Proto ho systemd service nevidí, ani když má správně nastavenou `PATH` s nvm node cestou.
| Příkaz | Typ | Dostupný v systemd service? |
|---|---|---|
| `node`, `npm`, `npx` | skutečné binárky v `.nvm/.../bin/` | ano, pokud je PATH nastavena explicitně |
| `nvm` | shell funkce v `.bashrc` | **ne nikdy** — `.bashrc` se nesourcuje |
Pro správu verzí Node.js z shellu → přihlásit se jako `nanobot` a volat `nvm` interaktivně. Z agenta nebo daemonu → volat `node`/`npx` přímo (fungují přes PATH).
---
## Skill `/keep` — explicit immediate memory
On-demand skill pro okamžitou explicitní paměť. Uživatel řekne „keep X" → agent reformuluje na terse fact → zapíše jako bullet do `workspace/keep.md`. Bez datumů. Dedup, compaction při >150 řádcích.
**Persistent awareness:** `keep.md` není v `BOOTSTRAP_FILES` (ty jsou hardcoded). Trvalé povědomí zajišťuje krátká reference `## workspace/keep.md` na konci `USER.md` (auto-loadovaný každý tah). Skill je tedy čistě write endpoint — neplýtvá context window každou session.
**Kde žije:** `workspace/keep.md` v rootu workspace (vedle `USER.md`, `MEMORY.md`). Edituje ho výhradně `/keep` skill; ostatní agent paths smí číst. **Odděleno od Dream / MEMORY.md** — Dream o `keep.md` neví, needituje ho.
**Dedup pokrývá `keep.md` i `MEMORY.md`:** Write protocol (krok 4) před appendem přečte `workspace/memory/MEMORY.md` a pokud tam je sémanticky podobný fakt (Dream ho mohl destilovat), upozorní uživatele a defaultně přeskočí. `MEMORY.md` je read-only — `/keep` do něj nikdy nezapisuje.
**Ukládá i *why*, ne jen *what* (od 2026-06-06):** Krok 2 Write protokolu rozlišuje typ záznamu — plain fakt (alergie, deploy window, jméno) jde bez důvodu; **rozhodnutí / preference / dead-end** dostane důvod inline na stejném řádku (`<fakt> — because <terse why>`). Pokud je vstup rozhodnutí/dead-end *bez* uvedeného důvodu, model se **jednou doptá** na why (decline/self-evident → uloží bez něj). Záměrně úzká varianta Claude memory.md vzoru, který why přidává jen u feedback/project, ne u reference/faktu. Žádné `Why:` bloky ani few-shot příklady — silné Ollama Cloud / OpenRouter modely zvládnou hranici fakt-vs-rozhodnutí zero-shot. Plný kontext: history.md 2026-06-06.
**Gotcha — BOOTSTRAP_FILES jsou hardcoded:** `nanobot/agent/context.py:25` má `BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]` — nelze přidat vlastní soubor bez patche. Vše, co má být vidět každý tah bez on-demand loadingu, musí být reference v existujícím bootstrap souboru (USER.md, SOUL.md, …).
---
## Skill `/note` — explicit notes (no auto-load)
On-demand skill pro ukládání poznámek do `workspace/notes.md`. Uživatel řekne „note X" → agent reformuluje na terse fact → zapíše jako bullet. Bez dedup, bez kompakce, bez dat.
**Klíčový rozdíl od `/keep`:** `notes.md` nemá referenci v `USER.md` ani jiném bootstrap souboru — nikdy nevstupuje do context window automaticky. Maže se výhradně přes `/note delete <pattern>` (by index nebo substring).
**Kde žije:** `workspace/notes.md`. Edituje ho výhradně `/note` skill. Odděleno od `/keep`, Dream, MEMORY.md — žádný cross-read ani cross-write.
---
## MCP servery v nanobotu — skrytá tokenová zátěž
Každý nakonfigurovaný MCP server přidává do system promptu svůj tool schema popis. Pro sqlite MCP to jsou ~12k tokenů, a to **každý tah** — bez ohledu na to, jestli tool vůbec použiješ.
U menších modelů s omezeným kontextovým oknem (typicky cloud MoE modely s efektivními ~32B params) je to zbytečné plýtvání. Přitom přímá alternativa (CLI `sqlite3` přes `exec`, nebo Python `sqlite3` stdlib přes `uv run`) **tuto zátěž nemá** a pro 95 % use-cases je dostatečná.
**Pravidlo:** MCP server zapojit jen pokud přidaná hodnota nad přímým přístupem výrazně převáží tokenovou cenu. Pro sqlite typicky nepřeváží.
---
## Reasoning stream (``) na konzoli — `channels.showReasoning`
Řádky prefixované ``, streamované token po tokenu (`✻ The`, `✻ user wants`, …) v `nanobot agent` CLI chatu nejsou debug ani chyba — je to **reasoning/thinking stream** modelu. Řídí ho jediný config klíč `channels.show_reasoning` (default `true`, camelCase alias `showReasoning`).
**Vypnout:** `channels.showReasoning = false` v `~/.nanobot/config.json`. Sourozenec `telegram`/`websocket` uvnitř `channels`, ne uvnitř konkrétního kanálu.
- **Je to globální flag, ne per-channel.** Gate čte globální `channels_config.show_reasoning` (`nanobot/cli/commands.py:345,354`), ne per-kanálový config. Nelze vypnout jen pro konzoli a nechat zapnuté ve WebUI — buď všude, nebo nikde. (Trade-off: ve WebUI se reasoning hodí při ladění „proč něco jde/nejde".)
- **Restart:** CLI (`nanobot agent`) čte config čerstvě při startu → stačí restart sezení. Gateway/WebUI/Telegram dostávají `channels` přes `AgentLoop.from_config()` jednou při startu → restart service.
- **Žádný runtime flag** `nanobot agent` na to není; `--logs/--no-logs` řídí jen loguru runtime log, ne reasoning stream.
- Příbuzné knoby v témže bloku: `sendProgress` (default `true`, progress řádky ``), `sendToolHints` (default `false`, tool-call hinty). Vykreslení `` na `commands.py:301`.
Zdroj: `nanobot/config/schema.py:37-39`, `nanobot/cli/commands.py:301,345,354`. Plný záznam: history 2026-06-01 „Vypnutí reasoning streamu".
---
## Context window presetů: default 65k, přepis přes `contextWindowTokens`
Nanobot má **hardcoded default `context_window_tokens = 65_536`** pro `ModelPresetConfig` i `AgentDefaults` (`nanobot/config/schema.py:101,124`). Pokud preset v `config.json` tuto hodnotu nepřepíše, jede model na 65k **bez ohledu na to, co reálně umí**. Klíč v JSON: `contextWindowTokens` (Base má `alias_generator=to_camel` + `populate_by_name=True`, `schema.py:24` → projde camelCase i snake_case). Sourozenec `maxTokens` (max output) má default jen `8192`.
Nastaveno 2026-06-02 per-preset na reálné limity modelů (kimi-k2.6 / qwen3.5 / nemotron-3-super 262144, minimax-m2.7 204800, glm-5.1 196608, deepseek-v4-flash 1048576) + `maxTokens` 16384. **Bez restartu** — `modelPresets` se hot-reloadují (viz sekce „Kdy je a není potřeba restart"). U `:cloud` modelů hostí kontext Ollama cloud, takže `contextWindowTokens` reálně rozšíří budget — není to lokální `num_ctx` žeroucí RAM. Plný záznam: history 2026-06-02.
**Důsledky (trade-off, ne čistá výhra):**
- **+** Méně ořezávání/komprese historie → lepší návaznost v dlouhých sezeních. Delší souvislé odpovědi (16k vs 8k output).
- **** „Lost in the middle": LLM neudrží kvalitu rovnoměrně přes celý kontext; info zahrabané uprostřed ~200k se vybavuje hůř. Propad je výraznější u slabších MoE modelů (glm/qwen/nemotron) než u špičkových (Kimi K2.6). Roste latence i protečené tokeny úměrně naplnění.
- Při běžném (nízkém) naplnění se kvalita **nemění** — efekt nastává až když sezení přeroste 65k.
**Otevřená otázka** (todo.md): nenechat slabším modelům kontext spíš na ~128k? Menší okno může dát lepší kvalitu „per token" než maximální naplnění.
---
## Rozpad input contextu (co zabírá tokeny každý tah)
Detailní naměřený rozpad ~15k baseline input contextu (system prompt po částech + tool definitions 18 toolů) je v samostatném souboru [`tokens-explain.md`](tokens-explain.md) — k 0.2.1, preset glm-5.1. Stručně: ~8,7k system prompt (největší `MEMORY.md`, `skills_section`, `SOUL.md`), ~5,2k tool defs, zbytek session zprávy.
## Optimalizovat skilly kvůli tokenům se nevyplatí
Celý blok skillů (~2,5k: `skills_section` 1,56k + always-skilly 0,95k) je při okně 196k jen **~1,3 % okna**. Smazat on-demand skill ušetří jen popis + framing (~4075 tok/kus) → fakticky neměřitelné. **Description neškrtat** — je to trigger pro progressive loading (model podle něj pozná, kdy skill načíst); bez něj skill přestane fungovat, ušetříš desítky tokenů a přijdeš o funkčnost. Jediná páka jsou `always: true` (jdou celým tělem), ale `my`+`memory` mají být always. Větší blok jsou tool defs (5,2k, jen vypnutím toolů v configu). **Závěr:** skilly maž podle užitečnosti, ne kvůli tokenům; reálný strop je `contextWindowTokens`, ne baseline. Začalo by to dávat smysl až u desítekstovek skillů nebo velkého těla jako `always`. Plný rozbor: [`tokens-explain.md`](tokens-explain.md).
---
## `maxTokens` se počítá dvakrát; prompt caching má 3 háčky
**`maxTokens`** jde jednak přímo do API jako strop výstupu (`runner.py:621`), jednak se **odečítá z input budgetu** jako rezerva na výstup — u snipu historie i u konsolidace: `budget = contextWindowTokens maxTokens 1024` (`runner.py:1262`, `memory.py:619`). Vyšší `maxTokens` tedy zmenšuje prostor pro kontext a uspíší konsolidaci → držet skromně (16k OK), u reasoning modelů víc (reasoning tokeny se počítají taky).
**Prompt caching** nanobot zapíná jen pro providery s `supports_prompt_caching=True` = **openrouter, anthropic, bedrock** (`registry.py:149,278`); `ollama` a `gemini` ne → aktivní `glm-5.1` přes ollama od nanobota **žádné cache breakpointy nedostává**. Háčky tam, kde caching jede:
1. **TTL 5 min** — holé `{"type": "ephemeral"}` (`anthropic_provider.py:400`). U sporadického chatu cache mezi tahy obvykle vyprší → platí se plný vstup; write navíc 1,25× base (read 0,1×).
2. **Konsolidace/snip rozbíjí prefix** — breakpoint sedí na system + `messages[-2]` + tools (`openai_compat:453`); jakmile Dream/`_snip_history` změní začátek pole, prefix se invaliduje.
3. **Interakce s kontextem:** vyšší `contextWindowTokens` = méně časté konsolidace = stabilnější cachovaný prefix → argument cachingem podporuje velké okno, ale jen na cachujících presetech.
---
## MiniLoop — změřená čísla `/remind add` parseru (PoC)
Samostatný `.NET` PoC v `src/MiniLoop/` (prompt-only parse text→JSON, `Microsoft.Extensions.AI` nad OpenAI SDK, swap providera přes config). Test `test` mód protáčí 17 párů × všechny modely **souběžně** (modely paralelně, příklady uvnitř modelu sekvenčně; NOW fixní `2026-06-03T14:30:00`). Souběžnost vůči provideru je omezená `maxConcurrency` v configu (`SemaphoreSlim` per provider) — ollama=3 dle kvóty předplatného. Běh 5 modelů s gate=3 (2026-06-03):
| Model | Provider | Úspěšnost | Wall median / avg | Tokeny in / out |
|---|---|---|---|---|
| glm-5.1 | ollama (nvidia.hell) | 17/17 | 1690 / 1927 ms | 21118 / 2896 |
| deepseek-v4-flash | ollama (nvidia.hell) | 17/17 | 4894 / 6118 ms | 21654 / 3487 |
| minimax-m2.7 | ollama (nvidia.hell) | 17/17 | 4815 / 4604 ms | 21969 / 2317 |
| claude-haiku-4.5 | openrouter | 16/17 | 1064 / 1135 ms | 23668 / 691 |
| gpt-5.4-nano | openrouter | 17/17 | 3034 / 4003 ms | 20987 / 553 |
**Hrdlo souběhu = kvóta paralelních dotazů providera, ne sdílený výpočet ani počet spojení.** Ollama předplatné povoluje **max 3 paralelní dotazy** ([ollama.com/pricing](https://ollama.com/pricing)). Když běží víc ollama modelů než 3 naráz, přebytečné dotazy čekají ve frontě a to čekání spadne do wall-clocku (stopky obalují jen HTTP call). Dřív (4 ollama modely bez stropu): glm median 3240 ms, jednotlivá volání qwen až 59 s. Po zavedení `maxConcurrency=3` (a redukci na 3 ollama modely, takže strop zatím ani nepřekáží): glm median zpět na **1690 ms**, žádné odlehlé hodnoty. `SemaphoreSlim` slot se navíc získává **mimo stopky**, takže i kdyby strop překážel, čekání na slot se do měřené latence nezapočte. OpenRouter běží na vlastní infře, strop nemá. Každý model má vlastní `OpenAIClient` (spojení se nesdílí) — víc spojení by nepomohlo.
**Reasoning = pomalé + drahé na out tokeny:** deepseek out=3487, minimax out=2317 — proto ~5 s. (Dříve zavržený qwen3.5 byl extrém: out=22671 tok ≈ jako input, volání i 59 s — proto vyhozen.) haiku/gpt-nano out 550700 tok = přímý parse bez reasoningu. glm rychlý (out~2,9k, ale median 1,7 s).
**FAILy:** jediný „FAIL" haiku = **false negative v test datech** (`zkontrolovat pečení` vs `pečeni`; JsonCompare porovnává `text` přesně, ordinálně). Ostatní modely 17/17. (Z dřívějška: gemma dělala skutečnou chybu data `příští pondělí`→`06-09` místo `06-08`; gemma teď v reálném configu není.)
**Teze PoC potvrzena:** ~1,2k input tokenů na `add` (vs ~2832k přes nanobot agent loop, ~30× méně) a ~1 s wall-clock u rychlých modelů (vs ~10 s u `/remind list` přes agenta). Sedí s odhady z [plans/remind-standalone-bot.md](plans/remind-standalone-bot.md). Plný záznam: history 2026-06-03 „MiniLoop paralelizace".
### Levné / OSS modely z OpenRouteru (změřeno 2026-06-03)
Test 5 levných OpenRouter modelů (cena $/M tok in/out), gate=3 na ollam(ě) se netýká — vše OpenRouter:
| Model | Cena | Úspěšnost | Wall median / avg | out tok |
|---|---|---|---|---|
| `mistralai/mistral-small-3.2-24b-instruct` | 0.075/0.20 | 17/17 | **934 / 1050 ms** | 610 |
| `google/gemma-3-27b-it` | 0.08/0.16 | 17/17 | 1413 / 1611 ms | 608 |
| `z-ai/glm-4-32b` | 0.10/0.10 | 16/17* | 1905 / 2072 ms | 519 |
| `qwen/qwen3-30b-a3b-instruct-2507` | 0.043/0.17 | 16/17* | 1991 / 1866 ms | 588 |
| `openai/gpt-oss-120b` | levný | 16/17 | 7238 / 12164 ms | 3769 |
\* false negative (slovosled `se protáhnout`/`protáhnout se`, resp. `pečení`/`pečeni`).
**Závěr:** **`mistral-small-3.2` má nejlepší poměr ze všech dosud měřených** — 934 ms median (rychlejší než glm-5.1 1690 ms i haiku 1064 ms), 17/17, out 610 tok, cena pakatel. Evropská/česká stopa Mistralu se potvrdila. `gemma-3-27b` těsně za ním. **`gpt-oss-120b` je jediný propadák — reasoning → 7 s a 6× víc out tokenů**, gpt-5.4-nano pokrývá OpenAI rychleji. Potvrzení teze o thinkingu: `qwen3-30b-a3b-instruct` 1991 ms vs cloud `qwen3.5` (thinking) 10852 ms + out=22671 — past byl režim thinking, ne qwen. Plný záznam: history 2026-06-03 „MiniLoop levné OSS".
### Malé ollama modely — ministral-3, nemotron-3-nano (změřeno 2026-06-03)
`:cloud` varianty registrované na nvidia.hell přes `POST /api/pull` (cloud pointer, žádný GB download):
| Model | Úspěšnost | Wall median / avg | out tok |
|---|---|---|---|
| `ministral-3:8b-cloud` | 15/17 | 1043 / 1169 ms | 653 |
| `nemotron-3-nano:30b-cloud` | 16/17 | 2015 / 2277 ms | 7805 |
**Ani jeden nepřekonal mistral-small-3.2 — oba zavrženy.** `ministral-3-8b` udělal **skutečnou chybu dne v týdnu** (`každý pátek` → cron `* * 6` sobota místo `* * 5`) — u připomínek vážné, na 8b je to znát; přitom **není ani rychlejší** než mistral-small (1043 vs 934 ms). `nemotron-3-nano-30b`**reasoning sklony (out=7805 tok, ~12× víc než mistral)**, je 2× pomalejší a jeho jediný FAIL byl rozsekání `1,3,5` na tři cron výrazy (rozvrh ekvivalentní, formát ne). Závěr: pod ~24b instruct (mistral-small, gemma-3-27b) klesá spolehlivost cronu a malé „nano" modely buď chybují, nebo zbytečně reasonují. Plný záznam: history 2026-06-03 „MiniLoop ministral/nemotron-nano".
## Rychlost: glm-5.1 vs minimax-m3 (Ollama nativní streaming, 2026-06-07)
Měřeno přímo proti Ollamě na `nvidia.hell` (stejný endpoint jako nanobot), streaming `/api/chat`, identický `/remind list` prompt, 3 běhy/model. **`:cloud` modely nevracejí sub-durations** (`eval_duration` ap. = `None`) — tok/s nutno měřit přes streaming (TTFT = čas 1. content chunku).
| Model | TTFT (medián) | Total wall (medián) | Out tok | End-to-end průtok (out/total) |
|---|---|---|---|---|
| glm-5.1 | ~5,9 s | ~7,5 s | 12001730 | **~198 tok/s** |
| minimax-m3 | ~6,8 s | ~10,8 s | 420460 | **~40 tok/s** |
**minimax-m3 je výrazně línější:** TTFT mají srovnatelný (start není problém), ale minimax má **~50 % delší celkovou dobu i přes 34× MÉNĚ vygenerovaných tokenů**. Čistá generace minimaxu ~95120 tok/s (streamuje plynule); glm ~5× vyšší end-to-end průtok. Pozn.: glm „1300 tok/s" z post-TTFT okna NEbrat doslovně — cloud buffer flushne dávku, proto měřit `out/total`. Na interaktivní úkoly je glm-5.1 jednoznačně svižnější. Plný záznam + per-run čísla: history 2026-06-07 „Měření rychlosti glm-5.1 vs minimax-m3".
**Širší rozhodovací rozbor** (GLM-5.1 vs MiniMax M3 vs Kimi K2.6 — kdy který za podmínky Ollama Cloud, capability cliffs, use-case mřížka): [`models.md`](models.md).
### Doplněk: minimax-m2.7 vs glm-5.1 (2026-06-07, prokládaně 5 kol)
`minimax-m2.7:cloud` zmizel z `/api/tags` (Ollama Cloud ho nahradila m3), ale `POST /api/pull` ho dotáhne (cloud pointer). Mediány (cloud byl vytížený → absolutní čísla vyšší než ranní m3 měření, ber jen poměr):
| Model | TTFT | Total wall | Out tok | e2e (out/total) |
|---|---|---|---|---|
| glm-5.1 | 15,4 s | 18,3 s | 1558 | **~91 tok/s** |
| minimax-m2.7 | 9,2 s | 11,3 s | 351 | **~28 tok/s** |
**m2.7 má decode ~3× pomalejší než glm (a horší než m3 ~40 tok/s).** Nižší wall-clock (11 vs 18 s) je **jen díky terseness** (~4,5× méně tokenů), ne rychlejším generováním. Pro delší agentní výstupy (tool args, kód) je pomalý decode handicap. Plný záznam: history 2026-06-07 17:51.
---
## minimax-m3 je pro nanobot agenta nepoužitelný (BLOCKED)
**Verdikt: nenasazovat `minimax-m3` jako agent model.** Vedle pomalosti (~40 tok/s end-to-end, viz sekce výše) má fatální slabinu v **agentní recovery** — neumí přečíst chybovou hlášku toolu a vystoupit ze smyčky.
Konkrétně (detach deep-research `ollama-cloud-models-research`, 2026-06-07): web_fetch velké stránky se perzistoval do souboru, parsování přes `exec` blokoval `restrictToWorkspace` guard, a minimax-m3 místo aby přesunul soubor / použil `read_file` (guard to doslova radil) **opakoval identický blokovaný příkaz s kosmetickými obměnami**, prokládal ho triviálními `print('ok')` sanity-checky (četl failure jako rozbitý interpreter) a jednou vystřelil 10× tentýž grep v jednom tahu → **spálil všech 200 `maxToolIterations` bez výsledku**. Stejný úkol s `kimi` doběhl za ~456 s.
K tomu už dřív známé: tool-result bug + výrazná pomalost. **Zkouší se náhrada `minimax-m2.7`** (starší MiniMax). Pro background deep-research drž GLM-5.1 / Kimi, ne MiniMax. Plný rozbor smyčky: session `detach_2026-06-07T170344-ollama-cloud-models-research.jsonl`.

33
develop/memory.md Normal file
View File

@@ -0,0 +1,33 @@
# Memory
## feedback: při deploymentu skillu synchronizovat celý adresář, ne jen scripts/
Při kopírování skillu na server vždy rsyncovat **celý adresář skillu** (např. `skills/remind/`), nikoli jen podadresář `scripts/`.
**Why:** 2026-05-29 — fix `remind_edit.py` byl rsyncem nasazen, ale `SKILL.md` s novou instrukcí ne. Agent proto stále četl starý `SKILL.md` a chybné chování přetrvávalo. Druhé kolo čištění dat bylo nutné zbytečně.
**How to apply:** `rsync -av skills/remind/ nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/skills/remind/` — cílový rsync pokrývá vše (SKILL.md i scripts/). Nikdy nekopírovat jen podadresář, pokud si nejsi jistý, že ostatní soubory jsou beze změny.
## feedback: Python skripty na serveru spouštět přes uv (PEP 723 + `uv run --script`)
Nové Python skripty v `~/.nanobot/workspace/` psát s shebang `#!/usr/bin/env -S uv run --script` a PEP 723 inline metadata (`# /// script` blok). Žádné přímé cesty do `~/.local/share/uv/tools/<tool>/bin/python` jako shebang, žádné `python3` s předpokladem správného venv.
**Why:** Uživatel explicitně řekl „python veci se maji spoustet pres uv" (2026-05-28, iterace #3 detach skillu). Důvod: uv-managed venv má izolaci, cache, a skript je portable — přežije reinstalaci tooly, upgrade Pythonu i přesun stroje.
**How to apply:** Pro každý nový stand-alone Python skript na serveru → PEP 723 hlavička. Pokud skript poběží přes user systemd unit, doplnit `Environment=PATH=%h/.local/bin:/usr/bin:/bin` do `.service`, jinak `uv` nebude v PATH. Detail pattern viz [[knowledge.md]] sekce „Python skripty na serveru".
## feedback: zálohy serverových configů ukládat do `~/.nanobot/backup/`
Před editací jakéhokoli configu na serveru (zejména `~/.nanobot/config.json`) ukládej zálohu do adresáře **`~/.nanobot/backup/`**, ne vedle původního souboru. Pojmenování s timestampem (např. `config.json.bak-YYYYMMDD-HHMMSS`).
**Why:** Uživatel to vyžádal 2026-06-02 po editaci context window presetů — záloha vedle configu (`config.json.bak-*`) zaneřáďuje `.nanobot/` root. Centrální `backup/` drží root čistý a zálohy pohromadě.
**How to apply:** `mkdir -p ~/.nanobot/backup` a `cp config.json ~/.nanobot/backup/config.json.bak-$(date +%Y%m%d-%H%M%S)` před in-place editem. Platí pro všechny serverové configy, které trackujeme/měníme.
## feedback: po změně knowledge/history/memory synchronizovat do serverového develop/
Po každém commitu, který mění `knowledge.md`, `history.md` nebo `memory.md`, rsyncni daný soubor i do `nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/develop/`, aby měl serverový nanobot agent aktuální verzi (čte je on-demand jako referenci „jak byla instance rozšiřována a laděna").
**Why:** Uživatel 2026-06-02 chtěl agentovi zpřístupnit develop kontext a zvolil průběžnou synchronizaci (ne jednorázovou kopii) — jinak agent časem uvidí zastaralý stav.
**How to apply:** `rsync -av <soubor> nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/develop/`. `README.md` v `develop/` je statický popis, ten se nesynchronizuje. Owner zůstává `nanobot:nanobot` (jdeme jako `nanobot`).

17
knowledge/README.md Normal file
View File

@@ -0,0 +1,17 @@
# knowledge/ — znalostní báze, ze které můžeš těžit
Ověřená fakta a naměřené hodnoty o tvém okolí. Ber je jako referenci, ne pravidla
chování. Nečtou se každý tah — čti on-demand, když jsou pro dotaz relevantní.
## Soubory
- **`models.md`** — fakta a naměřené hodnoty o modelech dostupných téhle instanci:
nakonfigurované presety (provider, kontext, multimodalita), přímo změřená rychlost a
latence na Ollama Cloud, profil verbozity, rozdíly ve schopnostech (kódování, tvrdé
znalosti, dlouhý kontext, multimodál), caveaty k benchmarkům. Sáhni sem, když se řeší
volba modelu — co je rychlé, co umí obrázky, o kolik je který pomalejší.
## Zdroj pravdy
Kopie z lokálního repa uživatele (`src/nanobot`). Tady jen pro tvoji informaci —
needituj je s očekáváním, že se změna propíše zpět.

161
knowledge/models.md Normal file
View File

@@ -0,0 +1,161 @@
# Modely — fakta a naměřené hodnoty
Co je o dostupných modelech ověřeno a změřeno. **Žádná doporučení** — rozhodnutí, co
použít, je na tobě. Mezi presety přepínáš v chatu příkazem `/model <preset>`.
---
## Nakonfigurované presety (`/model`)
Zdroj: `~/.nanobot/config.json``model_presets` (stav k 2026-06-07). Všechny mají
`maxTokens = 16384`, `temperature = 0.1`.
| Preset | Provider | Model id | Kontext | Vstup |
|---|---|---|---|---|
| `glm-5.1` *(default)* | ollama (cloud) | `glm-5.1:cloud` | 196 608 | **čistě textový** |
| `minimax-m3` | ollama (cloud) | `minimax-m3:cloud` | 1 048 576 | multimodální |
| `kimi-k2.6` | ollama (cloud) | `kimi-k2.6:cloud` | 262 144 | multimodální |
| `sonnet` | openrouter | `anthropic/claude-sonnet-4.6` | 256 000 | multimodální |
| `haiku` | openrouter | `anthropic/claude-haiku-4.5` | 200 000 | multimodální |
| `gemini-flash` | gemini | `gemini-3.5-flash` | 256 000 | multimodální |
| `gemini-flash-lite` | openrouter | `google/gemini-3.1-flash-lite` | 256 000 | multimodální |
Multimodalita GLM/M3/Kimi je z porovnání níž (GLM-5.1 nepřijímá obrázky/sken/video);
Claude a Gemini přijímají obrázky dle vendor dokumentace.
---
## Rychlost — přímé měření na Ollama Cloud (2026-06-07)
Měřeno proti Ollamě na `nvidia.hell` (stejný endpoint jako agent), streaming
`/api/chat`, identický prompt, 3 běhy/model. `:cloud` modely nevracejí sub-durations,
takže tok/s měřeno přes streaming (TTFT = čas 1. content chunku). **Tohle je to, co
reálně dostaneš** (na rozdíl od native-class čísel od Artificial Analysis níž).
| Model | TTFT (medián) | Total wall (medián) | Out tok | End-to-end průtok |
|---|---|---|---|---|
| `glm-5.1` | ~5,9 s | ~7,5 s | 12001730 | **~198 tok/s** |
| `minimax-m3` | ~6,8 s | ~10,8 s | 420460 | **~40 tok/s** |
`minimax-m3` je o cca **50 % pomalejší v celkové době, i přes 34× MÉNĚ vygenerovaných
tokenů**. TTFT mají srovnatelný (start není problém). Čistá generace m3 je ~95120 tok/s
(streamuje plynule), ale end-to-end průtok glm je ~5× vyšší. Pozn.: „1300 tok/s" u glm
z post-TTFT okna nebrat doslovně — cloud buffer flushne dávku, proto se měří `out/total`.
---
## Profil rychlosti a verbozity — Artificial Analysis (nezávislé)
Native-class profil (na optimální infře, ne na našem Ollama Cloud endpointu — reálnou
latenci viz měření výš). Wall-clock per turn ≈ vygenerované tokeny ÷ tok/s, takže
verbozita zdržuje stejně jako nízká propustnost.
| Model | Intelligence Index | Output speed | Verbozita (tok na II) | TTFT | AA verdikt |
|---|---|---|---|---|---|
| GLM-5.1 | 51 | ~62 t/s | nižší | ~1,6 s | faster than average |
| MiniMax M3 | 55 | ~40 t/s | 91M (průměr 29M) | ~2,32,5 s | notably slow + very verbose |
| Kimi K2.6 | 54 | ~44 t/s | 170M (průměr 43M) | ~2,23,0 s | notably slow + very verbose |
M3 i Kimi sdílejí slow+verbose profil; vyšší Intelligence Index se v interaktivní
smyčce může utopit v latenci.
---
## Schopnosti — kde se modely liší (GLM-5.1 vs M3 vs Kimi K2.6)
Některé rozdíly jsou capability cliff (GLM to neumí vůbec), jiné jen rozdíl míry.
**Capability cliffs (GLM-5.1 nemá):**
- **Multimodální vstup** (M3 i Kimi): obrázky, screenshoty, video, naskenované
dokumenty. GLM-5.1 je čistě textový.
- **Porozumění dokumentům** (M3): OmniDocBench 91,6 % — nejvyšší v porovnání, nad Opus
4.7 (89,3 %). *(vendor číslo)*
**Rozdíl míry (M3/Kimi měřitelně lepší):**
- **Tvrdé znalosti / expert reasoning** (Kimi): HLE 52,3 % vs GLM 34,7 %.
- **Kódování / agentní složitost** (Kimi): kódování ø 72 vs 60,9; agentní ø 73,1 vs
65,3. *(BenchLM, semi-nezávislý agregát)*
- **Dlouhý kontext v jednom průchodu**: M3 1M, Kimi 256K, GLM 200K.
- **Long-horizon agentní stabilita** (Kimi): 4000+ tool callů přes 13 h, swarm až 300
sub-agentů.
**Kde vede GLM-5.1:**
- **Code Arena Elo 1530** — nezávislý head-to-head signál developer-preference (3. na
světě v agentním web devu).
- Čistší MIT licence, levnější vstupní cena než Kimi.
---
## Caveaty k číslům
- **Vendor vs produkce:** Kili Technology zdokumentoval ~37% propad mezi lab benchmark
skóre a reálným nasazením. Benchmark měří schopnost, produkce spolehlivost.
- **Vendor benchmarky** (SWE-Bench, Terminal-Bench, OmniDocBench…) běží na vlastní infře
vendora s jeho scaffoldingem; nejsou napříč vendory přímo srovnatelné. Nejdůvěryhodnější
jsou nezávislé: AA Intelligence Index a Code Arena Elo.
- **Ollama Cloud:** předplatné povoluje max 3 paralelní dotazy. Žádný rychlý provider
(Fireworks/Cerebras/…) k dispozici, takže u ollama presetů platí native-class rychlost.
- **Prompt caching** zapíná nanobot jen pro `openrouter`, `anthropic`, `bedrock`. `ollama`
a `gemini` cache nedostávají — ollama presety (`glm-5.1`, `minimax-m3`, `kimi-k2.6`)
tedy žádné cache breakpointy.
---
## Kontextová okna
Nanobot má hardcoded default `context_window_tokens = 65 536`; pokud preset hodnotu
nepřepíše, jede model na 65k bez ohledu na to, co umí. Presety výš mají nastavené reálné
limity. U `:cloud` modelů hostí kontext Ollama cloud (nežere lokální RAM). Vyšší okno
znamená méně častou konsolidaci paměti, ale „lost in the middle" propad je výraznější u
slabších MoE modelů než u špičkových.
---
## Appendix: měření z MiniLoop (jiný kontext — NE agent presety)
Tahle čísla jsou ze samostatného `.NET` PoC parseru `/remind add` (text→JSON,
`src/MiniLoop/`), ne z agenta. Modely jako `mistral-small` nejsou nakonfigurované jako
presety — jsou tu jen jako naměřená fakta. Měřeno 2026-06-03, prompt-only parse, 17 párů.
**Ollama + OpenRouter, gate=3 na ollama (kvóta 3 paralelní dotazy):**
| Model | Provider | Úspěšnost | Wall median / avg | Tok in / out |
|---|---|---|---|---|
| glm-5.1 | ollama | 17/17 | 1690 / 1927 ms | 21118 / 2896 |
| deepseek-v4-flash | ollama | 17/17 | 4894 / 6118 ms | 21654 / 3487 |
| minimax-m2.7 | ollama | 17/17 | 4815 / 4604 ms | 21969 / 2317 |
| claude-haiku-4.5 | openrouter | 16/17* | 1064 / 1135 ms | 23668 / 691 |
| gpt-5.4-nano | openrouter | 17/17 | 3034 / 4003 ms | 20987 / 553 |
**Levné / OSS modely z OpenRouteru** (cena $/M tok in/out):
| Model | Cena | Úspěšnost | Wall median / avg | out tok |
|---|---|---|---|---|
| `mistralai/mistral-small-3.2-24b-instruct` | 0.075/0.20 | 17/17 | **934 / 1050 ms** | 610 |
| `google/gemma-3-27b-it` | 0.08/0.16 | 17/17 | 1413 / 1611 ms | 608 |
| `z-ai/glm-4-32b` | 0.10/0.10 | 16/17* | 1905 / 2072 ms | 519 |
| `qwen/qwen3-30b-a3b-instruct-2507` | 0.043/0.17 | 16/17* | 1991 / 1866 ms | 588 |
| `openai/gpt-oss-120b` | levný | 16/17 | 7238 / 12164 ms | 3769 |
**Malé ollama modely** (`:cloud`):
| Model | Úspěšnost | Wall median / avg | out tok |
|---|---|---|---|
| `ministral-3:8b-cloud` | 15/17 | 1043 / 1169 ms | 653 |
| `nemotron-3-nano:30b-cloud` | 16/17 | 2015 / 2277 ms | 7805 |
\* část „FAILů" jsou false negatives ve striktním porovnání (slovosled), ne chyby modelu.
Klíčová pozorování z MiniLoop: **reasoning/thinking režim = pomalé + drahé na out tokeny**
(deepseek/minimax/gpt-oss/nemotron-nano generují násobně víc tokenů → násobně delší wall);
přímé parsery (haiku, gpt-nano, mistral-small) jsou rychlé. Hrdlo souběhu na ollama je
kvóta 3 paralelních dotazů, ne výpočet.
---
*Zdroje: rychlostní/inteligenční čísla Artificial Analysis (nezávislé); capability čísla
mix nezávislých (BenchLM, AA) a vendor dat (označeno); přímá měření na `nvidia.hell`.
Stav k červnu 2026.*

1
memory/.cursor Normal file
View File

@@ -0,0 +1 @@
327

23
memory/MEMORY.md.bak Normal file
View File

@@ -0,0 +1,23 @@
# Long-term Memory
This file stores important information that should persist across sessions.
## User Information
(Important facts about the user)
## Preferences
(User preferences learned over time)
## Project Context
(Information about ongoing projects)
## Important Notes
(Things to remember)
---
*This file is automatically updated by nanobot when important information should be remembered.*

327
memory/history.jsonl Normal file

File diff suppressed because one or more lines are too long

50
plans/brain-short.md Normal file
View File

@@ -0,0 +1,50 @@
# Brain — Short Plan
## Repo
- Gitea: `git@git.fnet.cz:lachtan/brain.git`
- Private, branch `main`
- Local path: `/home/nanobot/brain/`
- Inside: standard `wiki/` structure from plugin
## Git
- SSH key: `/home/nanobot/.ssh/id_ed25519` (ed25519)
- Auto commit + push after every nanobot write
- Git history = safety net for bad ingests
## Wiki structure
- `wiki/index.md` — main index (shards at >300 lines)
- `wiki/pages/` — compiled pages (LLM output)
- `wiki/sources/` — raw inputs
- Every page has YAML frontmatter: title, type, tags, created, updated, sources
## Nanobot commands
- `/brain-write` — write raw source or page with auto frontmatter, auto commit+push
- `/brain-search` — fulltext search across markdown files
- `/brain-ingest` — full LLM ingest (raw source → compiled page), auto commit+push
- Keyword "brain/wiki" = write to brain; no keyword = MEMORY.md
## Models
- Ingest: `qwen-3.6-plus-openrouter` (fallback `kimi-k2.6-openrouter`)
- All models cloud via OpenRouter/Ollama gateway
- No local inference for ingest
## Scripts
- From `praneybehl/llm-wiki-plugin`: init, search, lint, stats, graph
- Prepared in Claude Code, integrated into nanobot skill wrapper
- Pure Python 3.10+, stdlib + optional PyYAML
## Scaling
- Page soft cap: 400 lines, hard cap: 800 lines
- Index shards at >300 lines or >150 pages
- BM25 fallback at >300 pages
## Machines
- home pc, office pc — Claude Code GUI
- wood.hell — Claude Code CLI
- this LXC — nanobot host
## Implementation phases
1. SSH key → Gitea, clone repo, init wiki, test git push
2. Nanobot skill: write, search
3. Test ingest with qwen-3.6-plus, implement /brain-ingest
4. Add lint/stats/graph scripts, sync across machines

213
plans/brain.md Normal file
View File

@@ -0,0 +1,213 @@
# Brain — LLM Wiki Deployment Plan
> Vytvoreno: 2026-05-28
> Cil: Nasadit a pouzivat plugin `praneybehl/llm-wiki-plugin` (Karpathy LLM Wiki pattern) jako osobni knowledge base "brain" napric pocitaci, s uchovavanim vseho contentu v gitu a integraci do nanobota.
---
## 1. Architektura
### 1.1 Repo
- **Nazev:** `brain`
- **Host:** Gitea self-hosted (`https://git.fnet.cz`)
- **URL:** `git@git.fnet.cz:lachtan/brain.git`
- **Viditelnost:** private
- **Branch:** `main` (primarni, ingest jde rovnou sem)
### 1.2 Lokalni cesta na tomto stroji
- `/home/nanobot/brain/`
- Uvnitr standardni `wiki/` struktura z pluginu (plugin hardcoduje `wiki/`, pozdeji lze patchnout)
### 1.3 Stroje
| Stroj | Role | Claude Code | Nanobot |
|-------|------|-------------|---------|
| home pc | primary dev | ano (GUI) | ne |
| office pc | secondary dev | ano (GUI) | ne |
| wood.hell | server/CLI | ano (CLI only) | ne |
| tento LXC | nanobot host | ne | ano |
### 1.4 Sync mechanismus
- Git push/pull mezi vsemi instancemi
- Okamzity `git commit && git push` po kazdem zapisu nanobotem
- Ostatni stroje si pulluji pri startu nebo periodicke
---
## 2. Git & SSH
### 2.1 SSH klic pro nanobot
- Vygenerovan: `/home/nanobot/.ssh/id_ed25519` (ed25519, bez passphrase)
- Public key: pridat do Gitea repo `brain` jako deploy key nebo k uzivatelskemu uctu
- Fingerprint: `SHA256:iMrDLkVKxNAGPo+xOup0Abi7c/49/D/rZx/MPUIPt4U`
### 2.2 Git config
- `user.name`: `nanobot`
- `user.email`: `nanobot@git.fnet.cz`
- Remote: `origin``git@git.fnet.cz:lachtan/brain.git`
---
## 3. Struktura wiki
Plugin vytvari adresar `wiki/` s nasledujici strukturou:
```
wiki/
index.md # hlavni index, sharded pri >300 radku
indexes/ # per-type indexy pri >150 strankach
pages/ # strukturovane stranky (vysledky ingestu)
sources/ # raw sources (vstupy pro ingest)
references/ # scaling playbook, schema docs
```
Kazda stranka ma YAML frontmatter:
```yaml
---
title: "Nazev"
type: concept | source | summary | index
tags: [tag1, tag2]
created: YYYY-MM-DD
updated: YYYY-MM-DD
sources: [slug-zdroje]
---
```
---
## 4. Nanobot integrace
### 4.1 Rozliseni zapisu
- "zapis do brain" / "do wiki" / "brain-write" → zapis do `brain/wiki/`
- Bez klicoveho slova → zapis do `MEMORY.md` (soucasne chovani)
### 4.2 Slash commands / skill tools
| Operace | Popis | Auto commit |
|---------|-------|-------------|
| `/brain-write` | Zapis raw source nebo stranky s YAML frontmatter | ano, okamzite |
| `/brain-search` | Fulltext search pres markdown soubory (grep/BM25) | neni potreba |
| `/brain-ingest` | Full LLM ingest — zkompiluje raw source do stranek | ano, okamzite |
### 4.3 Frontmatter
- `/brain-write` automaticky pridava YAML frontmatter:
- `created`, `updated` (datum)
- `source` (napr. `telegram`, `web`, `user-input`)
- `tags` (volitelne, zadane uzivatelem)
- `type` (`source` pro raw, `concept` pro zpracovane)
### 4.4 Ingest workflow
- Nanobot cte raw source z `wiki/sources/`
- Vola LLM (qwen-3.6-plus) pro strukturalni analyzu
- Vytvari/aktualizuje stranky v `wiki/pages/`
- Aktualizuje `wiki/index.md` a cross-references
- Commit + push do `main`
---
## 5. Modely
### 5.1 Dostupne modely (cloud pres OpenRouter/Ollama gateway)
- `deepseek-v3.2`
- `glm-5.1`
- `kimi-k2.6`
- `minimax-m2.7`
- `qwen-3.6-plus`
- `qwen3.5`
### 5.2 Model pro ingest
- **Primarni:** `qwen-3.6-plus-openrouter` — dobry reasoning/coding, cena
- **Fallback:** `kimi-k2.6-openrouter` — pro velke sources (>32K tokenu)
### 5.3 Ollama role
- Ollama na `nvidia.hell` slouzi jako gateway/proxy pro cloud modely
- Zadny lokalni inference pro ingest (vsechny modely jsou cloud)
---
## 6. Skripty a tooling
### 6.1 Pluginove skripty (z `praneybehl/llm-wiki-plugin`)
- `init_wiki.py` — inicializace wiki struktury
- `wiki_search.py` — BM25 search s frontmatter filtry
- `wiki_lint.py` — strukturalni lint (velikost stranek, odkazy)
- `wiki_stats.py` — statistiky a scaling thresholds
- `wiki_graph_*.py` — optional graph layer (vyzaduje PyYAML)
### 6.2 Nasazeni skriptu
- Skripty budou pripraveny v Claude Code a zkopirovany do `brain/skills/llm-wiki/scripts/`
- Nanobot skill wrapper je bude volat pres `exec` nebo jako tool
### 6.3 Skill wrapper pro nanobot
- Pripraven v Claude Code jako nanobot-compatible skill
- Minimalni sada na startu: `write`, `search`, `ingest`
- Rozsireni pozdeji podle potreby (lint, stats, graph)
---
## 7. Scaling & udrzba
### 7.1 Automaticke sharding
- `wiki/index.md` se sharduje pri >300 radcich
- Per-type indexy v `wiki/indexes/` pri >150 strankach
- BM25 search fallback pri >300 strankach
### 7.2 Velikostni limity
- Stranka: soft cap 400 radku, hard cap 800 radku
- Ingest velkych sources: chunked (po castech)
### 7.3 Safety
- Ingest jde rovnou do `main`
- Git history jako pojistka pro revert
- Chybne ingesty se opravuji v Claude Code
---
## 8. Implementacni kroky
### Faze 1: Zaklad
1. [ ] Pridat SSH public key do Gitea repo `brain`
2. [ ] Naklonovat `brain` do `/home/nanobot/brain/`
3. [ ] Nastavit git config (user.name, user.email)
4. [ ] Inicializovat wiki strukturu (`init_wiki.py` nebo rucne)
5. [ ] Otestovat git push/pull
### Faze 2: Nanobot skill
1. [ ] Pripravit skill wrapper v Claude Code
2. [ ] Implementovat `/brain-write` s frontmatter
3. [ ] Implementovat `/brain-search`
4. [ ] Testovat zapis a search
### Faze 3: Ingest
1. [ ] Otestovat modely na ingest (qwen-3.6-plus)
2. [ ] Implementovat `/brain-ingest`
3. [ ] Otestovat full workflow: raw source → ingest → stranka
### Faze 4: Rozsireni
1. [ ] Pridat pluginove skripty (lint, stats, graph)
2. [ ] Nastavit cron pro periodicke lint/stats
3. [ ] Integrovat s ostatnimi stroji (home pc, office pc, wood.hell)
---
## 9. Rozhodnuti z grill-me session
| Tema | Rozhodnuti |
|------|-----------|
| Nazev projektu | `brain` (repo), uvnitr `wiki/` (plugin default) |
| Lokalni cesta | `/home/nanobot/brain/` |
| Git workflow | Okamzity commit+push do `main` |
| SSH auth | Novy klic pro `nanobot` uzivatele |
| Zapis bez keywordu | MEMORY.md |
| Zapis s "brain/wiki" | `brain/wiki/` |
| Frontmatter | Automaticky pridavat |
| Ingest scope | Full LLM ingest (ne jen light) |
| Ingest model | qwen-3.6-plus (fallback kimi-k2.6) |
| Safety | Rovnou do `main`, git history jako pojistka |
| Skripty | Pripravit v Claude Code, integrovat pozdeji |
---
## 10. Odkazy
- Plugin: https://github.com/praneybehl/llm-wiki-plugin
- Karpathy gist: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
- Gitea: https://git.fnet.cz/lachtan/brain

202
plans/projects.md Normal file
View File

@@ -0,0 +1,202 @@
# Project Skill — plán a diskuze
Založeno: 2026-06-08 (session `websocket_12f851b1`)
Poslední aktualizace: 2026-06-09
---
## Původní požadavek
Oddělit část z notes do `projects/` — projekty, na kterých chci pracovat, ale na které neustále zapomínám. Každý projekt vlastní soubor s poznámkami, náhodné připomínky v rozumném intervalu.
---
## Rozhodnutí: Nový skill `/project`, čistě soubory, frontmatter
### Proč nový skill (ne rozšířit `/note` nebo `/remind`)
- `/note` je pro rychlé poznámky — přidat status, prioritu, next-step by ho překutilo
- `/remind` je pro konkrétní opakující se úkoly — projekty mají jinou životnost
- Samostatný skill = čistší interface, nezávislá evoluce
### Proč soubory místo SQLite
- Volné poznámky, editace částí textu, mazání odstavců — **soubor je přirozenější** než DB řádky
- DB je dobrá pro rychlé filtrování, ale špatná pro: "smaž druhý odstavec", "přidej poznámku mezi dvě existující"
- Frontmatter na začátku souboru = metadata se načtou bez procházení celého souboru
- Git-friendly, jeden commit = jedna změna, jeden diff
- Jedno místo pravdy — žádná desynchronizace mezi DB a souborem
### Proč frontmatter místo indexu + souborů
| Kritérium | Index + soubory | Frontmatter |
|-----------|----------------|-------------|
| Jedno místo pravdy | ❌ Dvě místa, riziko desynchronizace | ✅ Vše v jednom souboru |
| Rychlost listu | ✅ Index okamžitě | ⚡ Parse 1020 souborů = zanedbatelné |
| Editace metadat | Edit index + edit soubor | Edit jednoho souboru |
| Git/historie | Dva commity, dva diffy | Jeden commit, jeden diff |
| "Přepni se do projektu" | Najdi v indexu, otevři soubor | Otevři soubor, máš vše |
Index přináší jen rychlost listu, ale projektů bude max desítky — parse zanedbatelný. Frontmatter vyhrává na všem ostatním.
---
## Struktura
```
projects/
├── nuget-cache.md
├── grill-me-plugin.md
└── ...
```
### Šablona projektového souboru
```markdown
---
status: active
priority: high
created: 2026-06-09
slug: nuget-cache
---
# NuGet cache
Hostovat vlastní NuGet cache na Linuxu.
## Poznámky
- 2026-06-09: BaGetter podporuje S3
- 2026-06-09: Originální BaGet je mrtvý, BaGetter je fork
## Další krok
zkusit BaGetter v LXC
```
**Frontmatter obsahuje pouze metadata:** `status`, `priority`, `created`, `slug`. Žádný `next_step` — ten je v těle jako sekce `## Další krok`.
---
## Rozdělení odpovědnosti: skript vs. agent
### Skript `project.py` — metadata + základní operace
| Subcommand | Co dělá | Proč ve skriptu |
|------------|---------|-----------------|
| `add <název>` | Vytvoří `.md` s frontmatter a základní strukturou | Deterministické, žádná volba struktury |
| `list` | Parse frontmatter ze všech `.md`, vypíše active | Rychlé, žádný kontext potřeba |
| `show <slug>` | Vypíše celý soubor | Triviální |
| `status <slug> <active|paused|done>` | Změní `status:` v frontmatteru | Jednoduchý regex, deterministické |
### Agent — textové úpravy souboru
| Operace | Jak | Proč na agentovi |
|---------|-----|------------------|
| `project next <slug> "text"` | Agent `edit_file` na sekci `## Další krok` | Struktura může být libovolná, skript by to nezvládl robustně |
| `project note <slug> "text"` | Agent `edit_file` přidá řádek pod `## Poznámky` | Stejný důvod |
| Editace existující poznámky | Agent `edit_file` | Skript by musel parsovat přirozený jazyk |
| Mazání poznámky | Agent `edit_file` | Skript by musel identifikovat "tu poznámku o S3" |
| Změna struktury souboru | Agent `apply_patch` | Skript nemůže předvídat všechny struktury |
**Pravidlo:** Kdykoliv jde o volný text v těle souboru, použije agent `edit_file`/`apply_patch`. Skript řeší jen frontmatter a celkovou strukturu.
---
## Příkazy skillu `/project`
| Příkaz | Kdo provádí | Co dělá |
|--------|-------------|---------|
| `project add <název>` | Skript | Založí projekt, vygeneruje slug, vytvoří soubor |
| `project list` | Skript | Vypíše aktivní projekty (parse frontmatter) |
| `project show <slug>` | Skript | Zobrazí celý soubor |
| `project status <slug> <status>` | Skript | Změní `status` v frontmatteru |
| `project next <slug> <text>` | Agent | Nahradí obsah pod `## Další krok` |
| `project note <slug> <text>` | Agent | Přidá poznámku pod `## Poznámky` |
| `project switch <slug>` | Agent | Uloží slug do `my` scratchpadu pro aktuální session |
---
## Klíčová feature: "Přepni se do projektu"
- **Explicitní slug** — default, `project note nuget-cache "..."`
- **Session context** — `project switch nuget-cache``my set project_context=nuget-cache` → další příkazy bez slugu použijí kontext
- **Scope:** Jen aktuální session. Po restartu se kontext ztratí — musí se znovu `project switch`.
---
## Připomínky — odloženo na další kolo
Posílání upomínek je feature pro další iteraci. Nejsou součástí POC.
Navržený mechanismus (pro budoucí implementaci):
- Jeden cron job denně (náhodný čas 821h)
- Skript načte `active` projekty, váženě vybere podle priority
- Vypíše `"{name}: {next_step}"`
- Agent přepošle do Telegram
---
## Definitivní rozhodnutí (zodpovězeno 2026-06-09)
### 1. Slug generování
→ Jednoduchý kebab-case lowercase z prvních pár slov názvu. Např. "NuGet package caching" → `nuget-package-caching`. Max pár slov, zbytek se ořízne.
### 2. `next_step` — frontmatter vs. tělo
`next_step` je **pouze v těle** jako sekce `## Další krok`. Frontmatter obsahuje jen `status`, `priority`, `created`, `slug`. Důvod: jedno místo pravdy, frontmatter je jen metadata.
### 3. Editace poznámek — skript vs. agent
**Na agentovi.** Skript nedokáže robustně pracovat s libovolnou strukturou markdown souboru. Agent použije `edit_file`/`apply_patch`.
### 4. `project switch` — přežití mezi sessiony
**Nepřežije.** Kontext je jen v `my` scratchpadu aktuální session. Po restartu se ztratí — explicitní `project switch` znovu.
### 5. Formát priority
→ Slovní: `high`, `medium`, `low`. Čísla jsou nejednoznačná (1 může být nejvyšší i nejnižší).
### 6. Rozdělení skript/agent
→ Skript: `add`, `list`, `show`, `status`. Agent: `next`, `note`, `switch`, veškerá editace/smazání textu.
### 7. Formát data v poznámkách
**Nedefinováno.** Struktura souboru je volná. Agent přidává poznámky pod `## Poznámky` jako bullety, ale uživatel může mít libovolnou strukturu.
---
## Otevřené otázky — VŠECHNY ZODPOVĚZENY
### Identifikace projektů — název vs. slug
→ Slug je souborové jméno (`nuget-cache.md`). Uživatel používá slug v příkazech. Jednoznačné, lidsky přívětivé (kebab-case).
### Vztah k `/note` skillu
→ Oddělené. Explicitní `project note <slug>` jde do projektového souboru, obecný `/note` zůstává v SQLite.
### Vztah k `/remind` skillu
→ Oddělené. Projekty mají vlastní připomínkový mechanismus (až v další iteraci).
### Databáze vs. soubory
→ Čistě soubory s YAML frontmatter. Žádná DB.
### Co je "projekt" vs. "úkol"
→ Projekt = dlouhodobá věc s next-step a poznámkami. Úkol = jednorázová připomínka v `/remind`. Hranice je na uživateli.
### Je to nový systém, nebo rozšířit existující?
**Nový skill.** Méně systémů = méně údržby je obecně pravda, ale `/note` a `/remind` mají jiný charakter. Projektový skill potřebuje frontmatter, editaci souborů, session context — to by existující skilly překutilo.
---
## Implementační plán POC
| Krok | Co |
|------|-----|
| 1 | `project.py``add`, `list`, `show`, `status` |
| 2 | `SKILL.md` — protokol pro agenta (co dělá skript, co agent) |
| 3 | Test — vytvoř 2-3 projekty, ověř editaci přes agenta |
---
## Další kroky
- [ ] Implementovat `project.py` (krok 1)
- [ ] Napsat `SKILL.md`
- [ ] Otestovat základní CRUD
- [ ] Připomínky — další kolo

14
projects/radio-1.md Normal file
View File

@@ -0,0 +1,14 @@
---
status: active
priority: medium
created: '2026-06-09'
slug: radio-1
---
# Radio 1
## Poznámky
- 2026-06-09: Cílem je rozřezat vstupní audio stream z Radia 1 na jednotlivé písničky, přičemž se vyhází všechny reklamy. Mám na to plán jak to udělat (připravil Claude) a hodlám to realizovat za vydatné pomoci Claude Code. Nahrávat se může na wood.hell a dekódování pak poběží na nvidia.hell protože ma k dispozici RTX 4060. Sypat se to bude asi po dávkách.
## Další krok

View File

@@ -0,0 +1,693 @@
# /remind Skill — Codebase Analysis & Improvement Report
## 1. Executive Summary
The /remind skill consists of three scripts (`remind_edit.py`, `remind_send.py`, `random_times.py`) plus tests. The `random_times.py` module is well-structured and tested. The two main scripts (`remind_edit.py`, `remind_send.py`) suffer from:
- Manual YAML string construction instead of proper serialization
- No tests at all
- Missing core features (list, edit, deduplication, dry-run)
- Race conditions and data-loss risks
- One-time reminders firing repeatedly within the same minute
This report identifies 20+ concrete improvements with code examples.
---
## 2. Critical Issues
### 2.1 One-time `at` reminders fire repeatedly (BUG)
`remind_send.py` uses a 60-second window:
```python
def should_fire(candidate: datetime, now: datetime) -> bool:
return abs((now - candidate).total_seconds()) < 60
```
With a 1-minute cron, an `at: "2026-06-02T09:20:00"` reminder fires at 09:20:00 **and** 09:20:01..09:20:59 if the cron job happens to run multiple times or with slight delay. The log shows this:
```
2026-06-02T09:20:01 cedule proti kouření ve výtahu
```
Only one line, but if the cron ran twice in the same minute, it would duplicate.
**Fix:** Track fired one-time reminders in a state file, or narrow the window to `<= 30` and ensure the cron runs at :00.
```python
# Better: stateful deduplication for one-time reminders
FIRED_STATE_PATH = Path(__file__).parent.parent.parent / "db" / "remind_fired.sqlite"
# Or simpler: narrow window + minute-level dedup via log check
```
### 2.2 Non-atomic YAML writes = data loss risk
`remind_edit.py` writes directly to `reminder.yaml`:
```python
with open(REMINDER_FILE, "w") as f:
yaml.dump(data, f)
```
If the process crashes mid-write, the file is truncated/corrupted.
**Fix:** Atomic write via temp file + rename:
```python
import os
def atomic_write(path: Path, data: dict, yaml: YAML) -> None:
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
yaml.dump(data, f)
os.replace(tmp, path)
```
### 2.3 Concurrent edit + send = race condition
`remind_send.py` reads `reminder.yaml` every minute. `remind_edit.py` writes to it. No file locking means the reader could get a partially-written file.
**Fix:** Use `filelock` (already available via uv) or atomic writes (above) + read retry.
### 2.4 `remind_edit.py` has no `list` command (advertised but missing)
`SKILL.md` documents `list` and `remove` commands, but `remind_edit.py` only implements `add` and `remove`. There is no `list`.
**Fix:** Add `list` to `main()`:
```python
elif command == "list":
for i, r in enumerate(data.get("reminders", []), 1):
print(f"{i}. {r.get('text', '(no text)')}")
```
---
## 3. Code Quality — Shorten & Improve
### 3.1 Remove custom `LiteralScalarString` (redundant)
`remind_edit.py` defines:
```python
class LiteralScalarString(str):
__slots__ = ()
```
ruamel.yaml already provides `ruamel.yaml.scalarstring.LiteralScalarString`. The custom class is unnecessary and confusing.
**Fix:**
```python
from ruamel.yaml.scalarstring import LiteralScalarString
```
### 3.2 `format_reminder` manually builds YAML (fragile)
Current code concatenates strings to produce YAML:
```python
def format_reminder(text, schedule):
lines = [f"- text: {text}"]
for key, value in schedule.items():
if isinstance(value, list):
lines.append(f" {key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f" {key}: {value}")
return "\n".join(lines)
```
This breaks on special characters (quotes, colons, newlines in text), doesn't handle indentation consistently, and duplicates YAML serialization logic.
**Fix:** Build a dict and let ruamel.yaml serialize it:
```python
def build_reminder(text: str, schedule: dict) -> dict:
reminder = {"text": LiteralScalarString(text)}
for key, value in schedule.items():
if key in ("at", "at_times", "cron_exprs") and isinstance(value, list):
reminder[key] = [LiteralScalarString(v) for v in value]
elif key in ("at", "window") and isinstance(value, str):
reminder[key] = LiteralScalarString(value)
else:
reminder[key] = value
return reminder
```
Then append to `data["reminders"]` and dump the whole document.
### 3.3 `parse_schedule` is a long if-elif chain
```python
def parse_schedule(args):
if not args:
return {"cron_exprs": ["0 9 * * *"]}
elif args[0] == "at":
...
elif args[0] == "times":
...
elif args[0] == "cron":
...
else:
...
```
**Fix:** Dispatch table:
```python
SCHEDULE_PARSERS = {
"at": lambda args: {"at": args[1]},
"times": lambda args: {"at_times": args[1:]},
"cron": lambda args: {"cron_exprs": args[1:]},
}
def parse_schedule(args: list[str]) -> dict:
if not args:
return {"cron_exprs": ["0 9 * * *"]}
parser = SCHEDULE_PARSERS.get(args[0])
if parser:
return parser(args)
# fallback: treat all args as cron expressions
return {"cron_exprs": args}
```
### 3.4 `remove_reminder` dual-match logic is confusing
```python
def remove_reminder(data, text):
reminders = data.get("reminders", [])
for i, reminder in enumerate(reminders):
if reminder.get("text") == text:
del reminders[i]
return True
for i, reminder in enumerate(reminders):
if text.lower() in reminder.get("text", "").lower():
del reminders[i]
return True
return False
```
This silently falls back to substring match, which could delete the wrong reminder.
**Fix:** Be explicit. Support exact match and `--grep` flag:
```python
def remove_reminder(data: dict, text: str, grep: bool = False) -> bool:
reminders = data.get("reminders", [])
for i, reminder in enumerate(reminders):
reminder_text = reminder.get("text", "")
if (not grep and reminder_text == text) or (grep and text.lower() in reminder_text.lower()):
del reminders[i]
return True
return False
```
### 3.5 `main()` in `remind_edit.py` is a big if-elif
**Fix:** Same dispatch pattern:
```python
COMMANDS = {
"add": cmd_add,
"remove": cmd_remove,
"list": cmd_list,
}
def main():
args = sys.argv[1:]
if not args:
print("Usage: ...")
sys.exit(1)
cmd = COMMANDS.get(args[0])
if not cmd:
print(f"Unknown command: {args[0]}")
sys.exit(1)
cmd(args[1:])
```
### 3.6 `remind_send.py` `should_fire` window too wide
With 1-minute cron granularity, a 60-second window allows double-firing if there's any jitter. Use 30 seconds:
```python
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
delta = (now - candidate).total_seconds()
return 0 <= delta < window_sec
```
This also ensures we only fire **after** the scheduled time, not before (which `abs()` allowed).
### 3.7 `remind_send.py` catches bare `Exception`
```python
except Exception as e:
print(f"Error sending reminder: {e}", file=sys.stderr)
```
**Fix:** Catch specific exceptions (`telegram.error.TelegramError`, `NetworkError`).
### 3.8 `sys.path.insert` hacks in both scripts
Both scripts do:
```python
sys.path.insert(0, str(Path(__file__).parent))
```
This is a code smell. Since these are run via `uv run`, they should either:
- Be part of a proper Python package with `__init__.py`
- Or use `PYTHONPATH` in the cron job
- Or import via relative imports if refactored into a package
**Fix:** Add a `pyproject.toml` in `skills/remind/` declaring the scripts directory as part of the package, or set `PYTHONPATH` in the cron:
```cron
* * * * * PYTHONPATH=/home/nanobot/.nanobot/workspace/skills/remind/scripts uv run /home/nanobot/.nanobot/workspace/skills/remind/scripts/remind_send.py
```
Then use normal imports: `from random_times import compute_fire_times`.
---
## 4. Missing Functionality
### 4.1 No `list` command in `remind_edit.py`
Users cannot view reminders without `cat reminder.yaml`.
### 4.2 No `edit` command
To change a reminder, users must remove and re-add. An `edit` command would be useful:
```python
def edit_reminder(data: dict, old_text: str, new_text: str, new_schedule: dict | None = None) -> bool:
for reminder in data.get("reminders", []):
if reminder.get("text") == old_text:
reminder["text"] = new_text
if new_schedule:
# Remove old schedule keys, add new ones
for key in list(reminder.keys()):
if key != "text":
del reminder[key]
reminder.update(new_schedule)
return True
return False
```
### 4.3 No deduplication / "fired" tracking for one-time reminders
`at` and `at_times` reminders should fire exactly once. Currently they rely on the 60s window and cron granularity.
**Fix:** SQLite state tracking:
```python
# db/remind_state.sqlite
# table fired (text TEXT, fired_at TEXT PRIMARY KEY)
```
Or simpler: append a `fired:` list to each reminder in `reminder.yaml` (but this modifies user data). Better: separate state file.
### 4.4 No dry-run mode in `remind_send.py`
Users cannot preview what would fire without actually sending Telegram messages.
**Fix:** Add `--dry-run` flag:
```python
if dry_run:
print(f"[DRY-RUN] Would fire: {text} at {now}")
else:
fire_reminder(text)
```
### 4.5 No way to see today's schedule
Users can't ask "what reminders do I have today?"
**Fix:** Add a `today` or `schedule` command to `remind_edit.py` that computes and prints all fire times for the current day.
### 4.6 No support for disabling reminders
Users must delete reminders to stop them. A `disabled: true` flag would be useful.
### 4.7 No validation before write
`remind_edit.py` doesn't validate that the produced YAML is loadable by `remind_send.py`. A malformed entry could break the cron job silently.
**Fix:** After building the reminder dict, run it through `random_times.compute_fire_times` (if it has `random`) or `croniter` (if it has `cron_exprs`) to validate:
```python
def validate_reminder(reminder: dict) -> None:
if "random" in reminder:
compute_fire_times(date.today(), reminder["text"], reminder["random"])
if "cron_exprs" in reminder:
for expr in reminder["cron_exprs"]:
croniter(expr)
```
### 4.8 No backup before edit
**Fix:** Keep last N backups:
```python
import shutil
from datetime import datetime
def backup_reminders(path: Path) -> None:
backup = path.with_suffix(f".yaml.{datetime.now():%Y%m%d%H%M%S}.bak")
shutil.copy2(path, backup)
```
### 4.9 `random_times.py` lacks step syntax in days parser
Cron supports `*/2`, `1-5/2`. `_parse_days` doesn't handle this.
**Fix:**
```python
def _parse_days(spec: object) -> set[int]:
text = str(spec).strip()
if text == "*":
return set(range(7))
result: set[int] = set()
for part in text.split(","):
part = part.strip()
step = 1
if "/" in part:
part, step_str = part.split("/", 1)
step = int(step_str)
if "-" in part:
low_str, high_str = part.split("-", 1)
low, high = int(low_str), int(high_str)
result.update(_normalize_dow(d) for d in range(low, high + 1, step))
else:
result.add(_normalize_dow(int(part)))
return result
```
### 4.10 No `__main__` guard in `random_times.py`
Not critical since it's a library, but good practice.
---
## 5. Testing Gaps
| Component | Tests? | Coverage |
|-----------|--------|----------|
| `random_times.py` | Yes | Good (determinism, gaps, filters, errors) |
| `remind_edit.py` | **No** | Zero |
| `remind_send.py` | **No** | Zero |
### 5.1 Tests needed for `remind_edit.py`
- `parse_schedule` with all input variants
- `build_reminder` / `format_reminder` roundtrip
- `remove_reminder` exact vs substring
- YAML dump/load roundtrip preserves formatting
- Atomic write doesn't corrupt file
### 5.2 Tests needed for `remind_send.py`
- `should_fire` boundary conditions
- `fire_reminder` with mocked Telegram bot
- `main` with mocked `reminder.yaml` and mocked bot
- One-time reminder deduplication
- Random reminder integration with `random_times`
### 5.3 Test infrastructure
`conftest.py` only adds `sys.path`. It should also provide fixtures:
```python
@pytest.fixture
def sample_yaml(tmp_path):
path = tmp_path / "reminder.yaml"
path.write_text("reminders:\n- text: test\n at: 2026-06-01T10:00:00\n")
return path
@pytest.fixture
def mock_bot(monkeypatch):
class FakeBot:
def send_message(self, chat_id, text):
self.last_call = (chat_id, text)
bot = FakeBot()
monkeypatch.setattr("remind_send.Bot", lambda token: bot)
return bot
```
---
## 6. Architecture Improvements
### 6.1 Consolidate into a single CLI
The user has considered consolidating remind into a single script. Current split:
- `remind_edit.py` = user-facing CLI
- `remind_send.py` = cron daemon
- `random_times.py` = shared library
This split is actually reasonable. But `remind_edit.py` and `remind_send.py` share no code. Consider extracting common YAML I/O:
```python
# remind_common.py
from pathlib import Path
from ruamel.yaml import YAML
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
def load_reminders() -> dict:
yaml = YAML()
yaml.preserve_quotes = True
with open(REMINDER_FILE) as f:
return yaml.load(f) or {"reminders": []}
def save_reminders(data: dict) -> None:
yaml = YAML()
yaml.default_flow_style = False
yaml.indent(mapping=2, sequence=4, offset=2)
atomic_write(REMINDER_FILE, data, yaml)
```
### 6.2 Use SQLite for state (not YAML)
The user is evaluating SQLite vs YAML for remind data storage. Current YAML approach:
- **Pros:** Human-readable, easy to edit by hand, version-control friendly
- **Cons:** No schema validation, race conditions, no querying, append-only log is separate
**Recommendation:** Keep YAML for the reminder definitions (human-editable), but use SQLite for runtime state (fired tracking, history query):
```python
# db/remind_state.sqlite
CREATE TABLE fired (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_scheduled ON fired(scheduled_at);
```
This gives:
- Exact-once firing for one-time reminders
- Queryable history ("when did X last fire?")
- No modification to `reminder.yaml`
### 6.3 Refactor `remind_send.py` into a class
Current procedural style makes testing hard. A class-based design:
```python
class ReminderEngine:
def __init__(self, yaml_path: Path, bot: Bot | None = None, dry_run: bool = False):
self.yaml_path = yaml_path
self.bot = bot
self.dry_run = dry_run
self.now = datetime.now(TIMEZONE)
def load(self) -> list[dict]:
...
def should_fire(self, candidate: datetime) -> bool:
...
def fire(self, text: str) -> None:
...
def run(self) -> list[str]:
fired = []
for reminder in self.load():
for candidate in self.candidates(reminder):
if self.should_fire(candidate) and not self.already_fired(reminder, candidate):
self.fire(reminder["text"])
fired.append(reminder["text"])
return fired
```
---
## 7. Specific Code Examples
### 7.1 Atomic write for `remind_edit.py`
```python
import os
from pathlib import Path
from tempfile import mkstemp
def atomic_write_yaml(path: Path, data: dict, yaml: YAML) -> None:
fd, tmp = mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
yaml.dump(data, f)
os.replace(tmp, path)
except Exception:
os.unlink(tmp)
raise
```
### 7.2 Proper `LiteralScalarString` usage
```python
from ruamel.yaml.scalarstring import LiteralScalarString
def build_reminder(text: str, schedule: dict) -> dict:
reminder = {"text": LiteralScalarString(text)}
for key, value in schedule.items():
if isinstance(value, list):
reminder[key] = [LiteralScalarString(v) for v in value]
elif isinstance(value, str):
reminder[key] = LiteralScalarString(value)
else:
reminder[key] = value
return reminder
```
### 7.3 Deduplication for one-time reminders
```python
from pathlib import Path
import sqlite3
STATE_DB = Path(__file__).parent.parent.parent / "db" / "remind_state.sqlite"
def ensure_state_db() -> None:
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(STATE_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS fired (
text TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (text, scheduled_at)
)
""")
conn.commit()
conn.close()
def already_fired(text: str, scheduled_at: datetime) -> bool:
conn = sqlite3.connect(STATE_DB)
row = conn.execute(
"SELECT 1 FROM fired WHERE text = ? AND scheduled_at = ?",
(text, scheduled_at.isoformat())
).fetchone()
conn.close()
return row is not None
def record_fired(text: str, scheduled_at: datetime) -> None:
conn = sqlite3.connect(STATE_DB)
conn.execute(
"INSERT OR IGNORE INTO fired (text, scheduled_at) VALUES (?, ?)",
(text, scheduled_at.isoformat())
)
conn.commit()
conn.close()
```
### 7.4 Narrowed `should_fire` + dedup
```python
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
delta = (now - candidate).total_seconds()
return 0 <= delta < window_sec
# In main loop for one-time reminders:
if "at" in reminder:
candidate = parse_at(reminder["at"])
if should_fire(candidate, now) and not already_fired(text, candidate):
fire_reminder(text)
record_fired(text, candidate)
```
### 7.5 `remind_edit.py` with dispatch table
```python
from pathlib import Path
import sys
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from random_times import compute_fire_times
from croniter import croniter
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
# --- commands ---
def cmd_add(args: list[str]) -> None:
text = " ".join(args)
schedule = parse_schedule([]) # default cron
add_reminder(text, schedule)
def cmd_remove(args: list[str]) -> None:
text = " ".join(args)
remove_reminder(text)
def cmd_list(_args: list[str]) -> None:
data = load_reminders()
for i, r in enumerate(data.get("reminders", []), 1):
print(f"{i}. {r.get('text', '(no text)')}")
COMMANDS = {
"add": cmd_add,
"remove": cmd_remove,
"list": cmd_list,
}
def main() -> None:
args = sys.argv[1:]
if not args or args[0] not in COMMANDS:
print(f"Usage: {sys.argv[0]} [{'|'.join(COMMANDS)}] ...")
sys.exit(1)
COMMANDS[args[0]](args[1:])
```
---
## 8. Prioritized Action Plan
| Priority | Task | Effort | Impact |
|----------|------|--------|--------|
| **P0** | Fix one-time reminder double-firing (narrow window + dedup) | Small | High — prevents spam |
| **P0** | Add atomic writes to `remind_edit.py` | Small | High — prevents data loss |
| **P1** | Add `list` command to `remind_edit.py` | Small | Medium — advertised feature |
| **P1** | Replace custom `LiteralScalarString` with ruamel's | Tiny | Low — code cleanliness |
| **P1** | Replace manual YAML string building with dict+dump | Medium | High — robustness |
| **P1** | Add validation before write | Small | Medium — catches errors early |
| **P2** | Add tests for `remind_edit.py` and `remind_send.py` | Medium | High — enables refactoring |
| **P2** | Extract common YAML I/O to `remind_common.py` | Small | Medium — DRY |
| **P2** | Add `--dry-run` to `remind_send.py` | Small | Medium — safer testing |
| **P3** | Add SQLite state tracking for fired reminders | Medium | Medium — exact-once, queryable history |
| **P3** | Add `edit` command | Small | Low — convenience |
| **P3** | Add `disabled` flag | Small | Low — convenience |
| **P3** | Support cron step syntax in `_parse_days` | Small | Low — completeness |
---
## 9. Summary
The `random_times.py` module is solid. The main pain points are in `remind_edit.py` (manual YAML construction, no atomic writes, missing commands) and `remind_send.py` (double-firing risk, no deduplication, no tests). The highest-impact fixes are: (1) atomic YAML writes, (2) one-time reminder deduplication, and (3) replacing manual YAML string building with proper serialization. Adding tests for the two untested scripts is essential before any major refactoring.

View File

@@ -0,0 +1,187 @@
# Ollama Cloud Agent Model Comparison — Nanobot Deployment
**Date:** 2026-06-07
**Baseline:** `glm-5.1:cloud`
**Scope:** Evaluate all Ollama Cloud models against 12 criteria for sustained nanobot agent use.
---
## Executive Summary
**GLM-5.1:cloud remains the best default** for nanobot agent deployment on Ollama Cloud. It offers the best balance of speed (~198 tok/s), proven agentic reliability, MIT license, 200K context, and no known language-drift or tool-calling blockers.
**Viable alternatives (with tradeoffs):**
- **`deepseek-v4-flash:cloud`** — if you need 1M context and can tolerate slower speed. MIT license, open weights.
- **`qwen3.5:397b-cloud`** — if you need multimodal + 1M context + explicit Czech support (201 languages). Apache 2.0. Reported as slow with accuracy issues on Ollama Cloud.
- **`devstral-2:123b-cloud`** — if the workload is purely coding-heavy and 128K context is sufficient. Strong SWE-Bench / Terminal-Bench scores. Apache 2.0.
**Not recommended due to blockers:**
- `minimax-m3:cloud` — critical tool-result message bug on Ollama Cloud (ollama/ollama #16389).
- `kimi-k2.6:cloud` — random Chinese output drift (critical risk for Czech use).
- `deepseek-v4-pro:cloud` — strongest benchmarks but 15.4 tok/s and 57s TTFT cold-start make it impractical for interactive agent work.
**GLM-5.2 status:** Not released. No official announcement from Z.AI as of June 2026.
---
## Comparison Table
| Model | Speed (tok/s) | TTFT | SWE-Bench V | SWE-Bench Pro | Terminal-Bench | MCP-Atlas | HLE | Code Arena Elo | Tool Reliability | Czech / Multilingual | Context | Multimodal | License | Verbosity | Known Bugs | Pricing (OpenRouter proxy) | Long-Horizon | Self-Host |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| **glm-5.1:cloud** | ~198 | Low | ~58.4 (Pro) | 58.4 | 63.5 | 71.8 | 52.3 | 1530 | Excellent | No Czech claim; no drift observed | 200K (198K Ollama) | No | MIT | Low | None known | ~$4/M out | Proven | Yes |
| **deepseek-v4-pro:cloud** | ~15.4 | 57s cold | 80.6 | — | 67.9 | 74.2 | 56.2 | — | Good | Strong multilingual (MMMLU 90.3) | 1M | No | MIT | Medium | Extreme variance | $1.74/M in | Unknown | Yes |
| **deepseek-v4-flash:cloud** | ~30-50* | Moderate | ~75* | — | ~60* | — | — | — | Good | Strong multilingual | 1M | No | MIT | Medium | None known | $0.14/M in | Unknown | Yes |
| **qwen3.5:397b-cloud** | ~10-20* | High | ~66-70 | — | ~59.3 | — | — | — | Good | **201 languages incl. Czech** | 1M | Yes | Apache 2.0 | Medium | "Too slow, accuracy issues" per user benchmark | — | Unknown | Yes |
| **qwen3.5:cloud** | ~20-40* | Moderate | ~60-65 | — | ~55 | — | — | — | Good | 201 languages | 256K | Yes | Apache 2.0 | Medium | None known | — | Unknown | Yes |
| **minimax-m3:cloud** | ~40-60* | Low | — | — | — | — | — | — | **Broken** | Undeclared | 512K | Yes | Open weights (pending) | — | **Tool result messages fail (#16389)** | $0.60/M in | N/A | Yes (pending) |
| **kimi-k2.6:cloud** | ~30-50* | Moderate | 80.2 | — | 66.7 | — | 54.0 | — | Good | No Czech claim; **random Chinese drift** | 256K | Yes | Modified MIT | Medium | Chinese output bug; OR context bug (32K) | $0.60/M in | 200-300 tool calls | Yes |
| **kimi-k2-thinking:cloud** | ~25-40* | Moderate | — | — | — | — | — | — | Good | No Czech claim | 256K | No | Modified MIT | High | Older (Nov 2025) | — | 200-300 seq tool calls | Yes |
| **kimi-k2.5:cloud** | ~30-50* | Moderate | — | — | — | — | — | — | Good | No Czech claim | 256K | Yes | Modified MIT | Medium | Older (Jan 2026) | — | Unknown | Yes |
| **nemotron-3-ultra:cloud** | ~50-80* | Low | ~60-79* | — | — | 74.2 | — | — | Unknown | Undeclared | 200K | No | NVIDIA Open | Low | Too new (Jun 4 2026) | $0.60/M in | Unknown | Yes (NVFP4) |
| **nemotron-3-super:cloud** | ~60-100* | Low | 60.47 | — | — | — | — | — | Unknown | Undeclared | 1M | No | NVIDIA Open | Low | None known | — | Unknown | Yes |
| **gemma4:31b-cloud** | ~80-120* | Low | ~52.0 | — | ~29.2 | — | — | — | Native FC | 140+ languages | 256K | Yes | Apache 2.0 | Low | None known | — | Unknown | Yes |
| **devstral-2:123b-cloud** | ~40-60* | Moderate | 72.2 | — | 77.3 | — | — | — | Good | Undeclared | 128K | No | Apache 2.0 | Medium | None known | — | Unknown | Yes |
| **gpt-oss:120b-cloud** | ~30-50* | Moderate | ~41.9 | — | — | — | — | — | Good | Undeclared | 128K | No | Apache 2.0 | Medium | Older (Aug 2025) | $0.039/M in | Unknown | Yes |
| **gemini-3-flash-preview:cloud** | ~60-80* | Low | — | — | — | — | — | — | Good | Strong (Google) | 1M | Yes | Proprietary | Low | Older (Dec 2025) | — | Unknown | No |
| **qwen3-coder-next:cloud** | ~40-60* | Moderate | ~70.6 | — | — | — | — | — | Good | 201 languages | 512K | No | Apache 2.0 | Medium | None known | — | Unknown | Yes |
*Speed estimates marked with * are inferred from similar-size MoE models or provider benchmarks, not direct Ollama Cloud measurements. GLM-5.1's ~198 tok/s is the only Ollama Cloud-specific speed figure found in the knowledge base.
---
## Detailed Analysis by Criterion
### 1. Speed (TTFT, throughput, wall-clock latency)
- **GLM-5.1** is the clear speed leader on Ollama Cloud at ~198 tok/s.
- **DeepSeek V4-Pro** is the slowest at ~15.4 tok/s with extreme variance and 57s cold-start TTFT.
- **Gemma 4 31B** and **Nemotron 3 Super** are likely the fastest among alternatives due to small active parameter counts (31B dense, 12B active MoE).
- **Qwen3.5:397B** is reported as "too slow" in user benchmarks.
### 2. Intelligence (Benchmarks)
- **SWE-Bench Verified leaders:** DeepSeek V4-Pro (80.6%), Kimi K2.6 (80.2%), Devstral 2 (72.2%), Qwen3.5-397B (~66-70%), Nemotron 3 Super (60.47%), Gemma 4 31B (~52%), GPT-OSS 120B (~41.9%).
- **SWE-Bench Pro:** GLM-5.1 leads open models at 58.4%.
- **Terminal-Bench 2.0:** DeepSeek V4-Pro (67.9%), Kimi K2.6 (66.7%), Devstral 2 (77.3% — highest reported), GLM-5.1 (63.5%).
- **MCP-Atlas:** Nemotron 3 Ultra (74.2%), DeepSeek V4-Pro (74.2%), GLM-5.1 (71.8%).
- **HLE:** Kimi K2.6 (54.0%), GLM-5.1 (52.3%).
- **Code Arena Elo:** GLM-5.1 at 1530 (#3 globally for agentic web dev).
### 3. Tool Calling Reliability & Schema Adherence
- **GLM-5.1:** 99.6% schema adherence (per prior research), no known tool-calling failures.
- **MiniMax M3:** **Critical blocker** — fails on tool result messages via Ollama Cloud OpenAI-compatible endpoint (issue #16389, 6 days old as of Jun 7). Returns empty responses.
- **Kimi K2.6:** Good tool-call reliability but known OpenRouter context-length bug (reports 32K instead of 256K) that may affect Ollama.
- **Gemma 4 31B:** Native function calling support.
- **Nemotron 3 Ultra:** Too new; no verified tool-calling data yet.
### 4. Czech / Multilingual Support & Language Drift Risk
- **Qwen3.5** (all variants): Explicitly claims 201 languages including Czech. Best documented multilingual support.
- **Gemma 4 31B:** Claims 140+ languages, Apache 2.0.
- **DeepSeek V4:** Strong multilingual (MMMLU 90.3, C-Eval 93.1) but no explicit Czech claim.
- **GLM-5.1:** No explicit Czech claim, but **no known language drift** in practice.
- **Kimi K2.6:** **Critical risk** — multiple user reports of random Chinese output even with English prompts. No explicit Czech support claim.
- **MiniMax M3:** No explicit multilingual claim.
- **Nemotron / Devstral / GPT-OSS:** No explicit Czech claims.
### 5. Context Window Size
- **1M tokens:** deepseek-v4-pro, deepseek-v4-flash, nemotron-3-super, qwen3.5:397b-cloud, gemini-3-flash-preview
- **512K:** minimax-m3, qwen3-coder-next
- **256K:** kimi-k2.6, kimi-k2-thinking, kimi-k2.5, qwen3.5:cloud, gemma4:31b-cloud, devstral-2
- **200K:** glm-5.1, nemotron-3-ultra
- **128K:** gpt-oss:120b-cloud
### 6. Multimodality
- **Multimodal:** minimax-m3, kimi-k2.6, kimi-k2.5, qwen3.5:397b-cloud, qwen3.5:cloud, gemma4:31b-cloud, gemini-3-flash-preview
- **Text-only:** glm-5.1, deepseek-v4-pro, deepseek-v4-flash, kimi-k2-thinking, qwen3-coder-next, devstral-2, gpt-oss, nemotron-3-super/ultra
### 7. Open Weights & License
- **MIT:** GLM-5.1, GLM-5, DeepSeek V4-Pro/Flash
- **Apache 2.0:** Qwen3.5/Qwen3.6/Qwen3-coder, Gemma 4, GPT-OSS 120B, Devstral 2
- **Modified MIT:** Kimi K2.6, Kimi K2-thinking, Kimi K2.5
- **NVIDIA Open License:** Nemotron 3 Ultra/Super
- **Proprietary:** Gemini-3-flash-preview
- **MiniMax M3:** Open weights promised ~10 days after launch (early June 2026) — likely available by now.
### 8. Verbosity (Tokens per Answer)
- **Low:** GLM-5.1 ("nejmenší verbosity z MoE rodiny"), Nemotron 3 Ultra/Super (up to 30% fewer tokens per turn), Gemma 4 31B
- **Medium:** DeepSeek V4, Qwen3.5, Devstral 2, Kimi K2.6
- **High:** Kimi K2-thinking (reasoning model)
### 9. Known Bugs / Blockers on Ollama Cloud
- **MiniMax M3:** Tool result message failures (#16389) — **deploy blocker**.
- **Kimi K2.6:** Random Chinese output drift — **deploy blocker for Czech use**.
- **DeepSeek V4-Pro:** Extreme latency variance, 57s cold-start TTFT — usability issue.
- **Qwen3.5:397B:** User-reported "too slow, accuracy issues" on Ollama Cloud.
- **GLM-5.1:** No known bugs.
### 10. Pricing (OpenRouter proxy — Ollama Cloud is flat-rate $20/mo Pro)
- **Cheapest input:** GPT-OSS 120B ($0.039/M), DeepSeek V4-Flash ($0.14/M)
- **Mid-range:** DeepSeek V4-Pro ($1.74/M), MiniMax M3 ($0.60/M), Kimi K2.6 ($0.60/M), Nemotron 3 Ultra ($0.60/M)
- **Most expensive:** GLM-5.1 (~$4/M output)
- **Note:** Ollama Cloud Pro is flat-rate $20/month; per-token pricing only matters if switching to API/OpenRouter fallback.
### 11. Long-Horizon Agent Stability (Multi-turn, hundreds of tool calls)
- **GLM-5.1:** Proven over "hundreds of rounds" — best sustained productivity per user experience.
- **Kimi K2-thinking:** Explicitly designed for 200-300 sequential tool calls.
- **Kimi K2.6:** Supports 200-300 sequential tool calls.
- **Nemotron 3 Ultra:** Marketed for "long-running agents" but too new for verification.
- **DeepSeek V4:** Unknown for sustained multi-turn agent use on Ollama Cloud.
### 12. Self-Host Fallback Possibility
- **All models except Gemini-3-flash-preview** have open weights available on Hugging Face.
- **NVFP4 quantization:** Nemotron 3 Ultra/Super require NVIDIA-specific formats.
- **Hardware requirements:**
- GLM-5.1: ~198K context on Ollama; self-host requires significant VRAM.
- DeepSeek V4-Flash: 284B total / 13B active — efficient MoE, viable on consumer hardware.
- Qwen3.5:397B: 397B total / 17B active — large but efficient.
- Gemma 4 31B: Dense 31B — fits on 24GB GPU.
- Devstral 2 123B: Large but coding-optimized.
---
## Recommendations
### Primary Default (No Change)
**`glm-5.1:cloud`** remains the best default nanobot agent model on Ollama Cloud.
**Why:**
- Fastest measured speed (~198 tok/s)
- Best proven track record for sustained agent sessions
- MIT license
- No known bugs or language drift
- Strong benchmark suite (SWE-Bench Pro 58.4, Terminal-Bench 63.5, MCP-Atlas 71.8, Code Arena Elo 1530)
- Low verbosity = lower token burn
### Alternative Tier 1 (Specific Needs)
1. **`deepseek-v4-flash:cloud`** — Choose if you need 1M context for large codebase analysis or long-document processing. MIT license, open weights, cheaper than Pro. Tradeoff: slower than GLM-5.1 (~30-50 tok/s estimated).
2. **`qwen3.5:397b-cloud`** — Choose if you need multimodal input (screenshots, diagrams) or explicit Czech language support (201 languages claimed). Apache 2.0, 1M context. Tradeoff: slow, reported accuracy issues on Ollama Cloud.
### Alternative Tier 2 (Niche Use)
3. **`devstral-2:123b-cloud`** — Choose for pure coding-heavy workloads with strong benchmark scores (SWE-Bench 72.2%, Terminal-Bench 77.3%). Tradeoff: 128K context limit, no multimodal.
4. **`gemma4:31b-cloud`** — Choose if you need a fast, lightweight alternative with native function calling and 140+ language support. Tradeoff: weaker agent benchmarks (SWE-Bench ~52%, Terminal-Bench ~29%).
### Avoid (Blockers)
- **`minimax-m3:cloud`** — Tool-calling bug makes it unusable for agent work until Ollama fixes #16389.
- **`kimi-k2.6:cloud`** — Chinese language drift is unacceptable for Czech-language agent use.
- **`deepseek-v4-pro:cloud`** — 15.4 tok/s and 57s TTFT make it impractical for interactive agent sessions despite top benchmarks.
### Watch List
- **`nemotron-3-ultra:cloud`** — Too new (released June 4, 2026). Promising specs (550B/55B, 1M ctx, low verbosity) but needs real-world agent validation on Ollama Cloud.
- **`qwen3-coder-next:cloud`** — Strong coding focus, 512K context, Apache 2.0. Good candidate if coding is the primary workload.
---
## GLM-5.2 Status
**Not released.** As of June 7, 2026, Z.AI has made no official announcement of GLM-5.2. Reddit speculation from April 2026 suggested 50-83 days from GLM-5.1 launch (April 7, 2026), implying a June-July 2026 window, but no confirmation exists. It is not available on Ollama Cloud.
---
## Sources & Methodology
- Ollama Cloud model listings: ollama.com/search?c=cloud
- Benchmark aggregators: llm-stats.com, benchlm.ai, benchmark.space, swebench.com
- Vendor technical reports: NVIDIA Nemotron 3 Ultra (Jun 4, 2026), DeepSeek V4 (Apr 24, 2026), Qwen3.5/3.6 blog posts, Kimi K2.6 blog, Z.AI GLM-5.1 page
- Community benchmarks: ollama-cloud-benchmark GitHub (erikwangz), dev.to user benchmarks
- Bug trackers: ollama/ollama #16389 (MiniMax M3), Cursor/Reddit user reports (Kimi K2.6 Chinese drift)
- Pricing: OpenRouter proxy rates (Ollama Cloud itself is flat-rate $20/mo Pro)
*Note on speed: Only GLM-5.1 has a direct Ollama Cloud speed measurement in our knowledge base (~198 tok/s). All other speed figures are estimates inferred from MoE active-parameter counts, provider benchmarks, or similar-platform measurements. Actual Ollama Cloud performance may vary due to load, cold starts, and quantization.*

View File

@@ -0,0 +1,69 @@
# Přehled modelů Ollama Cloud (červen 2026)
**Datum:** 20260607
Tento report shrnuje všechny modely dostupné na stránce *Ollama Cloud* (https://ollama.com/models?c=cloud) a hodnotí je podle 12 kritérií relevantních pro nasazení nanobotagenta. Hodnocení vychází z interního knowledge/models.md, detailní srovnávací tabulky v `results/2026-06-07_ollama-cloud-agent-model-comparison.md` a veřejně dostupných benchmarků (SWEBench, TerminalBench, Code Arena, MCPAtlas, HLE atd.).
---
## 1. Tabulka přehledu
| Model | Rychlost (tok/s) | Inteligence (benchmark) | Toolcalling | Čeština / Multilingual | Kontext | Multimodální | Licence | Verbosita | Známé bugy / blokátory | Cena (OpenRouter proxy) | Longhorizon stabilita | Selfhost možnost |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| **glm-5.1:cloud** | ~198 (přímé měření) | SWEBench Pro 58.4% Code Arena Elo1530 | 99.6% schema adherence, žádné známé selhání | Žádná oficiální podpora češtiny, ale žádný drift | 200K (198K Ollama) | ❌ (textonly) | MIT | Nízká | | ~$4/M output (Ollama Pro flatrate $20/mo) | Ověřeno stovkami kol nejlepší | Ano (vyžaduje ~30GB VRAM) |
| **deepseek-v4-flash:cloud** | ~3050* (odhad) | SWEBench ~75% (odhad) | Dobrá | Žádná explicitní podpora češtiny, ale silná multilingvní skóre (MMMLU90.3) | 1M | ❌ | MIT | Střední | | $0.14/M in | Neověřeno (uživatelské benchmarky) | Ano (efektivní MoE, 13B aktivních) |
| **qwen3.5:397b-cloud** | ~1020* (odhad) | SWEBench 6670% | Dobrá | **201 jazyk včetně češtiny** (oficiální) | 1M | ✅ | Apache2.0 | Střední | Uživatelé hlásí pomalost a přesnostní problémy | (žádná proxy cena, Ollama Pro) | Neověřeno | Ano (Apache2.0, vyžaduje velké GPU) |
| **deepseek-v4-pro:cloud** | ~15.4 (přímé měření) | SWEBench 80.6% (nejvyšší) | Dobrá | Žádná explicitní podpora češtiny | 1M | ❌ | MIT | Střední | Extrémní latence (57s TTFT), vysoká variabilita | $1.74/M in | Neznámo | Ano (MIT) |
| **devstral-2:123b-cloud** | ~4060* (odhad) | SWEBench 72.2% TerminalBench 77.3% (nejvyšší) | Dobrá | Žádná explicitní podpora češtiny | 128K | ❌ | Apache2.0 | Střední | | | Neověřeno | Ano (Apache2.0) |
| **gemma4:31b-cloud** | ~80120* (odhad) | SWEBench ~52% Code Arena nízké | Native function calling | 140+ jazyků (neuvádí češtinu) | 256K | ✅ | Apache2.0 | Nízká | | | Neověřeno | Ano (31B dense) |
| **nemotron-3-ultra:cloud** | ~5080* (odhad) | SWEBench ~6079% (odhad) | Neznámo | Žádná oficiální podpora češtiny | 200K | ❌ | NVIDIA Open License | Nízká | Příliš nový žádná data o toolcallingu | $0.60/M in | Neověřeno | Ano (vyžaduje NVIDIAspecifické kvantování) |
| **nemotron-3-super:cloud** | ~60100* (odhad) | SWEBench 60.47% (odhad) | Neznámo | Žádná podpora češtiny | 1M | ❌ | NVIDIA Open License | Nízká | | | Neověřeno | Ano (vyžaduje NVIDIAspecifické kvantování) |
| **minimax-m3:cloud** | ~4060* (odhad) | (žádná veřejná benchmark data) | **Kritický bug** selhání toolresult zpráv (issue #16389) | Žádná explicitní podpora češtiny | 512K | ✅ | Open weights (brzy) | | Toolresult bug **nepoužitelné** | $0.60/M in | Neověřeno | Ano (otevřené váhy) |
| **kimi-k2.6:cloud** | ~3050* (odhad) | SWEBench 80.2% HLE 54.0% | Dobrá | Žádná podpora češtiny, **náhodný čínský drift** (kritické) | 256K | ✅ | Modified MIT | Střední | Čínský výstupní drift, OpenRouter kontextbug (32K) | $0.60/M in | 200300 tool calls (design) | Ano (MITlike) |
| **qwen3.6:cloud** | ~? (nepřímý odhad) | | | 201 jazyků (včetně češtiny) | 256K | ✅ | Apache2.0 | Střední | | | | Ano |
| **qwen3-coder-next:cloud** | ~4060* (odhad) | SWEBench ~70.6% (odhad) | Dobrá | 201 jazyků (včetně češtiny) | 512K | ❌ | Apache2.0 | Střední | | | | Ano |
| **lfm2.5:cloud** | (žádná data) | | | | | | | | | | | |
| **lfm2:cloud** | | | | | | | | | | | | |
| **glm-4.7:cloud** | | | | | | | | | | | | |
| **glm-4.7-flash:cloud** | | | | | | | | | | | | |
| **translategemma:cloud** | | | | | | | | | | | | |
| **gemini-3-flash-preview:cloud** | ~6080* (odhad) | | | Strong (Google) | 1M | ✅ | Proprietární | Nízká | | | | Ne (proprietární) |
*Poznámka: hvězdičkou označené rychlosti jsou odhady založené na podobných modelových velikostech a veřejných benchmarkech, protože přímé měření na Ollama Cloud není v knowledge base dostupné.*
---
## 2. Doporučení
### Primární výchozí model (bez změny)
**`glm-5.1:cloud`** nejrychlejší, nejstabilnější, MIT licence, žádné známé bugy, ověřená dlouhodobá agentní stabilita.
### Alternativy první úrovně (specifické potřeby)
1. **`deepseek-v4-flash:cloud`** pokud potřebujete 1M kontextu a nižší cenu, akceptujete střední rychlost a žádnou multimodalitu.
2. **`qwen3.5:397b-cloud`** pokud je pro vás klíčová podpora češtiny a multimodální vstup (obrázky, diagramy). Připravte se na pomalejší odezvu a možná mírná přesnost.
### Alternativy druhé úrovně (niche)
- **`devstral-2:123b-cloud`** výborný pro čistě kódovací úlohy, silné benchmarky, ale omezený kontext a žádná multimodalita.
- **`gemma4:31b-cloud`** lehký, rychlý, nízká verbosita, dobrá funkční volání, ale slabší agentní skóre.
### Modely k vyhnutí (blokátory)
- **`minimax-m3:cloud`** kritický bug v toolresult zprávách, nedostupný pro agentní práci.
- **`kimi-k2.6:cloud`** náhodný čínský výstup, nepřijatelný pro české nasazení.
- **`deepseek-v4-pro:cloud`** extrémní latence a variabilita, i přes špičkové benchmarky.
### Watchlist (sledujte vývoj)
- **`nemotron-3-ultra:cloud`** slibné specifikace, ale chybí reálná data o toolcalling a dlouhodobé stabilitě.
- **`qwen3-coder-next:cloud`** zaměřeno na kódování, 512K kontext, dobrá podpora jazyků.
---
## 3. Metodologie a zdroje
- **Seznam modelů:** https://ollama.com/models?c=cloud (scraped 20260607).
- **Benchmarky a metriky:** `knowledge/models.md`, `results/2026-06-07_ollama-cloud-agent-model-comparison.md`, veřejné benchmarky (SWEBench, TerminalBench, Code Arena, MCPAtlas, HLE, MMMLU, CEval).
- **Bugtrackery:** GitHub issue #16389 (MiniMax M3), Reddit/Cursor reporty o Kimi K2.6.
- **Ceny:** OpenRouter proxy rates (viz `results/..._agent-model-comparison.md`), Ollama Cloud Pro tarif $20/mo.
- **Licence a selfhost:** informace z oficiálních modelových repozitářů (Hugging Face, NVIDIA, ZAI).
---
*Report byl vygenerován automaticky na základě dostupných interních a veřejných dat. Pro konkrétní nasazení doporučuji provést vlastní rychlostní testy na vašem hardware a ověřit aktuální stav bugů.*

View File

@@ -0,0 +1,362 @@
# Analýza: /todo skill a unifikace /note, /remind, /keep
## 1. Současný stav — co každý skill dělá
| Skill | Storage | Příkazy | Klíčová vlastnost | Problémy |
|-------|---------|---------|-------------------|----------|
| **/keep** | `keep.md` (plain markdown) | `add`, `list` | Okamžitá persist, žádná struktura | Append-only, žádné mazání/úpravy, žádné kategorie, plaintext |
| **/note** | `db/note.sqlite` | `add`, `list`, `search`, `delete`, `edit` | Plné CRUD, kategorie, vyhledávání | Není "task-oriented", žádný status/due date |
| **/remind** | `reminder.yaml` + `.reminder_state.json` | `add`, `delete` | Časové plánování, Telegram notifikace | YAML race conditions, žádný `list`, žádné `edit`, fragile dedup |
| **/todo** *(navrhovaný)* | — | — | Seznam úkolů bez časového plánování | Neexistuje |
### 1.1 Překryv funkcionality
```
/keep add "koupit mléko" → plaintext záznam
/note add "koupit mléko" --cat shopping → strukturovaný záznam
/todo add "koupit mléko" → úkol (co se liší od note?)
/remind add "koupit mléko" at 18:00 → úkol + časová notifikace
```
**Základní entita je stejná:** text + metadata. Rozdíl je v *chování* (notifikace, status tracking).
---
## 2. Požadavky na /todo
Z uživatelova popisu: "podobný jako remind, jen tam není to přesné časové odesílání".
To znamená:
- Přidat úkol
- Označit jako hotový
- Seznam aktivních/dokončených úkolů
- Smazat úkol
- Možná priorita, kategorie, due date (bez notifikace)
**To je 90% funkcionality /note + jeden sloupec `status`.**
---
## 3. Architektonické varianty
### Varianta A: Jeden univerzální skill `/task` (nebo `/item`)
**Koncept:** Jeden SQLite DB, jedna tabulka `items`:
```sql
CREATE TABLE items (
id INTEGER PRIMARY KEY,
type TEXT CHECK(type IN ('note','todo','reminder','keep')),
content TEXT NOT NULL,
category TEXT,
status TEXT CHECK(status IN ('active','done','archived')),
due_at TIMESTAMP, -- pro todo + reminder
schedule TEXT, -- cron expr pro reminder
notify_channel TEXT, -- telegram, etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Příkazy:**
```
/task note add "obsah" --cat prace
/task todo add "udělat review" --priority high --due 2026-06-10
/task remind add "zavolat" --at 2026-06-08T10:00
/task keep add "zapamatuj si heslo"
/task list --type todo --status active
/task done <id>
/task delete <id>
```
**Výhody:**
- Jednotné API — uživatel se učí jeden skill
- Jeden storage — žádná duplicita dat
- Flexibilní — úkol může "proměnit" z todo na remind přidáním schedule
- Fulltext search přes všechny typy najednou
- Jedna codebase na CRUD
**Nevýhody:**
- Velká změna — migrace 3 existujících skillů
- `/remind` potřebuje minutový cron — to nelze udělat uvnitř LLM agenta
- Risk "one size fits none" — kompromisy v UI každého typu
- Složitější permission model (co když chci remind bez todo?)
**Verdikt:** Příliš monolitické. `/remind` cron mechanismus je technický důvod pro separaci.
---
### Varianta B: Zachovat separaci, přidat orchestraci
**Koncept:** Existující skilly zůstanou. Nový skill `/task` (nebo `/items`) je "meta-skill" — analyzuje záměr a deleguje na správný pod-skill.
```
Uživatel: "připomeň mi zítra v 10 zavolat"
→ /task rozpozná "připomeň" + čas → volá /remind
Uživatel: "zapiš si že Ollama má 5h limit"
→ /task rozpozná "zapiš si" → volá /note
Uživatel: "mám udělat review PR"
→ /task rozpozná úkol bez času → volá /todo
```
**Výhody:**
- Zachovává specializaci každého skillu
- Postupná adopce — nemusí se migrovat existující data
- `/remind` zůstane samostatný pro cron
**Nevýhody:**
- Orchestrace přes LLM je nespolehlivá (záměr se může špatně klasifikovat)
- Uživatel stále potřebuje znát 4 commandy
- Duplicitní kód (list, delete, search se opakují v každém skillu)
- "Magie" — uživatel neví, kam se data vlastně uložila
**Verdikt:** Přidává komplexitu bez jasného benefitu. Klasifikace záměru je problém, který LLM agent řeší už teď implicitně.
---
### Varianta C: Konvergence — sloučit /keep do /note, /todo jako rozšíření /note
**Koncept:**
1. **/keep** se stane aliasem na `/note add --cat keep` + `/note list --cat keep`
2. **/note** se rozšíří o sloupec `status` (NULL = note, 'active'/'done' = todo)
3. **/todo** je nový skill, ale volá stejný SQLite DB jako /note — jen s default filtrem `status IS NOT NULL`
4. **/remind** zůstane samostatný (YAML + cron), ale může číst z note DB pro kontext
**Schéma rozšíření:**
```sql
ALTER TABLE notes ADD COLUMN status TEXT CHECK(status IN ('active','done','archived'));
ALTER TABLE notes ADD COLUMN due_date TIMESTAMP; -- optional, bez notifikace
ALTER TABLE notes ADD COLUMN priority INTEGER DEFAULT 0; -- -1 low, 0 normal, 1 high
```
**Příkazy:**
```
/note add "Ollama limit 5h" --cat knowledge → klasická poznámka
/note add "koupit mléko" --cat shopping --status active --due 2026-06-10 → todo v note DB
/todo add "udělat review" --priority high → shortcut pro note s status=active
/todo list → note list --status active
/todo done <id> → note edit <id> --status done
/keep add "heslo je xyz" → alias: note add --cat keep
```
**Výhody:**
- /note a /todo sdílejí storage — žádná duplicita
- /keep se zjednoduší (odpadne custom markdown parser)
- Uživatel může používat /note pro vše, nebo /todo pro rychlý přístup
- Postupná migrace — /keep.md se může naimportovat do note DB
- /remind zůstane nezměněný (žádný cron refactoring)
**Nevýhody:**
- /todo skill je technicky tenká vrstva nad /note — může působit zbytečně
- Dvě cesty k jednomu cíli (`/note add --status active` vs `/todo add`)
**Verdikt:** Nejpragmatičtější. Zachovává existující investici, minimalizuje duplicitu.
---
### Varianta D: /todo jako samostatný skill s vlastním storage
**Koncept:** Úplně nový skill, vlastní SQLite DB `db/todo.sqlite`, žádná vazba na /note.
**Výhody:**
- Čistá separace concerns
- Nezávislý vývoj
- Jednoduché schéma optimalizované pro task tracking
**Nevýhody:**
- Další DB, další skill, další maintenance
- Uživatel musí rozhodnout: dát to do /note, /todo, nebo /remind?
- Překryv s /note je obrovský (90% kódu by bylo stejné)
**Verdikt:** Nepřijatelné. Vytváří problém, který řešíš.
---
## 4. Doporučená architektura
### Fáze 1: Rozšířit /note o task tracking (okamžitě)
Rozšířit `note.py` o:
- `status` sloupec (NULL = note, 'active'/'done'/'archived' = task)
- `due_date` sloupec (optional)
- `priority` sloupec (optional)
- Příkazy: `--status`, `--due`, `--priority` v `add` a `edit`
- `list` filtry: `--status`, `--due-before`, `--priority`
### Fáze 2: Vytvořit /todo jako thin wrapper (lehký skill)
`/todo` skill s vlastním SKILL.md, ale volá stejný `note.py` skript s přednastavenými parametry:
```bash
# /todo add "udělat review" → interně:
uv run scripts/note.py add "udělat review" --status active
# /todo list → interně:
uv run scripts/note.py list --status active --sort priority,due_date
# /todo done <id> → interně:
uv run scripts/note.py edit <id> --status done
```
Toto je podobné patternu, který používá např. `git switch` jako alias na `git checkout`.
### Fáze 3: Deprecate /keep (postupně)
- Přidat do /note kategorii `keep`
- Migrace: `keep.md` → import do note DB s cat=keep
- /keep skill zůstane jako read-only legacy, nebo se stane aliasem
### Fáze 4: /remind integrace (volitelně, později)
- /remind může číst z note DB — když uživatel řekne "připomeň mi úkol #5", /remind najde note s id=5 a vytvoří reminder
- Nebo: /remind může ukládat do note DB místo YAML (ale cron skript by musel číst SQLite — možné, ale větší změna)
---
## 5. Technické detaily /todo skillu
### 5.1 Schéma dat (rozšířené /note)
```sql
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
category TEXT,
status TEXT CHECK(status IN ('active','done','archived')),
due_date TIMESTAMP,
priority INTEGER DEFAULT 0 CHECK(priority IN (-1, 0, 1)),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_notes_status ON notes(status);
CREATE INDEX idx_notes_due ON notes(due_date);
CREATE INDEX idx_notes_priority ON notes(priority);
CREATE INDEX idx_notes_category ON notes(category);
```
### 5.2 Příkazy /todo
| Příkaz | Akce | Ekvivalent v /note |
|--------|------|-------------------|
| `todo add "text" [--cat] [--priority] [--due]` | Vytvoří aktivní úkol | `note add "text" --status active` |
| `todo list [--cat] [--all]` | Seznam aktivních | `note list --status active` |
| `todo done <id>` | Označí hotové | `note edit <id> --status done` |
| `todo undo <id>` | Vrátí do aktivních | `note edit <id> --status active` |
| `todo delete <id>` | Smaže | `note delete <id>` |
| `todo search <query>` | Fulltext | `note search <query> --status active` |
### 5.3 Proč thin wrapper místo vlastního skriptu?
- **Jedna codebase:** Bugfix v note.py se projeví v obou skillech
- **Jedna migrace:** Když se změní schéma, stačí jeden skript
- **Konzistence:** `todo search` najde i poznámky, pokud uživatel chce
- **Jednoduchost:** /todo SKILL.md je ~50 řádek, žádný Python kód
---
## 6. Srovnání variant
| Kritérium | A: Monolit | B: Orchestrace | C: Konvergence | D: Samostatný |
|-----------|-----------|----------------|----------------|---------------|
| Jednotné UI | ✅ | ⚠️ magie | ✅ /note+/todo | ❌ |
| Jednotný storage | ✅ | ❌ | ✅ | ❌ |
| Zachová /remind cron | ❌ | ✅ | ✅ | ✅ |
| Minimální změna existujícího | ❌ | ✅ | ✅ | ✅ |
| Žádná duplicita kódu | ✅ | ❌ | ✅ | ❌ |
| Postupná migrace | ❌ | ✅ | ✅ | ✅ |
| Uživatel se učí 1 command | ✅ | ❌ | ⚠️ 2 (/note, /todo) | ❌ |
| Spolehlivost | ⚠️ komplex | ❌ LLM klasifikace | ✅ | ✅ |
---
## 7. Konkrétní doporučení
**Implementuj variantu C s /todo jako thin wrapper nad /note.**
### Kroky:
1. **Rozšířit `note.py`:**
- Přidat `status`, `due_date`, `priority` do schématu (s migrací existující DB)
- Přidat `--status`, `--due`, `--priority` do `add` a `edit`
- Přidat `--status`, `--due-before`, `--priority` do `list`
- Upravit výstup `list` — pro status != NULL zobrazit `[ ]` / `[x]` prefix
2. **Vytvořit `/todo` skill:**
- SKILL.md s příkazy, které volají `note.py` s přednastavenými parametry
- Žádný vlastní Python kód (nebo minimální wrapper skript)
- `todo add``note add --status active`
- `todo list``note list --status active --sort priority,due_date`
- `todo done``note edit --status done`
3. **Deprecate `/keep`:**
- Přidat do /note podporu pro `--cat keep`
- Volitelně: import skript pro `keep.md`
- /keep SKILL.md upravit na aliasy
4. **Ponechat `/remind` nezměněný:**
- YAML + cron je technicky odůvodněný
- Později: integrační bod — /remind může číst z note DB
### Příklad použití po implementaci:
```
# Rychlá poznámka
> note add "Ollama limit 5h" --cat knowledge
# Úkol bez deadlinu
> todo add "refactor auth module" --priority high
# Úkol s deadlinem (bez notifikace)
> todo add "odeslat fakturu" --due 2026-06-10 --priority high
# Připomínka s notifikací
> remind add "odeslat fakturu" at 2026-06-10T09:00
# Seznam všech aktivních úkolů
> todo list
[ ] #12 refactor auth module [high]
[ ] #15 odeslat fakturu [high] due: 2026-06-10
# Seznam všech poznámek a úkolů
> note list --cat knowledge
#7 Ollama limit 5h [knowledge]
# Hotovo
> todo done 12
# Hledání přes všechno
> note search "faktura"
#15 [active] odeslat fakturu
```
---
## 8. Rizika a mitigace
| Riziko | Mitigace |
|--------|----------|
| Migrace existující note DB | `note.py` musí detekovat staré schéma a přidat sloupce automaticky |
| /todo jako wrapper je "podvod" | Dokumentovat v SKILL.md — uživatel chápe, že /todo je pohled na /note |
| Uživatel ztratí přehled co kam dát | Jasné pravidlo: potřebuješ notifikaci? → /remind. Úkol bez notifikace? → /todo. Čistá informace? → /note. |
| /keep uživatelé ztratí data | Import skript + /keep zůstane read-only dočasně |
---
## 9. Závěr
**Nejlepší cesta je konvergence, ne monolit.**
- `/note` se stane univerzálním storage pro všechny "item" typy (poznámky, úkoly, keep)
- `/todo` je pohled (view) na `/note` — uživatelsky přívětivý, technicky tenký
- `/remind` zůstane samostatný kvůli cron architektuře
- `/keep` se postupně absorbuje do `/note --cat keep`
Toto dává:
- **Jednotný storage** (SQLite)
- **Jednu codebase** na CRUD (note.py)
- **Specializované UI** pro každý use case (/note, /todo, /remind)
- **Postupnou migraci** bez big-bang
- **Technickou správnost** (cron zůstává mimo LLM agenta)

View File

@@ -0,0 +1,260 @@
# /remind skill — návrh přechodu z YAML na SQLite
## 1. Proč SQLite
| Aspekt | YAML (současné) | SQLite (navrhované) |
|--------|-----------------|---------------------|
| Atomicita | tmp+rename, žádné transakce | `BEGIN``COMMIT` |
| Query | Načíst celý soubor do paměti | SELECT s JOIN a indexy |
| Dedup | Externí `.reminder_state.json` | Tabulka `reminder_fires` |
| Datové typy | Vše string | INTEGER, TEXT ISO, CHECK |
| Edit | Chybí (celý záznam se přepisuje) | UPDATE / DELETE per sloupec |
| Testy | File-based, side-effects | `:memory:` databáze |
| Audit | Žádný | `reminder_fires.status` + `error_message` |
## 2. Navrhované schéma
```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Hlavní entita -----------------------------------------------------------
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
created_at TEXT NOT NULL, -- ISO-8601
updated_at TEXT NOT NULL, -- ISO-8601
deleted_at TEXT -- soft-delete, NULL = aktivní
);
-- One-time scheduly -------------------------------------------------------
CREATE TABLE schedule_at (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
at_datetime TEXT NOT NULL, -- ISO-8601 (lokální čas dle reminders.timezone)
enabled INTEGER NOT NULL DEFAULT 1
);
-- Recurring cron scheduly -------------------------------------------------
CREATE TABLE schedule_cron (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
cron_expr TEXT NOT NULL, -- standardní cron, např. "0 9 * * 1-5"
enabled INTEGER NOT NULL DEFAULT 1
);
-- Random scheduly ---------------------------------------------------------
CREATE TABLE schedule_random (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
times_per_day INTEGER NOT NULL,
window_start_min INTEGER NOT NULL, -- 0..1439 (minuty od půlnoci)
window_end_min INTEGER NOT NULL, -- 0..1440 (výhradně horní mez)
days_filter TEXT, -- např. "1-5", NULL = každý den
from_date TEXT, -- YYYY-MM-DD, NULL = okamžitě
until_date TEXT, -- YYYY-MM-DD, NULL = navždy
enabled INTEGER NOT NULL DEFAULT 1,
CHECK(times_per_day >= 1),
CHECK(window_start_min >= 0 AND window_start_min < 1440),
CHECK(window_end_min > 0 AND window_end_min <= 1440),
CHECK(window_start_min < window_end_min)
);
-- Audit / dedup / delivery log --------------------------------------------
CREATE TABLE reminder_fires (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
schedule_id INTEGER NOT NULL, -- ID v příslušné schedule_* tabulce
schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at','cron','random')),
fire_time TEXT NOT NULL, -- ISO-8601, plánovaný čas výstřelu
delivered_at TEXT, -- ISO-8601, skutečný čas doručení
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','delivered','failed')),
error_message TEXT
);
-- Indexy ------------------------------------------------------------------
CREATE INDEX idx_reminders_text ON reminders(text);
CREATE INDEX idx_fire_lookup ON reminder_fires(
reminder_id, schedule_type, schedule_id, fire_time, status
);
CREATE INDEX idx_at_datetime ON schedule_at(reminder_id, at_datetime);
CREATE INDEX idx_cron_expr ON schedule_cron(reminder_id, cron_expr);
```
## 3. Lepší datové typy oproti YAML
| Pole (YAML) | SQLite sloupec | Proč lepší |
|-------------|----------------|------------|
| `times_per_day: "5"` (string v YAML) | `times_per_day INTEGER` | Nativní číselná validace, CHECK constraint |
| `window: "09:00-21:00"` (string) | `window_start_min INTEGER`, `window_end_min INTEGER` | Umožňuje matematiku (`fire_minute BETWEEN 540 AND 1260`), sortable |
| `at: "2026-06-10T10:00:00"` | `at_datetime TEXT` | Sice stále TEXT, ale ISO formát je porovnatelný a sortable; SQLite nemá nativní datetime |
| `days: "1-5"` | `days_filter TEXT` | Zůstává TEXT — parsuje se až při běhu; alternativně normalizovat na `random_days(day_of_week INT)`, ale pro 15 položek to je overkill |
| `from` / `until` | `from_date TEXT`, `until_date TEXT` | ISO date je sortable; pro query stačí `date <= '2026-06-10'` |
**Poznámka k časům:** SQLite nemá nativní `DATETIME` typ. Doporučuji ukládat jako **TEXT v ISO-8601** (např. `2026-06-10T10:00:00+02:00`) místo Unix timestampu — je to čitelné, sortable a přímo použitelné s `datetime.fromisoformat()`.
## 4. Jednotlivé typy časů — proč 1:N a ne jedna tabulka
Současný YAML model:
```yaml
- text: "water the plants"
cron_exprs: ["0 19 * * *"]
random: {times_per_day: 2, window: "08:00-12:00"}
```
V DB to rozdělíme na **jeden řádek `reminders`** + **řádky v `schedule_cron` a `schedule_random`**. Důvody:
- **Normalizace**: Každý schedule má svůj životní cyklus — jde zapnout/vypnout, editovat, mazat bez dotyku ostatních.
- **Dedup**: `reminder_fires` odkazuje na konkrétní `schedule_id` + `schedule_type`. Víme přesně, který cron nebo random výstřel už byl doručen.
- **Extensibility**: Přidání nového typu schedule = nová tabulka, není potřeba migrovat existující data.
## 5. Dává smysl ukládat cron jako cron string?
**Ano.**
- Cron je de facto standard, `croniter` ho umí parsovat i expandovat (`get_prev` / `get_next`).
- Rozparsování na `cron_minute INT`, `cron_hour INT` atd. by ztratilo expresivitu (`*/15`, `L`, ranges, step values).
- Ukládání jako cron string je kompaktní a čitelné.
Random schedule **nelze** vyjádřit jako cron — je to vlastní algoritmus `compute_fire_times()`. Proto má samostatnou tabulku s parametry.
## 6. Dedup a state — z `.reminder_state.json` do DB
Současný mechanismus:
```python
key = hashlib.sha1(text.encode()).hexdigest()[:8]
last = state.get(key) # "2026-06-10T09:20:00"
```
Problém: hash textu je hrubý — změna textu znamená nový key, stejný text = stejný key pro všechny scheduly.
Nový mechanismus v SQLite:
```sql
-- Před odesláním:
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = ?
AND fire_time = ? AND status = 'delivered';
```
- Přesná dedup **per schedule**, ne per text.
- `status = 'failed'` umožňuje retry při příštím běhu.
- `error_message` zachytí proč Telegram API selhalo.
- `delivered_at` je audit trail.
## 7. Query pro remind_send.py (místo načítání celého YAML)
```sql
-- Najít všechny due fires za posledních 60 sekund
SELECT
r.id AS reminder_id,
r.text,
'at' AS schedule_type,
sa.id AS schedule_id,
sa.at_datetime AS fire_time
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND sa.enabled = 1
AND sa.at_datetime > datetime('now', '-60 seconds')
AND sa.at_datetime <= datetime('now')
AND NOT EXISTS (
SELECT 1 FROM reminder_fires rf
WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
AND rf.status = 'delivered'
)
UNION ALL
-- Cron: vypočítat v Pythonu přes croniter, ale DB řekne které expr existují
SELECT r.id, r.text, 'cron', sc.id, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND sc.enabled = 1;
-- croniter.get_prev() se provede v Pythonu, pak se porovná s now-60s
UNION ALL
-- Random: všechny aktivní random scheduly
SELECT r.id, r.text, 'random', sr.id, NULL
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND sr.enabled = 1
AND (sr.from_date IS NULL OR sr.from_date <= date('now'))
AND (sr.until_date IS NULL OR sr.until_date >= date('now'));
-- compute_fire_times(date.today(), ...) se provede v Pythonu
```
## 8. CLI změny
`remind_edit.py` zachová stejné CLI rozhraní, backend se změní:
| Subcommand | Změna |
|------------|-------|
| `list` | SQL JOIN místo `yaml.safe_load` + JSON dump |
| `add` | `INSERT INTO reminders` + `INSERT INTO schedule_*` v jedné transakci |
| `remove --keyword` | `SELECT id FROM reminders WHERE text LIKE '%keyword%'``DELETE` nebo `UPDATE deleted_at` |
| **nové** `edit --keyword` | `UPDATE reminders.text` nebo přidání/odebrání schedulů |
| **nové** `enable` / `disable` | `UPDATE reminders SET enabled = 0/1` |
## 9. Další věci k uvážení
### 9.1 Timezone
- Všechny `at_datetime` a `fire_time` by měly být **aware** (s offsetem `+02:00`) nebo explicitně v `reminders.timezone`.
- Cron výrazy jsou vždy v lokální čase — `croniter` běží nad `datetime.now(TZ)`.
- Doporučení: ukládat jako **TEXT s offsetem** (`2026-06-10T10:00:00+02:00`), při query převádět v Pythonu.
### 9.2 WAL mode
```sql
PRAGMA journal_mode = WAL;
```
Umožní čtení během zápisu. Pro remind_send.py (každou minutu SELECT) + remind_edit.py (občasný INSERT/UPDATE) je to kritické.
### 9.3 Schema versioning
```sql
CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY);
INSERT INTO _schema_version VALUES (1);
```
Při startu skriptu zkontrolovat verzi a spustit migrace.
### 9.4 Testování
- SQLite podporuje `:memory:` databázi — testy mohou běžet bez file I/O.
- `remind_edit.py` dostane parametr `--db PATH` (default `workspace/db/reminders.sqlite`).
### 9.5 Migrace z YAML
Jednorázový skript:
1. Načíst `reminder.yaml`
2. `BEGIN TRANSACTION`
3. Pro každý reminder: `INSERT INTO reminders` → získat `lastrowid`
4. Podle polí `at` / `at_times` / `cron_exprs` / `random` vložit do příslušných schedule tabulek
5. `COMMIT`
6. Přejmenovat `reminder.yaml``reminder.yaml.bak`
### 9.6 Soft delete vs hard delete
- `deleted_at TEXT` místo `DELETE FROM reminders` — zachová historii a umožní "undo".
- `remind_edit.py remove` by default nastaví `deleted_at`, `--hard` by provedl skutečný DELETE.
### 9.7 FTS5 (volitelně)
Pokud bude >100 reminderů, `CREATE VIRTUAL TABLE reminders_fts USING fts5(text)` urychlí fulltext vyhledávání pro `remove --keyword`.
### 9.8 Konfigurace cesty k DB
```python
DEFAULT_DB = Path(__file__).resolve().parent.parent.parent.parent / "db" / "reminders.sqlite"
```
Podle pravidel v AGENTS.md: *Always store SQLite databases under `db/*.sqlite`*.
## 10. Shrnutí rozhodnutí
| Otázka | Rozhodnutí |
|--------|------------|
| Ukládat cron jako string? | **Ano** — standard, expresivní, croniter to zvládne. |
| Random do cron stringu? | **Ne** — random je vlastní algoritmus, ukládat parametry. |
| Jedna tabulka vs schedule tabulky? | **3 schedule tabulky** (at, cron, random) — 1:N vztah. |
| Dedup externě nebo v DB? | **V DB** — `reminder_fires` per schedule. |
| Časy jako TEXT nebo INTEGER? | **TEXT ISO-8601** — čitelné, sortable, Python-compatible. |
| Window jako string nebo minuty? | **INTEGER minuty** — umožňuje SQL matematiku. |
| Hard delete nebo soft delete? | **Soft delete** (`deleted_at`) — audit trail. |
| Transakce? | **Ano** — každý `add` / `remove` / `edit` v `BEGIN…COMMIT`. |

View File

@@ -0,0 +1,141 @@
# Deep Research Audit: /remind Skill
**Datum:** 2026-06-02
**Model:** GLM-5.1:cloud
**Scope:** SKILL.md, remind_edit.py, remind_send.py, random_times.py, testy, reminder.yaml, .reminder_state.json, log, crontab
---
## 🔴 Kritické problémy
### 1. Žádný `update` příkaz
`remind_edit.py` má jen `list`, `add`, `remove`. Když chceš změnit čas existujícího reminderu, musíš ho smazat a vytvořit znovu. To je nebezpečné — `remove` matchuje substring, takže při recreate můžeš trefit špatný záznam nebo vytvořit duplikát.
**Návrh:** Přidat `update --keyword "..." --cron/--at/--random-*` příkaz, který najde reminder a upraví jen zadaná pole.
### 2. `at` remindery se nikdy nesmažou (no garbage collection)
Jednorázové `at` remindery zůstávají v `reminder.yaml` navždy. Po odeslání se jen přestanou spouštět, ale leží v YAML a loadují se každý minutovým cronem. Po čase tam bude stovky mrtvých záznamů.
**Návrh:** `remind_send.py` by měl po úspěšném doručení `at` reminderu zapsat flag nebo rovnou zavolat `remind_edit.py remove`. Nebo lépe — přidat `purge` subcommand, který smaže všechny `at` remindery s `at_time < now`.
### 3. Žádné stabilní ID — `remove` matchuje substring
`remove --keyword "boty"` by smazal "objednat boty xshoes", ale taky "koupit boty pro dědu". Substring match na textu je křehký.
**Návrh:** Přidat `id` pole (hash nebo UUID) přiřazené při `add`. `remove` i `update` by primárně pracovaly s `--id`. `--keyword` by zůstal jako fallback.
### 4. Deduplikace přes SHA1(text)[:8] — kolize a křehkost
`.reminder_state.json` klíč je `hashlib.sha1(text.encode())[:8]` — 4 bajty hex. Při ~65k reminderů je kolize pravděpodobná. Horší: když se text změní (i jen překlep), dedup key se změní a reminder se odešle znovu.
**Návrh:** Použít stabilní `id` z bodu 3 jako klíč do state. SHA1[:8] zahodit.
---
## 🟡 Střední problémy
### 5. Hardcoded `CHAT_ID` v remind_send.py
`CHAT_ID = "8826147089"` je natvrdo v kódu. Když se změní uživatel nebo přidá druhý, musí se upravovat zdroják.
**Návrh:** Číst `chat_id` z `config.json` (tam už je token), nebo z `reminder.yaml` jako globální `default_chat_id`.
### 6. Žádná validace `at` časů v budoucnosti
`remind_edit.py` přijme `--at "2020-01-01T00:00:00"` bez chyby. Zápis v minulosti nedává smysl a nikdy se nespustí.
**Návrh:** Validovat `at > now()` v `cmd_add`. Případně alespoň varování na stderr.
### 7. Žádný max-retry / TTL pro neodeslané remindery
Když Telegram API vrátí chybu, `remind_send.py` zkusí znovu příští minutu — ale jen pokud `last` state nebyl nastaven. Když selže 100x po sobě, zkusí to 100x. Žádný TTL ani exponential backoff.
**Návrh:** Přidat retry count do state. Po 3 selháních označit jako `failed` a přestat zkoušet. Nebo jednoduše: po 5 minutách od first fire time přestat retryovat.
### 8. Identity check bug v `cmd_remove`
```python
data["reminders"] = [r for r in data["reminders"] if r is not removed]
```
`is not` je identity check. Funguje, protože `matches[0]` je reference na stejný dict v seznamu, ale je to křehké — jakýkoliv refaktoring (deep copy, reload) to rozbije.
**Návrh:** Použít index nebo `id`-based filter.
### 9. Chybí dokumentace k `at_times` (multi-at)
SKILL.md dokumentuje `--at` jako "repeatable", ale `remind_send.py` zpracovává `at_times` pole, zatímco SKILL.md ho nezmíní jako samostatný koncept. Uživatel (nebo LLM) může být zmatený.
**Návrh:** Doplnit SKILL.md o příklad multi-at.
---
## 🔵 Zlepšení kódu
### 10. Přechod z YAML na SQLite
YAML je lidsky čitelný, ale:
- Atomic write přes `.tmp` + `os.replace` je správný, ale zbytečně složitý
- YAML nemá schema, snadno se rozbije ruční editací
- Dotazy (list, search) vyžadují full load
**Návrh:** Přesunout data do `db/reminders.sqlite` (konvence `db/*.sqlite`). YAML nechat jako read-only export nebo zahodit. `remind_edit.py` by pracoval s SQLite, `remind_send.py` taky. Výhody: ID autoincrement, atomicity zdarma, snadný search, žádný parse overhead.
### 11. Cachování Telegram tokenu
`_telegram_token()` čte a parsuje `config.json` každou minutu. Soubor se nemění.
**Návrh:** Načíst jednou při startu, cachovat v modulu. Nebo ještě lépe — environment variable `TELEGRAM_BOT_TOKEN`.
### 12. Log enrichment
`reminder.log` má jen `timestamp text`. Chybí: delivery status, fire time vs actual send time, reminder ID.
**Návrh:** Formát: `{ts} {id} {fire_time} {status} {text}`
### 13. `--dry-run` flag pro `add`
Užitečné pro LLM skill workflow — ukáže, co by se přidalo, bez zápisu.
### 14. Test coverage — chybí testy pro remind_edit.py a remind_send.py
Testy pokrývají jen `random_times.py`. `remind_edit.py` (CRUD) a `remind_send.py` (dedup, fire detection) nemají žádné testy.
**Návrh:** Přidat unit testy pro:
- `cmd_add` s různými kombinacemi flagů
- `cmd_remove` s 0/1/N matches
- `_due_fire` s různými typy reminderů
- Dedup state management
---
## 🟢 Chybějící funkce
### 15. Pause / disable reminder
Nemáš způsob jak reminder dočasně vypnout bez smazání. Běžný use case: "nech mě týden na pokoji".
**Návrh:** Přidat `enabled: true/false` pole. `remind_send.py` by skipoval `enabled: false`. Příkaz `remind_edit.py pause --id X` / `resume --id X`.
### 16. Cron s end date
Cron remindery běží navždy. Chybí `until` datum pro cron (podobně jako `random``from`/`until`).
**Návrh:** Přidat `until` pole na úroveň reminderu. `remind_send.py` by po `until` datumu reminder přeskočil.
### 17. Snooze
Když reminder přijde a uživatel není připraven, nemá jak ho odložit. To by vyžadovalo interakci s Telegram botem (callback button), což je mimo současný scope, ale je to přirozené rozšíření.
### 18. `list --due` nebo `list --next`
Užitečné zobrazit jen remindery, které se spustí v následujících N hodin. SKILL.md to neumožňuje.
**Návrh:** Přidat `list --due-within 2h` nebo `list --next 5`.
### 19. Per-reminder timezone
SKILL.md říká "Timezone is always Europe/Prague". To je OK pro jednoho uživatele, ale kód je tight-coupled — `TZ` je konstanta v `remind_send.py`. Pro multi-user by to muselo být konfigurovatelné.
---
## 📋 Prioritizovaný implementační plán
| Priorita | Co | Proč |
|----------|----|------|
| **P0** | Stabilní ID + dedup fix (body 3, 4) | Bez toho hrozí kolize a duplikátní doručení |
| **P0** | Garbage collection `at` reminderů (bod 2) | YAML poroste donekonečna |
| **P0** | Identity check fix v remove (bod 8) | Tichý bug, dnes funguje náhodou |
| **P1** | `update` příkaz (bod 1) | Zásadní UX zlepšení, snižuje riziko chyb |
| **P1** | Validace `at` v budoucnosti (bod 6) | Prevence nesmyslných vstupů |
| **P1** | Retry TTL (bod 7) | Prevence nekonečných retry |
| **P2** | SQLite backend (bod 10) | Architektonické zlepšení, ale není urgentní |
| **P2** | Testy pro edit/send (bod 14) | Spolehlivost |
| **P2** | `pause`/`resume` (bod 15) | Užitečná funkce |
| **P2** | `until` pro cron (bod 16) | Užitečná funkce |
| **P3** | Chat ID z configu (bod 5) | Multi-user příprava |
| **P3** | Log enrichment (bod 12) | Debugovatelnost |
| **P3** | `--dry-run` (bod 13) | Vývojářská ergonomie |
| **P3** | `list --due` (bod 18) | Nice-to-have |

View File

@@ -0,0 +1,87 @@
# /remind Skill — Top 5 Priorities (Merged from Two Audits)
**Datum:** 2026-06-02
**Model:** GLM-5.1:cloud
**Zdroje:** `results/2026-06-02_remind-skill-analysis-and-improvements.md` + `results/remind-skill-audit-2026-06-02.md`
---
## 1. Stabilní ID + dedup fix (nahradit SHA1[:8] + substring match)
**Problém:** Dva propojené bugy:
- `.reminder_state.json` používá `SHA1(text)[:8]` jako dedup klíč — 4 bajty hex, kolize při ~65k reminderů. Změna textu (i překlep) vytvoří nový klíč → duplikátní doručení.
- `remove` matchuje substring — `remove --keyword "boty"` smaže i "koupit boty pro dědu".
- `cmd_remove` používá `is not` identity check — funguje jen díky referenční shodě, po refaktoringu (deep copy, reload) se rozbije.
**Řešení:**
- Přidat `id` pole (UUID nebo short hash z text+timestamp) přiřazené při `add`.
- `remove` i `update` primárně přes `--id`, `--keyword` jako fallback.
- State file klíč → stabilní `id` místo SHA1[:8].
- `cmd_remove` filtrovat přes `id` nebo index, ne přes `is not`.
**Dopad:** Zabrání tichým datovým ztrátám a duplikátům. Bez toho je celý skill nespolehlivý.
---
## 2. Garbage collection `at` reminderů + deduplikace při doručení
**Problém:** Dva propojené bugy:
- Jednorázové `at` remindery zůstávají v `reminder.yaml` navždy. Po odeslání se jen přestanou spouštět, ale loadují se každý minutovým cronem. Po měsících tam budou stovky mrtvých záznamů.
- `should_fire()` má 60s okno — s minutovým cronem může `at` reminder doručit dvakrát (např. při dvojím spuštění cronu nebo časovém posunu). Log ukazuje, že to zatím proběhlo OK, ale není to garantováno.
**Řešení:**
- `remind_send.py` po úspěšném doručení `at` reminderu: buď ho smazat z YAML, nebo přidat `purge` subcommand pro ruční cleanup.
- Zužit existující state file pro dedup: zúžit okno na `0 <= delta < 30` a kontrolovat, zda už byl ve stejném minutovém okně doručen (state file už existuje, jen má špatný klíč — viz bod 1).
- Alternativně: SQLite state tabulka `fired(text, scheduled_at, fired_at)` s `PRIMARY KEY(text, scheduled_at)`.
**Dopad:** Zabrání spamu a nekonečnému růstu YAML souboru.
---
## 3. Atomic writes + odstranění ruční YAML konstrukce
**Problém:** Dva propojené problémy v `remind_edit.py`:
- Zápis do `reminder.yaml` je neatomický — `with open(REMINDER_FILE, "w")` může při crashu zanechat prázdný/s poškozený soubor = ztráta všech reminderů.
- `format_reminder()` ručně skládá YAML stringy (`f"- text: {text}"`) — neescapuje speciální znaky (uvozovky, dvojtečky, newlines), nedrží konzistentní odsazení, duplikuje logiku ruamel.yaml.
**Řešení:**
- Atomic write: `tmp = path.with_suffix(".tmp")``yaml.dump(data, f)``os.replace(tmp, path)`.
- Nahradit `format_reminder()` builděním dictu a `yaml.dump()` celého dokumentu. Použít `ruamel.yaml.scalarstring.LiteralScalarString` přímo z knihovny (smazat vlastní třídu).
- Přidat validaci před zápisem (schema check).
**Dopad:** Zabrání ztrátě dat a tichým YAML parse chybám. Největší robustness win s minimálním úsilím.
---
## 4. `update` příkaz + `list` příkaz
**Problém:**
- `remind_edit.py` má jen `add` a `remove`. Změna času = smazat a vytvořit znovu — rizikové (viz bod 1, substring match).
- `list` je dokumentovaný v SKILL.md, ale v kódu neexistuje. Uživatel (nebo LLM) nemá jak zkontrolovat aktuální stav.
**Řešení:**
- Přidat `update --id X [--cron ...] [--at ...] [--random-* ...]` — najde reminder a upraví jen zadaná pole.
- Přidat `list` — vypíše všechny remindery s ID, textem a typem schedule.
- Přidat `--dry-run` k `add` a `update` pro bezpečné testování.
**Dopad:** Zásadní UX zlepšení, snižuje riziko chyb při úpravách, doplňuje chybějící dokumentovanou funkci.
---
## 5. Testy pro `remind_edit.py` a `remind_send.py`
**Problém:** Testy pokrývají jen `random_times.py`. Dva hlavní skripty (CRUD operace, dedup, fire detection, YAML I/O) nemají žádné testy. Jakákoliv změna v bodech 14 bez testů = riziko regresí.
**Řešení:** Přidat unit testy pro:
- `cmd_add` s různými kombinacemi flagů (cron, at, random)
- `cmd_remove` s 0/1/N matches, substring kolize
- `should_fire` s různými typy reminderů a okraji časových oken
- Dedup state management (nový i starý formát)
- Atomic write (crash uprostřed zápisu)
- Validace `at` v budoucnosti
**Dopad:** Bez testů je jakýkoliv refaktoring hazard. S testy se body 14 dají implementovat bez strachu z regresí.
---
*Zbylé návrhy (SQLite backend, pause/resume, cron until, retry TTL, log enrichment, chat_id z configu, per-reminder TZ) jsou P2P3 — užitečné, ale nejsou blokátory.*

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""Check latest nanobot version from PyPI, GitHub releases, and Docker Hub."""
import importlib.metadata
import json
import sys
from urllib.error import URLError
from urllib.request import Request, urlopen
try:
CURRENT_VERSION = importlib.metadata.version("nanobot-ai")
except importlib.metadata.PackageNotFoundError:
CURRENT_VERSION = "unknown"
USER_AGENT = "nanobot-version-check/1.0"
def fetch_json(url: str, timeout: int = 10) -> dict | None:
req = Request(url, headers={"User-Agent": USER_AGENT})
try:
with urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except (URLError, json.JSONDecodeError, OSError) as e:
print(f" Error fetching {url}: {e}", file=sys.stderr)
return None
def check_pypi() -> str | None:
data = fetch_json("https://pypi.org/pypi/nanobot-ai/json")
if data:
return data.get("info", {}).get("version")
return None
def check_github() -> str | None:
data = fetch_json("https://api.github.com/repos/HKUDS/nanobot/releases/latest")
if data:
tag = data.get("tag_name", "")
return tag.lstrip("v") if tag else None
return None
def check_docker() -> str | None:
data = fetch_json("https://hub.docker.com/v2/repositories/smanx/nanobot/tags?page_size=10")
if data:
for tag in data.get("results", []):
name = tag.get("name", "")
if name and name != "latest":
return name
return None
def main():
print(f"Current nanobot version: {CURRENT_VERSION}")
pypi = check_pypi()
if pypi:
print(f"Latest PyPI version: {pypi}")
github = check_github()
if github:
print(f"Latest GitHub release: {github}")
docker = check_docker()
if docker:
print(f"Latest Docker Hub version tag: {docker}")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
scripts/parse_library.py Normal file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Parse the ollama library page text dump and list unique models."""
import re, json, sys
raw = open('scripts/ollama_library_full.txt').read()
first_line = raw.split('\n')[0]
# Each line is prefixed "N| " repeated; find the first { and the last }
start = first_line.find('{')
end = first_line.rfind('}') + 1
first_line = first_line[start:end]
j = json.loads(first_line)
text = j['text']
print('text length:', len(text))
# Pattern: ## [name desc](https://ollama.com/library/normalized)
matches = re.findall(r'## \[([a-z0-9.\-]+) [^\]]*\]\(https://ollama.com/library/([a-z0-9.\-]+)\)', text)
seen = {}
for desc, name in matches:
seen.setdefault(name, desc)
print('Total models in library page:', len(seen))
for n in sorted(seen):
print(f'{n}\t{seen[n][:80]}')

33
skills/article/SKILL.md Normal file
View File

@@ -0,0 +1,33 @@
# Zpracování článků
Uživatel vloží celý text článku a ty nad ním proveď souhrn.
## Co mě zajímá
AI, ML, programování, design aplikací, programovací techniky, Claude Code (workflows, MCP, agenti, skills, ...).
## Výstup
Česky. Odborné termíny v originále.
Začni hlavičkou:
Originální název
(novy radek)
Český překlad
Pak **shrnutí** — pár vět až jeden odstavec, hlavní teze článku.
Dál **rozbor**: 13 odstavce plynulé prózy, každý 24 věty. Žádné interní nadpisky uvnitř rozboru. Délka odpovídá hutnosti článku, ne jeho délce — řídký nebo marketingový článek dostane kratší rozbor, ne delší ve snaze vypadat důkladně. Co konkrétně tvrdí, na čem to staví, kde to skřípe.
Zakonči:
- **Verdikt:** 12 věty, stojí to za přečtení a komu. Neopakuje obsah rozboru — pokud se to už objevilo výš, vyber jen jedno místo.
- **Číst celé:** ANO / NE / ČÁSTEČNĚ (které části).
## Jak hodnotit
Poctivě, ne diplomaticky. Slabý článek je slabý i z prioritní oblasti. Ptej se: říká něco nového? je tam analýza nebo jen dohady, opírá se o data/zkušenost, nebo jen tvrdí? Je hutný, nebo by stačil odstavec? Délka výstupu odpovídá hodnotě článku — 11minutový marketingový text s jádrem na odstavec dostane rozbor na odstavec. Pokud jsou některé techniky, tooly nebo postupy vhodné pro mě osobně (Claude Code apod.), zmiň to krátce — větou v rozboru nebo ve verdiktu, ne samostatnou sekcí.
## Čemu se vyhnout
Prázdných frází bez důvodu. Doslovných citací delších než pár slov — parafrázuj. Opakování shrnutí v dalších odstavcích. Pseudostruktury (interní nadpisky, oddělené bloky "co skřípe", "pro tebe") uvnitř rozboru.

43
skills/bash/SKILL.md Normal file
View File

@@ -0,0 +1,43 @@
---
name: bash
description: >
Bash / shell script conventions and tooling.
Use for anything involving shell scripts.
---
# Bash Script Conventions
## Shebang and Strict Mode
- `#!/usr/bin/env bash` for portability.
- `set -euo pipefail` on the line after shebang (separated by a blank line).
- Hooks that check exit codes intentionally may omit `set -e`.
## Functions
- Declare local variables with `local`; never leak into global scope.
- Use `readonly` for values that must not change.
- Return data via stdout; capture with `$(fn)`. Do not use global variables for return values.
## Variables and Conditionals
- Always double-quote expansions and command substitutions: `"$var"`, `"${var}"`, `"$(cmd)"`, `"$@"`.
- Use `${var:-default}` for defaults, `${var:?error msg}` for required values.
- Use arrays for lists of values — do not split strings with IFS.
- Use `[[ ]]` instead of `[ ]`.
- Check command existence with `command -v cmd &> /dev/null`, not `which`.
## Output and Exit Codes
- Diagnostic/error messages go to stderr: `echo "error: ..." >&2`.
- Hook scripts use exit 0 (pass) and exit 2 (block). Do not use exit 1.
## Files and Paths
- Resolve script directory: `script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`.
- Temporary files: `tmp=$(mktemp)` with cleanup via `trap 'rm -f "$tmp"' SIGINT SIGTERM ERR EXIT`.
## ShellCheck
- All scripts must pass `shellcheck`.
- To suppress a check: `# shellcheck disable=SCxxxx` with a comment explaining why.

91
skills/bookmark/SKILL.md Normal file
View File

@@ -0,0 +1,91 @@
---
name: bookmark
description: Manage a personal reading list. Use when the user wants to save, list, mark as read, or remove article URLs for later reading. Triggers on "bookmark", "save URL", "read later", "reading list", "bookmarks".
---
# Bookmark
Manage a personal reading list stored in SQLite (`db/bookmark.sqlite`).
## Commands
All commands run via:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py <command> [args]
```
### Add a bookmark
```bash
bookmark.py add <url> "<description>" [--tags tag1,tag2]
```
- `url` — the article URL
- `description` — short human-readable description (required)
- `--tags` — optional comma-separated tags
Example:
```bash
bookmark.py add "https://example.com/rust-async" "Async Rust patterns" --tags rust,async
```
### List unread bookmarks
```bash
bookmark.py list [--tag <tag>]
```
Shows ID, URL, tags, description, and date added for each unread bookmark. Use `--tag` to filter.
### Mark as read
```bash
bookmark.py read <id>
```
Marks bookmark as read (stores `read_at` timestamp). Does **not** delete — entry stays in DB.
### Unmark (mark as unread again)
```bash
bookmark.py unread <id>
```
### Show bookmark details
```bash
bookmark.py show <id>
```
Shows full URL, description, tags, status (read/unread), and dates. Does **not** change any state.
### List read bookmarks (history)
```bash
bookmark.py history
```
Shows all bookmarks marked as read, with both `added` and `read` dates.
## Output formatting
When presenting bookmark lists or details to the user, **always use markdown links** so URLs are clickable in WebUI and Telegram:
```
#3 [hackaday.com](https://hackaday.com/2026/06/02/linux-fu-taming-strace/) [linux, strace] — lepší strace
```
Format: `#<id> [<domain>](<url>) [<tags>] — <description>`
- Domain is clickable, pointing to the full URL
- Tags in brackets, comma-separated
- Description after em-dash
- **Never** strip URLs from the output or replace them with plain-text summaries
## Workflow
1. User shares a URL → `add` with description and optional tags
2. User wants to see what to read → `list`
3. User wants to see details of a bookmark → `show <id>`
4. User finishes an article → `read <id>`
5. User wants to revisit → `unread <id>` or `history`

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Bookmark skill — CRUD for reading-list entries stored in SQLite."""
import argparse
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
DB_PATH = (
Path(__file__).resolve().parent.parent.parent.parent / "db" / "bookmark.sqlite"
)
EMPTY_TAGS_JSON = "[]"
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
read_at TEXT
);
"""
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
@contextmanager
def _connect() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
_init_db(conn)
try:
yield conn
finally:
conn.close()
def _parse_tags(raw: str) -> list[str]:
"""Parse comma-separated tags into a deduplicated sorted list."""
if not raw:
return []
tags = [t.strip() for t in raw.split(",") if t.strip()]
return sorted(set(tags))
def _tags_display(tags_json: str) -> str:
tags = json.loads(tags_json)
return ", ".join(tags) if tags else ""
def _domain(url: str) -> str:
"""Extract domain from URL (strip www. prefix)."""
try:
parsed = urlparse(url)
host = parsed.hostname or ""
return host.removeprefix("www.")
except (ValueError, AttributeError):
return url
def _print_bookmark(
row: sqlite3.Row, *, show_status: bool = False, show_read_date: bool = False
) -> None:
"""Format and print a single bookmark row."""
tags = json.loads(row["tags"])
tag_str = f" [{', '.join(tags)}]" if tags else ""
print(f"#{row['id']} {_domain(row['url'])}{tag_str}")
print(f" {row['description']}")
print(f" {row['url']}")
line = f" added: {row['created_at'][:10]}"
if show_status:
status = "read" if row["read_at"] else "unread"
line += f" status: {status}"
if show_read_date and row["read_at"]:
line += f" read: {row['read_at'][:10]}"
print(line)
def cmd_add(args: argparse.Namespace) -> None:
tags = _parse_tags(args.tags)
with _connect() as conn:
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO bookmarks (url, description, tags, created_at) VALUES (?, ?, ?, ?)",
(args.url, args.description, json.dumps(tags, ensure_ascii=False), now),
)
conn.commit()
bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
tag_info = f" [{', '.join(tags)}]" if tags else ""
print(f"Added bookmark #{bid}: {args.url}{tag_info}")
def cmd_list(args: argparse.Namespace) -> None:
with _connect() as conn:
if args.tag:
rows = conn.execute(
"""SELECT * FROM bookmarks
WHERE read_at IS NULL AND EXISTS (
SELECT 1 FROM json_each(tags) WHERE json_each.value = ?
)
ORDER BY created_at DESC""",
(args.tag,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
).fetchall()
if not rows:
print(
"No bookmarks." if not args.tag else f"No bookmarks with tag '{args.tag}'."
)
return
for r in rows:
_print_bookmark(r)
print()
def cmd_read(args: argparse.Namespace) -> None:
with _connect() as conn:
now = datetime.now(timezone.utc).isoformat()
cur = conn.execute(
"UPDATE bookmarks SET read_at = ? WHERE id = ? AND read_at IS NULL",
(now, args.id),
)
affected = cur.rowcount
conn.commit()
if affected == 0:
print(f"Bookmark #{args.id} not found or already marked as read.")
else:
print(f"Marked bookmark #{args.id} as read.")
def cmd_unread(args: argparse.Namespace) -> None:
with _connect() as conn:
cur = conn.execute(
"UPDATE bookmarks SET read_at = NULL WHERE id = ? AND read_at IS NOT NULL",
(args.id,),
)
affected = cur.rowcount
conn.commit()
if affected == 0:
print(f"Bookmark #{args.id} not found or not marked as read.")
else:
print(f"Unmarked bookmark #{args.id}.")
def cmd_show(args: argparse.Namespace) -> None:
with _connect() as conn:
row = conn.execute(
"SELECT * FROM bookmarks WHERE id = ?", (args.id,)
).fetchone()
if not row:
print(f"Bookmark #{args.id} not found.")
return
_print_bookmark(row, show_status=True)
def cmd_history(args: argparse.Namespace) -> None:
with _connect() as conn:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
).fetchall()
if not rows:
print("No read bookmarks.")
return
for r in rows:
_print_bookmark(r, show_read_date=True)
print()
def main() -> None:
parser = argparse.ArgumentParser(description="Bookmark CRUD")
sub = parser.add_subparsers(dest="command", required=True)
# add
p_add = sub.add_parser("add", help="Add a bookmark")
p_add.add_argument("url", help="URL to bookmark")
p_add.add_argument("description", help="Short description")
p_add.add_argument("--tags", default="", help="Comma-separated tags")
# list
p_list = sub.add_parser("list", help="List unread bookmarks")
p_list.add_argument("--tag", help="Filter by tag (exact match)")
# read (mark as read)
p_read = sub.add_parser("read", help="Mark bookmark as read")
p_read.add_argument("id", type=int, help="Bookmark ID")
# unread (unmark)
p_unread = sub.add_parser("unread", help="Unmark bookmark as read")
p_unread.add_argument("id", type=int, help="Bookmark ID")
# show (display details)
p_show = sub.add_parser("show", help="Show bookmark details")
p_show.add_argument("id", type=int, help="Bookmark ID")
# history (list read)
sub.add_parser("history", help="List read bookmarks")
dispatch = {
"add": cmd_add,
"list": cmd_list,
"read": cmd_read,
"unread": cmd_unread,
"show": cmd_show,
"history": cmd_history,
}
args = parser.parse_args()
dispatch[args.command](args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
---
name: deep-research
description: >
Multi-step research orchestration in the spirit of Claude/Gemini deep research.
Use when the user wants a thorough investigation, comparison, "find everything about…",
"research…", a well-sourced answer with multiple references — not a quick one-shot search.
---
# Deep Research
You orchestrate a multi-step investigation: decompose the question into
sub-questions, gather evidence from multiple sources, cross-check findings, and
synthesize a structured report with citations. Optimize for depth and
verifiability, not speed.
## Procedure
### 1. Plan (always first, visible to the user)
- Restate the question in one sentence to confirm scope.
- Decompose into **36 sub-questions** covering different axes of the topic.
- Show the plan briefly ("I'll split this into: …") and proceed — do not wait for
approval unless the request is genuinely ambiguous.
### 2. Gather
For each sub-question in sequence:
1. `web_search` — find relevant sources (DuckDuckGo, up to 8 results).
2. `web_fetch` on the 24 most promising results — read the actual page content,
not just the snippet.
3. Note: concise findings + the **URL of every source used** + confidence level.
### 3. Verify
- Cross-check findings across sub-questions and sources.
- **Flag contradictions explicitly** ("source A claims X, source B claims Y") —
do not paper over them.
- Mark claims supported by a single source as unverified.
- If an axis of the topic is under-covered, run one more `web_search` + `web_fetch`
round before synthesizing.
### 4. Synthesize (report)
Output structure:
```
## Shrnutí
24 sentences directly answering the original question.
## Zjištění
Organized by sub-question / axis. Every non-trivial claim carries an [n] citation.
## Rozpory a nejistoty
Where sources disagree, what could not be verified. (Omit the section if none.)
## Zdroje
[1] Title — URL
[2] …
```
## Progress reporting
Deep research can take several minutes. **Emit a short status message between
phases** so the user (especially on Telegram, where there is no thinking stream)
sees the task is alive. Examples:
- After step 1: `Plán: 5 podotázek — (1) … (2) … (3) …`
- After each sub-question: `[2/5] kimi-k2 benchmarks — 3 zdroje, hotovo`
- Before step 4: `Všechny podotázky pokryty, syntetizuji report.`
Keep status lines to one short sentence. No filler, no emojis. The final report
comes as a separate, full message at the end.
If a `web_fetch` fails or stalls, say so in a status line and continue — do not
abort the whole run silently.
## Rules
- **Always cite URLs.** Claims without a source must be labeled as your own
inference / estimate.
- Prefer primary and recent sources; for fast-moving topics, watch publication dates.
- Do not invent facts. If something cannot be found, say "not found" — do not guess.
- Length proportional to the question. No filler.
- **Respond in the user's language.**
## Tools used
`web_search` · `web_fetch` · `write_file` (optional: persist the report under `workspace/`).

108
skills/detach/SKILL.md Normal file
View File

@@ -0,0 +1,108 @@
---
name: detach
description: >-
Run a task in the background and notify via Telegram when done. Subactions:
detach (capture), list (pending/done), read (fetch result), archive (move done tasks out of sight).
Triggers on: "detach", "background", "fire and forget", "list tasks", "result <slug>",
"archive tasks", "archive done tasks", "archive task".
---
# Detach
Four subactions:
1. **`detach`** (default) — capture a goal, write it to `workspace/tasks/inbox/`. A daemon runs the task in an isolated `nanobot agent` session and notifies the user via Telegram when done.
2. **`list`** — list pending and completed background tasks.
3. **`read`** — fetch and present the result of a completed task.
4. **`archive`** — move completed tasks from `done/` to `archive/` to keep the list clean.
## Rules
- **Respond to the user in their own language** (auto-detect from their message) — this skill is written in English, but all user-facing messages adapt to the user's language.
- **Do not** start solving a detached task yourself. Capture it and stop.
---
## Subaction: `detach` (capture)
### When NOT to use detach
- Fast tasks (<1 min) — answer directly in chat.
- **Reminders** ("remind me in an hour") — use the builtin `cron` tool.
- **Recurring** tasks ("every day at 9") — use `cron` with `cron_expr` / `every_seconds`.
### Procedure (execute in this order, no need to wait for confirmation)
#### 1. Identify channel and chat_id
The system prompt's runtime context contains `Channel: <name>` and `Chat ID: <id>`. Read both. If `Chat ID` is missing, see Failure handling.
#### 2. Prepare slug, goal, and optional model
- **Slug**: 35 words from the goal, kebab-case (`[a-z0-9-]` only). Example: "Research Qdrant vs Weaviate" → `qdrant-vs-weaviate`.
- **Goal**: restate the goal so it is self-contained without chat history. State-oriented, bounded, with a clear deliverable.
- **Model** (optional): only when the user explicitly names a model or preset for this task ("run it on kimi", "use the m3 model", "with glm"). Pass that spoken token verbatim as `--model "<token>"` — the script fuzzy-matches it against the configured presets. If the user says nothing about a model, omit `--model` and the task runs on the agent default.
#### 3. Create the task
Run:
```bash
exec skills/detach/scripts/create-task.py \
--goal "<self-contained goal>" \
--slug "<slug>" \
--channel "<channel from runtime context>" \
--chat-id "<chat_id from runtime context>"
```
Add `--constraint "<text>"` for each extra constraint (optional). Add `--model "<token>"` only when the user explicitly chose a model (see step 2). The script creates the task, ensures queue directories exist, and atomically moves the file into `tasks/inbox/` to trigger the daemon.
#### 4. Confirm to the user
Tell the user the task was queued — include the slug and the fetch hint (`result <slug>`). Do not restate the full goal.
### Failure handling for `detach`
- **No `Chat ID` in runtime context**: tell the user "Detach needs a chat context to remember where to read back results. Want me to do this task synchronously here instead?" — and do NOT create a task file.
- **Unknown or ambiguous `--model`**: the script exits non-zero and prints the available presets. Show the user those presets and ask which one to use, or offer to queue the task on the default model. Do NOT silently fall back to the default when the user explicitly asked for a model.
- **Script exits non-zero** (other reasons): report the error output, offer synchronous execution.
---
## Subaction: `list`
Triggered by phrases like "list detached", "list tasks", "pending tasks".
### Procedure
1. `exec skills/detach/scripts/list-tasks.py`
2. Output the result **verbatim** — it is already formatted as a bullet list. Do not convert it into a table or otherwise restructure it.
---
## Subaction: `read <identifier>`
Triggered by "result <identifier>", "result of <identifier>". If no identifier is given ("what was the last result?") → use most recent.
### Procedure
1. `exec skills/detach/scripts/read-task.py <identifier>` — omit the argument if no identifier.
2. If the output lists multiple matches, ask the user to pick one by slug.
3. Show the output to the user.
---
## Subaction: `archive`
Triggered by "archive tasks", "archive done tasks", "archive task".
### Procedure
1. If the user did not specify which tasks to archive, call `exec skills/detach/scripts/list-tasks.py` and show the `done/` contents, then ask which tasks to archive (or all).
2. If the user said "archive all" or equivalent → `exec skills/detach/scripts/archive-tasks.py --all`
3. If the user named specific task(s) by slug → `exec skills/detach/scripts/archive-tasks.py --slug <slug>` (repeat `--slug` for each).
4. Show the script output to the user.
---
_For a full description of the task lifecycle, directories, and scripts, see `architecture.md` in this skill directory._

View File

@@ -0,0 +1,78 @@
# Detach skill — architecture
## Directory layout
```
~/.nanobot/workspace/tasks/
inbox/ — tasks waiting to be picked up (written atomically from new/)
running/ — task currently executing
done/ — completed tasks (success)
failed/ — completed tasks (exception or timeout)
archive/ — tasks moved out of the active view; no longer shown by list
new/ — atomic write staging: skill writes here, then renames into inbox/
```
## Task lifecycle
```
capture (skill) → inbox/ → running/ → done/ or failed/ → archive/
```
1. **Capture** — the skill calls `create-task.py`, which writes the task file into `new/` and atomically renames it into `inbox/`. This rename is the trigger for the daemon. The filename timestamp uses microsecond precision (`%Y-%m-%d_%H_%M_%S_%f`, e.g. `2026-06-07_15_00_00_123456-slug.md`), making filename collisions impossible even for simultaneous calls with the same slug. `tasks_common.py` parsers accept both the old second-precision format (`T`-joined, e.g. `2026-06-07T150000`) and the new underscore format for backward compatibility with existing task files. When `--model <token>` is given, the script fuzzy-resolves it to an exact preset against `config.json` *at capture time* (fail-fast in chat) and stores it in the `model:` frontmatter field.
2. **Daemon pickup**`tasks-daemon.py` is started by a systemd `.path` unit whenever `inbox/` is non-empty. It processes all files in one pass (Type=oneshot). Concurrency is handled by systemd: the service won't start again while the previous run is still live; the level-triggered `.path` unit re-triggers it after the run if inbox is still non-empty.
3. **Execution** — for each file in `inbox/`: move to `running/`, read frontmatter, call `Nanobot.run(goal, session_key="detach:<stem>")` with a 45-minute timeout in an isolated session. If the frontmatter carries `model: <preset>`, the daemon switches to it via `bot._loop.set_model_preset(preset)` before running (the same switch the `/model` chat command performs); otherwise the task runs on `agents.defaults.modelPreset`.
4. **Completion** — daemon appends `## Result` and a trailing metadata block (`completed`, `duration_seconds`, `status`) to the file, then moves it to `done/` (success) or `failed/` (exception or timeout).
5. **Notification** — daemon sends a Telegram message to `chat_id` from the frontmatter (or falls back to the first `allowFrom` ID for non-Telegram channels).
6. **Archive** — user explicitly calls the `archive` subaction; `archive-tasks.py` moves selected files from `done/` to `archive/`.
## File format
Each task is a single Markdown file:
```
---
created: <ISO 8601>
channel: telegram | websocket | ...
chat_id: "<id>"
slug: <kebab-case>
model: <preset> # optional; omitted → agent default
---
# Goal
<self-contained goal text>
# Constraints
- No user interaction (isolated session, no clarification questions — work with what you have).
- <optional extra constraints>
# Result
<appended by daemon after completion>
---
completed: <ISO 8601>
duration_seconds: <int>
status: done | failed
```
The daemon appends the `# Result` section and the trailing `---` block; everything before that is written by `create-task.py` at capture time.
## Scripts
| Script | Role |
|---|---|
| `create-task.py` | Capture: writes task file, ensures queue dirs, atomically moves to `inbox/`; logs `CREATE` to `detach.log` |
| `tasks-daemon.py` | Long-running one-shot systemd service; executes tasks, notifies via Telegram; logs lifecycle events to `detach.log` |
| `list-tasks.py` | List `running/`, `done/`, `failed/` (capped at 10 newest each) as a flat bullet list, one task per bullet (slug · time · age + indented goal) |
| `read-task.py` | Format and print a completed task's result |
| `archive-tasks.py` | Move tasks from `done/` to `archive/` (by slug or all); logs `ARCHIVE` to `detach.log` |
| `tasks_common.py` | Shared stdlib helpers: paths, parsers, formatters, model-preset resolution, shared `log()``~/.nanobot/workspace/log/detach.log` |
## Systemd units
Two user-level units under `~/.config/systemd/user/`:
- `tasks-daemon.service` — Type=oneshot, runs `tasks-daemon.py`
- `tasks-daemon.path` — level-triggered, watches `inbox/`, starts the service when non-empty

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import FILENAME_RE, TASKS, log
def find_by_slug(done: Path, slug: str) -> list[Path]:
return [f for f in done.glob("*.md") if (m := FILENAME_RE.match(f.name)) and m.group(2) == slug]
def main() -> int:
parser = argparse.ArgumentParser(description="Archive tasks from done/ to archive/")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--all", action="store_true", help="Archive all tasks in done/")
group.add_argument("--slug", action="append", dest="slugs", metavar="SLUG", help="Archive a specific task by slug (repeatable)")
args = parser.parse_args()
archive = TASKS / "archive"
archive.mkdir(exist_ok=True)
done = TASKS / "done"
if args.all:
targets = list(done.glob("*.md"))
else:
targets = []
for slug in args.slugs:
matches = find_by_slug(done, slug)
if not matches:
print(f"Not found in done/: {slug}", file=sys.stderr)
return 1
targets.extend(matches)
if not targets:
print("Nothing to archive.")
return 0
for f in targets:
f.rename(archive / f.name)
log(f"ARCHIVE {f.name}")
print(f"Archived {len(targets)} task(s).")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import (
TASKS,
build_task_content,
build_task_filename,
load_preset_names,
log,
resolve_preset,
)
def ensure_queue_dirs() -> None:
for name in ("new", "inbox", "running", "done", "failed"):
(TASKS / name).mkdir(parents=True, exist_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Create a detach task and drop it in inbox/")
parser.add_argument("--goal", required=True, help="Self-contained goal restatement")
parser.add_argument("--slug", required=True, help="Short kebab-case identifier")
parser.add_argument("--channel", required=True, help="Channel name (e.g. telegram, websocket)")
parser.add_argument("--chat-id", required=True, dest="chat_id", help="Chat ID string")
parser.add_argument("--constraint", action="append", default=[], dest="constraints",
help="Extra constraint bullet (repeatable)")
parser.add_argument("--model", default=None,
help="Model preset to run the task on (fuzzy-matched against config.json); "
"omit to use the agent default")
args = parser.parse_args()
model = None
if args.model:
try:
model = resolve_preset(args.model, load_preset_names())
except KeyError as e:
print(e.args[0], file=sys.stderr)
return 1
ensure_queue_dirs()
now = datetime.now().astimezone()
timestamp_str = now.strftime("%Y-%m-%d_%H_%M_%S_%f")
created_iso = now.isoformat()
filename = build_task_filename(timestamp_str, args.slug)
content = build_task_content(
created_iso=created_iso,
channel=args.channel,
chat_id=args.chat_id,
slug=args.slug,
goal=args.goal,
constraints=args.constraints,
model=model,
)
tmp_path = TASKS / "new" / filename
inbox_path = TASKS / "inbox" / filename
try:
tmp_path.write_text(content)
os.replace(tmp_path, inbox_path)
log(f"CREATE {filename} slug={args.slug}")
except Exception as e:
print(f"Error writing task: {e}", file=sys.stderr)
return 1
print(args.slug)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import TASKS, render_list
def list_dir(path: Path) -> list[Path]:
if not path.exists():
return []
return sorted(path.glob("*.md"), key=lambda f: f.name, reverse=True)
def main() -> None:
running = list_dir(TASKS / "running")
done_all = list_dir(TASKS / "done")
failed_all = list_dir(TASKS / "failed")
if not running and not done_all and not failed_all:
print('No detached tasks yet. Start one by saying "detach: <your goal>".')
return
sections = []
if running:
sections.append(f"## Running ({len(running)})\n\n{render_list(running, len(running))}")
if done_all:
sections.append(f"## Done ({len(done_all)})\n\n{render_list(done_all[:10], len(done_all))}")
if failed_all:
sections.append(f"## Failed ({len(failed_all)})\n\n{render_list(failed_all[:10], len(failed_all))}")
print("\n\n".join(sections))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import FILENAME_RE, TASKS, format_result
def completed_files() -> list[Path]:
paths = []
for d in ("done", "failed"):
p = TASKS / d
if p.exists():
paths.extend(p.glob("*.md"))
return sorted(paths, key=lambda f: f.name, reverse=True)
def find_matches(identifier: str) -> list[Path]:
if not identifier:
done = sorted((TASKS / "done").glob("*.md"), key=lambda f: f.name, reverse=True) if (TASKS / "done").exists() else []
if done:
return [done[0]]
failed = sorted((TASKS / "failed").glob("*.md"), key=lambda f: f.name, reverse=True) if (TASKS / "failed").exists() else []
return [failed[0]] if failed else []
return [f for f in completed_files() if identifier.lower() in f.name.lower()]
def main() -> None:
for d in ("done", "failed"):
(TASKS / d).mkdir(parents=True, exist_ok=True)
identifier = sys.argv[1] if len(sys.argv) > 1 else ""
matches = find_matches(identifier)
if not matches:
if identifier:
print(f"No task matches `{identifier}`. Try `list` to see what's available.")
else:
print("No completed tasks yet.")
return
if len(matches) == 1:
print(format_result(matches[0]))
return
# Multiple matches — list for user to pick
print(f"Multiple tasks match `{identifier}`:\n")
for f in matches:
m = FILENAME_RE.match(f.name)
slug = m.group(2) if m else f.stem
ts = m.group(1) if m else ""
print(f"- `{slug}` ({ts}, {f.parent.name})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""tasks-daemon: vyprázdni ~/.nanobot/workspace/tasks/inbox/ v jednom průchodu.
Spouštěn systemd .path unitem (tasks-daemon.path) jakmile inbox není
prázdný. Souběh řeší systemd sám: Type=oneshot service se nespustí
podruhé, dokud první běh trvá; level-triggered .path ho restartne po
doběhu, pokud inbox stále není prázdný.
Partial-write race řeší skill atomickým mv z tasks/new/ → tasks/inbox/,
takže tu žádný flock není potřeba.
Pro každý *.md v inbox/:
1. mv → running/<file>.md
2. načti frontmatter (chat_id povinný, channel default telegram)
3. spusť Nanobot.run(goal, session_key=f"detach:<stem>") s 45min timeoutem;
pokud frontmatter nese `model: <preset>`, přepni na něj (jinak default)
4. append ## Result do souboru, mv → done/<file>.md (success)
nebo failed/<file>.md (exception/timeout)
5. pošli Telegram zprávu uživateli (chat_id z frontmatteru)
"""
import asyncio
import json
import shutil
import sys
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import LOG, TASKS, log, parse_frontmatter
from nanobot import Nanobot
CONFIG = Path.home() / ".nanobot" / "config.json"
TIMEOUT_SECONDS = 20 * 60
def telegram_send(chat_id: str, text: str) -> None:
token = json.loads(CONFIG.read_text())["channels"]["telegram"]["token"]
url = f"https://api.telegram.org/bot{token}/sendMessage"
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
resp.read()
def resolve_telegram_chat_id(fm: dict[str, str]) -> tuple[str, str]:
"""Return (chat_id, source) — Telegram chat ID + 'frontmatter' or 'fallback'.
Pokud task přišel z Telegramu, použij chat_id z frontmatteru (multi-user ready).
Jinak (WebUI, CLI, ...) padni na první ID z channels.telegram.allowFrom v config.json.
"""
if fm.get("channel") == "telegram":
return fm["chat_id"], "frontmatter"
cfg = json.loads(CONFIG.read_text())
return cfg["channels"]["telegram"]["allowFrom"][0], "fallback"
async def run_agent(goal: str, session_key: str, preset: str | None = None) -> str:
bot = Nanobot.from_config()
if preset:
# Same switch the `/model <preset>` chat command performs; an invalid
# preset raises KeyError, caught by process_task and routed to failed/.
bot._loop.set_model_preset(preset)
result = await bot.run(goal, session_key=session_key)
return result.content or ""
def process_task(path: Path) -> None:
try:
content = path.read_text()
except Exception as e:
log(f"FAILED {path.name} read-error: {e}")
shutil.move(path, TASKS / "failed" / path.name)
return
fm, body = parse_frontmatter(content)
if not fm or "chat_id" not in fm:
log(f"FAILED {path.name} missing-chat_id-in-frontmatter")
shutil.move(path, TASKS / "failed" / path.name)
return
notify_chat_id, notify_source = resolve_telegram_chat_id(fm)
slug = fm.get("slug", path.stem)
preset = fm.get("model")
running = TASKS / "running" / path.name
shutil.move(path, running)
log(f"START {path.name} preset={preset or 'default'}")
goal = body.strip()
session_key = f"detach:{path.stem}"
started = datetime.now().astimezone()
try:
result_text = asyncio.run(
asyncio.wait_for(run_agent(goal, session_key, preset), timeout=TIMEOUT_SECONDS)
)
status = "done"
outcome = "✅ Hotovo"
except asyncio.TimeoutError:
result_text = f"(TIMEOUT po {TIMEOUT_SECONDS // 60} min)"
status = "failed"
outcome = "⏱️ Timeout"
log(f"TIMEOUT {path.name}")
except Exception as e:
result_text = f"(EXCEPTION: {e}\n\n{traceback.format_exc()})"
status = "failed"
outcome = "❌ Selhalo"
log(f"EXCEPTION {path.name}: {e}")
completed = datetime.now().astimezone()
duration_s = int((completed - started).total_seconds())
appended = (
f"{content}\n\n# Result\n\n{result_text}\n\n"
f"---\ncompleted: {completed.isoformat()}\n"
f"duration_seconds: {duration_s}\nstatus: {status}\n"
)
running.write_text(appended)
target_dir = TASKS / status
shutil.move(running, target_dir / path.name)
# Telegram notifikace — vždy přes Telegram, chat_id buď z frontmatteru
# (Telegram session) nebo z fallback configu (WebUI / CLI / atd.).
lines = result_text.strip().splitlines()
summary_line = lines[0][:200] if lines else "(prázdný výstup)"
msg = (
f"{outcome}: `{slug}`\n\n"
f"{summary_line}\n\n"
f"V chatu si vyžádej plný report: `výsledek {slug}`"
)
try:
telegram_send(notify_chat_id, msg)
log(f"NOTIFY {path.name} chat={notify_chat_id} source={notify_source}")
except Exception as e:
log(f"NOTIFY-FAILED {path.name}: {e}")
log(f"END {path.name} status={status} duration={duration_s}s")
def main() -> int:
for d in ("new", "inbox", "running", "done", "failed"):
(TASKS / d).mkdir(parents=True, exist_ok=True)
LOG.parent.mkdir(parents=True, exist_ok=True)
inbox = TASKS / "inbox"
tasks = sorted(inbox.glob("*.md"))
if not tasks:
return 0
log(f"DRAIN start {len(tasks)} task(s)")
for path in tasks:
try:
process_task(path)
except Exception as e:
log(f"FATAL {path.name}: {e}\n{traceback.format_exc()}")
log("DRAIN end")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,241 @@
"""Shared pure stdlib helpers for detach skill scripts."""
import json
import re
from datetime import datetime
from pathlib import Path
WORKSPACE = Path.home() / ".nanobot" / "workspace"
TASKS = WORKSPACE / "tasks"
CONFIG = Path.home() / ".nanobot" / "config.json"
LOG = WORKSPACE / "log" / "detach.log"
FILENAME_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2}(?:T\d{6}|_\d{2}_\d{2}_\d{2}_\d{6}))-(.+)\.md$"
)
_NO_INTERACTION_BULLET = (
"- No user interaction (isolated session, no clarification questions"
" — work with what you have)."
)
def log(msg: str) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a") as f:
f.write(f"{datetime.now().astimezone().isoformat()} {msg}\n")
def parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
"""Parse YAML-ish frontmatter delimited by --- lines.
Returns (fields, body). On no match returns ({}, original content).
"""
m = re.match(r"^---\n(.*?)\n---\n(.*)", content, re.DOTALL)
if not m:
return {}, content
fm: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
k, _, v = line.partition(":")
fm[k.strip()] = v.strip().strip('"').strip("'")
return fm, m.group(2)
def parse_kv(text: str) -> dict[str, str]:
"""Parse simple key: value lines into a dict (no quote stripping)."""
result: dict[str, str] = {}
for line in text.splitlines():
if ":" in line:
k, _, v = line.partition(":")
result[k.strip()] = v.strip()
return result
def parse_filename(name: str) -> tuple[str, str] | None:
"""Return (timestamp_str, slug) from a task filename, or None if no match."""
m = FILENAME_RE.match(name)
if not m:
return None
return m.group(1), m.group(2)
def parse_timestamp(ts_str: str) -> datetime:
"""Parse a filename timestamp in old (T-joined) or new (underscore-separated) format."""
for fmt in ("%Y-%m-%d_%H_%M_%S_%f", "%Y-%m-%dT%H%M%S"):
try:
return datetime.strptime(ts_str, fmt)
except ValueError:
continue
raise ValueError(f"unrecognized timestamp: {ts_str}")
def format_time(ts_str: str) -> str:
"""Format a filename timestamp to HH:MM."""
try:
return parse_timestamp(ts_str).strftime("%H:%M")
except ValueError:
return ts_str
def format_age(ts_str: str) -> str:
"""Return a human-readable age for a filename timestamp."""
try:
delta = datetime.now() - parse_timestamp(ts_str)
s = max(0, int(delta.total_seconds()))
if s < 60:
return f"{s}s ago"
if s < 3600:
return f"{s // 60}m ago"
if s < 86400:
return f"{s // 3600}h ago"
return f"{s // 86400}d ago"
except ValueError:
return "?"
def goal_summary(path: Path, width: int = 80) -> str:
"""Return first non-empty line of the Goal section, truncated to width."""
try:
_, body = parse_frontmatter(path.read_text())
except OSError:
return ""
goal = (extract_section(body, "Goal") or "").strip()
first = next((line for line in goal.splitlines() if line.strip()), "")
return first if len(first) <= width else first[:width - 1].rstrip() + ""
def render_list(paths: list[Path], total: int) -> str:
"""Render tasks as a flat bullet list — robust for LLM relaying (no table grammar)."""
blocks = []
for path in paths:
parsed = parse_filename(path.name)
if parsed:
ts_str, slug = parsed
head = f"- `{slug}` · {format_time(ts_str)} · {format_age(ts_str)}"
else:
head = f"- `{path.name}`"
summary = goal_summary(path)
blocks.append(f"{head}\n {summary}" if summary else head)
out = "\n".join(blocks)
if total > len(paths):
out += f"\n\n_(+ {total - len(paths)} older)_"
return out
def extract_section(text: str, name: str) -> str | None:
"""Return the text content of a markdown section by heading name, or None."""
m = re.search(rf"(?m)^#+ {re.escape(name)}\s*\n(.*?)(?=^#|\Z)", text, re.DOTALL)
return m.group(1).strip() if m else None
def format_result(path: Path) -> str:
"""Format a completed task file as a human-readable result block."""
content = path.read_text()
sep = "\n\n---\n"
main_part, _, meta_str = content.rpartition(sep)
if not main_part:
main_part = content
meta_str = ""
trailing = parse_kv(meta_str)
orig_fm, body = parse_frontmatter(main_part)
m = FILENAME_RE.match(path.name)
slug = m.group(2) if m else path.stem
goal = extract_section(body, "Goal") or body.strip()
result = extract_section(body, "Result") or "(no result)"
created = orig_fm.get("created", "")
completed = trailing.get("completed", "")
duration = trailing.get("duration_seconds", "")
status = trailing.get("status", path.parent.name)
model = orig_fm.get("model", "")
model_suffix = f" · model: `{model}`" if model else ""
if created:
meta_line = f"_Done in `{duration}`s · `{created}` → `{completed}` · status: `{status}`{model_suffix}_"
else:
meta_line = f"_Done in `{duration}`s · completed: `{completed}` · status: `{status}`{model_suffix}_"
return "\n".join([
f"**Result: `{slug}`**",
"",
goal,
"",
"---",
"",
result,
"",
"---",
meta_line,
])
def load_preset_names() -> list[str]:
"""Return the configured model preset names from config.json, sorted.
The config key may be written either camelCase (`modelPresets`) or
snake_case (`model_presets`) — nanobot accepts both, so we read both.
"""
config = json.loads(CONFIG.read_text())
presets = config.get("modelPresets") or config.get("model_presets") or {}
return sorted(presets.keys())
def resolve_preset(token: str, names: list[str]) -> str:
"""Resolve a user-typed model token to an exact preset name.
Exact match (case-insensitive) wins; otherwise a unique case-insensitive
substring match. Raises KeyError when nothing or more than one matches.
"""
token = token.strip()
exact = [n for n in names if n.lower() == token.lower()]
if exact:
return exact[0]
substring = [n for n in names if token.lower() in n.lower()]
if len(substring) == 1:
return substring[0]
available = ", ".join(names) or "(none)"
if not substring:
raise KeyError(f"model {token!r} not found. Available: {available}")
raise KeyError(f"model {token!r} is ambiguous: {', '.join(substring)}")
def build_task_filename(timestamp_str: str, slug: str) -> str:
"""Build the task filename from a formatted timestamp and slug."""
return f"{timestamp_str}-{slug}.md"
def build_task_content(
created_iso: str,
channel: str,
chat_id: str,
slug: str,
goal: str,
constraints: list[str],
model: str | None = None,
) -> str:
"""Build the full frontmatter+body content for a new task file."""
constraint_lines = [_NO_INTERACTION_BULLET] + [f"- {c}" for c in constraints]
constraints_block = "\n".join(constraint_lines)
model_line = f"model: {model}\n" if model else ""
return (
f"---\n"
f"created: {created_iso}\n"
f'channel: {channel}\n'
f'chat_id: "{chat_id}"\n'
f"slug: {slug}\n"
f"{model_line}"
f"---\n"
f"\n"
f"# Goal\n"
f"\n"
f"{goal}\n"
f"\n"
f"# Constraints\n"
f"\n"
f"{constraints_block}\n"
)

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Trigger detach daemon when tasks/inbox has files
[Path]
DirectoryNotEmpty=%h/.nanobot/workspace/tasks/inbox
Unit=tasks-daemon.service
[Install]
WantedBy=paths.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=nanobot detach tasks daemon (drain inbox)
After=nanobot.service
# Tolerate transient startup crashes without permanently latching the pipeline.
# 20 retries per 30 min, then pause + auto-resume as the window slides — no manual reset-failed.
StartLimitIntervalSec=1800
StartLimitBurst=20
[Service]
Type=oneshot
# On a crash (exit!=0) retry after a delay; clean drain (exit 0) and systemd-initiated
# stop (SIGTERM) do not restart. Gives a fixed deploy time to recover automatically.
Restart=on-failure
RestartSec=60
Environment=PATH=%h/.local/bin:/usr/bin:/bin
ExecStart=%h/.nanobot/workspace/skills/detach/scripts/tasks-daemon.py
StandardOutput=append:%h/.nanobot/workspace/log/tasks-daemon.stdout.log
StandardError=append:%h/.nanobot/workspace/log/tasks-daemon.stderr.log

View File

@@ -0,0 +1,5 @@
import sys
from pathlib import Path
# tasks_common.py lives in the sibling scripts/ directory.
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

View File

@@ -0,0 +1,490 @@
"""Tests for tasks_common pure logic — no I/O beyond tmp_path, no network."""
import json
from pathlib import Path
import pytest
import tasks_common
from tasks_common import (
FILENAME_RE,
build_task_content,
build_task_filename,
extract_section,
format_age,
format_result,
format_time,
goal_summary,
load_preset_names,
parse_filename,
parse_frontmatter,
parse_kv,
parse_timestamp,
render_list,
resolve_preset,
)
# ---------------------------------------------------------------------------
# parse_frontmatter
# ---------------------------------------------------------------------------
def test_parse_frontmatter_basic():
content = "---\ncreated: 2026-01-01T10:00:00+00:00\nslug: my-task\n---\n\n# Goal\n\nDo something.\n"
fm, body = parse_frontmatter(content)
assert fm["created"] == "2026-01-01T10:00:00+00:00"
assert fm["slug"] == "my-task"
assert "# Goal" in body
def test_parse_frontmatter_quoted_chat_id():
content = '---\nchat_id: "12345"\nchannel: telegram\n---\nbody\n'
fm, body = parse_frontmatter(content)
assert fm["chat_id"] == "12345"
assert fm["channel"] == "telegram"
assert body == "body\n"
def test_parse_frontmatter_no_match():
content = "No frontmatter here."
fm, body = parse_frontmatter(content)
assert fm == {}
assert body == content
def test_parse_frontmatter_roundtrip():
original = "---\nfoo: bar\nbaz: qux\n---\nbody text\n"
fm, body = parse_frontmatter(original)
assert fm == {"foo": "bar", "baz": "qux"}
assert body == "body text\n"
# ---------------------------------------------------------------------------
# parse_kv
# ---------------------------------------------------------------------------
def test_parse_kv_basic():
text = "completed: 2026-01-02T11:00:00+00:00\nduration_seconds: 42\nstatus: done\n"
kv = parse_kv(text)
assert kv["completed"] == "2026-01-02T11:00:00+00:00"
assert kv["duration_seconds"] == "42"
assert kv["status"] == "done"
def test_parse_kv_empty():
assert parse_kv("") == {}
def test_parse_kv_no_colon_lines_ignored():
kv = parse_kv("no colon here\nkey: value\n")
assert kv == {"key": "value"}
# ---------------------------------------------------------------------------
# FILENAME_RE / parse_filename
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
("2026-01-15T143022-my-task.md", ("2026-01-15T143022", "my-task")),
("2026-12-31T235959-qdrant-vs-weaviate.md", ("2026-12-31T235959", "qdrant-vs-weaviate")),
("2026-06-07_15_00_00_123456-qdrant-vs-weaviate.md", ("2026-06-07_15_00_00_123456", "qdrant-vs-weaviate")),
("2026-06-07_09_05_59_000001-deploy-api.md", ("2026-06-07_09_05_59_000001", "deploy-api")),
])
def test_filename_re_matches(name, expected):
assert parse_filename(name) == expected
@pytest.mark.parametrize("name", [
"not-a-task.md",
"2026-01-15-missing-time.md",
"2026-01-15T1430-short.md",
"2026-06-07_15_00_123456-too-few-groups.md",
])
def test_filename_re_no_match(name):
assert parse_filename(name) is None
def test_filename_re_direct_old_format():
assert FILENAME_RE.match("2026-06-02T120000-test-slug.md") is not None
def test_filename_re_direct_new_format():
assert FILENAME_RE.match("2026-06-07_15_00_00_123456-test-slug.md") is not None
# ---------------------------------------------------------------------------
# format_time
# ---------------------------------------------------------------------------
def test_format_time_old_format():
assert format_time("2026-06-02T143022") == "14:30"
def test_format_time_new_format():
assert format_time("2026-06-07_15_00_00_123456") == "15:00"
def test_format_time_invalid():
assert format_time("not-a-time") == "not-a-time"
# ---------------------------------------------------------------------------
# parse_timestamp
# ---------------------------------------------------------------------------
def test_parse_timestamp_old_format():
dt = parse_timestamp("2026-06-02T143022")
assert dt.hour == 14
assert dt.minute == 30
assert dt.second == 22
def test_parse_timestamp_new_format():
dt = parse_timestamp("2026-06-07_15_00_00_123456")
assert dt.hour == 15
assert dt.minute == 0
assert dt.microsecond == 123456
def test_parse_timestamp_invalid():
import pytest as _pytest
with _pytest.raises(ValueError):
parse_timestamp("not-a-timestamp")
# ---------------------------------------------------------------------------
# format_age
# ---------------------------------------------------------------------------
def test_format_age_invalid():
assert format_age("bad-value") == "?"
def test_format_age_future_clamps_to_zero():
# A timestamp far in the future still returns a non-negative age string.
result = format_age("2099-01-01T000000")
assert result.endswith("ago") or result == "0s ago"
# ---------------------------------------------------------------------------
# render_list / goal_summary
# ---------------------------------------------------------------------------
def _make_task_path(tmp_path: Path, name: str, goal: str = "Test goal.") -> Path:
path = tmp_path / name
path.write_text(
f'---\nslug: test\nchat_id: "1"\nchannel: telegram\n---\n\n# Goal\n\n{goal}\n'
)
return path
def test_render_list_basic(tmp_path):
paths = [
_make_task_path(tmp_path, "2026-06-01T120000-alpha.md", "Alpha goal."),
_make_task_path(tmp_path, "2026-06-02T130000-beta.md", "Beta goal."),
]
out = render_list(paths, len(paths))
assert "- `alpha`" in out
assert "- `beta`" in out
assert "|" not in out # no markdown table grammar
def test_render_list_shows_goal(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-mytask.md", "Research Qdrant.")]
out = render_list(paths, 1)
assert "- `mytask`" in out
assert "\n Research Qdrant." in out # goal on its own indented line
def test_render_list_truncation_note(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-alpha.md")]
out = render_list(paths, 5)
assert "(+ 4 older)" in out
def test_render_list_no_truncation_note_when_exact(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-alpha.md")]
out = render_list(paths, 1)
assert "older" not in out
def test_render_list_unknown_filename(tmp_path):
path = tmp_path / "weird-name.md"
path.write_text("no frontmatter")
out = render_list([path], 1)
assert "- `weird-name.md`" in out
assert "" not in out # no table placeholders
def test_render_list_new_format_filename(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-07_15_00_00_123456-new-slug.md", "New task.")]
out = render_list(paths, 1)
assert "- `new-slug`" in out
assert "New task." in out
assert "15:00" in out
def test_render_list_omits_goal_line_when_empty(tmp_path):
path = tmp_path / "2026-06-01T120000-nogoal.md"
path.write_text('---\nslug: nogoal\n---\n\nNo goal section here.\n')
out = render_list([path], 1)
assert out.startswith("- `nogoal` · 12:00 · ")
assert "\n " not in out # no indented goal line
def test_goal_summary_truncates(tmp_path):
long_goal = "A" * 100
path = _make_task_path(tmp_path, "2026-06-01T120000-long.md", long_goal)
summary = goal_summary(path)
assert len(summary) <= 80
assert summary.endswith("")
def test_goal_summary_missing_file():
from pathlib import Path as _Path
assert goal_summary(_Path("/nonexistent/file.md")) == ""
# ---------------------------------------------------------------------------
# extract_section
# ---------------------------------------------------------------------------
def test_extract_section_found():
text = "# Goal\n\nDo something useful.\n\n# Constraints\n\n- bullet\n"
assert extract_section(text, "Goal") == "Do something useful."
def test_extract_section_not_found():
assert extract_section("# Goal\n\ntext\n", "Result") is None
def test_extract_section_stops_at_next_heading():
text = "# Goal\n\ngoal text\n\n# Result\n\nresult text\n"
assert extract_section(text, "Goal") == "goal text"
assert extract_section(text, "Result") == "result text"
# ---------------------------------------------------------------------------
# format_result (uses tmp_path)
# ---------------------------------------------------------------------------
def _make_task_file(tmp_path: Path, slug: str, goal: str, result_text: str) -> Path:
filename = f"2026-06-01T120000-{slug}.md"
path = tmp_path / "done" / filename
path.parent.mkdir(parents=True, exist_ok=True)
content = (
f"---\ncreated: 2026-06-01T12:00:00+02:00\nchannel: telegram\n"
f'chat_id: "99"\nslug: {slug}\n---\n\n'
f"# Goal\n\n{goal}\n\n# Result\n\n{result_text}\n\n"
f"---\ncompleted: 2026-06-01T12:05:00+02:00\nduration_seconds: 300\nstatus: done\n"
)
path.write_text(content)
return path
def test_format_result_contains_slug(tmp_path):
path = _make_task_file(tmp_path, "my-slug", "Research X.", "Found Y.")
output = format_result(path)
assert "my-slug" in output
def test_format_result_contains_goal(tmp_path):
path = _make_task_file(tmp_path, "task-one", "Research X.", "Found Y.")
output = format_result(path)
assert "Research X." in output
def test_format_result_contains_result(tmp_path):
path = _make_task_file(tmp_path, "task-two", "Research X.", "Found Y.")
output = format_result(path)
assert "Found Y." in output
def test_format_result_contains_meta(tmp_path):
path = _make_task_file(tmp_path, "task-three", "Do it.", "Done.")
output = format_result(path)
assert "300" in output # duration_seconds
assert "done" in output
# ---------------------------------------------------------------------------
# build_task_filename
# ---------------------------------------------------------------------------
def test_build_task_filename_old_format():
name = build_task_filename("2026-06-02T153045", "my-slug")
assert name == "2026-06-02T153045-my-slug.md"
assert FILENAME_RE.match(name) is not None
def test_build_task_filename_new_format():
name = build_task_filename("2026-06-07_15_00_00_123456", "my-slug")
assert name == "2026-06-07_15_00_00_123456-my-slug.md"
assert FILENAME_RE.match(name) is not None
# ---------------------------------------------------------------------------
# build_task_content
# ---------------------------------------------------------------------------
FIXED_TS = "2026-06-02T153045"
FIXED_ISO = "2026-06-02T15:30:45+02:00"
def test_build_task_content_frontmatter_fields():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
fm, body = parse_frontmatter(content)
assert fm["created"] == FIXED_ISO
assert fm["channel"] == "telegram"
assert fm["chat_id"] == "42"
assert fm["slug"] == "test-task"
def test_build_task_content_goal_section():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
_, body = parse_frontmatter(content)
assert extract_section(body, "Goal") == "Do the thing."
def test_build_task_content_constraints_section_has_no_interaction():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
_, body = parse_frontmatter(content)
constraints = extract_section(body, "Constraints")
assert constraints is not None
assert "No user interaction" in constraints
def test_build_task_content_extra_constraints():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=["Max 5 minutes.", "Output must be JSON."],
)
_, body = parse_frontmatter(content)
constraints = extract_section(body, "Constraints")
assert "Max 5 minutes." in constraints
assert "Output must be JSON." in constraints
def test_build_task_content_parseable_by_daemon():
"""The content produced must be parseable by parse_frontmatter as tasks-daemon does."""
content = build_task_content(
created_iso=FIXED_ISO,
channel="websocket",
chat_id="777",
slug="daemon-check",
goal="Verify parsing.",
constraints=[],
)
fm, body = parse_frontmatter(content)
assert fm.get("chat_id") == "777"
assert fm.get("channel") == "websocket"
assert "# Goal" in body
assert "# Constraints" in body
def test_build_task_content_omits_model_by_default():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
fm, _ = parse_frontmatter(content)
assert "model" not in fm
def test_build_task_content_includes_model_when_set():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
model="kimi-k2.6-openrouter",
)
fm, _ = parse_frontmatter(content)
assert fm["model"] == "kimi-k2.6-openrouter"
# ---------------------------------------------------------------------------
# resolve_preset
# ---------------------------------------------------------------------------
PRESETS = ["glm-5.1-ollama", "kimi-k2.6-openrouter", "qwen-3.5-ollama", "qwen-3.6-plus"]
def test_resolve_preset_exact_case_insensitive():
assert resolve_preset("Kimi-K2.6-OpenRouter", PRESETS) == "kimi-k2.6-openrouter"
def test_resolve_preset_unique_substring():
assert resolve_preset("kimi", PRESETS) == "kimi-k2.6-openrouter"
assert resolve_preset("glm", PRESETS) == "glm-5.1-ollama"
def test_resolve_preset_unknown_raises_with_available():
with pytest.raises(KeyError) as exc:
resolve_preset("gpt5", PRESETS)
assert "not found" in exc.value.args[0]
assert "kimi-k2.6-openrouter" in exc.value.args[0]
def test_resolve_preset_ambiguous_raises_with_candidates():
with pytest.raises(KeyError) as exc:
resolve_preset("qwen", PRESETS)
assert "ambiguous" in exc.value.args[0]
assert "qwen-3.5-ollama" in exc.value.args[0]
assert "qwen-3.6-plus" in exc.value.args[0]
# ---------------------------------------------------------------------------
# load_preset_names (uses tmp_path + monkeypatched CONFIG)
# ---------------------------------------------------------------------------
def test_load_preset_names_reads_camelcase(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"modelPresets": {"b-preset": {}, "a-preset": {}}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == ["a-preset", "b-preset"]
def test_load_preset_names_reads_snake_case(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"model_presets": {"kimi": {}, "glm": {}}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == ["glm", "kimi"]
def test_load_preset_names_empty_when_absent(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"channels": {}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == []

10
skills/grill-me/SKILL.md Normal file
View File

@@ -0,0 +1,10 @@
---
name: grill-me
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
---
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time.
If a question can be answered by exploring the codebase, explore the codebase instead.

77
skills/keep/SKILL.md Normal file
View File

@@ -0,0 +1,77 @@
---
name: keep
description: >
Explicit immediate memory.
Use when user says "keep X", "zapamatuj si X", "ulož si X", "pamatuj si X", "/keep X".
Adds, deduplicates, and compacts entries in workspace/keep.md.
Separate from MEMORY.md / Dream pipeline.
---
# Keep
Explicit memory store. User says "keep X" → reformulate, write to
`/home/nanobot/.nanobot/workspace/keep.md`, dedup, compact when too long.
## File
`workspace/keep.md`. Flat bullet list. One entry = one line: `- <terse fact>`.
**No dates.** Date adds noise, has no value for keep/discard decisions.
## Write protocol
1. Extract the core fact. Drop filler ("you know", "important is that", "watch
out for", "remember please").
2. Rewrite as a 515 word terse fact. Telegraphic style. Dashes / parentheses
for context. **Preserve the language of the input — never translate.** Czech
input → Czech entry, English input → English entry.
- Input: "you know, Honza from marketing is allergic to peanuts"
- Entry: `Honza (marketing) — peanut allergy`
- If the entry is a decision, preference, or dead-end (not a plain fact),
append the reason inline on the same line: `<fact> — because <terse why>`.
Plain facts (allergy, deploy window, name) get no reason.
- If it is a decision/preference/dead-end but the input gives no reason, ask
the user once for the why before storing. If they supply it → append it. If
they decline or it is self-evident → store without it.
3. Read `workspace/keep.md` (create if missing).
4. Read `workspace/memory/MEMORY.md` and check if a semantically similar fact
already exists there (Dream may have already distilled it).
- If similar fact in MEMORY.md → tell the user (`"Already in MEMORY.md:
<existing fact>. Keep anyway?"`) and act on their answer. Default: skip.
5. Check duplicates: case-insensitive substring match against existing lines.
- If duplicate → ask user: replace, append as variant, or skip.
6. Append the new line.
7. If line count > 150 → run **compaction** (below) before responding.
8. Confirm: `Kept: <terse fact>`. Respond in the user's language (the model
localizes the confirmation itself).
## Compaction
Trigger: line count > 150, or user says "keep compact" / "udělej compaction".
1. Read full `keep.md`.
2. Rewrite under ~120 lines (headroom). Strategies:
- Merge duplicates and near-duplicates.
- Drop stale one-shot info (past meetings, transient states, expired notes).
- Shorten verbose entries.
3. Write the new file in one go.
4. Report: `Compaction: 151 → 117 lines`.
## Edge cases
- `/keep` with no content → ask "What should I remember?".
- Vague input ("remember this", "that thing") → ask for the concrete fact; do
not store a placeholder.
- File missing → create it on first write.
- Multi-line input → collapse newlines to spaces; one entry = one line.
## Rules
- Never store the verbatim input. Always reformulate.
- Reason (why) only for decisions / preferences / dead-ends — never for plain
facts. Always terse and inline on the same line; never a separate Why: block.
- Preserve input language; never translate.
- Do not store smalltalk or meta-commentary about memory itself.
- Keep is separate from MEMORY.md and Dream. Read MEMORY.md only for the dedup
check (step 4); never edit it or any Dream file from this skill.
- Touch `keep.md` only via this skill. Other agent paths should read it
(per USER.md reference) but not edit it.

88
skills/note/SKILL.md Normal file
View File

@@ -0,0 +1,88 @@
---
name: note
description: >
Explicit notes.
Use when user says "note X", "note it".
---
# Note
Explicit note store backed by SQLite. User says "note X" → extract tags,
reformulate content, store via `note.py add`. Delete only on explicit user request. Notes are stored to sqlite db.
## Backend
`skills/note/scripts/note.py` — CLI wrapper around `db/note.sqlite`.
Operation log: `log/note.log` (append-only, all write operations).
## Tag protocol
Tags are the **first token** right after the trigger — comma-separated, no spaces:
```
/note arch explanation of the architecture decision → tags: [arch]
/note hw,linux interesting article about kernel → tags: [hw, linux]
/note this is a note without tags → tags: []
```
Rules:
- Lowercase only; multi-word tags use `-`: `cli`, `soft-delete`, `task-queue`
- If user writes `#tag`, strip `#` before passing to the script
- If no tag is given — that is fine, use no tags; never force tags
Tags are created automatically on first use — no registration needed.
## Write protocol
1. Extract inline tags from the first token (see Tag protocol above).
2. Reformulate the remaining text into a terse fact. One concept per entry —
split if too complex; omit context that is not itself a fact. Preserve
input language; never translate. Drop filler.
- Input: "poznamenej si, glow zobrazuje markdown v terminálu #cli"
- Run: `uv run skills/note/scripts/note.py add "glow displays markdown in terminal" --tags cli`
3. Echo: `Noted [#1]: <content> [#tag1 #tag2]` (tags omitted if none).
`#1` is the display ID of the new note — use it to delete immediately if needed.
No dedup. No MEMORY.md lookup. Blind append.
## List protocol
Trigger: `/note list`, `show notes`, `what notes do you have?`
1. Run: `uv run skills/note/scripts/note.py list [--limit N] [--tag TAG [TAG ...]]`
2. Echo output. If empty → respond "No notes."
`--tag` accepts one or more tags; OR logic (notes with at least one matching tag).
The number before each note (`1.`, `2.`, …) is the **display ID** — sequential
among active notes, newest first. Renumbers after every deletion.
## Delete protocol
Trigger: `/note delete`, `delete a note`, `remove a note`.
1. If the user has not specified an ID, run `list` first to show current notes.
2. Run: `uv run skills/note/scripts/note.py delete <display-id>`
- Exit 0 → confirm deletion.
- Exit 1 → display ID out of range; respond accordingly.
3. Nothing is deleted automatically. Only this explicit protocol deletes.
Display IDs renumber after every deletion (e.g., after deleting #3, the old #4
becomes #3). Always run `list` first if unsure of current IDs.
## Edge cases
- `/note` with no content → ask "What should I note?"
- Vague input → ask for the concrete fact; do not store a placeholder.
- `/note delete` with no ID → run `list` first, then ask which display ID.
- Multi-line input → collapse to one line; one entry = one row.
## Rules
- Never store verbatim input. Always reformulate. Preserve input language.
- Do not store smalltalk or meta-commentary about the note skill itself.
- **No auto-load:** `note.sqlite` is never referenced in bootstrap files.
- **No auto-delete / no compaction.** Only explicit delete marks an entry.
- **Delete is soft** — the entry is marked with a timestamp, not removed from
the database. The operation log (`log/note.log`) is the primary audit trail.
- Separate from `/keep`, `MEMORY.md`, Dream — never cross-write or cross-read.

200
skills/note/scripts/note.py Normal file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = []
# ///
"""
note.py — backend for /note skill.
SQLite-backed note store with tags, soft-delete, and operation log.
"""
import argparse
import json
import re
import sqlite3
import sys
from collections.abc import Generator
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent.parent / "db" / "note.sqlite"
LOG_PATH = Path(__file__).resolve().parent.parent.parent.parent / "log" / "note.log"
_TAG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
deleted_at TEXT
);
"""
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
_migrate(conn)
def _migrate(conn: sqlite3.Connection) -> None:
cols = {row[1] for row in conn.execute("PRAGMA table_info(notes)")}
if "tags" not in cols:
conn.execute("ALTER TABLE notes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
if "deleted_at" not in cols:
conn.execute("ALTER TABLE notes ADD COLUMN deleted_at TEXT")
conn.commit()
@contextmanager
def _connect() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
_init_db(conn)
try:
yield conn
finally:
conn.close()
def _validate_tags(tags: list[str]) -> None:
for tag in tags:
if not _TAG_RE.match(tag):
raise ValueError(
f"Invalid tag '{tag}' — use lowercase letters, digits, hyphens only (e.g. cli, soft-delete)"
)
def _tags_display(tags_json: str) -> str:
tags = json.loads(tags_json)
if not tags:
return ""
return " [" + " ".join(f"#{t}" for t in tags) + "]"
def _log(op: str, detail: str) -> None:
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
with LOG_PATH.open("a") as f:
f.write(f"{ts} {op} {detail}\n")
def _active_ids(conn: sqlite3.Connection) -> list[int]:
rows = conn.execute(
"SELECT id FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC"
).fetchall()
return [row["id"] for row in rows]
def cmd_add(args: argparse.Namespace) -> int:
tags: list[str] = args.tags or []
try:
_validate_tags(tags)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
content = args.text.strip()
tags_json = json.dumps(tags)
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cur = conn.execute(
"INSERT INTO notes(content, tags, created_at) VALUES(?, ?, ?)",
(content, tags_json, created_at),
)
conn.commit()
nid = cur.lastrowid
tags_log = ",".join(tags)
_log("ADD", f"id={nid} tags=[{tags_log}] {content}")
print(f"Noted [#1]: {content}{_tags_display(tags_json)}")
return 0
def cmd_list(args: argparse.Namespace) -> int:
with _connect() as conn:
id_to_display = {nid: i + 1 for i, nid in enumerate(_active_ids(conn))}
if args.tag:
placeholders = ",".join("?" * len(args.tag))
rows = conn.execute(
f"""
SELECT id, content, tags FROM notes
WHERE deleted_at IS NULL
AND (
SELECT count(*) FROM json_each(notes.tags)
WHERE value IN ({placeholders})
) > 0
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(*args.tag, args.limit, args.offset),
).fetchall()
else:
rows = conn.execute(
"SELECT id, content, tags FROM notes"
" WHERE deleted_at IS NULL"
" ORDER BY created_at DESC LIMIT ? OFFSET ?",
(args.limit, args.offset),
).fetchall()
tag_filter = ",".join(args.tag) if args.tag else "None"
_log("LIST", f"tag={tag_filter} returned={len(rows)}")
if not rows:
print("No notes.")
return 0
for row in rows:
print(f"{id_to_display[row['id']]}. {row['content']}{_tags_display(row['tags'])}")
return 0
def cmd_delete(args: argparse.Namespace) -> int:
display_id: int = args.id
deleted_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
ids = _active_ids(conn)
idx = display_id - 1
if idx < 0 or idx >= len(ids):
print(f"No active note with display id={display_id}.")
return 1
nid = ids[idx]
row = conn.execute(
"SELECT id, content, tags FROM notes WHERE id = ?", (nid,)
).fetchone()
conn.execute("UPDATE notes SET deleted_at = ? WHERE id = ?", (deleted_at, nid))
conn.commit()
tags_log = ",".join(json.loads(row["tags"]))
_log("DELETE", f"display_id={display_id} id={nid} tags=[{tags_log}] content={row['content']!r}")
print(f"Deleted: {row['content']}")
return 0
def _main() -> int:
parser = argparse.ArgumentParser(description="Note store")
sub = parser.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add", help="Add a note")
p_add.add_argument("text", help="Note content")
p_add.add_argument("--tags", nargs="+", metavar="TAG", default=[], help="Tags (lowercase, hyphens allowed)")
p_list = sub.add_parser("list", help="List active notes")
p_list.add_argument("--limit", type=int, default=50)
p_list.add_argument("--offset", type=int, default=0)
p_list.add_argument("--tag", nargs="+", metavar="TAG", help="Filter by tag (OR logic)")
p_del = sub.add_parser("delete", help="Soft-delete a note by ID")
p_del.add_argument("id", type=int, help="Note ID")
args = parser.parse_args()
if args.cmd == "add":
return cmd_add(args)
if args.cmd == "list":
return cmd_list(args)
if args.cmd == "delete":
return cmd_delete(args)
return 0
if __name__ == "__main__":
sys.exit(_main())

96
skills/plan/SKILL.md Normal file
View File

@@ -0,0 +1,96 @@
---
name: plan
description: >
Plan mode — explore read-only, write a plan to workspace/plans/, get approval,
execute only after the user explicitly says so (now or later). Mirrors Claude
Code plan mode.
Use when user says "/plan X", "plan mode", "first plan then do X".
---
# Plan
Explore the task read-only, design an approach, write it to a plan file, and
**stop for approval**. Mutate nothing until the user explicitly approves
execution — which can happen now or much later. Plan now, execute whenever.
Four phases, run linearly. Emit a short status line between phases so the user
(especially on Telegram, where there is no thinking stream) sees progress.
## 1. Explore (read-only)
1. Restate the task in one sentence to confirm scope.
2. Investigate the relevant files and state: `read_file`, `ssh … cat` / `rsync`
for server files, `--help`, the official wiki. Look for existing code,
skills, or patterns to reuse instead of proposing new ones.
3. **No mutations.** Reading only.
Default: explore **linearly, yourself**. Planning is iterative — what you find
decides where you look next — and that does not split cleanly up front.
Use `spawn` **only** when the task is large and breaks into genuinely
independent parts (e.g. "explore three separate subsystems"). Then spawn one
subagent per part and wait for their results before phase 2. `spawn` is async
(results arrive via the message bus, not inline), so reach for it only at real
divisible scale — never routinely.
## 2. Design
Design the approach: what changes, where, and how it will be verified. Reuse
what you found in phase 1. If the request is genuinely ambiguous, ask now;
otherwise proceed.
## 3. Write the plan
1. Pick a kebab-case slug from the topic.
2. Write the plan to `/home/nanobot/.nanobot/workspace/plans/<slug>.md` (create
the `plans/` directory if missing). This file write is the **only** write
allowed before approval.
3. Plan structure:
```
# <Title>
## Kontext
Why this change — the problem, what prompted it, the intended outcome.
## Postup
Numbered steps. Name the files to touch. Reference reusable code found
in phase 1 with its path.
## Ověření
How to test the change end-to-end (run it, run tests, check behavior).
```
4. Also print a short version of the plan into the chat.
## 4. Approval (replaces ExitPlanMode — over chat)
Stop and ask: `Plán uložen do workspace/plans/<slug>.md. Schvaluješ? Mám ho
vykonat teď?` Then wait. Mutate nothing on your own.
- Approved + execute now → drop the read-only discipline and execute the plan in
this conversation.
- Approved but **not now** → planning is done. The plan stays in
`workspace/plans/<slug>.md` for later; the user can run it anytime by pointing
at the file.
- Wants changes → rewrite the plan file (still read-only otherwise) and ask again.
## Edge cases
- `/plan` with no task → ask "What should I plan?".
- Tiny one-step task (typo fix, single-line change) → say a full plan is
overkill and offer to just do it; don't force the ceremony.
- User already approved earlier and now says "execute the plan" → read the plan
file and execute; no need to re-plan.
## Rules
- **Read-only through phases 13.** Do not write or edit files (except the plan
file in phase 3), run mutating commands, change config, or restart services.
- Execute only after explicit approval to execute now. Approval to "save the
plan" is not approval to run it.
- Reuse before inventing — prefer existing code, skills, and patterns found
in phase 1.
- Respond in the user's language (the model localizes status and questions
itself); keep the plan-file body and structure as above.
- Keep status lines to one short sentence. No filler, no emojis.

88
skills/project/SKILL.md Normal file
View File

@@ -0,0 +1,88 @@
---
name: project
aliases: [proj]
description: >
Project management — long-running things with notes, next-steps, and status.
Use when user mentions "project".
---
# Project
File-backed project store in `projects/`. Each project is one markdown file
with YAML frontmatter (`status`, `priority`, `created`, `slug`) and a free-form
body for notes and next-steps.
## Backend
`skills/project/scripts/project.py` — deterministic CRUD for frontmatter and
basic operations. Agent handles all body edits via `edit_file` / `apply_patch`.
## Commands
### `project add <název>` — create
1. Run: `uv run skills/project/scripts/project.py add "<název>" [--priority high|medium|low]`
2. Default priority is `medium`.
3. Echo: `Created project '<slug>' (priority: <priority>)`
### `project list` — list active
1. Run: `uv run skills/project/scripts/project.py list`
2. Echo JSON output. Format as:
```
Active projects:
- <slug> (priority: high) — <first line of body / project name>
```
3. If empty → "No active projects."
### `project show <slug>` — display
1. Run: `uv run skills/project/scripts/project.py show <slug>`
2. Echo the full markdown file.
### `project status <slug> <active|paused|done>` — change status
1. Run: `uv run skills/project/scripts/project.py status <slug> <status>`
2. Echo: `Project '<slug>' is now <status>.`
### `project next <slug> <text>` — set next step
1. Read the project file.
2. Use `edit_file` to replace the content under `## Další krok` with the new text.
3. If the section does not exist, add it before the end of the file.
4. Echo: `Next step for '<slug>' updated.`
### `project note <slug> <text>` — add a note
1. Read the project file.
2. Use `edit_file` to append a bullet under `## Poznámky`:
`- <today>: <text>`
3. If `## Poznámky` does not exist, add it after the first heading.
4. Echo: `Note added to '<slug>'.`
### `project switch <slug>` — session context
1. Run `my(action="set", key="project_context", value="<slug>")`.
2. Echo: `Switched to project '<slug>'. Next project commands without slug will use this context.`
3. If a command is missing a slug and `project_context` is set, use it automatically.
## Rules
- **Slug** = kebab-case from first 4 words of the name. Used as filename (`<slug>.md`).
- **Frontmatter** is read-only for the agent — never edit it directly in the file.
Use `project.py status` to change status.
- **Body edits** (notes, next-step, structure changes) are always done by the agent
via `edit_file` / `apply_patch`.
- **No database** — pure markdown files. Git-friendly, one commit per change.
- **Session context** (`project switch`) lives only in `my` scratchpad and is lost
on restart. Re-run `project switch` after restart if needed.
- **Priority** = `high` | `medium` | `low`. `list` sorts by priority (high first).
- **Status** = `active` | `paused` | `done`. `list` shows only `active`.
## Edge cases
- `project add` with existing slug → error, do not overwrite.
- `project show` / `project status` / `project next` / `project note` with
missing slug → "Project '<slug>' not found."
- Missing `## Poznámky` or `## Další krok` → agent creates the section.
- Empty `projects/` → `list` returns empty array.

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["pyyaml"]
# ///
"""
project.py — backend for /project skill.
Deterministic CRUD for project markdown files with YAML frontmatter.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import date
from pathlib import Path
import yaml
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
PROJECTS_DIR = WORKSPACE / "projects"
# Valid statuses and priorities
STATUSES = {"active", "paused", "done"}
PRIORITIES = {"high", "medium", "low"}
def _slugify(name: str) -> str:
"""Kebab-case slug from first few words of name. Max 4 words."""
words = re.sub(r"[^a-zA-Z0-9\s]", "", name).lower().split()
words = words[:4]
return "-".join(words) if words else "project"
def _list_projects() -> list[dict]:
"""Parse frontmatter from all .md files in projects/."""
if not PROJECTS_DIR.exists():
return []
projects = []
for path in sorted(PROJECTS_DIR.glob("*.md")):
text = path.read_text(encoding="utf-8")
frontmatter, _ = _split_frontmatter(text)
if frontmatter:
meta = yaml.safe_load(frontmatter) or {}
meta["_file"] = path.name
projects.append(meta)
return projects
def _split_frontmatter(text: str) -> tuple[str | None, str]:
"""Split YAML frontmatter from body. Returns (frontmatter_yaml, body)."""
if not text.startswith("---\n"):
return None, text
end = text.find("\n---\n", 4)
if end == -1:
return None, text
return text[4:end], text[end + 5 :]
def _load_file(slug: str) -> tuple[Path, str, str | None, str]:
"""Load project file. Returns (path, full_text, frontmatter_yaml, body)."""
path = PROJECTS_DIR / f"{slug}.md"
if not path.exists():
raise FileNotFoundError(f"Project '{slug}' not found ({path.name})")
text = path.read_text(encoding="utf-8")
fm, body = _split_frontmatter(text)
return path, text, fm, body
def _write_file(path: Path, frontmatter: dict, body: str) -> None:
"""Write project file with YAML frontmatter."""
fm_yaml = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False)
path.write_text(f"---\n{fm_yaml}---\n{body}", encoding="utf-8")
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_add(args: argparse.Namespace) -> int:
name = (args.name or "").strip()
if not name:
print(json.dumps({"error": "name must not be empty"}), file=sys.stderr)
return 1
slug = _slugify(name)
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
path = PROJECTS_DIR / f"{slug}.md"
if path.exists():
print(json.dumps({"error": f"project '{slug}' already exists"}), file=sys.stderr)
return 1
priority = (args.priority or "medium").lower()
if priority not in PRIORITIES:
print(json.dumps({"error": f"invalid priority '{priority}' — use high/medium/low"}), file=sys.stderr)
return 1
frontmatter = {
"status": "active",
"priority": priority,
"created": date.today().isoformat(),
"slug": slug,
}
body = f"# {name}\n\n## Poznámky\n\n## Další krok\n\n"
_write_file(path, frontmatter, body)
print(json.dumps({"added": {"slug": slug, "name": name, "path": str(path.relative_to(WORKSPACE))}}, ensure_ascii=False))
return 0
def cmd_list(_args: argparse.Namespace) -> int:
projects = _list_projects()
active = [p for p in projects if p.get("status") == "active"]
# Sort by priority: high > medium > low
priority_order = {"high": 0, "medium": 1, "low": 2}
active.sort(key=lambda p: priority_order.get(p.get("priority", "medium"), 1))
print(json.dumps({"projects": active}, ensure_ascii=False))
return 0
def cmd_show(args: argparse.Namespace) -> int:
slug = (args.slug or "").strip()
try:
_path, text, _fm, _body = _load_file(slug)
except FileNotFoundError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
print(text)
return 0
def cmd_status(args: argparse.Namespace) -> int:
slug = (args.slug or "").strip()
new_status = (args.status or "").strip().lower()
if new_status not in STATUSES:
print(json.dumps({"error": f"invalid status '{new_status}' — use active/paused/done"}), file=sys.stderr)
return 1
try:
path, text, fm_yaml, body = _load_file(slug)
except FileNotFoundError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
if not fm_yaml:
print(json.dumps({"error": "no frontmatter found"}), file=sys.stderr)
return 1
frontmatter = yaml.safe_load(fm_yaml) or {}
frontmatter["status"] = new_status
_write_file(path, frontmatter, body)
print(json.dumps({"updated": {"slug": slug, "status": new_status}}, ensure_ascii=False))
return 0
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="Project file backend")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="Create a new project")
p_add.add_argument("name", help="Project name")
p_add.add_argument("--priority", default="medium", help="Priority: high/medium/low")
sub.add_parser("list", help="List active projects")
p_show = sub.add_parser("show", help="Show full project file")
p_show.add_argument("slug", help="Project slug")
p_status = sub.add_parser("status", help="Change project status")
p_status.add_argument("slug", help="Project slug")
p_status.add_argument("status", help="New status: active/paused/done")
args = parser.parse_args()
dispatch = {"add": cmd_add, "list": cmd_list, "show": cmd_show, "status": cmd_status}
return dispatch[args.command](args)
if __name__ == "__main__":
sys.exit(main())

64
skills/python/SKILL.md Normal file
View File

@@ -0,0 +1,64 @@
---
name: python
description: >
Python coding conventions, style, and tooling.
Use for anything involving Python code.
---
# Python Coding Conventions
## Tooling
- **Always use `uv`** — never bare `pip`, `python`, `venv`, or `virtualenv`.
- Run code: `uv run script.py` (or `uv run python -m module`)
- Add dependencies: `uv add <pkg>`; sync: `uv sync`
- One-off tools: `uv run --with <pkg> ...` or `uvx <tool>`
- **Format before done:** `uv run ruff format`
- **Lint before done:** `uv run ruff check --fix`
- Treat "done" as: formatted, linted clean, type hints present.
## Core Principles
- **Readability first** — code must be easily readable and understandable at a glance
- **Simplicity** — prefer the simplest solution that solves the problem; avoid unnecessary abstractions and cleverness
- **Clean Code** — meaningful names, small focused functions, single responsibility, no duplication (DRY), clear intent
## Style
- Follow PEP 8, but max line length **120 characters** (not the default 88)
## Types and Annotations
- Use Python 3.12+ built-in generics: `list[str]`, `dict[str, int]`, `tuple[int, ...]`
- Use `X | Y` instead of `Union[X, Y]`, `str | None` instead of `Optional[str]`
- Do not import from `typing` unless truly necessary (e.g., `Protocol`, `TypeVar`)
- All public functions and methods must have type hints
## Docstrings and Comments
- Add a docstring or comment only when it explains **intent** not obvious from the code or signature
- First line: short imperative summary; omit parameter/return docs if self-explanatory
- Prefer clear naming over explanatory comments; never restate what the code does
## Functions
- Break complex functions into smaller ones; one thing at one level of abstraction
- Keep the parameter count low (03)
- No boolean flag arguments — split into two well-named functions or use an enum
- Command-Query Separation: a function that returns a value must not mutate state
- Handle edge cases explicitly; prefer specific exceptions over bare `except`
## Control Flow
- Fail fast — validate inputs up front with guard clauses and early returns
- Avoid deep nesting (max 23 levels); invert conditions to return early
- Replace magic numbers and strings with named constants
## Error Handling
- Never silently swallow exceptions
- Do not unnecessarily wrap exceptions in other exception types
## Paths
- Prefer `pathlib.Path` over `os.path`

View File

@@ -0,0 +1,693 @@
# /remind Skill — Codebase Analysis & Improvement Report
## 1. Executive Summary
The /remind skill consists of three scripts (`remind_edit.py`, `remind_send.py`, `random_times.py`) plus tests. The `random_times.py` module is well-structured and tested. The two main scripts (`remind_edit.py`, `remind_send.py`) suffer from:
- Manual YAML string construction instead of proper serialization
- No tests at all
- Missing core features (list, edit, deduplication, dry-run)
- Race conditions and data-loss risks
- One-time reminders firing repeatedly within the same minute
This report identifies 20+ concrete improvements with code examples.
---
## 2. Critical Issues
### 2.1 One-time `at` reminders fire repeatedly (BUG)
`remind_send.py` uses a 60-second window:
```python
def should_fire(candidate: datetime, now: datetime) -> bool:
return abs((now - candidate).total_seconds()) < 60
```
With a 1-minute cron, an `at: "2026-06-02T09:20:00"` reminder fires at 09:20:00 **and** 09:20:01..09:20:59 if the cron job happens to run multiple times or with slight delay. The log shows this:
```
2026-06-02T09:20:01 cedule proti kouření ve výtahu
```
Only one line, but if the cron ran twice in the same minute, it would duplicate.
**Fix:** Track fired one-time reminders in a state file, or narrow the window to `<= 30` and ensure the cron runs at :00.
```python
# Better: stateful deduplication for one-time reminders
FIRED_STATE_PATH = Path(__file__).parent.parent.parent / "db" / "remind_fired.sqlite"
# Or simpler: narrow window + minute-level dedup via log check
```
### 2.2 Non-atomic YAML writes = data loss risk
`remind_edit.py` writes directly to `reminder.yaml`:
```python
with open(REMINDER_FILE, "w") as f:
yaml.dump(data, f)
```
If the process crashes mid-write, the file is truncated/corrupted.
**Fix:** Atomic write via temp file + rename:
```python
import os
def atomic_write(path: Path, data: dict, yaml: YAML) -> None:
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
yaml.dump(data, f)
os.replace(tmp, path)
```
### 2.3 Concurrent edit + send = race condition
`remind_send.py` reads `reminder.yaml` every minute. `remind_edit.py` writes to it. No file locking means the reader could get a partially-written file.
**Fix:** Use `filelock` (already available via uv) or atomic writes (above) + read retry.
### 2.4 `remind_edit.py` has no `list` command (advertised but missing)
`SKILL.md` documents `list` and `remove` commands, but `remind_edit.py` only implements `add` and `remove`. There is no `list`.
**Fix:** Add `list` to `main()`:
```python
elif command == "list":
for i, r in enumerate(data.get("reminders", []), 1):
print(f"{i}. {r.get('text', '(no text)')}")
```
---
## 3. Code Quality — Shorten & Improve
### 3.1 Remove custom `LiteralScalarString` (redundant)
`remind_edit.py` defines:
```python
class LiteralScalarString(str):
__slots__ = ()
```
ruamel.yaml already provides `ruamel.yaml.scalarstring.LiteralScalarString`. The custom class is unnecessary and confusing.
**Fix:**
```python
from ruamel.yaml.scalarstring import LiteralScalarString
```
### 3.2 `format_reminder` manually builds YAML (fragile)
Current code concatenates strings to produce YAML:
```python
def format_reminder(text, schedule):
lines = [f"- text: {text}"]
for key, value in schedule.items():
if isinstance(value, list):
lines.append(f" {key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f" {key}: {value}")
return "\n".join(lines)
```
This breaks on special characters (quotes, colons, newlines in text), doesn't handle indentation consistently, and duplicates YAML serialization logic.
**Fix:** Build a dict and let ruamel.yaml serialize it:
```python
def build_reminder(text: str, schedule: dict) -> dict:
reminder = {"text": LiteralScalarString(text)}
for key, value in schedule.items():
if key in ("at", "at_times", "cron_exprs") and isinstance(value, list):
reminder[key] = [LiteralScalarString(v) for v in value]
elif key in ("at", "window") and isinstance(value, str):
reminder[key] = LiteralScalarString(value)
else:
reminder[key] = value
return reminder
```
Then append to `data["reminders"]` and dump the whole document.
### 3.3 `parse_schedule` is a long if-elif chain
```python
def parse_schedule(args):
if not args:
return {"cron_exprs": ["0 9 * * *"]}
elif args[0] == "at":
...
elif args[0] == "times":
...
elif args[0] == "cron":
...
else:
...
```
**Fix:** Dispatch table:
```python
SCHEDULE_PARSERS = {
"at": lambda args: {"at": args[1]},
"times": lambda args: {"at_times": args[1:]},
"cron": lambda args: {"cron_exprs": args[1:]},
}
def parse_schedule(args: list[str]) -> dict:
if not args:
return {"cron_exprs": ["0 9 * * *"]}
parser = SCHEDULE_PARSERS.get(args[0])
if parser:
return parser(args)
# fallback: treat all args as cron expressions
return {"cron_exprs": args}
```
### 3.4 `remove_reminder` dual-match logic is confusing
```python
def remove_reminder(data, text):
reminders = data.get("reminders", [])
for i, reminder in enumerate(reminders):
if reminder.get("text") == text:
del reminders[i]
return True
for i, reminder in enumerate(reminders):
if text.lower() in reminder.get("text", "").lower():
del reminders[i]
return True
return False
```
This silently falls back to substring match, which could delete the wrong reminder.
**Fix:** Be explicit. Support exact match and `--grep` flag:
```python
def remove_reminder(data: dict, text: str, grep: bool = False) -> bool:
reminders = data.get("reminders", [])
for i, reminder in enumerate(reminders):
reminder_text = reminder.get("text", "")
if (not grep and reminder_text == text) or (grep and text.lower() in reminder_text.lower()):
del reminders[i]
return True
return False
```
### 3.5 `main()` in `remind_edit.py` is a big if-elif
**Fix:** Same dispatch pattern:
```python
COMMANDS = {
"add": cmd_add,
"remove": cmd_remove,
"list": cmd_list,
}
def main():
args = sys.argv[1:]
if not args:
print("Usage: ...")
sys.exit(1)
cmd = COMMANDS.get(args[0])
if not cmd:
print(f"Unknown command: {args[0]}")
sys.exit(1)
cmd(args[1:])
```
### 3.6 `remind_send.py` `should_fire` window too wide
With 1-minute cron granularity, a 60-second window allows double-firing if there's any jitter. Use 30 seconds:
```python
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
delta = (now - candidate).total_seconds()
return 0 <= delta < window_sec
```
This also ensures we only fire **after** the scheduled time, not before (which `abs()` allowed).
### 3.7 `remind_send.py` catches bare `Exception`
```python
except Exception as e:
print(f"Error sending reminder: {e}", file=sys.stderr)
```
**Fix:** Catch specific exceptions (`telegram.error.TelegramError`, `NetworkError`).
### 3.8 `sys.path.insert` hacks in both scripts
Both scripts do:
```python
sys.path.insert(0, str(Path(__file__).parent))
```
This is a code smell. Since these are run via `uv run`, they should either:
- Be part of a proper Python package with `__init__.py`
- Or use `PYTHONPATH` in the cron job
- Or import via relative imports if refactored into a package
**Fix:** Add a `pyproject.toml` in `skills/remind/` declaring the scripts directory as part of the package, or set `PYTHONPATH` in the cron:
```cron
* * * * * PYTHONPATH=/home/nanobot/.nanobot/workspace/skills/remind/scripts uv run /home/nanobot/.nanobot/workspace/skills/remind/scripts/remind_send.py
```
Then use normal imports: `from random_times import compute_fire_times`.
---
## 4. Missing Functionality
### 4.1 No `list` command in `remind_edit.py`
Users cannot view reminders without `cat reminder.yaml`.
### 4.2 No `edit` command
To change a reminder, users must remove and re-add. An `edit` command would be useful:
```python
def edit_reminder(data: dict, old_text: str, new_text: str, new_schedule: dict | None = None) -> bool:
for reminder in data.get("reminders", []):
if reminder.get("text") == old_text:
reminder["text"] = new_text
if new_schedule:
# Remove old schedule keys, add new ones
for key in list(reminder.keys()):
if key != "text":
del reminder[key]
reminder.update(new_schedule)
return True
return False
```
### 4.3 No deduplication / "fired" tracking for one-time reminders
`at` and `at_times` reminders should fire exactly once. Currently they rely on the 60s window and cron granularity.
**Fix:** SQLite state tracking:
```python
# db/remind_state.sqlite
# table fired (text TEXT, fired_at TEXT PRIMARY KEY)
```
Or simpler: append a `fired:` list to each reminder in `reminder.yaml` (but this modifies user data). Better: separate state file.
### 4.4 No dry-run mode in `remind_send.py`
Users cannot preview what would fire without actually sending Telegram messages.
**Fix:** Add `--dry-run` flag:
```python
if dry_run:
print(f"[DRY-RUN] Would fire: {text} at {now}")
else:
fire_reminder(text)
```
### 4.5 No way to see today's schedule
Users can't ask "what reminders do I have today?"
**Fix:** Add a `today` or `schedule` command to `remind_edit.py` that computes and prints all fire times for the current day.
### 4.6 No support for disabling reminders
Users must delete reminders to stop them. A `disabled: true` flag would be useful.
### 4.7 No validation before write
`remind_edit.py` doesn't validate that the produced YAML is loadable by `remind_send.py`. A malformed entry could break the cron job silently.
**Fix:** After building the reminder dict, run it through `random_times.compute_fire_times` (if it has `random`) or `croniter` (if it has `cron_exprs`) to validate:
```python
def validate_reminder(reminder: dict) -> None:
if "random" in reminder:
compute_fire_times(date.today(), reminder["text"], reminder["random"])
if "cron_exprs" in reminder:
for expr in reminder["cron_exprs"]:
croniter(expr)
```
### 4.8 No backup before edit
**Fix:** Keep last N backups:
```python
import shutil
from datetime import datetime
def backup_reminders(path: Path) -> None:
backup = path.with_suffix(f".yaml.{datetime.now():%Y%m%d%H%M%S}.bak")
shutil.copy2(path, backup)
```
### 4.9 `random_times.py` lacks step syntax in days parser
Cron supports `*/2`, `1-5/2`. `_parse_days` doesn't handle this.
**Fix:**
```python
def _parse_days(spec: object) -> set[int]:
text = str(spec).strip()
if text == "*":
return set(range(7))
result: set[int] = set()
for part in text.split(","):
part = part.strip()
step = 1
if "/" in part:
part, step_str = part.split("/", 1)
step = int(step_str)
if "-" in part:
low_str, high_str = part.split("-", 1)
low, high = int(low_str), int(high_str)
result.update(_normalize_dow(d) for d in range(low, high + 1, step))
else:
result.add(_normalize_dow(int(part)))
return result
```
### 4.10 No `__main__` guard in `random_times.py`
Not critical since it's a library, but good practice.
---
## 5. Testing Gaps
| Component | Tests? | Coverage |
|-----------|--------|----------|
| `random_times.py` | Yes | Good (determinism, gaps, filters, errors) |
| `remind_edit.py` | **No** | Zero |
| `remind_send.py` | **No** | Zero |
### 5.1 Tests needed for `remind_edit.py`
- `parse_schedule` with all input variants
- `build_reminder` / `format_reminder` roundtrip
- `remove_reminder` exact vs substring
- YAML dump/load roundtrip preserves formatting
- Atomic write doesn't corrupt file
### 5.2 Tests needed for `remind_send.py`
- `should_fire` boundary conditions
- `fire_reminder` with mocked Telegram bot
- `main` with mocked `reminder.yaml` and mocked bot
- One-time reminder deduplication
- Random reminder integration with `random_times`
### 5.3 Test infrastructure
`conftest.py` only adds `sys.path`. It should also provide fixtures:
```python
@pytest.fixture
def sample_yaml(tmp_path):
path = tmp_path / "reminder.yaml"
path.write_text("reminders:\n- text: test\n at: 2026-06-01T10:00:00\n")
return path
@pytest.fixture
def mock_bot(monkeypatch):
class FakeBot:
def send_message(self, chat_id, text):
self.last_call = (chat_id, text)
bot = FakeBot()
monkeypatch.setattr("remind_send.Bot", lambda token: bot)
return bot
```
---
## 6. Architecture Improvements
### 6.1 Consolidate into a single CLI
The user has considered consolidating remind into a single script. Current split:
- `remind_edit.py` = user-facing CLI
- `remind_send.py` = cron daemon
- `random_times.py` = shared library
This split is actually reasonable. But `remind_edit.py` and `remind_send.py` share no code. Consider extracting common YAML I/O:
```python
# remind_common.py
from pathlib import Path
from ruamel.yaml import YAML
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
def load_reminders() -> dict:
yaml = YAML()
yaml.preserve_quotes = True
with open(REMINDER_FILE) as f:
return yaml.load(f) or {"reminders": []}
def save_reminders(data: dict) -> None:
yaml = YAML()
yaml.default_flow_style = False
yaml.indent(mapping=2, sequence=4, offset=2)
atomic_write(REMINDER_FILE, data, yaml)
```
### 6.2 Use SQLite for state (not YAML)
The user is evaluating SQLite vs YAML for remind data storage. Current YAML approach:
- **Pros:** Human-readable, easy to edit by hand, version-control friendly
- **Cons:** No schema validation, race conditions, no querying, append-only log is separate
**Recommendation:** Keep YAML for the reminder definitions (human-editable), but use SQLite for runtime state (fired tracking, history query):
```python
# db/remind_state.sqlite
CREATE TABLE fired (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_scheduled ON fired(scheduled_at);
```
This gives:
- Exact-once firing for one-time reminders
- Queryable history ("when did X last fire?")
- No modification to `reminder.yaml`
### 6.3 Refactor `remind_send.py` into a class
Current procedural style makes testing hard. A class-based design:
```python
class ReminderEngine:
def __init__(self, yaml_path: Path, bot: Bot | None = None, dry_run: bool = False):
self.yaml_path = yaml_path
self.bot = bot
self.dry_run = dry_run
self.now = datetime.now(TIMEZONE)
def load(self) -> list[dict]:
...
def should_fire(self, candidate: datetime) -> bool:
...
def fire(self, text: str) -> None:
...
def run(self) -> list[str]:
fired = []
for reminder in self.load():
for candidate in self.candidates(reminder):
if self.should_fire(candidate) and not self.already_fired(reminder, candidate):
self.fire(reminder["text"])
fired.append(reminder["text"])
return fired
```
---
## 7. Specific Code Examples
### 7.1 Atomic write for `remind_edit.py`
```python
import os
from pathlib import Path
from tempfile import mkstemp
def atomic_write_yaml(path: Path, data: dict, yaml: YAML) -> None:
fd, tmp = mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
yaml.dump(data, f)
os.replace(tmp, path)
except Exception:
os.unlink(tmp)
raise
```
### 7.2 Proper `LiteralScalarString` usage
```python
from ruamel.yaml.scalarstring import LiteralScalarString
def build_reminder(text: str, schedule: dict) -> dict:
reminder = {"text": LiteralScalarString(text)}
for key, value in schedule.items():
if isinstance(value, list):
reminder[key] = [LiteralScalarString(v) for v in value]
elif isinstance(value, str):
reminder[key] = LiteralScalarString(value)
else:
reminder[key] = value
return reminder
```
### 7.3 Deduplication for one-time reminders
```python
from pathlib import Path
import sqlite3
STATE_DB = Path(__file__).parent.parent.parent / "db" / "remind_state.sqlite"
def ensure_state_db() -> None:
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(STATE_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS fired (
text TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (text, scheduled_at)
)
""")
conn.commit()
conn.close()
def already_fired(text: str, scheduled_at: datetime) -> bool:
conn = sqlite3.connect(STATE_DB)
row = conn.execute(
"SELECT 1 FROM fired WHERE text = ? AND scheduled_at = ?",
(text, scheduled_at.isoformat())
).fetchone()
conn.close()
return row is not None
def record_fired(text: str, scheduled_at: datetime) -> None:
conn = sqlite3.connect(STATE_DB)
conn.execute(
"INSERT OR IGNORE INTO fired (text, scheduled_at) VALUES (?, ?)",
(text, scheduled_at.isoformat())
)
conn.commit()
conn.close()
```
### 7.4 Narrowed `should_fire` + dedup
```python
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
delta = (now - candidate).total_seconds()
return 0 <= delta < window_sec
# In main loop for one-time reminders:
if "at" in reminder:
candidate = parse_at(reminder["at"])
if should_fire(candidate, now) and not already_fired(text, candidate):
fire_reminder(text)
record_fired(text, candidate)
```
### 7.5 `remind_edit.py` with dispatch table
```python
from pathlib import Path
import sys
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from random_times import compute_fire_times
from croniter import croniter
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
# --- commands ---
def cmd_add(args: list[str]) -> None:
text = " ".join(args)
schedule = parse_schedule([]) # default cron
add_reminder(text, schedule)
def cmd_remove(args: list[str]) -> None:
text = " ".join(args)
remove_reminder(text)
def cmd_list(_args: list[str]) -> None:
data = load_reminders()
for i, r in enumerate(data.get("reminders", []), 1):
print(f"{i}. {r.get('text', '(no text)')}")
COMMANDS = {
"add": cmd_add,
"remove": cmd_remove,
"list": cmd_list,
}
def main() -> None:
args = sys.argv[1:]
if not args or args[0] not in COMMANDS:
print(f"Usage: {sys.argv[0]} [{'|'.join(COMMANDS)}] ...")
sys.exit(1)
COMMANDS[args[0]](args[1:])
```
---
## 8. Prioritized Action Plan
| Priority | Task | Effort | Impact |
|----------|------|--------|--------|
| **P0** | Fix one-time reminder double-firing (narrow window + dedup) | Small | High — prevents spam |
| **P0** | Add atomic writes to `remind_edit.py` | Small | High — prevents data loss |
| **P1** | Add `list` command to `remind_edit.py` | Small | Medium — advertised feature |
| **P1** | Replace custom `LiteralScalarString` with ruamel's | Tiny | Low — code cleanliness |
| **P1** | Replace manual YAML string building with dict+dump | Medium | High — robustness |
| **P1** | Add validation before write | Small | Medium — catches errors early |
| **P2** | Add tests for `remind_edit.py` and `remind_send.py` | Medium | High — enables refactoring |
| **P2** | Extract common YAML I/O to `remind_common.py` | Small | Medium — DRY |
| **P2** | Add `--dry-run` to `remind_send.py` | Small | Medium — safer testing |
| **P3** | Add SQLite state tracking for fired reminders | Medium | Medium — exact-once, queryable history |
| **P3** | Add `edit` command | Small | Low — convenience |
| **P3** | Add `disabled` flag | Small | Low — convenience |
| **P3** | Support cron step syntax in `_parse_days` | Small | Low — completeness |
---
## 9. Summary
The `random_times.py` module is solid. The main pain points are in `remind_edit.py` (manual YAML construction, no atomic writes, missing commands) and `remind_send.py` (double-firing risk, no deduplication, no tests). The highest-impact fixes are: (1) atomic YAML writes, (2) one-time reminder deduplication, and (3) replacing manual YAML string building with proper serialization. Adding tests for the two untested scripts is essential before any major refactoring.

71
skills/remind/SKILL.md Normal file
View File

@@ -0,0 +1,71 @@
---
name: remind
description: >-
Create recurring reminders for tasks. Use when the user wants to set up a
reminder for something they need to do regularly, or when they mention tasks
they keep forgetting. Also handles listing and removing reminders. Triggers on
words like "remind", "reminder".
---
# Remind
Create, list, and manage recurring reminders for tasks.
## CRUD Script
All mutations to `reminder.yaml` go through `scripts/remind_edit.py` (paths in this skill are relative to the skill directory).
Run via: `uv run scripts/remind_edit.py <subcommand>`
Subcommands:
- **`list`** — prints JSON `{"reminders": [...]}`.
- **`add --text "..." --cron "EXPR" [--cron "EXPR"]`** — add recurring reminder; validates cron syntax.
- **`add --text "..." --at "ISO_DATETIME" [--at "ISO_DATETIME"]`** — add one-time reminder(s); `--at` is repeatable.
- **`add --text "..." --at "ISO" --cron "EXPR"`** — combine one-time and recurring times in one entry.
- **`add --text "..." --random-times-per-day N --random-window "HH:MM-HH:MM" [--random-days "1-5"] [--random-from "YYYY-MM-DD"] [--random-until "YYYY-MM-DD"]`** — random but deterministic times: fires `N` times per day at random moments inside the window. Use when the user wants something a few times a day without a fixed clock time (e.g. "remind me to drink water a few times during the day"). `--random-days` is a cron day-of-week filter; `--random-from` / `--random-until` bound the active period. Minimum gap between fires is a fixed constant in `scripts/random_times.py`. Combinable with `--at` / `--cron`.
- **`remove --keyword "..."`** — removes by case-insensitive substring match. Returns error JSON if 0 or >1 matches.
All outputs are JSON. Errors go to stderr with non-zero exit code.
## Create Workflow
1. **Identify the task** — What does the user want to be reminded about? If unclear, ask.
2. **Check for duplicates** — Run `remind_edit.py list` and compare existing reminder texts against the new one. If a similar reminder already exists:
- Show the user the existing reminder
- Ask whether they really want a duplicate, or want to modify the existing one
- Only proceed if the user explicitly confirms
3. **Determine frequency** — Ask how often the reminder should fire. Suggest common options:
- Every N minutes/hours/days
- Specific time of day (e.g. "every weekday at 9am")
- Specific day of week/month
- One-time at a specific datetime
- A few times a day at random moments (use the `--random-*` flags)
4. **Create the cron expression(s) or `at` field** — Map user input to cron syntax for recurring reminders, or ISO datetime for one-time reminders.
5. **Add via script** — Run a single `add` call combining all times (see CRUD Script for the exact flags). **Never call `add` multiple times for the same task** — put all times into one call.
6. **Confirm** — Show the user what was created (text, schedule).
## List Workflow
1. Run `uv run remind_edit.py list` and parse the JSON output.
2. Present all reminders in a table with columns: number, task, schedule.
3. Convert each schedule to human-readable text **in the user's language** (e.g. "every day at 9:00", "every Tuesday at 9:00"). For a `random` block, describe it like "5× a day at random between 9:0021:00, MonFri" (include `days`/`from`/`until` only if present).
4. If `reminders` is empty, say so.
## Remove / Done Workflow
1. Run `uv run remind_edit.py remove --keyword "..."`.
2. If exit code is non-zero, read the error JSON:
- `"no match"` → tell the user no reminder matches the keyword.
- `"ambiguous"` → show the matches and ask the user to be more specific.
3. If success, confirm what was removed.
## Rules
- **Respond to the user in their own language** (e.g. Czech) — this skill is written in English, but user-facing messages adapt to the user's language.
- **Never edit `reminder.yaml` directly** — no `edit_file`, `write_file`, or any direct write. All mutations go exclusively through `scripts/remind_edit.py`.
- **Read via the `list` subcommand** — never read the YAML file directly; always `remind_edit.py list`.
- Always confirm the reminder text and frequency with the user before creating.
- When listing, always show a human-readable schedule.
- Completed or removed reminders are deleted from `reminder.yaml` entirely — no `done` field, no `status` field.
- Timezone is always `Europe/Prague` unless the user explicitly requests otherwise.

View File

@@ -0,0 +1,39 @@
# Example reminder.yaml — shows every schedule type at a glance.
# This is documentation only; the live file is managed via scripts/remind_edit.py.
# Timezone is always Europe/Prague.
reminders:
# One-time reminder.
- text: "call the dentist"
at: "2026-06-10T10:00:00"
# Several one-time reminders in one entry.
- text: "order shoes"
at_times:
- "2026-06-01T10:00:00"
- "2026-06-01T11:00:00"
# Recurring via cron expressions.
- text: "pay the membership fee"
cron_exprs:
- "0 9 * * *"
- "0 14 * * *"
# Random but deterministic times: N fires per day inside a window, spaced at
# least MIN_GAP_MIN apart (constant in scripts/random_times.py). The times are
# derived from (date, text), so they are stable for a given day yet vary daily.
- text: "drink water / stretch"
random:
times_per_day: 5 # required: how many fires per day (int >= 1)
window: "09:00-21:00" # required: daily time window HH:MM-HH:MM (start < end)
days: "1-5" # optional: cron day-of-week filter (0/7=Sun, 1=Mon..6=Sat); default every day
from: "2026-06-01" # optional: start date, inclusive; omitted = active immediately
until: "2026-12-31" # optional: end date, inclusive; omitted = no end
# Fields can be combined freely in one entry.
- text: "water the plants"
cron_exprs:
- "0 19 * * *"
random:
times_per_day: 2
window: "08:00-12:00"

View File

@@ -0,0 +1,122 @@
"""Deterministic random fire-time computation for reminders.
Shared by remind_send.py (runtime) and remind_edit.py (validation). Stdlib only,
so it imports cleanly regardless of the caller's uv/PEP 723 environment.
A reminder's `random` block produces `times_per_day` fire times inside a daily
`window`, spaced at least MIN_GAP_MIN apart. The times are random but fully
determined by (date, text): any script computing them for the same day gets the
same result, so no state needs to be persisted.
"""
from __future__ import annotations
import random
from datetime import date, datetime, time
MIN_GAP_MIN = 15 # minimum gap between fire times in minutes; tune here
def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]:
"""Deterministic fire times for one day.
Returns [] when the day falls outside the days/from/until filters. Raises
ValueError on a malformed config (bad window, days, dates, or when the
requested count cannot fit the window with MIN_GAP_MIN spacing) — these are
structural and validated before any date filter, so the same call validates
a config regardless of the date passed in.
"""
count = _parse_count(cfg.get("times_per_day"))
start, end = _parse_window(cfg.get("window"))
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None
until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None
total = end - start
required = (count - 1) * MIN_GAP_MIN
if required > total:
raise ValueError(
f"{count} times with a {MIN_GAP_MIN}-min gap need {required} min, "
f"but the window is only {total} min wide"
)
if from_date is not None and target_date < from_date:
return []
if until_date is not None and target_date > until_date:
return []
if day_set is not None and _cron_weekday(target_date) not in day_set:
return []
slack = total - required
rnd = random.Random(f"{target_date.isoformat()}|{text}")
offsets = sorted(rnd.randint(0, slack) for _ in range(count))
minutes = [start + offset + index * MIN_GAP_MIN for index, offset in enumerate(offsets)]
return [datetime.combine(target_date, _minute_to_time(m)) for m in minutes]
def _parse_count(raw: object) -> int:
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
raise ValueError(f"times_per_day must be an int >= 1, got {raw!r}")
return raw
def _parse_window(raw: object) -> tuple[int, int]:
if not isinstance(raw, str) or "-" not in raw:
raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}")
start_str, end_str = raw.split("-", 1)
start = _hhmm_to_minutes(start_str.strip())
end = _hhmm_to_minutes(end_str.strip())
if start >= end:
raise ValueError(f"window start must be before end: {raw!r}")
return start, end
def _hhmm_to_minutes(value: str) -> int:
parts = value.split(":")
if len(parts) != 2:
raise ValueError(f"invalid time {value!r}, expected HH:MM")
hours, minutes = int(parts[0]), int(parts[1])
if not (0 <= hours < 24 and 0 <= minutes < 60):
raise ValueError(f"time out of range: {value!r}")
return hours * 60 + minutes
def _minute_to_time(total_minutes: int) -> time:
return time(total_minutes // 60, total_minutes % 60)
def _parse_date(raw: object) -> date:
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
return date.fromisoformat(str(raw))
def _parse_days(spec: object) -> set[int]:
"""Parse a cron day-of-week spec into a set of cron weekdays (0/7=Sun, 1=Mon..6=Sat)."""
text = str(spec).strip()
if text == "*":
return set(range(7))
result: set[int] = set()
for part in text.split(","):
part = part.strip()
if "-" in part:
low_str, high_str = part.split("-", 1)
low, high = int(low_str), int(high_str)
result.update(_normalize_dow(day) for day in range(low, high + 1))
else:
result.add(_normalize_dow(int(part)))
return result
def _normalize_dow(value: int) -> int:
"""cron allows 7 for Sunday; normalize it to 0."""
if not 0 <= value <= 7:
raise ValueError(f"day-of-week out of range (0-7): {value}")
return 0 if value == 7 else value
def _cron_weekday(target: date) -> int:
"""Map Python weekday (Mon=0..Sun=6) to cron weekday (Sun=0, Mon=1..Sat=6)."""
return (target.weekday() + 1) % 7

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic CRUD for reminder.yaml.
CLI tool for LLM skills to create, list, and remove reminders atomically.
Never edits reminder.yaml directly — always writes to a .tmp file and renames.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import date, datetime
from pathlib import Path
import yaml
from croniter import croniter
from random_times import compute_fire_times
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
REMINDER_YAML = WORKSPACE / "reminder.yaml"
def _load() -> dict:
if not REMINDER_YAML.exists():
return {"reminders": []}
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
if "reminders" not in data:
data["reminders"] = []
return data
def _save(data: dict) -> None:
tmp = REMINDER_YAML.with_suffix(".yaml.tmp")
tmp.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
os.replace(tmp, REMINDER_YAML)
def cmd_list(_args: argparse.Namespace) -> int:
data = _load()
print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False))
return 0
def cmd_add(args: argparse.Namespace) -> int:
text = (args.text or "").strip()
if not text:
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
return 1
try:
random_cfg = _build_random(args)
except ValueError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
if not args.at and not args.cron and not random_cfg:
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
return 1
item: dict = {"text": text}
if args.at:
for at_str in args.at:
try:
datetime.fromisoformat(at_str)
except ValueError as exc:
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
return 1
if len(args.at) == 1:
item["at"] = args.at[0]
else:
item["at_times"] = args.at
if args.cron:
for expr in args.cron:
if not croniter.is_valid(expr):
print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr)
return 1
item["cron_exprs"] = args.cron
if random_cfg:
item["random"] = random_cfg
data = _load()
data["reminders"].append(item)
_save(data)
print(json.dumps({"added": item}, ensure_ascii=False))
return 0
def _build_random(args: argparse.Namespace) -> dict | None:
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
fields = {
"times_per_day": args.random_times_per_day,
"window": args.random_window,
"days": args.random_days,
"from": args.random_from,
"until": args.random_until,
}
if all(value is None for value in fields.values()):
return None
if fields["times_per_day"] is None or fields["window"] is None:
raise ValueError("random schedule needs --random-times-per-day and --random-window")
cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config
return cfg
def cmd_remove(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
data = _load()
matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()]
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
removed = matches[0]
data["reminders"] = [r for r in data["reminders"] if r is not removed]
_save(data)
print(json.dumps({"removed": removed}, ensure_ascii=False))
return 0
def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminder.yaml")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="List all reminders as JSON")
add_p = sub.add_parser("add", help="Add a new reminder")
add_p.add_argument("--text", required=True, help="Reminder text")
add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable, combinable with --cron)")
add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day")
add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window")
add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter, e.g. '1-5' (optional)")
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date, inclusive (optional)")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
args = parser.parse_args()
dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove}
sys.exit(dispatch[args.command](args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic reminder sender.
Runs every minute from the nanobot user crontab (NOT through the agent).
Reads reminder.yaml, finds reminders due this minute, sends each directly to
Telegram via the Bot API, appends the delivery to reminder.log, and dedups via
.reminder_state.json so each scheduled fire is delivered exactly once.
No LLM and no nanobot process involved on purpose -- see knowledge.md/history
for why the previous agent-driven cron job spammed empty-output messages.
"""
from __future__ import annotations
import hashlib
import json
import sys
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from croniter import croniter
from random_times import compute_fire_times
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
REMINDER_YAML = WORKSPACE / "reminder.yaml"
STATE_FILE = WORKSPACE / ".reminder_state.json"
LOG_DIR = WORKSPACE / "log"
LOG_FILE = LOG_DIR / "reminder.log"
CONFIG = Path.home() / ".nanobot" / "config.json"
TZ = ZoneInfo("Europe/Prague")
CHAT_ID = "8826147089" # Telegram user id (Martin); same target the old cron job used
def _telegram_token() -> str:
data = json.loads(CONFIG.read_text(encoding="utf-8"))
return data["channels"]["telegram"]["token"]
def _send_telegram(text: str) -> None:
token = _telegram_token()
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
resp.read()
def _load_state() -> dict:
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
def _key(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
def _due_fire(item: dict, now: datetime) -> datetime | None:
"""Most recent scheduled fire-time within the last 60s, or None."""
fire: datetime | None = None
at_str = item.get("at")
if at_str:
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
if 0 <= (now - at_time).total_seconds() < 60:
fire = at_time
for at_str in item.get("at_times", []):
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
if 0 <= (now - at_time).total_seconds() < 60 and (fire is None or at_time > fire):
fire = at_time
for expr in item.get("cron_exprs", []):
prev = croniter(expr, now).get_prev(datetime)
if 0 <= (now - prev).total_seconds() < 60 and (fire is None or prev > fire):
fire = prev
random_cfg = item.get("random")
if random_cfg:
try:
for ft in compute_fire_times(now.date(), (item.get("text") or "").strip(), random_cfg):
if 0 <= (now - ft).total_seconds() < 60 and (fire is None or ft > fire):
fire = ft
except ValueError as exc: # malformed config: skip this reminder, keep others working
print(f"remind_send: bad random config for {item.get('text')!r}: {exc}", file=sys.stderr)
return fire
def main() -> None:
if not REMINDER_YAML.exists():
return
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
now = datetime.now(TZ).replace(tzinfo=None)
state = _load_state()
fresh: dict[str, str] = {}
for item in data.get("reminders", []):
text = (item.get("text") or "").strip()
if not text:
continue
key = _key(text)
last = state.get(key)
fire = _due_fire(item, now)
if fire is None:
if last: # preserve dedup info for reminders not due this minute
fresh[key] = last
continue
fire_iso = fire.isoformat()
if last == fire_iso: # this exact fire was already delivered
fresh[key] = last
continue
try:
_send_telegram(f"⏰ Reminder: {text}")
except Exception as e: # leave state untouched so next run retries
print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr)
if last:
fresh[key] = last
continue
ts = datetime.now(TZ).replace(tzinfo=None).isoformat(timespec="seconds")
LOG_DIR.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(f"{ts} {text}\n")
fresh[key] = fire_iso
if fresh != state:
STATE_FILE.write_text(json.dumps(fresh, indent=2, ensure_ascii=False), encoding="utf-8")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
import sys
from pathlib import Path
# random_times.py lives in the sibling scripts/ directory.
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

View File

@@ -0,0 +1,96 @@
from datetime import date, datetime
import pytest
from random_times import MIN_GAP_MIN, compute_fire_times
# Window 09:00-21:00 = minutes 540..1260 -> 720 min wide.
WINDOW = "09:00-21:00"
WINDOW_START = datetime(2026, 3, 21, 9, 0)
WINDOW_END = datetime(2026, 3, 21, 21, 0)
def cfg(**overrides) -> dict:
base = {"times_per_day": 5, "window": WINDOW}
base.update(overrides)
return base
def test_deterministic():
day = date(2026, 3, 21)
assert compute_fire_times(day, "drink water", cfg()) == compute_fire_times(day, "drink water", cfg())
def test_differs_per_text():
day = date(2026, 3, 21)
assert compute_fire_times(day, "drink water", cfg()) != compute_fire_times(day, "stretch", cfg())
@pytest.mark.parametrize("count", [1, 2, 5, 10])
def test_count_matches_times_per_day(count):
times = compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=count))
assert len(times) == count
def test_min_gap_respected():
times = compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=8))
for earlier, later in zip(times, times[1:]):
assert (later - earlier).total_seconds() >= MIN_GAP_MIN * 60
def test_within_window():
for fire in compute_fire_times(date(2026, 3, 21), "x", cfg()):
assert WINDOW_START <= fire <= WINDOW_END
@pytest.mark.parametrize(
"day,expected",
[
(date(2026, 3, 23), True), # Monday
(date(2026, 3, 27), True), # Friday
(date(2026, 3, 28), False), # Saturday
(date(2026, 3, 29), False), # Sunday
],
)
def test_days_weekday_filter(day, expected):
times = compute_fire_times(day, "x", cfg(days="1-5"))
assert bool(times) == expected
@pytest.mark.parametrize(
"spec,day,expected",
[
("*", date(2026, 3, 28), True), # Saturday allowed by wildcard
("0", date(2026, 3, 29), True), # Sunday as 0
("7", date(2026, 3, 29), True), # Sunday as 7
("1,3,5", date(2026, 3, 25), True), # Wednesday
("1,3,5", date(2026, 3, 24), False), # Tuesday
],
)
def test_days_parser(spec, day, expected):
times = compute_fire_times(day, "x", cfg(days=spec))
assert bool(times) == expected
def test_from_until_filter():
bounded = cfg(**{"from": "2026-06-01", "until": "2026-06-30"})
assert compute_fire_times(date(2026, 5, 31), "x", bounded) == []
assert compute_fire_times(date(2026, 7, 1), "x", bounded) == []
assert len(compute_fire_times(date(2026, 6, 15), "x", bounded)) == 5
def test_infeasible_count_raises():
# 50 times * 15-min gap = 735 min required > 720 min window.
with pytest.raises(ValueError):
compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=50))
def test_bad_window_raises():
with pytest.raises(ValueError):
compute_fire_times(date(2026, 3, 21), "x", cfg(window="21:00-09:00"))
def test_config_validated_before_date_filter():
# Out-of-range date still surfaces a structural error rather than returning [].
with pytest.raises(ValueError):
compute_fire_times(date(2000, 1, 1), "x", cfg(times_per_day=50, until="1999-01-01"))