skill and develop

This commit is contained in:
lachtan
2026-08-27 14:18:51 +02:00
parent 8119f780e7
commit f78f667823
4 changed files with 682 additions and 70 deletions

View File

@@ -2173,3 +2173,157 @@ Běh 5 modelů s gate=3:
**Co zbývá:** Zápis do `decisions.md` (návrh připraven, čeká na formulaci autora — ruší dosavadní rámování „duplicitní texty povoleny by design"). Behaviorální ověření dělá uživatel v chatu. **Co zbývá:** Zápis do `decisions.md` (návrh připraven, čeká na formulaci autora — ruší dosavadní rámování „duplicitní texty povoleny by design"). Behaviorální ověření dělá uživatel v chatu.
**Jak vrátit zpět:** Repo `git revert 61e7068` + `rsync` na server. Změna je čistě logika CLI (žádná migrace DB), revert je bezpečný. **Jak vrátit zpět:** Repo `git revert 61e7068` + `rsync` na server. Změna je čistě logika CLI (žádná migrace DB), revert je bezpečný.
## 2026-07-15 — sync skillů ze serveru: bookmark autonomně upravený Dreamem
**Cíl:** Uživatel požádal o obecný sync skillů ze serveru do repa (bez upřesnění, který). Podle pravidla v `CLAUDE.md` („server je zdroj pravdy") jsem prošel checksumem všechny lokální skilly proti serveru před jakýmkoli přepisem.
**Co jsem zkusil:** `rsync --dry-run --checksum --itemize-changes` pro každý skill (`article`, `bash`, `bookmark`, `deep-research`, `detach`, `grill-me`, `keep`, `llm-wiki`, `note`, `plan`, `python`; `remind` ověřen zvlášť plným stažením a `diff -rq`).
**Co fungovalo a proč:** `remind` beze změny (server == lokál, potvrzuje předchozí záznam). `detach` a `llm-wiki` hlásily rozdíl jen v mtime (`.f..t......`, bez `c`/`s`) → obsah identický, ignorováno. `bookmark/SKILL.md` měl skutečný obsahový rozdíl (`>fcst......`) — server má autonomní úpravu (pravděpodobně Dream): (1) přehozené pořadí ve formátu výpisu — `<description>` teď před `[<tags>]`, ne za nimi; (2) nová sekce „Known limitations" (chybí `edit`/`update`, `delete` je jen soft-delete, weby za Cloudflare nejdou auto-fetchnout). Stáhl jsem přesně tuto verzi do `skills/bookmark/SKILL.md`, ověřeno `diff` na 100% shodu.
**Co zbývá:** Server != repo u `keep` (SKILL.md) — dry-run to ukázal, ale nebylo součástí zadání, needitoval jsem. Zvážit v příští session. Drobná nekonzistence, kterou sync odhalil a nechal beze změny: příklad výpisu na řádku `#3 [hackaday.com](...) [linux, strace] — lepší strace` v `bookmark/SKILL.md` neodpovídá nově popsanému formátu (description by měl být před tagy) — je to takto i na serveru, nešlo o repo vs. server rozdíl, takže jsem to nepravil bez zeptání.
## 2026-07-15 — compact-memory-auto-daily: diagnóza "podivných hlášek" na Telegramu + migrace na crontab s čerstvou session
**Cíl:** Uživatel hlásil podivné/nesmyslné hlášky na Telegramu z posledních dní; v noci má běžet `compact-memory` z cronu, ale něco je špatně. Nejdřív jen prozkoumat (bez zásahu), pak — po potvrzení, že denní doručení je záměr — opravit kontaminaci kontextu.
**Diagnóza (nainstalovaný `nanobot-ai` v0.2.2):** Cron job `compact-memory-auto-daily` (`jobs.json`, id `69484aa6`) byl `agent_turn` s `payload.session_key = "telegram:8826147089"` — **stejná trvalá session jako běžný Telegram chat** (potvrzeno v `sessions/telegram_8826147089.jsonl`: 75/77 zpráv obyčejná konverzace, jen 2 cron marker). `is_bound_cron_job`/`run_bound_cron_job` (nahrazuje starý "hardcoded reminder preamble + evaluate_response" mechanismus popsaný výše v knowledge.md pro starší verzi) doručuje odpověď **bez jakéhokoli gatingu** — cokoli agent v tahu napíše, jde přímo do Telegramu. Navíc `origin_delivery_context` (kam se doručí) je **nezávislé** na `session_key` (do jaké session/historie se zapíše) — to je klíč k opravě. 2026-07-14 se agent v cron tahu spletl a pokusil se spustit neexistující `scripts/compact_memory.py` (skill ho nikdy neměl mít), napsal do chatu zmatenou zprávu, uživatel ho opravil ve stejné session — a protože session je trvalá, ten omyl kontaminoval i další noci (přesně text, co uživatel citoval jako "podivnou hlášku", je z nočního běhu 2026-07-15 02:00).
**Řešení (schváleno přes plan mode):** Migrace na vzor `/remind`/`detach` — systémový crontab + samostatný skript mimo nanobot `cron/jobs.json`, s **unikátní `session_key` per běh** (nikdy znovu použitý → nikdy žádná akumulace historie), doručení přímo přes Telegram Bot API (mimo nanobot channel pipeline).
**Co jsem udělal:**
- Stáhl `skills/compact-memory/SKILL.md` ze serveru do repa poprvé (skill dosud nebyl trackovaný lokálně) + přidal větu "This skill has no accompanying script — every step below is performed by you, the agent..." — cílená prevence přesně té 07-14 halucinace.
- Nový `skills/compact-memory/scripts/compact_memory_auto.py` (PEP 723, vzor `tasks-daemon.py`/`remind_send.py`): `Nanobot.from_config().run(GOAL, session_key=f"compact-memory-auto:{timestamp}")` s 10min timeoutem, výsledek poslán přímo `urllib` na Telegram Bot API (token/chat_id z `config.json`, stejný `_telegram_config()` vzor).
- Nasazeno `rsync -av skills/compact-memory/` (celý adresář), owner `nanobot:nanobot` ✓.
- Přidán řádek do system crontabu (`0 2 * * * uv run .../compact_memory_auto.py >> log/compact_memory_auto_cron.log`), stávající 3 řádky (remind/wiki-compile/note-compile) nedotčené.
- Odebrán job `69484aa6` z `jobs.json` (in-place Python edit, bez restartu — zbylé 3 joby beze změny).
- Opravena zastaralá věta v serverovém `memory/MEMORY.md` (psala, že job běží v Telegram session — teď už neplatí).
**Co fungovalo a proč:** Ruční test skriptu na serveru proběhl čistě (exit 0): reálný audit MEMORY.md (32 řádků), skutečně smazal 1 duplicitu a sloučil 2 dvojice, zapsal zálohu do `backup/2026-07-15_0541_memory.backup.md` i do `log/memory-clean.log`, report doručen do Telegramu. Ověřeno, že vznikla **nová izolovaná** session `sessions/compact-memory-auto_20260715-054147.jsonl` a `telegram_8826147089.jsonl` zůstal nedotčený (78 řádků beze změny) — potvrzuje, že `session_key` a `origin_channel`/`origin_chat_id` jsou v `nanobot-ai` 0.2.2 opravdu nezávislé, přesně jak ukázala explorace zdrojáku.
**Co zbývá:** Počkat na první reálný noční běh (příští 2:00) a ověřit stejné chování automaticky. Navrhnout uživateli větu do `decisions.md` (čeká na formulaci autora): "Nanobot `agent_turn` cron joby pro neinteraktivní/background úlohy vždy mimo `jobs.json`, přes systémový crontab + přímé `Nanobot.run()` s per-run unikátní `session_key` — stejný vzor jako `/remind` a `detach`." Zvážit doplnění knowledge.md o `is_bound_cron_job`/`origin_delivery_context` mechanismus 0.2.2 (nahrazuje starší popsaný "hardcoded preamble + evaluate_response" — ten je teď verzí-specifický/zastaralý).
**Jak vrátit zpět:** V `jobs.json` přidat job `69484aa6` zpět (zálohovaný obsah je v git historii commitu před touto změnou), odebrat řádek z crontabu (`crontab -e`), smazat `skills/compact-memory/scripts/compact_memory_auto.py`. Migrace `MEMORY.md` (smazaná duplicita, sloučené bullet) je nedestruktivní — plný snapshot je v `backup/2026-07-15_0541_memory.backup.md` na serveru.
## 2026-07-22 — nový skill `project` (nahrazen netrackovaný server-side skill, migrace `radio-1`)
**Cíl:** Uživatel chtěl nový skill `project`, analogii Claude.ai "Projects" — pojmenované dlouhodobé pracovní kontexty s vlastním promptem, historií a artefakty. Plánováno v plan mode: prozkoumáno 13 existujících skillů + `knowledge.md` pro konvence (`description` = jen routing signál s "Triggers on:", cesty workspace-relativní, žádný sticky session-state u nanobota). Uživatel v review odmítl jmenovat konkrétní jiné skilly v description/těle (odlišení jen funkčně) a trval na tom, že skill smí zakládat nový projekt jen po explicitním potvrzení, nikdy automaticky. Přidán čtvrtý soubor `state.md` (živý, přepisovaný dokument "kde to teď je") vedle append-only `memory.md`.
**Co jsem zkusil:** Před nasazením (pravidlo "server je zdroj pravdy") jsem ověřil `~/.nanobot/workspace/skills/project/` na serveru — **a objevil tam už existující, plně funkční `project` skill, nikde v tomto repu netrackovaný** (žádná zmínka v `history.md`/`knowledge.md`/`decisions.md`, žádná git historie k `skills/project/`). Starý design: plochý soubor `projects/<slug>.md` s YAML frontmatterem (`status: active|paused|done`, `priority: high|medium|low`), tělo se sekcemi `## Poznámky`/`## Další krok`, CLI backend `scripts/project.py` (add/list/show/status), `switch` ukládající aktivní projekt do `my(action="set", key="project_context", ...)` scratchpad nástroje, frontmatter měl i `aliases: [proj]`. Existovala reálná data: `projects/radio-1.md` (založeno 2026-06-09, projekt na stříhání audio streamu Radia 1).
**Co fungovalo a proč:** Zastavil jsem se a nechal uživatele rozhodnout (AskUserQuestion) — odpověď: přepsat novým designem, `radio-1` migrovat na `radio1`. Provedeno: `rsync -av --delete skills/project/ nanobot@...` (smazal starý `scripts/project.py`, nahradil `SKILL.md` novým), obsah `radio-1.md` ručně rozparsován do nového formátu — dated bullet z `## Poznámky` → `projects/radio1/memory.md`, cíl+stav+priorita syntetizovány do `projects/radio1/state.md`, `prompt.md` prázdný (nic k migraci), `artifacts/` prázdný adresář — nahráno přes `rsync` do `~/.nanobot/workspace/projects/radio1/`, starý `projects/radio-1.md` smazán. Ověřeno vlastnictví `nanobot:nanobot` u všech nových souborů/adresářů.
**Co zbývá:** Ověřit na živém chatu, že skill funguje end-to-end (aktivace, switch, ukončení, listing, zápis do `memory.md`/`state.md`) — zatím jen nasazeno a data migrována, ne vyzkoušeno v konverzaci. Zvážit zápis do `knowledge.md`: nanobot má `my(action="set"/"get", key=..., value=...)` scratchpad nástroj pro perzistentní stav napříč tahy (starý skill ho používal pro `project_context`) — objeveno náhodou, nebylo součástí dřívějšího průzkumu "nanobot nemá sticky session-state"; stálo by za ověření zdrojáku, jak přesně `my` funguje (per-session, nebo globální — starý skill sám poznamenává "lost on restart", žádnou zmínku o per-session scoping). Navrhnout `decisions.md` zápis (viz níže, čeká na souhlas autora).
**Jak vrátit zpět:** Stará verze skillu (`SKILL.md` + `scripts/project.py`) je stažená v `tmp/server-project-check/` (negitované, lokální). Obsah `radio-1.md` je beze změny zachycen ve stejném adresáři přes `git show`/`cat ~/.nanobot/workspace/projects/radio1/memory.md`+`state.md` (sloučit zpět ručně). Žádná záloha stažené staré verze v gitu — je jen v `tmp/`, které je gitignored; pokud má mít trvalou stopu, je potřeba ji dodatečně zkopírovat mimo `tmp/`.
## 2026-07-22 — nový projekt `life` založen na serveru
**Cíl:** Uživatel chtěl na serveru přes skill `project` založit dlouhodobý projekt `life` — osobní průvodce/deníček k udržitelnému životnímu stylu (cvičení, běh, jídelní návyky, skloubení s rodinou a dítětem), kam si bude průběžně zapisovat úvahy. Primární cíl projektu: změna váhy + dobrá kondice.
**Co jsem zkusil:** Ověřil stav serveru (skill `project` nasazený a identický s repem, existoval jen `radio1`, žádný `life`). V plan mode přes AskUserQuestion vyjasnil scope. Rozhodnutí uživatele: jídlo řešit jako návyky (večerní jedení, sladké), NE počítat kalorie ani zapisovat jídlo (kalorické tabulky má stranou); oblasti navíc = alkohol + protahování/mobilita (dělá po běhu, chce integrovat pravidelněji); cvičení (vlastní váha/kettlebell) i běh zatím nepravidelné; pokrok sleduje denním vážením + vnímanou kondicí a bolestmi; role agenta = průvodce/deníček, přístup se bude zpřesňovat; do `artifacts/` jdou prohřešky a reálné překážky.
**Co fungovalo a proč:** Připravil 4 soubory lokálně ve scratchpadu a nasadil `rsync -av scratchpad/life/ nanobot@nanobot.hell:.../projects/life/` — `prompt.md` (kontext + oblasti + kam co zapisovat + „první session" checklist), `state.md` (skeleton výchozího stavu/cílů), `memory.md` (první záznam se založením), `artifacts/prekazky-a-prohresky.md` (prázdný starter). Ověřeno: strom 4 soubory + adresář, vše `nanobot:nanobot`. Skilly/projekty se čtou bez restartu služby.
**Co zbývá:** Vyzkoušet v živém chatu aktivaci („projekt life" → načtení `prompt.md`). Výchozí čísla (aktuální váha, časový rozpočet, cílová váha/horizont) doplní uživatel v první session — záměrně nevyplněno (skill: nefabrikovat obsah). Projekt žije jen na serveru, do repa se necommituje (stejně jako `radio1`).
---
## 2026-07-25 — compact-memory: zkrácení nočního reportu (jeden řádek při nule) + append-only fix logu
**Cíl:** Noční běh `compact-memory` (crontab `0 2 * * *`) posílal na Telegram 7001300 znaků i v nocích, kdy nic nesmazal. Uživatel: při nule stačí věta „nebylo nic k smazání", při reálné změně stačí *co* bylo smazáno/sloučeno a *proč*. Frekvence zůstává.
**Diagnóza:** Server = repo (`diff` čistý, žádný drift). Poslední 4 noci (07-22…07-25) všechny `deleted: 0, merged: 0` a přesto narativní walkthrough sekce po sekci + statistický code block + „Borderline kept". Čtyři zdroje, všechny předepsané:
1. `SKILL.md` Auto mode krok 4 — „Print a detailed list of changes."
2. `SKILL.md` Output format → Auto mode report — vynucený blok `deleted/merged/unchanged` + sekce `Details:`, kterou agent vyplnil i při nule.
3. `SKILL.md` Rules — „list the borderline items you kept in the report" (vyrobilo dvoubodový seznam 07-25).
4. `GOAL` ve skriptu — žádné omezení výstupu + formulace jako uživatelský příkaz → agent si to vyložil jako explicitní vyvolání („protože jsi explicitně vyvolal skill, dělal jsem full audit" — 3 ze 4 nocí).
**Co jsem zkusil:** `SKILL.md` Auto mode přepsán na dvě výstupní cesty (nula → jedna věta, stop, žádná záloha ani log; změna → záloha + řádek per položku s kategorií a důvodem), zrušeno hlášení borderline-kept a přidán explicitní zákaz narace auditu, `Output format` má teď dva auto tvary. `GOAL` doplněn o „unattended nightly run, not an interactive request" + převeden z implicitní konkatenace literálů na triple-quoted string (precedens `SCHEMA = """…"""` v `bookmark.py`/`db.py`). Interactive mode nedotčen. Nasazeno `rsync -av skills/compact-memory/` (celý adresář), owner ✓, bez restartu.
**Co fungovalo a proč:** Tři reálné běhy na serveru (exit 0):
- **Nula změn:** doručeno 56 znaků (`Memory compact: nothing to delete (MEMORY.md, 28 lines).`), žádná nová záloha, log nedotčen, `MEMORY.md` beze změny.
- **Se změnou** (reverzibilní test — vložena zjevně ephemeral odrážka do `MEMORY.md`): doručeno 165207 znaků = hlavička + jeden řádek `[ephemeral] "…" — důvod`, smazána právě ta odrážka, vznikla záloha `backup/2026-07-25_0640_memory.backup.md`, `MEMORY.md` se vrátil do původního stavu (`diff` vs. pretest kopie čistý až na doplněný trailing newline).
**Vedlejší nález a fix — agent přepisoval `memory-clean.log`:** V prvním testu změnové cesty log po zápisu **zmenšil** (246 → 196 B) — starý řádek z 07-19 zmizel. Tohle je stejná ztráta, jaká už 07-15 spolkla zápisy o reálném mazání; prózové „append-only" nestačí, když má agent `write_file`, a moje nová formulace („log obsahuje jen smazané položky") ho ještě pozvala nevyhovující řádek „uklidit". Fix: `SKILL.md` teď předepisuje **deterministický append přes `exec`** (`printf '%s\n' '…' >> log/memory-clean.log`), zakazuje `write_file`/`edit_file` na log a explicitně zakazuje mazat/reformátovat existující řádky, i když neodpovídají aktuálnímu formátu. Po redeployi třetí běh: 196 → 350 B, starý řádek zůstal ✓. (Oba záznamy skončily na jednom řádku, protože předchozí zápis neměl trailing newline — soubor teď newline má, takže se to samo srovná.)
**Co zbývá:** Zkontrolovat noční běh 2026-07-26 v `log/compact_memory_auto_cron.log` (řádek `Response to cli:user:` má být krátký).
**Jak vrátit zpět:** `git checkout ef0d6cc~1 -- skills/compact-memory/` + `rsync -av skills/compact-memory/ nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/skills/compact-memory/`. Testovací zásahy do `MEMORY.md` jsou už vrácené (agent smazal právě ty vložené odrážky); zálohy z běhů jsou v `backup/2026-07-25_06*`.
## 2026-07-26 — compact-memory: model navrhuje, skript validuje a aplikuje (konec prompt-compliance)
**Cíl:** Noční běh 2026-07-26 02:00 poslal na Telegram 1737 znaků narace („Now I have all four files. Let me audit MEMORY.md systematically…"), přestože včerejší oprava přesně tohle zakazovala. Uzavírá položku „zkontrolovat noční běh 07-26" z předchozího záznamu — a odpovídá na ni negativně.
**Diagnóza:** Skill na serveru identický s repem (`diff -ru` → IDENTICAL), takže žádný drift. V session `compact-memory-auto_20260726-020001.jsonl` je finální assistant zpráva 1737 znaků: celá úvaha jako text, na konci správný jednořádkový report. Skript posílal `result.content` beze změny → doručilo se všechno. Srovnání s včerejšími testy (56 a 165 znaků, 3 iterace, dávkové tool cally) vs. dnešek (8 iterací po jednom volání, soubory čtené přes `grep pattern="."`): stejný skill, stejný `GOAL`, stejný model `glm-5.1:cloud`, jiné chování. Závěr: formát výstupu nesmí viset na prompt-compliance, 3 čisté testy nedokazují nic.
**Co jsem zkusil (návrh schválen v plan mode, plán `~/.claude/plans/dobre-pojme-vylepsit-ten-buzzing-quasar.md`):** Obrácení zodpovědností — agent v nočním režimu **needituje nic** a vrací jen JSON change-set (`{"changes": [{op, category, original, new_text, reason}]}`), skript ho validuje, aplikuje, zálohuje, loguje a **sám skládá českou zprávu**. Klíčové volby: `original`/`new_text` jsou **pole řádků** (ne string s `\n` — tam modely kazí escapování); položky se identifikují **doslovným textem, ne čísly řádků**, a blok se musí v souboru vyskytovat právě jednou (halucinovaný citát spadne na validaci místo aby smazal špatný řádek); cílový soubor je **konstanta ve skriptu**, ne pole v JSON (uživatelova připomínka: pole `file` je zbytečná ceremonie, když je whitelist jednoprvkový); limity (`reason` ≤ 120 znaků, `new_text` ≤ 3 řádky/300 znaků, `original` ≤ 20 řádků, smazání < 50 % souboru) jsou **chyba, ne oříznutí**; nevalidní výstup se až **3× vrací modelu** s konkrétním seznamem chyb ve stejné (jednorázové) session; **SHA-256 guard** pozná, kdyby agent soubor upravil sám — agent má `write_file`/`exec` pořád k dispozici, takže zákaz v promptu je jen prosba. Noční běh navíc přepnut z `glm` na preset **`kimi27`** (`kimi-k2.7-code:cloud`) přes `Nanobot.from_config(model_preset=…)` — dopad omezen na tento job. Tvrdá selhání (timeout, výjimka) nově taky posílají jednořádkovou hlášku, dřív skončila tiše exit kódem 1.
**Co fungovalo a proč:**
- **Offline testy parseru** (30 kontrol, `scratchpad/test_parser.py`, stub modulu `nanobot` + `importlib`): validní delete/merge, prázdný change-set, bare JSON bez fence, JSON obalený narací, `reason` přes limit, citát co v souboru není, citát vyskytující se 11×, neznámé pole, špatná kategorie/`op`, merge co nezkracuje, překryv bloků, smazání 19 z 28 řádků, sběr chyb ze všech položek najednou — plus aplikace na kopii (záloha, append-only log, přestavěný soubor) a formát reportu. Vše zelené.
- **Test 1 (nic k smazání):** jeden pokus, model vrátil přesně `{"changes": []}`, exit 0, hash `MEMORY.md` beze změny, log 350 B beze změny, žádná nová záloha.
- **Test 2 (reverzibilní změna):** vložena ephemeral odrážka → model ji jako jedinou navrhl smazat, skript aplikoval, `diff` proti pretest kopii čistý, log 350 → 550 B (starý řádek zachován), nová záloha `backup/2026-07-26_0648_memory.backup.md`.
- **Test 3a (chybová cesta):** kopie skriptu s `GOAL`, který vynucuje prózu → 3 pokusy, model postupně konvergoval (próza → JSON bez `changes` → JSON se špatným `op`), exit 1, na Telegram 145 znaků `Memory compact: CHYBA — po 3 pokusech: …`, `MEMORY.md` nedotčen.
- **Test 3b (zotavení po odmítnutí):** kopie s uměle odmítnutým prvním pokusem → follow-up přijat, druhý pokus prošel, exit 0.
- **`kimi27` uspěl na první dobrou** ve všech reálných bězích a i pod chybovým tlakem reagoval na konkrétní validační hlášky.
**Co zbývá:** Zkontrolovat noční běh 2026-07-27 (`log/compact_memory_auto_cron.log`) — odpověď modelu tam teď bude JSON, ale doručená zpráva z ní nevzniká. Zvážit zápis do `knowledge.md` (jak SDK přepíná model per běh, seznam presetů) a návrh do `decisions.md` (viz níže, čeká na formulaci autora). Interaktivní režim skillu zůstal beze změny a nebyl retestován.
**Pozor na `__pycache__`:** Lokální test skriptu přes `importlib` vyrobil `skills/compact-memory/scripts/__pycache__/`, který se `rsync -av` dostal na server (smazáno na obou stranách). Deploy skillů se skripty dělat s `--exclude='__pycache__'`.
**Jak vrátit zpět:** `git checkout 9c82894~1 -- skills/compact-memory/` + `rsync -av --exclude='__pycache__' skills/compact-memory/ nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/skills/compact-memory/`. Testovací zásahy do `MEMORY.md` jsou vrácené (ověřeno `diff` proti kopii pořízené před testy, hash `249015fd…` shodný před i po). Testovací záznam v `log/memory-clean.log` a záloha `backup/2026-07-26_0648_*` zůstávají jako stopa po testu — log je append-only, nemažou se.
## 2026-07-27 — compact-memory: anglické hlášky + testy, a ruff/ty lint hook pro celý repo
**Cíl:** Tři věci na jedno zadání. (1) `compact_memory_auto.py` měl anglické docstringy, prompty i validátorové hlášky, ale sedm uživatelských hlášek zůstalo česky — doručený report byl jazykově rozpolcený. (2) Skript má bohatou čistou logiku (parser, validátor, přestavba souboru, report), ale jako jediný netriviální skill neměl `tests/`; offline testy parseru z 07-26 žily jen ve scratchpadu a zmizely. (3) V repu nebyla žádná Python lint konfigurace ani hook, přestože `skills/python/SKILL.md` definuje hotovo jako `ruff format` + `ruff check --fix`.
**Co jsem zkusil:** Plán `~/.claude/plans/vsechny-hlasky-do-anglictiny-curious-lantern.md`, schválený v plan mode.
- **Jazyk:** přeloženo všech 7 českých hlášek (`nebylo nic k smazání` → `nothing to remove`, `CHYBA —` → `ERROR —`, atd.). Součástí byla i změna `SKILL.md`: pravidlo `reason` — max 120 characters, **in Czech** → **in English** plus dva příklady v JSON schématu. Bez toho by report zůstal míchaný, protože `reason` od modelu se vkládá doslova do Telegram zprávy i do `log/memory-clean.log`.
- **Testy:** nový `skills/compact-memory/tests/test_compact_memory_auto.py` — 51 testů, vzor podle `skills/note/tests/test_note_compile.py` (`sys.path.insert` na `../scripts`, `tmp_path` fixtura). Pokrývá `_extract_json`, validaci všech polí, agregaci chyb, hledání/unikátnost bloku, překryv, 50% limit, `apply_change_set` (obsah, záloha, append-only log, počet řádků), `format_report`, `_log_line`, `_shorten`, `_quote`, `_telegram_config`, `_workspace`. Aby to šlo importovat bez `nanobot-ai`, přesunut `from nanobot import Nanobot` z module-level do `_run()` + `TYPE_CHECKING` import pro anotaci — stejný vzor, jaký už používá `note_compile.py`.
- **Lint:** nový `ruff.toml` (line-length 120, target py311, `select = ["E","F","I","UP","B","BLE","SIM","C4","RUF"]`), nový `ty.toml` (extra-paths na všechny `skills/*/scripts`, `unresolved-import = "ignore"`), nový hook `.claude/hooks/python-check.sh` registrovaný jako třetí položka v existující `PostToolUse` skupině `Edit|Write|MultiEdit`.
**Co fungovalo a proč:**
- **Hook je fail-fast a ověřený všemi cestami:** non-`.py` → exit 0; soubor pod `tmp/` → exit 0; neopravitelný ruff nález (`F821`) → exit 2 s `ruff findings`; čistý lint ale typová chyba → exit 2 s `ty findings`; čistý soubor → exit 0. Auto-fix ověřen (nepoužitý import ruff sám smazal). Latence: ruff ~10 ms, ty ~95 ms.
- **`unset VIRTUAL_ENV` v hooku je nutnost, ne opatrnost.** V tomhle prostředí je proměnná zděděná a ukazuje na neexistující `/home/lachtan/.pyenv/versions/3.11.5`; `ty` na tom padá s `Failed to discover local Python environment` ještě před kontrolou.
- **Explicitní `select` v `ruff.toml` je taky nutnost.** Ruff 0.16 má bez configu velmi širokou výchozí sadu (125 nálezů repo-wide včetně `DTZ*` a `EXE001`) a délku řádku 88, což odporuje `.claude/rules/python.md` (120). `BLE` je ve výběru schválně: skript má dvě promyšlená `# noqa: BLE001` a bez `BLE` by je `RUF100` označil za mrtvá a `--fix` je i s vysvětlujícím komentářem smazal.
- **Ruff sám opravil, co jsem nehledal:** `asyncio.TimeoutError` → `TimeoutError` (`UP041`) a sloučení tří zalomených `raise ChangeSetError(...)` na jeden řádek (dřív zalomené kvůli 88 znakům).
- **`ty` našel reálnou chybu:** `sorted(set(raw) - allowed)` — klíče jsou po `isinstance` zúžení typované `object`, což nesplňuje `SupportsRichComparison`. Opraveno na `sorted(str(key) for key in set(raw) - allowed)`.
- **Server byl před nasazením identický s repo HEAD** (`rsync` do `tmp/srv-check-cm/` + `diff -r`) — žádný drift od Dream procesoru. Po nasazení `md5sum` shodné na obou stranách, vlastník `nanobot:nanobot`, `py_compile` na serveru OK, crontab beze změny.
**Zádrhel — PEP 723 přepíná `ty` do script-mode, kde projektový `ty.toml` neplatí na pravidla.** `unresolved-import = "ignore"` fungovalo na běžné moduly, ale ne na `compact_memory_auto.py`. Izolováno probe souborem: identický soubor **s** `# /// script` hlavičkou hlásí `unresolved-import`, **bez** ní projde. `--project .` nepomůže; `--config "rules.unresolved-import='ignore'"` ano, ale flag v hooku by znamenal, že ruční `ty check` dělá něco jiného než hook. Řešení: inline `# ty: ignore[unresolved-import]` u obou `nanobot` importů + komentář v `ty.toml`, že sekce `[rules]` na PEP 723 skripty nedosáhne.
**Zádrhel — `rsync` dvou souborů naráz je položil do stejného cílového adresáře.** `rsync SKILL.md scripts/compact_memory_auto.py server:.../compact-memory/` udělal na serveru `compact-memory/compact_memory_auto.py` vedle `compact-memory/scripts/compact_memory_auto.py`. Stray kopie smazána, skript nasazen zvlášť do `scripts/`. Příště buď celý adresář, nebo jeden soubor na jeden `rsync`.
**Co zbývá:** Ostrý noční běh 2026-07-28 02:00 — první, který poběží s anglickými hláškami a anglickým `reason`; zkontrolovat `log/compact_memory_auto_cron.log` a nové řádky v `log/memory-clean.log` (historické české řádky zůstávají, log je append-only). Manuální ostrý běh jsem nedělal — mutuje reálný `MEMORY.md` a posílá Telegram. Repo-wide zbývá 92 ruff a 16 ty nálezů v ostatních skriptech; hook je nahlásí, až se těch souborů někdo dotkne (nejvíc `skills/llm-wiki/scripts/wiki_lint.py` = 9). Ostatní skripty s českými hláškami (`tasks-daemon.py`, `wiki_compile.py`, `note_compile.py`) zůstaly podle rozhodnutí česky. `.claude/rules/bash.md` řádek 45 tvrdí, že bash formátování hlídá `check-bash.sh` PostToolUse hook — ten v repu neexistuje ani není registrovaný; dokument je rozsynchronizovaný.
**Jak vrátit zpět:** `git checkout <tento commit>~1 -- skills/compact-memory/ .claude/settings.json` + `rm ruff.toml ty.toml .claude/hooks/python-check.sh` + `rsync -av --exclude='__pycache__' --exclude='tests' skills/compact-memory/ nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/skills/compact-memory/`. Na serveru se nic nemazalo ani nemutovalo mimo dva přepsané soubory (`SKILL.md`, `scripts/compact_memory_auto.py`) — `MEMORY.md`, zálohy ani logy zásah nepotkal.
## 2026-07-27 — Kontrola presetu `kimi3` (Kimi K3 na Ollama Cloud) + oprava context window
**Cíl:** Ověřit, že nově vydaný Kimi K3 (`kimi-k3:cloud` na Ollamě) je v `~/.nanobot/config.json` nastavený správně a má správné parametry.
**Co jsem zkusil:**
- `ssh nanobot@nanobot.hell` → dump `config.json`. Pozor: top-level klíč je `model_presets` (snake_case), ne `modelPresets`, jak tvrdila knowledge — první pokus o výpis vrátil `null`.
- `curl http://nvidia.hell:11434/api/tags` (model v katalogu je) a `api/show` pro `kimi-k3:cloud` i pro srovnání `kimi-k2.6`, `kimi-k2.7-code`, `glm-5.1`, `glm-5.2`.
- Funkční test: `curl .../v1/chat/completions` s `kimi-k3:cloud`, pak totéž s `glm-5.2:cloud` a `kimi-k2.7-code:cloud` jako kontrolní skupina.
- `journalctl --user -u nanobot.service` — hledání stop po přepnutí na preset.
- Online ověření na žádost uživatele: [ollama.com/pricing](https://ollama.com/pricing), [ollama.com/library/kimi-k3](https://ollama.com/library/kimi-k3), oznámení Ollamy na X.
- `curl https://openrouter.ai/api/v1/models` — hledání alternativní cesty ke stejnému modelu.
**Co fungovalo a proč:**
- **Konfigurace presetu je věcně správná.** `kimi3` = `{model: kimi-k3:cloud, provider: ollama, maxTokens: 16384, temperature: 0.1, reasoningEffort: null}`. Název modelu přesně sedí s `api/tags`, `apiBase` má povinný `/v1` suffix, model má capabilities `tools`+`thinking`+`vision`. Journal potvrdil, že runtime přepnutí funguje: `Runtime model switched for next turn: glm-5.1:cloud -> kimi-k3:cloud`. `reasoningEffort: null` není opomenutí — `schema.py:141` říká, že `None` zachovává default providera.
- **Blokuje to Ollama, ne config.** Přímé volání endpointu vrací HTTP 402 `this model uses extra usage only (not included plan usage) and your extra usage balance is empty`. Kontrolní skupina (`glm-5.2:cloud`, `kimi-k2.7-code:cloud`) přitom odpovídá normálně → není to stav účtu ani API klíč, je to per-model politika. Online potvrzeno: K3 vyžaduje Pro/Max **a** nenulový dokoupený extra usage balance, kapacita se teprve rozšiřuje. Nanobotí hláška *„API key is out of quota or the account is in arrears"* je zavádějící překlad tohoto 402.
- **Oprava parametru:** `contextWindowTokens` bylo `967000` proti reálnému `1048576` (z `api/show`, klíč `kimi-k3.context_length`) — ~81k tokenů ležel ladem. Ostatní presety drží ~97 % reálného okna (`glm-5.1`: 196608/202752, `glm-5.2`: 976000/1000000, kimi K2.x: 262144 = přesně). Srovnáno na **1 020 000**. Záloha `~/.nanobot/backup/config.json.bak-20260727-202245`, `diff` proti ní ukazuje jediný změněný řádek, vlastník `nanobot:nanobot` a práva `600` beze změny. Restart nebyl potřeba — presety se načítají každý tah (`_refresh_provider_snapshot`).
- **Vedlejší nález:** knowledge tvrdila konvenci pojmenování presetů `<model>-<provider>` (`kimi-k2.6-openrouter`), reálně jsou na serveru krátké aliasy (`kimi`, `kimi27`, `kimi3`, `glm`, `glm52`, `sonnet`, `haiku`, `gemini-flash`). Opraveno v `knowledge.md` spolu s názvem klíče `model_presets`.
**Co zbývá:** Model je do dobití extra usage na ollama.com nepoužitelný. Alternativa bez dobíjení, pokud ji uživatel bude chtít: preset přes OpenRouter (`moonshotai/kimi-k3`, ctx 1 048 576, $3/M in + $15/M out) — provider `openrouter` je v configu už nakonfigurovaný. Nezakládal jsem ho, nebylo zadáno.
**Jak vrátit zpět:** `ssh nanobot@nanobot.hell 'cp ~/.nanobot/backup/config.json.bak-20260727-202245 ~/.nanobot/config.json'` — mimo tuhle jednu hodnotu jsem na serveru nic neměnil.

View File

@@ -80,6 +80,24 @@ Nanobot cron job **vždy** běží přes agenta (`agent.process_direct`) — nea
Plný rozbor: history 2026-05-27 "Spam Output was empty". Plný rozbor: history 2026-05-27 "Spam Output was empty".
**Pozn. (2026-07-15):** Popis výše (`on_cron_job` hardcoded preamble + `evaluate_response` fail-open) platí pro starší nanobot. V `nanobot-ai` 0.2.2 je mechanismus jiný — viz "Cron `agent_turn` joby v 0.2.2 — `is_bound_cron_job`" níže. Závěr (LLM cron job pro non-reminder úlohy je nespolehlivý → řešit mimo `jobs.json`) platí dál, jen jinou cestou.
---
## Cron `agent_turn` joby v 0.2.2 — `is_bound_cron_job`, `session_key` nezávislý na doručení
V nainstalovaném `nanobot-ai` 0.2.2 nahradil starý "hardcoded reminder preamble + evaluate_response" mechanismus (viz sekce výše, teď zastaralá popiskem) nový: `dream` a `heartbeat` jsou speciální system joby v `on_cron_job` (`nanobot/cli/commands.py`); libovolný jiný `agent_turn` job z `jobs.json` jde přes `is_bound_cron_job()` (`nanobot/cron/session_turns.py`) → `run_bound_cron_job()` (`nanobot/cron/bound_runner.py`).
**`is_bound_cron_job(job)`** vrací `True`, když `payload.kind == "agent_turn"` a má `session_key`+`origin_channel`+`origin_chat_id`, a **žádné** z `deliver`/`channel`/`to`/`channel_meta` není nastavené. Bound joby se spustí přes `agent.submit_cron_turn(...)` **jako normální session turn****bez jakéhokoli delivery gatingu** (na rozdíl od `heartbeat`, který volá `evaluate_response(..., default_notify=False)` s fail-closed chováním). Cokoli agent v bound cron tahu odpoví, jde přímo do kanálu.
**`session_key` (kam se zapíše historie) je nezávislý na `origin_channel`/`origin_chat_id` (kam se doručí)** — `origin_delivery_context()` (`nanobot/cron/session_delivery.py`) čte výhradně `payload.origin_channel`/`origin_chat_id`, vůbec ne `session_key`. Lze tedy nastavit `session_key` na cokoli (jiná/izolovaná session, nebo — pokud jde mimo `jobs.json` — per-run unikátní hodnota) a doručení do daného kanálu/chatu zůstane funkční.
**Gotcha:** `jobs.json` neumí dynamický/per-run `session_key` (je to statický string v definici jobu) — takže "vždy čerstvá session" se **nedá** vyřešit uvnitř `jobs.json`, i když by teoreticky šlo nastavit fixní odlišnou session (izolovanou od živého chatu, ale pořád akumulující historii mezi běhy). Pro opravdu čistý kontext při každém běhu je potřeba jít mimo `jobs.json` úplně — Python API `Nanobot.run(session_key=…)` s novou hodnotou (např. timestamp) při každém spuštění, vzor `detach` (`f"detach:{path.stem}"`). `Nanobot.run(ephemeral=True)` (`nanobot.py:126-135`, `process_direct(ephemeral=True)` interně) existuje, ale neprochází channel-delivery pipeline — po ephemeral běhu by bylo nutné doručení řešit ručně (publish `OutboundMessage` nebo přímé Bot API volání).
**Praktický důsledek:** Cron job navázaný `session_key` na **stejnou session jako živý chat uživatele** (`f"{channel}:{chat_id}"`, typicky vznikne, když se job zakládá přímo z chatu) kontaminuje conversation historii mezi denním chatem a nočními běhy navzájem — omyl agenta v jednom běhu se táhne do dalších. Řešeno pro `compact-memory-auto-daily`: history 2026-07-15.
Zdroj: `nanobot/cron/session_turns.py:is_bound_cron_job`, `nanobot/cron/bound_runner.py:run_bound_cron_job`, `nanobot/cron/session_delivery.py:origin_delivery_context`, `nanobot/nanobot.py:126-165` (`Nanobot.run`).
--- ---
## Workspace vzniká při prvním spuštění agenta ## Workspace vzniká při prvním spuštění agenta
@@ -213,11 +231,12 @@ Zdroj: `nanobot/channels/telegram.py:258-326` (BotCommand registrace, regex rout
## Postup: přidání nového modelu (preset) ## 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`). Modely se přidávají jako položky do `model_presets` v `~/.nanobot/config.json` na serveru `nanobot.hell` (uživatel `nanobot`). Pozor: top-level klíč je v souboru **snake_case** (`model_presets`), vnitřní klíče presetu camelCase (`maxTokens`, `contextWindowTokens`, `reasoningEffort`).
**Kroky:** **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`. 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`.
**Dostupnost v katalogu ≠ použitelnost** — u Ollama Cloud ověř i funkčním voláním: `curl http://nvidia.hell:11434/v1/chat/completions -d '{"model":"<id>","messages":[{"role":"user","content":"say OK"}],"max_tokens":20}'` (viz sekce o extra usage níže).
2. **Edituj config in-place** přes Python (zachová ostatní klíče včetně secrets): 2. **Edituj config in-place** přes Python (zachová ostatní klíče včetně secrets):
```bash ```bash
@@ -238,10 +257,29 @@ Modely se přidávají jako položky do `modelPresets` v `~/.nanobot/config.json
4. **V chatu** (Telegram/WebUI) přepneš příkazem `/model <preset-name>`. 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. **Konvence pojmenování presetů:** krátký alias podle modelu — `kimi`, `kimi27`, `kimi3`, `glm`, `glm52`, `sonnet`, `haiku`, `gemini-flash`. (Dřív tu stálo `<model>-<provider>` jako `kimi-k2.6-openrouter`; reálný stav na serveru je od nějaké doby krátká forma, ověřeno 2026-07-27.)
**Parametry presetu:** `maxTokens` 16384 a `temperature` 0.1 napříč všemi presety. `contextWindowTokens` se drží na **~97 % reálného okna modelu** (rezerva na výstup), reálné okno se čte z `curl http://nvidia.hell:11434/api/show -d '{"model":"<id>"}'` → `model_info["<family>.context_length"]`. `reasoningEffort: null` = zachovat default providera (`schema.py:141`), explicitní hodnota jen kde ji chceme vynutit (`glm52: high`).
**Ollama gotcha:** `providers.ollama.apiBase` musí končit `/v1` (`http://nvidia.hell:11434/v1`) — viz [[Ollama provider potřebuje `/v1` suffix v `apiBase`]]. **Ollama gotcha:** `providers.ollama.apiBase` musí končit `/v1` (`http://nvidia.hell:11434/v1`) — viz [[Ollama provider potřebuje `/v1` suffix v `apiBase`]].
## Ollama Cloud: některé modely jsou „extra usage only" (kimi-k3)
`kimi-k3:cloud` je v `api/tags` vidět a `/model kimi3` v nanobotu se přepne bez chyby, ale **každé volání skončí HTTP 402**:
```text
this model uses extra usage only (not included plan usage) and your extra usage
balance is empty, add extra usage or turn on auto reload at ollama.com/settings
```
Nanobot to uživateli přeloží na *„API key is out of quota or the account is in arrears"* — což svádí na chybu v configu; **není to chyba configu**. Ollama má dvě oddělené peněženky: *plan usage* (zahrnuto v Pro/Max) a *extra usage balance* (dokupovaný kredit). K3 je zařazený mimo plán a čerpá **jen** z extra usage — podle [model page](https://ollama.com/library/kimi-k3) vyžaduje Pro/Max **a** nenulový extra balance, kapacita se teprve rozšiřuje. Billing je GPU-time-based, per-token sazba pro extra usage není veřejně zveřejněná.
**Diagnostika:** ostatní cloud modely (`glm-5.2:cloud`, `kimi-k2.7-code:cloud`) přitom jedou normálně — když 402 sedí jen na jednom modelu, je to tahle politika, ne stav účtu.
**Alternativa bez dobíjení:** stejný model na OpenRouteru jako `moonshotai/kimi-k3` (ctx 1 048 576, $3/M in, $15/M out) — provider `openrouter` je v configu nakonfigurovaný.
Zdroj: [Ollama pricing](https://ollama.com/pricing), [ollama.com/library/kimi-k3](https://ollama.com/library/kimi-k3), ověřeno 2026-07-27 (history).
## Logování: gateway `-v`/`--verbose`, agent `--logs` ## 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 defaultně **vypíná vlastní logy** (`logger.disable("nanobot")`), proto v běžném výstupu nic není. Zapínají se podle příkazu:
@@ -389,6 +427,70 @@ do meta-statusu.
**externím daemonem mimo agent loop** — viz "Detach skill" níže. Stejný pattern **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). už používá `/remind` (viz "Cron job s LLM agentem je nespolehlivý…" výše).
**AKTUALIZACE (ověřeno 2026-07-15 na `nanobot_ai-0.2.2` živém na serveru): tohle popisuje jen fallback pro "unbound" joby.** Skutečná `agent_turn` architektura je jiná — viz "Cron `agent_turn` job = normal session turn (bound cron, od `nanobot_ai` 0.2.2)" níže. `on_cron_job` v `cli/commands.py` má teď větev `is_bound_cron_job(job)` → `run_bound_cron_job(...)`; jen když job **není** bound (chybí `session_key`/`origin_channel`/`origin_chat_id`, nebo má vyplněné staré `deliver`/`channel`/`to`/`channel_meta`), spadne do staré větve výše s hardcoded preamblem — a ta dnes končí `CronJobSkippedError("unbound agent cron job must be recreated from a chat session")`, ne úspěšným během. Reálný job v `jobs.json` (`nanobot-version-check`) je bound.
---
## Cron `agent_turn` job = normal session turn (bound cron, od `nanobot_ai` 0.2.2)
**Kontext, který LLM dostane při odpálení cron jobu, je (skoro) identický s běžnou zprávou v té session, kde byl job vytvořen** — ne izolovaný/ephemerní jednorázový volání jako dřív (`process_direct`). Ověřeno čtením zdroje na serveru (`nanobot_ai-0.2.2.dist-info`), potvrzeno reálným `jobs.json` (job `nanobot-version-check`: `sessionKey: "websocket:52d0f338-…"`, `originChannel: "websocket"`, `originChatId: "…"`, `deliver: false`, `channel: null`).
**Tok (`nanobot/cron/bound_runner.py:run_bound_cron_job`):**
1. Job musí být **bound** (`nanobot/cron/session_turns.py:is_bound_cron_job`): `payload.kind == "agent_turn"` + `session_key`/`origin_channel`/`origin_chat_id` vyplněné + staré delivery pole (`deliver`/`channel`/`to`/`channel_meta`) prázdné. Bound joby vznikají tak, že se vytvoří z chatu (agent zná runtime `Channel`/`Chat ID` z kontextu a předá je do `cron` toolu).
2. Zpráva se vyrenderuje z šablony `templates/agent/cron_reminder.md`: *"The scheduled time has arrived. Execute this scheduled cron job now and report the result to the user in the same session."* + pravidla (mluv přímo, nenarrativizuj, žádné user ID, žádné "Done") + `Cron job: {{ message }}` (= `payload.message`).
3. Sestaví se `InboundMessage` s `channel`/`chat_id` = **origin** hodnoty z jobu, `session_key_override = payload.session_key`, a metadata `_cron_trigger` (job_id, job_name, run_id, prompt_ref) + `_cron_defer_until_session_idle: true`.
4. Ta zpráva jde přes `agent.submit_cron_turn()` → `CronTurnCoordinator.submit()` → **stejnou frontu `bus.consume_inbound()`/`_dispatch()`/`_process_message()` jako kterákoli chatová zpráva** (`agent/loop.py:run`/`_dispatch`). Žádná zkratka, žádný oddělený "cron mód" LLM volání.
**Co z toho plyne pro obsah promptu, který LLM vidí:**
- **Celý systémový prompt jako obvykle** — bootstrap soubory (`AGENTS.md`/`SOUL.md`/`USER.md`/`TOOLS.md`), `memory/MEMORY.md`, always-skilly (`ContextBuilder.build_messages`, stejná cesta jako běžný tah).
- **Celá historie té session** (`session_key` = session, ve které byl cron job vytvořen — typicky WebUI/Telegram konverzace), ne prázdná/ephemerní session. Cron zpráva se do historie i zapíše (`_persist_user_message_early` s `cron_history_overrides`), ale zobrazená verze v historii je zkrácená: `"Scheduled cron job triggered: <job_name>\n\n<payload.message>"`, ne celá vyrenderovaná šablona — a metadata na ní má `cron_job_id`/`cron_job_name`/`cron_run_id`/`cron_prompt_ref`.
- **Runtime context blok** (`Channel`/`Chat ID`/`Current Time`) ukazuje origin channel/chat_id jobu — model tedy vidí, do jakého kanálu odpovídá, stejně jako u normální zprávy.
- **Všechny toolly** jsou k dispozici stejně jako v normálním tahu (žádný omezený tool subset jako u Dream `build_dream_tools()`).
- **Odpověď se doručí automaticky** — finální text tahu (`_process_message` return) jde přes `bus.publish_outbound()` do origin channel/chat_id, přesně jako běžná odpověď. Žádný druhý fail-open `evaluate_response` gate (ten zůstal jen u `heartbeat` větve).
**Deferral:** pokud je cílová session zrovna aktivní (probíhá jiný tah), `CronTurnCoordinator.defer_if_active()` job zafrontuje a spustí ho, až se session uvolní (`publish_next_deferred` po dokončení běžícího tahu) — cron turn tedy nikdy neběží souběžně s live chatem ve stejné session, ale ani ho nezahodí.
**Praktický důsledek:** cron `agent_turn` job dnes **umí** to, co starý hardcoded preamble bránil (viz sekce výše) — protože jede jako normální tah, může provést multi-step úkol, zapsat soubor, a odpověď je jen finální zpráva tahu, ne meta-status obalený cizím promptem. `nanobot-version-check` job v produkčním `jobs.json` toho využívá (kontrola PyPI verze + podmíněné oznámení).
Zdroj: `nanobot/cron/bound_runner.py`, `nanobot/cron/session_turns.py`, `nanobot/agent/cron_turns.py` (`CronTurnCoordinator`), `nanobot/agent/loop.py:574-578,868-870,1035-1117` (`submit_cron_turn`, `_dispatch`), `nanobot/templates/agent/cron_reminder.md`, `nanobot_ai-0.2.2` na `nanobot.hell` (živý zdroj, ne upstream repo).
---
## Bound cron job sdílí session s chatem — důsledky pro růst kontextu, `/new` a (ne)možnost odpoutání
Navazuje na sekci výše. Bound cron job **nemá vlastní session** — `payload.sessionKey` je přesně ten samý klíč jako chat, odkud vznikl (`CronTool.set_context()`: `session_key = f"{channel}:{chat_id}"`). Každé odpálení jobu appendne celý svůj tah (trigger zpráva + tool volání + finální odpověď) do `session.messages` té konverzace — soutěží o prostor s reálným chatem, ne v izolaci.
**Co to omezuje a co ne:**
- **`maxMessages: 120`** (server `config.json`, `agents.defaults.maxMessages` / schema default 120) — do LLM promptu se replayuje jen posledních 120 zpráv (`session/manager.py::get_history`). Token náklad per-tah tedy neroste do nekonečna, stará historie se jen vysouvá z okna.
- **`idleCompactAfterMinutes: 0` na `nanobot.hell` — auto-compact (`AutoCompact`) je VYPNUTÝ.** Default v schématu je 15 min; tady je natvrdo 0, takže `check_expired()` (`agent/loop.py`, volané na 1s timeoutu hlavní smyčky) nikdy nic nekomprimuje na 8 zpráv + summary.
- I kdyby zapnutý byl: **periodický cron job v té session drží `session.updated_at` čerstvý** → session nikdy nevypadá jako idle → auto-compact by se pro ni stejně nikdy nespustil. Recurring cron bound na chat tedy fakticky blokuje auto-compact té session, i kdyby byl jinde v config zapnutý.
- Výsledek: `session.messages` na disku roste bez omezení (jen replay do LLM je capnutý na 120) a nic z toho neprochází konsolidací do `MEMORY.md`/Dream, dokud uživatel neudělá `/new`.
**`/new` (`cmd_new`, `command/builtin.py:205`) čistí přesně tenhle sdílený session_key** — `session.clear()` + na pozadí archivace starého obsahu přes `consolidator` (feed pro Dream). Protože cron job běží ve stejné session, `/new` v daném chatu **vyprázdní i kontext, který cron job uvidí** při příštím odpálení (stará historie se archivuje, ne ztratí — jen zmizí z "recent" replaye).
**Odpoutat cron job od session přes `cron` tool nejde — strukturálně.** `CronTool._add_job` (`agent/tools/cron.py`) bere `session_key`/`origin_channel`/`origin_chat_id` vždy z aktuálního request kontextu; bez nich vrátí chybu `"scheduled cron jobs must be created from a chat session"`. Parametr `deliver` v `execute()` existuje, ale nikam se nepředává do `add_job` — mrtvý pozůstatek staré (unbound) architektury. Ruční editace `jobs.json` na "unbound" tvar (bez `sessionKey`/`originChannel`/`originChatId`) taky nepomůže — `is_bound_cron_job()` vrátí `False` a `on_cron_job` rovnou shodí `CronJobSkippedError("unbound agent cron job must be recreated from a chat session")`; **stará větev s hardcoded preamblem (viz sekce "Cron preamble je hardcoded" výše) se pro `agent_turn` joby dnes nikdy neprovede — je to mrtvá cesta.**
**Pro skutečně nezávislou (session-decoupled) periodickou úlohu použij [[Detach skill]] níže** — vlastní `session_key` namespace (`detach:<slug>`), mimo cron/jobs.json úplně, doručení přímo přes Telegram Bot API bez účasti chatové session.
Zdroj: `nanobot/agent/tools/cron.py` (`CronTool._add_job`, `set_context`), `nanobot/agent/autocompact.py` (`AutoCompact`), `nanobot/config/schema.py:147-156` (`session_ttl_minutes`/`idleCompactAfterMinutes`, `max_messages`/`maxMessages`), `nanobot/session/manager.py` (`get_history` replay slicing), `nanobot/command/builtin.py:205` (`cmd_new`), živý `~/.nanobot/config.json` na `nanobot.hell` (`idleCompactAfterMinutes: 0`, `maxMessages: 120`) — ověřeno 2026-07-15.
### Upstream stav: žádný flag na vypnutí sdílení session zatím neexistuje (ověřeno 2026-07-15)
Sdílení session cronu s originálním chatem (viz výše) je **záměrný design**, ne bug: [PR #4299](https://github.com/HKUDS/nanobot/pull/4299) *"feat(cron): bind scheduled automations to sessions"* (mergnuto 2026-06-12) ho zavedlo cíleně, aby se vyřešilo doručování/injektování zpráv doprostřed živého chatu (`bound_runner.py` vznikl přesně touhle PR).
Na neomezený růst kontextu si ale stěžují i jinde a řeší se to teď (oba stavy **open, nemergnuto**):
- [Issue #4082](https://github.com/HKUDS/nanobot/issues/4082) — *"cron jobs reuse fixed cron:{job.id} session context across runs"*, navrhuje buď per-run isolaci, nebo *"an explicit config flag controlling cron context retention"*.
- [PR #4550](https://github.com/HKUDS/nanobot/pull/4550) — řeší to per-run izolovanou session (`session_key:run_id`, smazanou hned po tahu přes nový `agent.delete_session()`), **bez** configurovatelného flagu. Autor PR k automatizované review připomínce (ztráta cross-run kontinuity pro monitoring/diff joby) napsal: *"a future `shared_session` option could be added, but that's a design decision for maintainers"* — tedy jen nápad, ne odsouhlasený roadmap item, žádný maintainer zatím nezareagoval. `mergeable_state: dirty` (2026-07-06).
- Starší konkurenční [PR #2457](https://github.com/HKUDS/nanobot/pull/2457) (open od 2026-03-25) řeší podobný problém na starším kódu, pravděpodobně obsoletní vůči #4550.
**Souvisí:** [PR #4370](https://github.com/HKUDS/nanobot/pull/4370) *"Enable idle auto-compact by default"* (mergnuto 2026-06-16) změnilo schema default `idleCompactAfterMinutes` z `0` na `15` — právě kvůli krátkým chatům, co se nikdy nekompaktují. Náš server má v `config.json` **explicitní `0`** (přebíjí schema default bez ohledu na verzi) — nezávisí na tom, jestli byl nastaven před nebo po téhle změně.
**Akce:** sledovat #4082/#4550 (a případně #2457), jestli se mergne a jestli přibude `shared_session` flag — pak přehodnotit, jestli přenastavit `nanobot-version-check` (a budoucí podobné joby) na isolated mód. Zatím jediná cesta k plnému odpoutání = [[Detach skill]] níže.
--- ---
## Detach skill — background úlohy přes externí daemon (mimo agent loop) ## Detach skill — background úlohy přes externí daemon (mimo agent loop)
@@ -567,6 +669,22 @@ Přepsáno z SQLite row-store na **capture → compile pipeline** (vzor llm-wiki
- **Search:** `/note search|find <dotaz>` → načti celý `notes.md`, odpověz (read-only). Mazání = editace dokumentu (žádné display ID, žádné tagy). - **Search:** `/note search|find <dotaz>` → načti celý `notes.md`, odpověz (read-only). Mazání = editace dokumentu (žádné display ID, žádné tagy).
- **Odděleno** od `/keep`, `MEMORY.md`, Dream, llm-wiki (`cml/`). **Kolize triggerů s `keep`:** NL „poznamenej si/ulož si …" může spadnout do `keep` (obě to claimují) — spolehlivý je explicitní `/note` prefix; description note skillu odlišen („NOT durable facts → keep"). Starý `note.py`/`note.sqlite` vyřazeny (`note.sqlite` v `backup/note-retired/`). - **Odděleno** od `/keep`, `MEMORY.md`, Dream, llm-wiki (`cml/`). **Kolize triggerů s `keep`:** NL „poznamenej si/ulož si …" může spadnout do `keep` (obě to claimují) — spolehlivý je explicitní `/note` prefix; description note skillu odlišen („NOT durable facts → keep"). Starý `note.py`/`note.sqlite` vyřazeny (`note.sqlite` v `backup/note-retired/`).
---
## Skill `/project` — pojmenované dlouhodobé pracovní kontexty (přepsáno 2026-07-22)
Substituce Claude.ai "Projects". Adresář na projekt, ne jeden soubor: `workspace/projects/<slug>/{prompt.md, memory.md, state.md, artifacts/}`. `prompt.md` = kontext/instrukce čtené při aktivaci; `memory.md` = append-only chronologická historie/rozhodnutí; `state.md` = **živý** dokument (přepisuje se na místě, syntéza "kde to teď je" — ne deník); `artifacts/` = generované soubory bez zvláštní evidence.
**Aktivní projekt = čistě konverzační paměť**, žádný stavový soubor na disku pro "co je aktivní" (viz [[decisions.md]]). Nový projekt vzniká jen po explicitním potvrzení uživatele — skill bez ptaní pracuje jen s existujícími projekty. Mazání/přejmenování projektu je mimo scope v1 (ruční zásah v adresáři).
**Nahradil netrackovaný server-side skill** (existoval na serveru mimo tento repo, žádná zmínka v history/knowledge/decisions před tímto datem): plochý soubor `projects/<slug>.md` s frontmatterem `status`/`priority`/`created`, CLI backend (`scripts/project.py`: add/list/show/status), `switch` ukládal aktivní projekt do `my` scratchpad nástroje (viz níže). Reálná data (`projects/radio-1.md`, projekt na stříhání audio streamu Radia 1, 2026-06-09) přemigrována do nového formátu jako `projects/radio1/`. Plná historie: history.md 2026-07-22.
## `my` nástroj — přece jen nějaký perzistentní scratchpad existuje
Zjištěno 2026-07-22 při objevu starého `/project` skillu výše: ten používal `my(action="set", key="project_context", value="<slug>")` k uložení aktivního projektu (a zjevně `action="get"` k přečtení). To je v napětí s dřívějším závěrem [[Bound cron job sdílí session s chatem]] výše, že nanobot nemá žádný session-scoped state kromě historie zpráv — `my` je zjevně nějaká forma key-value scratchpadu dostupná agentovi jako tool.
**Needs-verify:** starý skill sám dokumentoval jen „lives only in `my` scratchpad and is lost on restart" — neříká, jestli je klíč per-`session_key` nebo globální napříč celým agentem. Nebylo ověřeno proti zdrojáku (`nanobot/agent/tools/*.py`), jen pozorováno z použití v `SKILL.md` starého skillu (kopie stažená do `tmp/server-project-check/`, negitované). Pokud je globální, nemusí řešit "aktivní projekt per konverzace" o nic líp než čistě konverzační paměť — proto zůstal nový `/project` skill navržen bez `my`. Stojí za ověření zdrojáku, než se `my` použije jinde jako spolehlivý state store.
## Nanobot MÁ `web` tool (search + Jina Reader fetch) — `TOOLS.md` ho nezmiňuje ## Nanobot MÁ `web` tool (search + Jina Reader fetch) — `TOOLS.md` ho nezmiňuje
Config `tools.web` (server `config.json`) má `enable: true` s **search** (`provider: duckduckgo`, maxResults 5) a **fetch** (`useJinaReader: true`). Agent tedy umí stáhnout URL přes `web` tool — Jina Reader vrací čistý markdown a zvládne JS i měkké paywally. `workspace/TOOLS.md` (dokumentace toolů) `web` **neuvádí** (jen `exec`/`grep`/`cron`) — je neúplný; zdroj pravdy je `config.json`. Pro fetch URL ve skillu preferuj `web` tool; `exec`+curl (`-o file` kvůli 10k/60s limitu `exec`) je fallback. Ověřeno při přepisu `/note` (DT Glass URL reálně stažena, history 2026-07-01). Config `tools.web` (server `config.json`) má `enable: true` s **search** (`provider: duckduckgo`, maxResults 5) a **fetch** (`useJinaReader: true`). Agent tedy umí stáhnout URL přes `web` tool — Jina Reader vrací čistý markdown a zvládne JS i měkké paywally. `workspace/TOOLS.md` (dokumentace toolů) `web` **neuvádí** (jen `exec`/`grep`/`cron`) — je neúplný; zdroj pravdy je `config.json`. Pro fetch URL ve skillu preferuj `web` tool; `exec`+curl (`-o file` kvůli 10k/60s limitu `exec`) je fallback. Ověřeno při přepisu `/note` (DT Glass URL reálně stažena, history 2026-07-01).

View File

@@ -3,14 +3,12 @@ name: compact-memory
description: > description: >
Audit and compact memory/MEMORY.md by removing superseded, duplicated, overly detailed, Audit and compact memory/MEMORY.md by removing superseded, duplicated, overly detailed,
or ephemeral entries, and merging related items within a subsection. or ephemeral entries, and merging related items within a subsection.
Runs interactively by default; use auto mode for unattended execution. Runs interactively when the user asks; nightly mode is driven by a script.
--- ---
# compact-memory # compact-memory
Compact `memory/MEMORY.md` when it grows too large or stale. The skill reads `memory/MEMORY.md` plus `USER.md`, `SOUL.md`, and `keep.md` (all three in the workspace root) to detect duplicates and outdated context, but only edits `memory/MEMORY.md`. Compact `memory/MEMORY.md` when it grows too large or stale. The skill reads `memory/MEMORY.md` plus `USER.md`, `SOUL.md`, and `keep.md` (all three in the workspace root) to detect duplicates and outdated context, but only ever changes `memory/MEMORY.md`.
This skill has no accompanying script — every step below is performed by you, the agent, directly with your file read/edit tools. There is nothing to `exec` or spawn.
## When to use ## When to use
@@ -25,6 +23,8 @@ The line threshold is only a proactive trigger. When the user invokes the skill
### Interactive mode (default) ### Interactive mode (default)
You perform every step yourself with your file tools. There is nothing to `exec` or spawn.
1. Read `memory/MEMORY.md`, and — for duplicate detection — also `USER.md`, `SOUL.md`, and `keep.md` (all three in the workspace root). 1. Read `memory/MEMORY.md`, and — for duplicate detection — also `USER.md`, `SOUL.md`, and `keep.md` (all three in the workspace root).
2. Count total lines (`wc -l memory/MEMORY.md`). If the skill was **not** invoked explicitly by the user, the file is ≤ 250 lines, and there is no obvious staleness, report "nothing to clean up" and stop. When invoked explicitly, always continue to the audit. 2. Count total lines (`wc -l memory/MEMORY.md`). If the skill was **not** invoked explicitly by the user, the file is ≤ 250 lines, and there is no obvious staleness, report "nothing to clean up" and stop. When invoked explicitly, always continue to the audit.
3. **Audit the WHOLE file in a single pass.** Walk every `##` section and every `###` subsection in order, top to bottom, and evaluate every bullet. Do not stop after the first few findings — the proposal in step 5 must cover the entire file at once. Re-running the skill should find nothing left, not "the next batch". 3. **Audit the WHOLE file in a single pass.** Walk every `##` section and every `###` subsection in order, top to bottom, and evaluate every bullet. Do not stop after the first few findings — the proposal in step 5 must cover the entire file at once. Re-running the skill should find nothing left, not "the next batch".
@@ -45,32 +45,26 @@ The line threshold is only a proactive trigger. When the user invokes the skill
- `delete 2, 5` — delete only listed items. - `delete 2, 5` — delete only listed items.
- `cancel` — abort. - `cancel` — abort.
8. Apply approved changes to `memory/MEMORY.md`. 8. Apply approved changes to `memory/MEMORY.md`.
9. Append deleted items to `log/memory-clean.log` with timestamp `YYYY-MM-DD HH:MM` (create the file if it does not exist). 9. Append deleted items to `log/memory-clean.log` with timestamp `YYYY-MM-DD HH:MM`, one line per item, via `exec` shell append so existing lines cannot be lost:
`printf '%s\n' '<YYYY-MM-DD HH:MM> DELETED [<category>] "<text>" — <reason>' >> log/memory-clean.log`
10. Report summary. 10. Report summary.
### Auto mode ### Nightly mode (script-driven)
Triggered by the user saying "memory-compact auto" or an equivalent with explicit auto intent. Triggered only by `scripts/compact_memory_auto.py` from the crontab. Never start this mode from a chat message — the change-set you produce here is applied by the script, and outside that script nothing would apply it. A user asking for an unattended-style cleanup in chat gets interactive mode.
1. Perform the same audit as interactive mode. **You change nothing in this mode.** Do not edit `memory/MEMORY.md`, do not write a backup, do not touch `log/memory-clean.log`. The script applies the change-set, writes the backup and the log, and composes the message the user receives. Your prose is never delivered.
2. Before deleting anything, write a full snapshot of `MEMORY.md` to `backup/<YYYY-MM-DD_HHMM>_memory.backup.md`.
3. Apply changes immediately without waiting for approval.
4. Print a detailed list of changes.
5. Append deleted items to `log/memory-clean.log` (create the file if it does not exist).
## Rules 1. Run the same audit as interactive mode steps 15.
2. Answer with **exactly one ```json code block and nothing else** — no narration, no summary, no text before or after it.
3. Nothing qualifies → a block with an empty `changes` list.
4. If the script replies that the validator rejected your change-set, fix exactly what it lists and answer again with one corrected block and nothing else.
- Only edit `memory/MEMORY.md`. When in doubt, keep the item — no user is there to prune, and there is no way to mention what you kept.
- Do not touch `SOUL.md`, `USER.md`, or `keep.md`.
- In interactive mode do not create backups of `MEMORY.md`; rely on `log/memory-clean.log` for traceability. Auto mode always writes a full snapshot to `backup/` before deleting.
- Merge only within the same `###` subsection (never across `##` sections or across different `###` subsections).
- Preserve active project context, user preferences, and durable infrastructure facts.
- Be exhaustive: propose every qualifying candidate in one pass, not a handful. The user prunes via `keep`/`delete`, so in interactive mode propose generously and flag borderline items rather than silently keeping them.
- In **auto mode** only, when in doubt keep it (no user is there to prune); list the borderline items you kept in the report.
## Output format ## Output format
Interactive proposal: ### Interactive proposal
``` ```
Found X candidates to change in MEMORY.md: Found X candidates to change in MEMORY.md:
@@ -88,18 +82,58 @@ Merge:
Commands: apply | keep <numbers> | delete <numbers> | cancel Commands: apply | keep <numbers> | delete <numbers> | cancel
``` ```
Auto mode report: ### Nightly change-set
```json
{
"changes": [
{
"op": "delete",
"category": "ephemeral",
"original": ["- Debug run 2026-07-25: output-format test in progress"],
"reason": "one-off debug marker, task finished"
},
{
"op": "merge",
"original": [
"- Runs as a systemd user service `nanobot.service`",
"- Model switching via `my` tool requires `tools.my.allow_set = true`"
],
"new_text": ["- Runs as a systemd user service `nanobot.service`; model switching via `my` needs `tools.my.allow_set = true`"],
"reason": "same subsection, one topic"
}
]
}
``` ```
Memory compact (auto):
- deleted: X
- merged: Y
- unchanged: Z
Details: Nothing to change:
...
```json
{
"changes": []
}
``` ```
Field rules — the validator rejects the whole change-set if any of these is violated:
- `op``"delete"` or `"merge"`. No other fields than the ones shown above are allowed.
- `category``delete` only, one of `superseded`, `detail`, `duplicate`, `ephemeral`, `stale-section`.
- `original` — list of whole lines copied **character-for-character** from `MEMORY.md`, in file order, max 20 lines. The block must appear in the file exactly once, so include enough surrounding lines to make it unique.
- `new_text``merge` only, list of lines replacing `original`. Max 3 lines, max 300 characters, and always fewer lines than `original`.
- `reason` — max 120 characters, **in English**, one clause saying why. It is shown to the user verbatim.
- Blocks of different items must not overlap, and the change-set must not remove more than half of the file.
## Rules
- Only ever change `memory/MEMORY.md` — and in nightly mode not even that; the script does it.
- Do not touch `SOUL.md`, `USER.md`, or `keep.md`.
- In interactive mode do not create backups of `MEMORY.md`; rely on `log/memory-clean.log` for traceability.
- `log/memory-clean.log` is append-only: write new lines with `>>` via `exec`, never with `write_file`/`edit_file`. Never delete, reformat, or "clean up" existing lines — not even ones that do not match the current format.
- Write to `log/memory-clean.log` only when something was deleted or merged — never a "no changes" entry.
- Merge only within the same `###` subsection (never across `##` sections or across different `###` subsections).
- Preserve active project context, user preferences, and durable infrastructure facts.
- Be exhaustive: propose every qualifying candidate in one pass, not a handful. In interactive mode the user prunes via `keep`/`delete`, so propose generously and flag borderline items rather than silently keeping them.
## Example ## Example
User: `compact memory` User: `compact memory`

386
skills/compact-memory/scripts/compact_memory_auto.py Executable file → Normal file
View File

@@ -3,49 +3,122 @@
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["nanobot-ai"] # dependencies = ["nanobot-ai"]
# /// # ///
"""Nightly compact-memory auto run, triggered by the nanobot user crontab. """Nightly compact-memory run, triggered by the nanobot user crontab.
Runs the compact-memory skill in auto mode via the Nanobot Python API with a The agent only decides *what* to remove or merge and returns it as a JSON change-set;
fresh, never-reused `session_key` per invocation, then delivers the result this script validates it, applies it, writes the backup and the audit log, and composes
directly to Telegram. This bypasses nanobot's cron/jobs.json `agent_turn` the Telegram message. Nothing the agent writes as prose ever reaches Telegram, so the
mechanism entirely, so the turn never gets appended to the user's live chat delivered report no longer depends on the model following output-format instructions.
session (which caused context contamination across nights) and needs no
delivery-gating evaluator (there is none for bound cron jobs in nanobot-ai
0.2.2 — whatever the agent answers would otherwise go straight to the chat).
Same pattern as skills/remind/scripts/remind_send.py and Runs with a fresh, never-reused `session_key` per invocation and delivers straight to the
skills/detach/scripts/tasks-daemon.py: external script, direct Telegram Bot Telegram Bot API, bypassing nanobot's cron/jobs.json `agent_turn` mechanism (which would
API delivery, no nanobot channel pipeline involved. append the turn to the user's live chat session and deliver whatever the agent answered).
Same external-script pattern as skills/remind/scripts/remind_send.py and
skills/detach/scripts/tasks-daemon.py.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import json import json
import re
import sys import sys
import traceback import traceback
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any
from nanobot import Nanobot if TYPE_CHECKING:
from nanobot import Nanobot # ty: ignore[unresolved-import]
CONFIG = Path.home() / ".nanobot" / "config.json" CONFIG = Path.home() / ".nanobot" / "config.json"
WORKSPACE_FALLBACK = Path.home() / ".nanobot" / "workspace"
MEMORY_REL = "memory/MEMORY.md"
BACKUP_REL = "backup"
CLEAN_LOG_REL = "log/memory-clean.log"
FALLBACK_CHAT_ID = "8826147089" FALLBACK_CHAT_ID = "8826147089"
TIMEOUT_SECONDS = 10 * 60 TIMEOUT_SECONDS = 10 * 60
MAX_ATTEMPTS = 3
MODEL_PRESET = "kimi27"
GOAL = ( DELETE_CATEGORIES = frozenset({"superseded", "detail", "duplicate", "ephemeral", "stale-section"})
"Read skills/compact-memory/SKILL.md and run its Auto mode to audit and " DELETE_KEYS = frozenset({"op", "category", "original", "reason"})
"compact memory/MEMORY.md. Perform every step yourself using your file " MERGE_KEYS = frozenset({"op", "original", "new_text", "reason"})
"tools — there is no script to run."
) MAX_REASON_CHARS = 120
MAX_NEW_TEXT_LINES = 3
MAX_NEW_TEXT_CHARS = 300
MAX_ORIGINAL_LINES = 20
MAX_REMOVED_RATIO = 0.5
QUOTE_CHARS = 100
MAX_ERROR_CHARS = 300
JSON_FENCE = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
GOAL = """\
Read skills/compact-memory/SKILL.md and run its Nightly mode to audit memory/MEMORY.md.
This is an unattended nightly run. Do NOT edit, write or create any file — not memory/MEMORY.md,
not a backup, not a log entry. Your only job is to decide what should be removed or merged and
to hand it over as a change-set; a script applies it.
Answer with exactly one ```json code block following the Nightly mode schema in the skill, and
nothing else — no narration, no summary, no text before or after the block. If nothing qualifies,
answer with a block whose "changes" list is empty.
"""
RETRY_PROMPT = """\
Your change-set was rejected by the validator:
{errors}
Answer again with exactly one corrected ```json code block and nothing else. Copy every `original`
line character-for-character from memory/MEMORY.md — read the file again if you are unsure.
"""
def _telegram_config() -> tuple[str, str]: class CompactMemoryError(Exception):
"""A failure worth reporting to the user in one line."""
class ChangeSetError(CompactMemoryError):
"""The agent's change-set is malformed or does not match the current file."""
@dataclass(frozen=True)
class Change:
op: str
category: str
original: tuple[str, ...]
new_text: tuple[str, ...]
reason: str
@dataclass(frozen=True)
class LocatedChange:
change: Change
start: int
end: int
def _config() -> dict[str, Any]:
return json.loads(CONFIG.read_text(encoding="utf-8"))
def _workspace(config: dict[str, Any]) -> Path:
configured = config.get("agents", {}).get("defaults", {}).get("workspace")
return Path(configured).expanduser() if configured else WORKSPACE_FALLBACK
def _telegram_config(config: dict[str, Any]) -> tuple[str, str]:
"""Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback.""" """Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback."""
data = json.loads(CONFIG.read_text(encoding="utf-8")) telegram = config["channels"]["telegram"]
telegram = data["channels"]["telegram"]
allow_from = telegram.get("allowFrom") or [] allow_from = telegram.get("allowFrom") or []
chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID
return telegram["token"], chat_id return telegram["token"], chat_id
@@ -59,36 +132,269 @@ def _send_telegram(text: str, token: str, chat_id: str) -> None:
resp.read() resp.read()
async def _run_audit(session_key: str) -> str: def _sha256(path: Path) -> str:
bot = Nanobot.from_config() return hashlib.sha256(path.read_bytes()).hexdigest()
result = await bot.run(GOAL, session_key=session_key)
return result.content or ""
def _shorten(text: str, limit: int) -> str:
return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
def _quote(lines: tuple[str, ...]) -> str:
return _shorten(" ".join(line.strip() for line in lines), QUOTE_CHARS)
def _extract_json(content: str) -> Any:
"""Return the JSON payload from the last parseable fenced block, or from the whole answer."""
candidates = [*JSON_FENCE.findall(content), content]
for candidate in reversed(candidates):
try:
return json.loads(candidate.strip())
except json.JSONDecodeError:
continue
raise ChangeSetError("- the answer contains no parseable ```json block")
def _string_list(raw: Any, field: str, index: int) -> tuple[str, ...]:
if not isinstance(raw, list) or not raw or not all(isinstance(line, str) for line in raw):
raise ChangeSetError(f'- item {index}: "{field}" must be a non-empty list of strings')
return tuple(raw)
def _parse_change(raw: Any, index: int) -> Change:
if not isinstance(raw, dict):
raise ChangeSetError(f"- item {index}: must be a JSON object")
op = raw.get("op")
if op not in ("delete", "merge"):
raise ChangeSetError(f'- item {index}: "op" must be "delete" or "merge"')
allowed = DELETE_KEYS if op == "delete" else MERGE_KEYS
unknown = sorted(str(key) for key in set(raw) - allowed)
if unknown:
raise ChangeSetError(f"- item {index}: unknown fields {unknown}, allowed are {sorted(allowed)}")
reason = raw.get("reason")
if not isinstance(reason, str) or not reason.strip():
raise ChangeSetError(f'- item {index}: "reason" must be a non-empty string')
if len(reason) > MAX_REASON_CHARS:
raise ChangeSetError(f'- item {index}: "reason" has {len(reason)} characters, the limit is {MAX_REASON_CHARS}')
original = _string_list(raw.get("original"), "original", index)
if len(original) > MAX_ORIGINAL_LINES:
raise ChangeSetError(f'- item {index}: "original" has {len(original)} lines, the limit is {MAX_ORIGINAL_LINES}')
if op == "delete":
category = raw.get("category")
if category not in DELETE_CATEGORIES:
raise ChangeSetError(f'- item {index}: "category" must be one of {sorted(DELETE_CATEGORIES)}')
return Change(op=op, category=category, original=original, new_text=(), reason=reason.strip())
new_text = _string_list(raw.get("new_text"), "new_text", index)
if len(new_text) > MAX_NEW_TEXT_LINES:
raise ChangeSetError(f'- item {index}: "new_text" has {len(new_text)} lines, the limit is {MAX_NEW_TEXT_LINES}')
joined = "\n".join(new_text)
if len(joined) > MAX_NEW_TEXT_CHARS:
raise ChangeSetError(
f'- item {index}: "new_text" has {len(joined)} characters, the limit is {MAX_NEW_TEXT_CHARS}'
)
if len(new_text) >= len(original):
raise ChangeSetError(f'- item {index}: a merge must produce fewer lines than "original" has')
return Change(op=op, category="merge", original=original, new_text=new_text, reason=reason.strip())
def _find_block(lines: list[str], block: tuple[str, ...]) -> list[int]:
"""Return every start index where the block matches whole lines (ignoring trailing whitespace)."""
needle = [line.rstrip() for line in block]
haystack = [line.rstrip() for line in lines]
span = len(needle)
return [start for start in range(len(haystack) - span + 1) if haystack[start : start + span] == needle]
def _locate(changes: list[Change], lines: list[str]) -> list[LocatedChange]:
errors: list[str] = []
located: list[LocatedChange] = []
for index, change in enumerate(changes, start=1):
matches = _find_block(lines, change.original)
if not matches:
errors.append(f'- item {index}: "original" does not appear in MEMORY.md: {_quote(change.original)}')
elif len(matches) > 1:
errors.append(f'- item {index}: "original" appears {len(matches)} times, it must be unique')
else:
located.append(LocatedChange(change=change, start=matches[0], end=matches[0] + len(change.original)))
if errors:
raise ChangeSetError("\n".join(errors))
return sorted(located, key=lambda item: item.start)
def _check_spans(located: list[LocatedChange], total_lines: int) -> None:
cursor = 0
for item in located:
if item.start < cursor:
raise ChangeSetError(f"- overlapping items around line {item.start + 1}, each block must be separate")
cursor = item.end
removed = sum(len(item.change.original) - len(item.change.new_text) for item in located)
if removed > total_lines * MAX_REMOVED_RATIO:
raise ChangeSetError(
f"- the change-set removes {removed} of {total_lines} lines, more than "
f"{int(MAX_REMOVED_RATIO * 100)}% of the file"
)
def parse_change_set(content: str, memory_text: str) -> list[LocatedChange]:
"""Validate the agent's answer against the current file. Raises ChangeSetError on any problem."""
payload = _extract_json(content)
if not isinstance(payload, dict):
raise ChangeSetError("- the JSON block must be an object")
raw_changes = payload.get("changes")
if not isinstance(raw_changes, list):
raise ChangeSetError('- the JSON object must have a "changes" list')
errors: list[str] = []
changes: list[Change] = []
for index, raw in enumerate(raw_changes, start=1):
try:
changes.append(_parse_change(raw, index))
except ChangeSetError as error:
errors.append(str(error))
if errors:
raise ChangeSetError("\n".join(errors))
lines = memory_text.splitlines()
located = _locate(changes, lines)
_check_spans(located, len(lines))
return located
def _rebuild(lines: list[str], located: list[LocatedChange]) -> list[str]:
result: list[str] = []
cursor = 0
for item in located:
result.extend(lines[cursor : item.start])
result.extend(item.change.new_text)
cursor = item.end
result.extend(lines[cursor:])
return result
def _log_line(change: Change, stamp: str) -> str:
quoted = _quote(change.original)
if change.op == "delete":
return f'{stamp} DELETED [{change.category}] "{quoted}"{change.reason}'
return f'{stamp} MERGED [merge] "{quoted}""{_quote(change.new_text)}"{change.reason}'
def apply_change_set(memory: Path, located: list[LocatedChange], workspace: Path, now: datetime) -> int:
"""Back up the file, apply the change-set, append the audit log. Returns the new line count."""
text = memory.read_text(encoding="utf-8")
backup_dir = workspace / BACKUP_REL
backup_dir.mkdir(parents=True, exist_ok=True)
(backup_dir / f"{now:%Y-%m-%d_%H%M}_memory.backup.md").write_text(text, encoding="utf-8")
new_lines = _rebuild(text.splitlines(), located)
memory.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
clean_log = workspace / CLEAN_LOG_REL
clean_log.parent.mkdir(parents=True, exist_ok=True)
stamp = f"{now:%Y-%m-%d %H:%M}"
with clean_log.open("a", encoding="utf-8") as handle:
for item in located:
handle.write(_log_line(item.change, stamp) + "\n")
return len(new_lines)
def format_report(located: list[LocatedChange], lines_before: int, lines_after: int) -> str:
if not located:
return f"Memory compact: nothing to remove (MEMORY.md, {lines_before} lines)."
deleted = sum(1 for item in located if item.change.op == "delete")
merged = len(located) - deleted
rows = [f"Memory compact: deleted {deleted}, merged {merged} ({lines_before}{lines_after} lines)."]
for item in located:
change = item.change
quoted = _quote(change.original)
if change.op == "delete":
rows.append(f'- [{change.category}] "{quoted}"{change.reason}')
else:
rows.append(f'- [merge] "{quoted}""{_quote(change.new_text)}"{change.reason}')
return "\n".join(rows)
async def _resolve_change_set(bot: Nanobot, session_key: str, memory: Path) -> list[LocatedChange]:
"""Ask the agent for a change-set, retrying with validator feedback in the same session."""
memory_text = memory.read_text(encoding="utf-8")
memory_hash = _sha256(memory)
message = GOAL
last_error = ChangeSetError("- no attempt was made")
for attempt in range(1, MAX_ATTEMPTS + 1):
result = await bot.run(message, session_key=session_key)
content = result.content or ""
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] answer:\n{content}", file=sys.stderr)
if _sha256(memory) != memory_hash:
raise CompactMemoryError("the agent changed MEMORY.md itself although it must not — nothing applied")
try:
return parse_change_set(content, memory_text)
except ChangeSetError as error:
last_error = error
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] rejected:\n{error}", file=sys.stderr)
message = RETRY_PROMPT.format(errors=error)
first_reason = str(last_error).splitlines()[0].lstrip("- ")
raise CompactMemoryError(f"after {MAX_ATTEMPTS} attempts: {first_reason}")
async def _run(session_key: str, workspace: Path, now: datetime) -> str:
# Deferred so the change-set logic can be imported and tested without nanobot-ai installed.
from nanobot import Nanobot # ty: ignore[unresolved-import]
memory = workspace / MEMORY_REL
lines_before = len(memory.read_text(encoding="utf-8").splitlines())
bot = Nanobot.from_config(model_preset=MODEL_PRESET)
located = await _resolve_change_set(bot, session_key, memory)
if not located:
return format_report([], lines_before, lines_before)
lines_after = apply_change_set(memory, located, workspace, now)
return format_report(located, lines_before, lines_after)
def main() -> int: def main() -> int:
session_key = f"compact-memory-auto:{datetime.now():%Y%m%d-%H%M%S}" now = datetime.now()
session_key = f"compact-memory-auto:{now:%Y%m%d-%H%M%S}"
config = _config()
workspace = _workspace(config)
try: try:
content = asyncio.run(asyncio.wait_for(_run_audit(session_key), timeout=TIMEOUT_SECONDS)) message = asyncio.run(asyncio.wait_for(_run(session_key, workspace, now), timeout=TIMEOUT_SECONDS))
except asyncio.TimeoutError: failed = False
print(f"compact_memory_auto: timeout after {TIMEOUT_SECONDS // 60} min (session={session_key})", file=sys.stderr) except TimeoutError:
return 1 message = f"Memory compact: ERROR — timed out after {TIMEOUT_SECONDS // 60} min."
except Exception as e: failed = True
print(f"compact_memory_auto: run failed (session={session_key}): {e}\n{traceback.format_exc()}", file=sys.stderr) except CompactMemoryError as error:
return 1 message = _shorten(f"Memory compact: ERROR — {error}.", MAX_ERROR_CHARS)
failed = True
except Exception as error: # noqa: BLE001 — the nightly job must report, not just die
traceback.print_exc()
message = _shorten(f"Memory compact: ERROR — {type(error).__name__}: {error}.", MAX_ERROR_CHARS)
failed = True
if not content.strip(): if failed:
print(f"compact_memory_auto: empty response (session={session_key})", file=sys.stderr) message += " Nothing applied, details in log/compact_memory_auto_cron.log."
return 1 print(f"compact_memory_auto: {message} (session={session_key})", file=sys.stderr)
token, chat_id = _telegram_config() token, chat_id = _telegram_config(config)
try: try:
_send_telegram(content, token, chat_id) _send_telegram(message, token, chat_id)
except Exception as e: except Exception as error: # noqa: BLE001 — delivery failure must not hide the original outcome
print(f"compact_memory_auto: telegram delivery failed (session={session_key}): {e}", file=sys.stderr) print(f"compact_memory_auto: telegram delivery failed (session={session_key}): {error}", file=sys.stderr)
return 1 return 1
return 0 return 1 if failed else 0
if __name__ == "__main__": if __name__ == "__main__":