Zalohovani vsech podstatnych souboru
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
16
HEARTBEAT.md
Normal 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
25
develop/README.md
Normal 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
1420
develop/history.md
Normal file
File diff suppressed because it is too large
Load Diff
681
develop/knowledge.md
Normal file
681
develop/knowledge.md
Normal 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 |
|
||||||
|
| 6–7 | **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é (10–30 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 ~1–2k 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 (~40–75 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ítek–stovek 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 550–700 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 ~28–32k 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` má **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 | 1200–1730 | **~198 tok/s** |
|
||||||
|
| minimax-m3 | ~6,8 s | ~10,8 s | 420–460 | **~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 3–4× MÉNĚ vygenerovaných tokenů**. Čistá generace minimaxu ~95–120 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
33
develop/memory.md
Normal 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
17
knowledge/README.md
Normal 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
161
knowledge/models.md
Normal 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 | 1200–1730 | **~198 tok/s** |
|
||||||
|
| `minimax-m3` | ~6,8 s | ~10,8 s | 420–460 | **~40 tok/s** |
|
||||||
|
|
||||||
|
`minimax-m3` je o cca **50 % pomalejší v celkové době, i přes 3–4× MÉNĚ vygenerovaných
|
||||||
|
tokenů**. TTFT mají srovnatelný (start není problém). Čistá generace m3 je ~95–120 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,3–2,5 s | notably slow + very verbose |
|
||||||
|
| Kimi K2.6 | 54 | ~44 t/s | 170M (průměr 43M) | ~2,2–3,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
1
memory/.cursor
Normal file
@@ -0,0 +1 @@
|
|||||||
|
327
|
||||||
23
memory/MEMORY.md.bak
Normal file
23
memory/MEMORY.md.bak
Normal 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
327
memory/history.jsonl
Normal file
File diff suppressed because one or more lines are too long
50
plans/brain-short.md
Normal file
50
plans/brain-short.md
Normal 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
213
plans/brain.md
Normal 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
202
plans/projects.md
Normal 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 10–20 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 8–21h)
|
||||||
|
- 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
14
projects/radio-1.md
Normal 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
|
||||||
|
|
||||||
693
results/2026-06-02_remind-skill-analysis-and-improvements.md
Normal file
693
results/2026-06-02_remind-skill-analysis-and-improvements.md
Normal 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.
|
||||||
187
results/2026-06-07_ollama-cloud-agent-model-comparison.md
Normal file
187
results/2026-06-07_ollama-cloud-agent-model-comparison.md
Normal 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.*
|
||||||
69
results/2026-06-07_ollama-cloud-model-report.md
Normal file
69
results/2026-06-07_ollama-cloud-model-report.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Přehled modelů Ollama Cloud (červen 2026)
|
||||||
|
|
||||||
|
**Datum:** 2026‑06‑07
|
||||||
|
|
||||||
|
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í nanobot‑agenta. 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ů (SWE‑Bench, Terminal‑Bench, Code Arena, MCP‑Atlas, HLE atd.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Tabulka přehledu
|
||||||
|
|
||||||
|
| Model | Rychlost (tok/s) | Inteligence (benchmark) | Tool‑calling | Čeština / Multilingual | Kontext | Multimodální | Licence | Verbosita | Známé bugy / blokátory | Cena (OpenRouter proxy) | Long‑horizon stabilita | Self‑host možnost |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **glm-5.1:cloud** | ~198 (přímé měření) | SWE‑Bench Pro 58.4 % – Code Arena Elo 1530 | 99.6 % schema adherence, žádné známé selhání | Žádná oficiální podpora češtiny, ale žádný drift | 200 K (198 K Ollama) | ❌ (text‑only) | MIT | Nízká | – | ~$4 /M output (Ollama Pro flat‑rate $20 /mo) | Ověřeno stovkami kol – nejlepší | Ano (vyžaduje ~30 GB VRAM) |
|
||||||
|
| **deepseek-v4-flash:cloud** | ~30‑50* (odhad) | SWE‑Bench ~75 % (odhad) | Dobrá | Žádná explicitní podpora češtiny, ale silná multilingvní skóre (MMMLU 90.3) | 1 M | ❌ | MIT | Střední | – | $0.14 /M in | Neověřeno (uživatelské benchmarky) | Ano (efektivní MoE, 13 B aktivních) |
|
||||||
|
| **qwen3.5:397b-cloud** | ~10‑20* (odhad) | SWE‑Bench 66‑70 % | Dobrá | **201 jazyk včetně češtiny** (oficiální) | 1 M | ✅ | Apache 2.0 | Střední | Uživatelé hlásí pomalost a přesnostní problémy | – (žádná proxy cena, Ollama Pro) | Neověřeno | Ano (Apache 2.0, vyžaduje velké GPU) |
|
||||||
|
| **deepseek-v4-pro:cloud** | ~15.4 (přímé měření) | SWE‑Bench 80.6 % (nejvyšší) | Dobrá | Žádná explicitní podpora češtiny | 1 M | ❌ | MIT | Střední | Extrémní latence (57 s TTFT), vysoká variabilita | $1.74 /M in | Neznámo | Ano (MIT) |
|
||||||
|
| **devstral-2:123b-cloud** | ~40‑60* (odhad) | SWE‑Bench 72.2 % – Terminal‑Bench 77.3 % (nejvyšší) | Dobrá | Žádná explicitní podpora češtiny | 128 K | ❌ | Apache 2.0 | Střední | – | – | Neověřeno | Ano (Apache 2.0) |
|
||||||
|
| **gemma4:31b-cloud** | ~80‑120* (odhad) | SWE‑Bench ~52 % – Code Arena nízké | Native function calling | 140+ jazyků (neuvádí češtinu) | 256 K | ✅ | Apache 2.0 | Nízká | – | – | Neověřeno | Ano (31 B dense) |
|
||||||
|
| **nemotron-3-ultra:cloud** | ~50‑80* (odhad) | SWE‑Bench ~60‑79 % (odhad) | Neznámo | Žádná oficiální podpora češtiny | 200 K | ❌ | NVIDIA Open License | Nízká | Příliš nový – žádná data o tool‑callingu | $0.60 /M in | Neověřeno | Ano (vyžaduje NVIDIA‑specifické kvantování) |
|
||||||
|
| **nemotron-3-super:cloud** | ~60‑100* (odhad) | SWE‑Bench 60.47 % (odhad) | Neznámo | Žádná podpora češtiny | 1 M | ❌ | NVIDIA Open License | Nízká | – | – | Neověřeno | Ano (vyžaduje NVIDIA‑specifické kvantování) |
|
||||||
|
| **minimax-m3:cloud** | ~40‑60* (odhad) | – (žádná veřejná benchmark data) | **Kritický bug** – selhání tool‑result zpráv (issue #16389) | Žádná explicitní podpora češtiny | 512 K | ✅ | Open weights (brzy) | – | Tool‑result bug – **nepoužitelné** | $0.60 /M in | Neověřeno | Ano (otevřené váhy) |
|
||||||
|
| **kimi-k2.6:cloud** | ~30‑50* (odhad) | SWE‑Bench 80.2 % – HLE 54.0 % | Dobrá | Žádná podpora češtiny, **náhodný čínský drift** (kritické) | 256 K | ✅ | Modified MIT | Střední | Čínský výstupní drift, OpenRouter kontext‑bug (32 K) | $0.60 /M in | 200‑300 tool calls (design) | Ano (MIT‑like) |
|
||||||
|
| **qwen3.6:cloud** | ~? (nepřímý odhad) | – | – | 201 jazyků (včetně češtiny) | 256 K | ✅ | Apache 2.0 | Střední | – | – | – | Ano |
|
||||||
|
| **qwen3-coder-next:cloud** | ~40‑60* (odhad) | SWE‑Bench ~70.6 % (odhad) | Dobrá | 201 jazyků (včetně češtiny) | 512 K | ❌ | Apache 2.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** | ~60‑80* (odhad) | – | – | Strong (Google) | 1 M | ✅ | 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 1 M 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 tool‑result 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.
|
||||||
|
|
||||||
|
### Watch‑list (sledujte vývoj)
|
||||||
|
- **`nemotron-3-ultra:cloud`** – slibné specifikace, ale chybí reálná data o tool‑calling a dlouhodobé stabilitě.
|
||||||
|
- **`qwen3-coder-next:cloud`** – zaměřeno na kódování, 512 K kontext, dobrá podpora jazyků.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Metodologie a zdroje
|
||||||
|
- **Seznam modelů:** https://ollama.com/models?c=cloud (scraped 2026‑06‑07).
|
||||||
|
- **Benchmarky a metriky:** `knowledge/models.md`, `results/2026-06-07_ollama-cloud-agent-model-comparison.md`, veřejné benchmarky (SWE‑Bench, Terminal‑Bench, Code Arena, MCP‑Atlas, HLE, MMMLU, C‑Eval).
|
||||||
|
- **Bug‑trackery:** 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 self‑host:** informace z oficiálních modelových repozitářů (Hugging Face, NVIDIA, Z‑AI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*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ů.*
|
||||||
362
results/2026-06-07_todo-skill-unification-analysis.md
Normal file
362
results/2026-06-07_todo-skill-unification-analysis.md
Normal 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)
|
||||||
260
results/2026-06-10_remind-skill-sqlite-redesign.md
Normal file
260
results/2026-06-10_remind-skill-sqlite-redesign.md
Normal 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 1–5 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`. |
|
||||||
141
results/remind-skill-audit-2026-06-02.md
Normal file
141
results/remind-skill-audit-2026-06-02.md
Normal 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` má `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 |
|
||||||
87
results/remind-skill-top5-priorities-2026-06-02.md
Normal file
87
results/remind-skill-top5-priorities-2026-06-02.md
Normal 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 1–4 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 1–4 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 P2–P3 — užitečné, ale nejsou blokátory.*
|
||||||
74
scripts/check_nanobot_version.py
Executable file
74
scripts/check_nanobot_version.py
Executable 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()
|
||||||
5
scripts/ollama_library.txt
Normal file
5
scripts/ollama_library.txt
Normal file
File diff suppressed because one or more lines are too long
27
scripts/ollama_library_full.txt
Normal file
27
scripts/ollama_library_full.txt
Normal file
File diff suppressed because one or more lines are too long
23
scripts/parse_library.py
Normal file
23
scripts/parse_library.py
Normal 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
33
skills/article/SKILL.md
Normal 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**: 1–3 odstavce plynulé prózy, každý 2–4 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:** 1–2 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
43
skills/bash/SKILL.md
Normal 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
91
skills/bookmark/SKILL.md
Normal 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`
|
||||||
226
skills/bookmark/scripts/bookmark.py
Normal file
226
skills/bookmark/scripts/bookmark.py
Normal 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()
|
||||||
89
skills/deep-research/SKILL.md
Normal file
89
skills/deep-research/SKILL.md
Normal 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 **3–6 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 2–4 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í
|
||||||
|
2–4 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
108
skills/detach/SKILL.md
Normal 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**: 3–5 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._
|
||||||
78
skills/detach/architecture.md
Normal file
78
skills/detach/architecture.md
Normal 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
|
||||||
BIN
skills/detach/scripts/__pycache__/create-task.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/create-task.cpython-314.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-312.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-312.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-314.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-313.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-313.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-314.pyc
Normal file
Binary file not shown.
53
skills/detach/scripts/archive-tasks.py
Normal file
53
skills/detach/scripts/archive-tasks.py
Normal 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())
|
||||||
83
skills/detach/scripts/create-task.py
Executable file
83
skills/detach/scripts/create-task.py
Executable 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())
|
||||||
41
skills/detach/scripts/list-tasks.py
Executable file
41
skills/detach/scripts/list-tasks.py
Executable 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()
|
||||||
61
skills/detach/scripts/read-task.py
Executable file
61
skills/detach/scripts/read-task.py
Executable 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()
|
||||||
170
skills/detach/scripts/tasks-daemon.py
Executable file
170
skills/detach/scripts/tasks-daemon.py
Executable 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())
|
||||||
241
skills/detach/scripts/tasks_common.py
Normal file
241
skills/detach/scripts/tasks_common.py
Normal 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"
|
||||||
|
)
|
||||||
9
skills/detach/systemd/tasks-daemon.path
Normal file
9
skills/detach/systemd/tasks-daemon.path
Normal 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
|
||||||
18
skills/detach/systemd/tasks-daemon.service
Normal file
18
skills/detach/systemd/tasks-daemon.service
Normal 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
|
||||||
Binary file not shown.
Binary file not shown.
5
skills/detach/tests/conftest.py
Normal file
5
skills/detach/tests/conftest.py
Normal 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"))
|
||||||
490
skills/detach/tests/test_tasks_common.py
Normal file
490
skills/detach/tests/test_tasks_common.py
Normal 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
10
skills/grill-me/SKILL.md
Normal 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
77
skills/keep/SKILL.md
Normal 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 5–15 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
88
skills/note/SKILL.md
Normal 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
200
skills/note/scripts/note.py
Normal 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
96
skills/plan/SKILL.md
Normal 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 1–3.** 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
88
skills/project/SKILL.md
Normal 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.
|
||||||
184
skills/project/scripts/project.py
Normal file
184
skills/project/scripts/project.py
Normal 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
64
skills/python/SKILL.md
Normal 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 (0–3)
|
||||||
|
- 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 2–3 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`
|
||||||
693
skills/remind/IMPROVEMENTS_REPORT.md
Normal file
693
skills/remind/IMPROVEMENTS_REPORT.md
Normal 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
71
skills/remind/SKILL.md
Normal 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:00–21:00, Mon–Fri" (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.
|
||||||
39
skills/remind/reminder.example.yaml
Normal file
39
skills/remind/reminder.example.yaml
Normal 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"
|
||||||
BIN
skills/remind/scripts/__pycache__/random_times.cpython-313.pyc
Normal file
BIN
skills/remind/scripts/__pycache__/random_times.cpython-313.pyc
Normal file
Binary file not shown.
122
skills/remind/scripts/random_times.py
Normal file
122
skills/remind/scripts/random_times.py
Normal 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
|
||||||
171
skills/remind/scripts/remind_edit.py
Executable file
171
skills/remind/scripts/remind_edit.py
Executable 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()
|
||||||
150
skills/remind/scripts/remind_send.py
Normal file
150
skills/remind/scripts/remind_send.py
Normal 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()
|
||||||
Binary file not shown.
Binary file not shown.
5
skills/remind/tests/conftest.py
Normal file
5
skills/remind/tests/conftest.py
Normal 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"))
|
||||||
96
skills/remind/tests/test_random_times.py
Normal file
96
skills/remind/tests/test_random_times.py
Normal 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"))
|
||||||
Reference in New Issue
Block a user