Compare commits

...

3 Commits

Author SHA1 Message Date
lachtan
6d43517a69 runtime 2026-09-16 08:56:39 +02:00
lachtan
5407931bdc runtime 2026-09-16 08:56:28 +02:00
lachtan
b0827177d2 nanobot: 2026-09-16 07:26:44 2026-09-16 07:26:45 +02:00
17 changed files with 1074 additions and 32 deletions

View File

@@ -1,5 +1,134 @@
# History
## 2026-09-11 — AGENTS.md: pravidlo o exec guardu globálně + srovnání skillu `note`
**Cíl:** Navázat na opravu skillu `project` (záznam níže) a zapsat pravidlo o exec
guardu na místo, kde pomůže všem skillům, ne jen tomu, kde se zrovna projevilo.
**Co jsem zkusil / co jsem našel:**
Nejdřív jsem zjišťoval, který soubor nanobot vůbec načítá do system promptu.
`build_system_prompt()` (`agent/context.py:70121`) skládá prompt z
`BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]`. Z nich je **`AGENTS.md` jediný
user-ownovaný** — `SOUL.md`, `USER.md` i `memory/MEMORY.md` přepisuje Dream, bundled
`templates/` přepíše upgrade balíčku.
Při čtení `AGENTS.md` vyšlo najevo, že **tentýž bug už tam byl zdokumentovaný dvakrát
izolovaně**: sekce `## Git commit timestamps` říká, že `date '+%Y-%m-%d %H:%M:%S'` guard
blokuje (ověřeno — token `H:%M:%S`, `H` po `%`), a sekce `## exec Tool` řešila chybějící
workspace path. Ani jednou se nepojmenovala společná příčina. Třetí výskyt byl živý:
`note_capture.py --text "Cíl: koupit mléko"` → blok (token `l:`), přičemž
`note/SKILL.md` explicitně nařizuje „Pass the input **as-is**". Poznámka uživatele
začínající `Cíl:` tedy tiše selhávala — a capture bere vstup doslova, takže to nebyl
okrajový případ, ale běžná česká formulace.
**Co fungovalo a proč:**
- **`workspace/AGENTS.md`** — nejdřív dotažena autoritativní serverová verze do repa
samostatným commitem (lokální kopie byla zastaralá a server je zdroj pravdy), teprve
pak přidáno pravidlo do sekce `## exec Tool`: text nikdy do command stringu, vždy
`write_file` do `tmp/` + předání cesty. Sekce `## Git commit timestamps` ponechána —
jmenuje konkrétní skript a je jen instancí obecného pravidla.
- **`skills/note`** — `--text` nahrazen za `--file <path>` (fail fast na chybějící
soubor), obě volací místa v SKILL.md převedena na dvoukrok, přidána sekce *Never pass
the text on the command line*. Testy 19 → 22.
Ověřeno na serveru proti **nainstalovanému** guardu:
```text
PROJDE NOVY --file -> []
BLOKOVAN STARY --text -> ['l:', 'r:']
```
End-to-end: `write_file` textu `Cíl: koupit mléko. Závěr: zítra.` + `--file` → soubor
v `notes/inbox/` s korektním frontmatterem, exit 0. Inbox pak uklizen, **`log/note.log`
ne** — je to append-only audit log a mazat ho by porušilo přesně to pravidlo, které
tahle práce kodifikuje. Zůstal tam jeden testovací CAPTURE řádek z 15:09.
Ověřeno i to, že se `AGENTS.md` do promptu opravdu dostává (liší se od
`templates/AGENTS.md`; při shodě by ho `context.py:179182` tiše vynechalo).
**Co zbývá:**
- `bookmark` (český článek v heredocu) a `remind` (`edit --text`) mají pořád stejnou
expozici — vědomě mimo scope, vedeno v `knowledge.md`.
- `AGENTS.md` se bere z `project_root` aktuálního tahu (`context.py:163`), takže
v session scoped do `tmp/<x>` se root verze nenačte. Pro chat workflow to nevadí;
univerzální varianta by byl skill s `always: true`.
- Regex v nanobotu zůstává rozbitý; tohle je workaround, ne fix.
**Jak to vrátit zpět:** `git revert` příslušných commitů + rsync `workspace/AGENTS.md`
a `skills/note/` na server. Nic se nemazalo kromě vlastních testovacích artefaktů.
## 2026-09-11 — project: zápis do memory.md padal na exec guardu, převeden na --file
**Cíl:** Agent na serveru nedokázal zapsat záznam do `projects/life/memory.md`
tři pokusy po sobě spadly na `Command blocked by safety guard (path outside
working dir)`. Zjistit proč a opravit skill, aby se to neopakovalo.
**Co jsem zkusil / co jsem našel:**
Stažena session `002f2196` z `~/.nanobot/webui/`. Pořadí selhání: quoted heredoc
`printf | pipe``--text`, všechny se stejnou chybou; uspěl až `write_file` do
`tmp/` + `log life < tmp/soubor`. Agent si to ve vlastním shrnutí vyložil jako
„nedeterministický blok na datu a tečkách" — **to je mylné**.
Skutečná příčina, ověřená spuštěním nainstalovaného guardu
(`/home/nanobot/.local/share/uv/tools/nanobot-ai/…/nanobot/agent/tools/shell.py`)
na reálných příkazech: `ExecTool._extract_absolute_paths()` hledá windowsové cesty
regexem s **ASCII-only negativním lookbehindem** `(?<![A-Za-z])[A-Za-z]:…`. Znak
s diakritikou před ASCII písmenem lookbehind neutne, takže `Cíl:` → token `l:`,
`Závěr:`/`směr:``r:`. `Path("r:").resolve()` se rozvine vůči cwd démona
(`/home/nanobot`, ne vůči workdiru příkazu) → `/home/nanobot/r:` → mimo workspace
→ blok. Guard navíc jede nad **raw command stringem bez shell parseru**, takže
nerozliší argument od obsahu heredocu — proto padly všechny tři varianty stejně.
Blokovaný text obsahoval `Diagnóza/směr:`; `artifacts/fyzio-cviky-kycele.md` ani
lomítko samo o sobě vinné nebyly.
Kontrolní vzorek proti nainstalovanému guardu (`--file` vs. staré varianty):
```text
PROJDE NOVY --file -> []
BLOKOVAN STARY heredoc -> ['l:', 'r:', 'r:']
BLOKOVAN STARY --text -> ['l:', 'r:', 'r:']
BLOKOVAN STARY printf|pipe -> ['l:', 'r:', 'r:']
```
**Co fungovalo a proč:**
Oprava není „jiný způsob citace", ale **text se nikdy nesmí dostat do command
stringu**. Konkrétně:
- `project_cli.py`: `--text` odstraněn, přidán `--file <path>` (fail fast na
neexistující soubor). Stdin zůstal jako fallback. Flag místo shell redirectu
proto, že celý failure mode byl model improvizující shell syntax.
- `SKILL.md`: sekce „Writing to memory.md" přepsána na dvoukrok `write_file` do
`tmp/` + `log <slug> --file …`, s vysvětlením **proč**, ať to nikdo
neoptimalizuje zpátky na heredoc.
- `SKILL.md` Rules: zákaz testovacích zápisů do ostrého projektu — agent si při
diagnostice zanesl do append-only `memory.md` dvě test položky a pak je ručně
odstranil, čímž porušil „never rewrite history". Od toho je `new <scratch-slug>`.
- Testy: 15 → 20. Sekce `log` převedena na fixture `entry_file`, takže testuje
reálnou cestu přes soubor. Přibyl regresní test s `Cíl:`/`Závěr:`, test na
chybějící soubor a pojistka, že `--text` v argparse skončí `SystemExit 2`.
Před nasazením ověřena shoda serveru s repem (`rsync` + `diff -r` → lišil se jen
`__pycache__`). Nasazeno rsyncem, vlastník `nanobot:nanobot` ověřen, restart
služby není potřeba. End-to-end test proti scratch projektu `guard-test` (ne proti
`life`) s textem `Cíl: … Diagnóza/směr: … Závěr: …` prošel, exit 0; scratch
projekt i `tmp/guard-test.md` pak smazány.
**Co zbývá:**
Guard v nanobotu je pořád rozbitý a **stejnou expozici mají další skilly**
`note` (`--text "<raw input>"` se syrovým českým vstupem uživatele, nejrizikovější),
`bookmark` (český článek v heredocu) a `remind` (`edit --text`). Vědomě mimo scope
této opravy, zapsáno do `knowledge.md` jako známý dluh. Plošný fix by znamenal
unicode-aware lookbehind v upstreamu (resp. ve forku `nanobot-custom-commands`).
**Jak to vrátit zpět:** `git revert` commitu se skillem + rsync `skills/project/`
na server. Žádná data se nemazala kromě scratch projektu `guard-test`.
## 2026-09-02 — reflect: noční běh padal na timeout a zahazoval hotové dávky
**Cíl:** Noční cron (`30 3 * * *`) neodběhl — ve 4:00 přišlo na Telegram
@@ -3455,3 +3584,278 @@ návrh do `decisions.md`) se nemění.
**Jak vrátit zpět:** `git revert 3adc2ef`, pak na serveru rsync skillu a
`wiki_sync.py --full` (titulky se vrátí na NULL). Index se tím nerozbije — `title` je
jen sloupec v katalogu, retrieval na něm nestojí.
---
## 2026-09-15 06:20 — Ollama usage poller: revize serverového plánu + nasazení sběru
**Cíl:** Uživatel chtěl dotáhnout serverový plán `plans/ollama-usage-poller.md`
(napsal si ho nanobot sám 2026-09-14) do repa a probrat, jak ho realizovat a kde
má slabiny. Záměr plánu: minutový cron → SQLite, aby šlo zpětně zjistit, kolik
která session stála.
**Co jsem zkusil:** Nejdřív ověření předpokladů plánu proti reálnému serveru
(ne proti tomu, co plán tvrdí). Nálezy, kvůli kterým se návrh měnil:
- **`memory/history.jsonl` nemá tokeny.** Plán na něm stavěl budoucí atribuci
(„per-request timestamps, token counts, session ids"). Reálně obsahuje jen
`{cursor, timestamp, content}` (+ `session_key` u 59 ze 430 řádků) — je to
destilát paměti z Dreamu. `sessions/*.jsonl` má `role/content/timestamp/
tool_calls/reasoning_content/latency_ms`, **taky bez tokenů a bez modelu**.
Jediný zdroj tokenů je `LLM usage:` v journalu, což je DEBUG a `-v` už v
`ExecStart` **není** (poslední takový řádek je z 2026-05-27) — `knowledge.md`
tvrdila opak, opraveno.
- **`limits.*.usage` má rozlišení 0,1 %** (API vrací 3 desetinná místa).
Plánovaný report `Δsession %, Δweekly %` per dvojice vzorků sliboval přesnost,
kterou data nemají → osa reportu přepnuta na `request_count` per model.
- **`models` je pole `[{name, request_count}]`**, ne slovník jak tvrdil komentář
ve schématu → kanonizace na setříděný dict, jinak by se zapisovalo každou minutu.
- **Umístění `workspace/scripts/`** odporovalo vzoru — všech 6 existujících cron
entry míří do `skills/<name>/scripts/` → přesunuto do `skills/usage/scripts/`,
čímž odpadl i `sys.path` hack kolem reuse `load_api_key`.
- **Write-on-change nerozliší klid od výpadku** → doplněna tabulka
`meta(last_ts, last_status)`, přepisovaná při každém běhu včetně chybového.
- Ukládá se i `session.models` (plán si nechával jen `weekly`).
**Co fungovalo a proč:** Skill `usage` dotažen do repa (dosud tam vůbec nebyl),
přidány `ollama_usage_poll.py` + `ollama_usage_report.py` (stdlib only, EN),
sekce „Continuous sampling" do `SKILL.md`, rsync na server (`nanobot:nanobot`),
cron entry `* * * * *`. Ověřeno:
1. Dva běhy po sobě → `samples` má 1 řádek, `meta.last_ts` se posunulo oběma
(04:19:46 → 04:19:53). Write-on-change funguje.
2. `OLLAMA_API_KEY=bogus` → exit 0, `meta.last_status = 'http_401'`,
žádný nový řádek. Cron se tím nezanáší.
3. Logika reportu ověřena na syntetické DB ve scratchpadu: hranice session okna
(pokles `session_usage` i součtu `session_models`), mezera v datech,
per-model delty, souhrn.
4. Cron potvrzen (`meta.last_ts` se posouvá bez ručního spuštění,
`log/ollama_usage_cron.log` prázdný).
**Co zbývá:** Atribuce na sessions — přesunuto do `todo.md`, až bude pár dní dat.
Vedlejší přínos, který plán nepojmenoval: minutová řada ověří/vyvrátí domněnku
ve `skills/usage/SKILL.md`, že session okno je kalendářní hodina
(`until_next_full_hour` je odhad, ne údaj z API).
**Jak vrátit zpět:** `crontab -e` a smazat entry `ollama-usage`;
`rm ~/.nanobot/workspace/db/ollama_usage.sqlite`;
`rm ~/.nanobot/workspace/skills/usage/scripts/ollama_usage_{poll,report}.py`
a rsync `SKILL.md` z gitu před commitem. Lokálně `git revert`.
---
## 2026-09-15 10:05 — Session okno: fixní 5h blok, fáze kalibrovaná z DB
**Cíl:** Skill `/usage` hlásil „Session: … resets in 2 minutes", zatímco web
ukazoval 4 hodiny. Zjistit proč a dát tomu odhad, který se dá věřit.
**Co jsem zkusil:**
1. Porovnání serveru s repem — server běžel se starou verzí z 14. 9.
(`until_next_full_hour()`). **Oprava z předchozí session se nikdy nenasadila**
a ležela necommitnutá v repu. To je celé vysvětlení „2 minut" (běželo 06:58 UTC).
2. Data z `db/ollama_usage.sqlite`: session spadla z `0.077`/21 req na `0.0`/`{}`
mezi vzorky 04:28:01 a 06:00:01 → **fixní blok, ne rolling okno** (rolling by
se rozpadal postupně).
3. Kalibrace proti webu (uživatel hlásil, co vidí): ~06:58 „4 hodiny",
~07:05 „3 hodiny", session **2,3 %** — procento přesně sedí s API, takže
dashboard a `/api/usage` čtou totéž.
4. Délka okna z primárního zdroje — [transparent-pricing](https://ollama.com/blog/transparent-pricing):
nové plány zrušily „5-hour or weekly limits", tenhle klíč je má → **5 h**.
5. Původní návrh přidával do polleru heartbeat, aby šlo mezeru v `samples`
odlišit od výpadku. **Uživatel ho zamítl** — DB má zůstat čistě změnová.
Ukázalo se, že je zbytečný (viz níže).
**Co fungovalo a proč:** Mřížka je periodická, takže každý pozorovaný reset
omezuje **tutéž fázi**. Jedno pozorování ohraničí hranici intervalem
`(poslední vzorek před, první vzorek po]`; průnik těch intervalů **modulo 300
minut** fázi dopočítá. Dvě vlastnosti, proč to heartbeat nepotřebuje:
- Ohraničení platí, i když poller neběžel — **výpadek interval jen rozšíří**,
nikdy nezpůsobí špatný závěr. Není tedy co dokazovat.
- Když je délka bloku špatně, průnik se **vyprázdní** místo tiché chyby.
Dokud je kandidátů víc než `MAX_SPREAD`, reset se nevypisuje — dnešní data dávají
93 z 300, takže skill zatím hlásí holé `Session: 2.4 %`. To je správný výstup.
Nález při psaní výpisu mřížky: **5 h nedělí 24 h**, takže „mřížka 00/05/10/15/20
UTC" nemůže být stálá — buď startuje denně (s kratším posledním blokem), nebo
plave. Obě hypotézy jsou zatím konzistentní s daty a průnik je rozliší; report
proto tiskne jen **příští hranici**, ne seznam denních.
Časy ve výstupu jsou v lokální zóně serveru (`.astimezone()`, nic
nehardcodováno), ale výpočet zůstává v UTC — v lokálním čase by ho přechod na
zimní čas 25. 10. posunul o hodinu a zahodil kalibraci.
Ověřeno: 19 kontrol na syntetických datech (zúžení průniku, prázdný průnik,
bracket ≥ 300 min, drift přes den, rozestup přes DST), proti reálné DB
(93 kandidátů → `None`), formátování pod `TZ=Europe/Prague`/`UTC`/`America/New_York`,
a po nasazení přímo na serveru včetně běhu polleru. Uzavírá položku
z `todo.md` o ověření domněnky o kalendářní hodině.
**Co zbývá:** Rozhodující pozorování — příští hranice ve **12:00 nebo 13:00
CEST**. Podle toho, jak blízko ní bude poslední aktivita, se fáze zúží a skill
začne reset hlásit sám. Pak porovnat s webem.
**Jak vrátit zpět:** `git revert ff9ac3e` + rsync `skills/usage/` na server.
## 2026-09-15 10:15 — Session okno se kotví requestem, ne mřížkou (oprava modelu)
**Cíl:** Uživatel namítl dvě věci k předchozímu záznamu: (a) mlčení místo odhadu
mu nepomáhá, „lepší něco než nic"; (b) **hranice přece nemusí být pevná — nemůže
se začít počítat od prvního dotazu po pauze?**
**Co jsem zkusil:** Bod (b) je testovatelný, tak jsem šel do dat dřív, než jsem
dodělal (a). Z `sessions/*.jsonl` jsem vytáhl timestampy assistant zpráv a
poskládal bloky aktivity oddělené pauzami. Rekonstrukce (UTC):
| Okno | Start | Konec |
|---|---|---|
| W2 | 00:00 (první request po vypršení W1) | 05:00 |
| W3 | 06:00 (první request po vypršení W2) | 11:00 |
**Co fungovalo a proč:** Rozhodl **chybějící zápis v 05:00**. Kdyby existovala
pevná mřížka, usage by se v 05:00 vynulovala a poller (běží každou minutu) by
změnu zapsal. Místo toho se nezměnilo nic až do 06:00, kdy přišel první request
po 93minutové pauze. Vzorek `0.0`/`{}` v 06:00:01 tedy **není okamžik resetu, ale
okamžik startu nového okna**. Pevná mřížka by navíc vyžadovala, aby ten request
padl na hranici náhodou (~1:93).
Počty requestů to nerozhodly (23 v 00:0005:00 vs 19 v 01:0006:00 proti
hlášeným 21) — assistant zprávy nejsou 1:1 s LLM requesty.
Model přepsán na **poslední pozorovaný přechod + 5 h**. Vedlejší efekt: je to
výrazně jednodušší než průnik kandidátních fází modulo 300 min, který jsem
nasadil o hodinu dřív, a **hlásí reset okamžitě** — čímž vyřešil i bod (a).
Server teď říká `Session: 2.5 %, resets 13:00 CEST (in 2 h 47 min)`.
Za konec okna se nic neextrapoluje: po vypršení výstup řekne, že další okno
začne dalším requestem. Report dostal `Rollover gap:` řádky jako průběžný test
modelu — rozestup delší než blok potvrzuje kotvení requestem, rozestup přesně
roven bloku přes dlouhou nečinnost by ukazoval zpět na pevnou mřížku.
Ověřeno 15 kontrolami na syntetické DB (kotva, víc přechodů, chybějící DB,
vypršelé okno, okno přes přechod na zimní čas, formátování v UTC i CEST),
proti reálné DB a po nasazení na serveru.
**Poučení:** Předchozí záznam (09:xx) tvrdil „fixní blok" a stavěl na tom celou
kalibrační mašinerii. Hypotézu „start prvním requestem" jsem měl v úvahách na
začátku, ale zahodil ji bez testu — přitom šla ověřit ze `sessions/*.jsonl`,
které jsem měl celou dobu k dispozici. Past, která to držela naživu: crony
běží na celé hodiny, takže hranice *vypadaly* jako mřížka.
**Jak vrátit zpět:** `git revert 9f39d29` + rsync `skills/usage/` na server.
Předchozí (mřížkový) model je `ff9ac3e`.
## 2026-09-15 10:40 — Review a zeštíhlení skillu `usage`
**Cíl:** Projít `skills/usage/SKILL.md` — jestli splňuje náležitosti skillu a jestli
v něm nejsou zbytečnosti. Plus odpovědět, k čemu je v DB tabulka `meta`.
**Co jsem zkusil:** Přečetl SKILL.md, všechny tři skripty, `knowledge.md`, porovnal
serverovou verzi s lokální (server == HEAD, žádný drift). Review podle
`core:skill-authoring`.
**Co fungovalo a proč:**
- **Zbytečnosti.** Sekce „Reset times" byla ze ~40 řádků z větší části forenzní důkaz
— *jak* jsme model session okna naměřili. 1:1 duplikát `knowledge.md` („Ollama session
okno"), history 2026-09-15 10:15 a docstringů `window_rollovers()` / `print_window()`.
V SKILL.md se platí kontextem při každém načtení a agentovi neříká, co má dělat —
vyhozeno, důkazy zůstávají v tracking souborech a v kódu. Pravidlo „žádné modely na
Session/Weekly řádcích" bylo řečené třikrát, falzifikační odstavec o `Rollover gap:`
dvakrát. 117 → 96 řádků.
- **Nepřesnosti vůči skriptům** (všechny opraveny): `print_session()` má tři větve,
dokumentovaná byla jedna (chybělo „window expired…" a samotné procento); Weekly řádek
slíbený jako „resets in Y days", ale `fmt_delta_short()` pod 24 h tiskne hodiny;
„report ends with `Session window:`" — ve skutečnosti končí `Last poll:`; zdroj klíče
je env **pak** `workspace/.env`, ne jen `.env`.
- **Tabulka `meta` — odpověď na otázku.** Zapisuje se do ní (`record_poll()` na všech
třech cestách pollu: `ok`, `http_<kód>`, `unreachable`), ale je to **jeden řádek**
(`id = 1`), ne log. Čte ji jen `print_poll_status()` → `Last poll: <ts> (status)`.
Smysl: `samples` se plní jen při změně, takže bez `meta` nejde odlišit „nic se
nezměnilo" od „poller je mrtvý / API vrací 401". Zůstává; zapisovat vzorek při každém
pollu bylo dřív zamítnuto (knowledge „Zamítnuto: heartbeat v usage polleru"), `meta`
je právě ta zmenšená náhrada. Opraven ale **popis** v SKILL.md i v docstringu polleru
— tvrdil „records every poll, so a gap can be told apart from a failed poll", což
naznačuje historii; přepsaný řádek rozsoudí jen liveness *teď*.
- **Trigger fráze** ukotveny na „ollama" (`ollama usage/quota/credits/limits`, `how much
of the ollama plan is left`, `ollama usage report`). `"usage history"` zrušeno a obecné
`"quota"` zamítnuto uživatelem — netriggerovalo by, tahalo by skill na cizí dotazy.
- **Verifikace na serveru:** `ollama_usage.py` → `Session: 2.8 %, resets 13:00 CEST
(in 2 h 22 min)` / `Weekly: 16.2 %, resets in 5 days` + Models sekce — sedí se šablonou
řádek po řádku. Report končí `Last poll: 2026-09-15T08:37:01+00:00 (ok)`, `meta` má
jeden řádek s čerstvým timestampem. Verifikace odhalila i poslední nepřesnost: při
jediném rolloveru v historii report žádné `Rollover gap:` řádky netiskne
(`itertools.pairwise`) — formulace upravena na „from the second recorded rollover on".
Nasazeno rsyncem, md5 lokál == server, vlastník `nanobot:nanobot`, bez restartu služby.
**Co zbývá:** Nic. Commit `a7a725e`.
## 2026-09-16 07:20 — Fix po přejmenování presetů `glm53`→`glm`, `kimi27`→`kimi`
**Cíl:** Uživatel přejmenoval aliasy modelů v serverovém `config.json`. Rozbily se dvě
noční cron úlohy (`KeyError: "model_preset 'kimi27' not found"` z compact-memory,
`'glm53'` z reflectu). Vrátit je do provozu a najít i místa, kde je rozbití tiché.
**Co jsem zkusil / co jsem našel:**
Dva paralelní skeny — lokální repo a server. Aktuální stav configu: 11 presetů, default
`glm` (= `glm-5.3:cloud`), fallback `kimi` (= `kimi-k2.7-code:cloud`). Obě přejmenování
jsou 1:1 na stejný model string, takže oprava je mechanická a joby poběží na tomtéž
modelu jako dřív.
*Proč to nechytila validace:* `Config._validate_model_preset` (nanobot `config/schema.py`,
model_validator mode="after") ověřuje při startu **jen** `agents.defaults.modelPreset` a
`fallbackModels`. Obojí uživatel přejmenoval správně, takže gateway nastartovala bez
chyby. Preset předaný ad-hoc do `Nanobot.from_config(model_preset=...)` — což dělají
právě oba cron skripty — neprochází žádnou statickou kontrolou a spadne až za běhu
v `Config.resolve_preset()`.
*Tichá část (to, co „není vidět"):* serverový `~/.nanobot/workspace/knowledge/models.md`
je tabulka presetů, podle které si agent vybírá `/model`. Obsahovala neplatné aliasy —
a navíc **neodpovídala configu ani před přejmenováním**: `glm` měl model id
`glm-5.3-flash` (reálně `glm-5.3`, flash je samostatný preset `flash`), překlep `glmi51`
místo `glm51`, chyběly presety `flash`, `sonnet`, `haiku`, `gemini-flash(-lite)`, a řádek
„Kódování: `kimi27` (fallback: `kimi`)" byl po přejmenování sebereferenční nesmysl.
Uživatel rozhodl srovnat celou tabulku s realitou a sjednotit daily driver i těžké úlohy
na `glm` (glm-5.3); eskalační sekce „Kdy přepnout na glm53" tím ztratila smysl a je
nahrazena „Kdy sáhnout po `flash`" (multimodální vstup — `glm` je text-only — a levné
rutinní tahy).
*Co rozbité nebylo:* `skills/detach/` (resolvuje presety dynamicky přes
`load_preset_names()`/`resolve_preset()`), `skills/usage/`, `cron/jobs.json`
(dream/heartbeat/version-check preset nenastavují, `dream.modelOverride` je `null`),
systemd unity, `workspace/USER.md` (`Preset: glm` je dál platné), fronty
`tasks/{inbox,new,running}`. Komentáře v `reflect_auto.py:49` a `reflect_distill.py:55`
zmiňují **model** `glm-5.3:cloud`, ne preset — zůstaly.
**Co fungovalo a proč:**
1. Nejdřív `rsync` serverového `knowledge/models.md` do repa vlastním commitem (`3d17bf8`) —
lokální verze byla z 2026-06 a úplně jiný soubor, slepý přepis by serverovou verzi
(přepsanou 2026-08-29) zahodil. Pravidlo „server je zdroj pravdy" se vyplatilo.
2. `MODEL_PRESET` opraven na `"glm"` / `"kimi"`, `compact-memory/README.md` srovnán,
`knowledge/models.md` přepsán proti reálnému `modelPresets` (commit `dcb66d5`).
3. Nasazeno rsyncem celých adresářů skillů + `knowledge/models.md`, vlastník
`nanobot:nanobot`, bez restartu služby.
4. Verifikace: `Nanobot.from_config(model_preset=...)` pro `glm` i `kimi` proběhne a loguje
přepnutí na `glm-5.3:cloud` / `kimi-k2.7-code:cloud` (přesně ta cesta, co padala);
grep starých aliasů přes `skills/`, `knowledge/` a `config.json` je prázdný; reflect
`--dry-run --max-batches 1` exit 0; lokálně 219 testů zelených.
5. Grep zpočátku hlásil zbylý výskyt v `skills/reflect/scripts/__pycache__/*.pyc` —
zastaralý bytecode ze 2. 9., smazán (Python si ho vygeneruje znovu).
**Gotcha pro příště:** `~/.local/bin/uv` plnou cestou — v neinteraktivním SSH není `uv`
v `PATH` (`bash: uv: command not found`).
**Co zbývá:**
- E2E potvrzení nočního běhu (02:00 compact-memory, 03:30 reflect) — v logu nesmí být
`KeyError` a má přijít Telegram report. `compact_memory_auto.py` nemá dry-run a mutuje
`memory/MEMORY.md`, takže ruční spuštění jsem nedělal.
- Mimo rozsah, hlášeno uživateli: `src/MiniLoop/config.json` je commitnutý v gitu včetně
živého OpenRouter API klíče v plaintextu (řádek v `src/MiniLoop/.gitignore` je
zakomentovaný). Vyžaduje rotaci klíče a odstranění z historie.
- Automatický validátor presetů uživatel zamítl — Telegram alert z nočního běhu je jako
detekce dostatečný. Postup ruční kontroly zapsán do `knowledge.md`.

View File

@@ -260,12 +260,21 @@ Modely se přidávají jako položky do `model_presets` v `~/.nanobot/config.jso
4. **V chatu** (Telegram/WebUI) přepneš příkazem `/model <preset-name>`.
**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.)
**Konvence pojmenování presetů:** krátký alias podle modelu. Aktuální sada (ověřeno 2026-09-16): `glm`, `flash`, `glm52`, `glm51`, `kimi`, `kimi26`, `kimi3`, `sonnet`, `haiku`, `gemini-flash`, `gemini-flash-lite`. **Nepojmenovaný alias = aktuální generace** (`glm` = glm-5.3, `kimi` = kimi-k2.7-code), číslovaný = zamrzlá starší verze. (Dřív tu stálo `<model>-<provider>` jako `kimi-k2.6-openrouter`, pak `glm53`/`kimi27`; 2026-09-16 přejmenováno na současnou formu.)
**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`]].
**Gotcha — přejmenování presetu validace nechytí.** `Config._validate_model_preset` (nanobot `config/schema.py`, model_validator mode="after") ověřuje při startu **jen** `agents.defaults.modelPreset` a `fallbackModels`. Preset předaný ad-hoc do `Nanobot.from_config(model_preset=...)` neprojde žádnou statickou kontrolou a spadne až za běhu v `Config.resolve_preset()` — u nočních cron jobů tedy ve 2:00 do logu a Telegramu, ne při editaci configu. **Po každém přejmenování presetu projdi tahle místa:**
- `skills/reflect/scripts/reflect_auto.py` → `MODEL_PRESET`
- `skills/compact-memory/scripts/compact_memory_auto.py` → `MODEL_PRESET`
- serverový `~/.nanobot/workspace/knowledge/models.md` — tabulka presetů, podle které si agent vybírá `/model`; neplatný alias tam je tichá chyba (agent zkusí neexistující preset)
- `skills/detach/` řešit nemusíš — preset resolvuje dynamicky přes `load_preset_names()`/`resolve_preset()`
Jednorázový check: `ssh nanobot@nanobot.hell 'grep -rn -e <starý-alias> ~/.nanobot/workspace/skills/ ~/.nanobot/workspace/knowledge/ ~/.nanobot/config.json'` (pozor na `__pycache__` — zastaralý `.pyc` matchne, ale nic neovlivní). Automatický validátor uživatel 2026-09-16 explicitně zamítl: Telegram alert z nočního běhu je jako detekce dostatečný. Plný záznam: history 2026-09-16.
## 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**:
@@ -792,7 +801,7 @@ Nanobot má **hardcoded default `context_window_tokens = 65_536`** pro `ModelPre
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.
**Gotcha — `agents.defaults.contextWindowTokens` je zavádějící číslo.** V serverovém `config.json` je `65536`, ale to platí jen když preset vlastní hodnotu nemá. Reálné okno aktuálního defaultu (**`glm53`: 976 000**, `glm52`/`glm-flash` 976 000, `kimi3` 1 020 000, `sonnet`/`gemini-flash` 256 000) je ~15× větší, než `defaults` napovídá. **Než z 65k něco odvodíš, přečti `model_presets`, ne `agents.defaults`** — na tomhle jsem 2026-09-02 postavil celý (zamítnutý) rozpočet velikosti pro `/project`. Limit, který v praxi kouše, je `maxToolResultChars: 16000`, ne okno.
**Gotcha — `agents.defaults.contextWindowTokens` je zavádějící číslo.** V serverovém `config.json` je `65536`, ale to platí jen když preset vlastní hodnotu nemá. Reálné okno aktuálního defaultu (**`glm`: 976 000**, `flash`/`glm52` 976 000, `kimi3` 1 020 000, `sonnet`/`gemini-flash` 256 000) je ~15× větší, než `defaults` napovídá. **Než z 65k něco odvodíš, přečti `model_presets`, ne `agents.defaults`** — na tomhle jsem 2026-09-02 postavil celý (zamítnutý) rozpočet velikosti pro `/project`. Limit, který v praxi kouše, je `maxToolResultChars: 16000`, ne okno.
**Důsledky (trade-off, ne čistá výhra):**
@@ -1137,7 +1146,7 @@ Naměřeno na reálném destilátu (90 kB promptu → 75 135 tokenů podle tikto
## `contextWindowTokens` presetu přebíjí `agents.defaults`
`agents.defaults.contextWindowTokens` je 65536, ale preset `glm53` má 976000 a **vyhrává**`agent/loop.py:476` (`context_window_tokens = extra.pop(...) or resolved.context_window_tokens`). Efektivní input budget = `contextWindowTokens - maxTokens - 1024` (`SNIP_SAFETY_BUFFER`, `agent/context_governance.py:105`), tedy ~958k tokenů pro glm53.
`agents.defaults.contextWindowTokens` je 65536, ale preset `glm` má 976000 a **vyhrává**`agent/loop.py:476` (`context_window_tokens = extra.pop(...) or resolved.context_window_tokens`). Efektivní input budget = `contextWindowTokens - maxTokens - 1024` (`SNIP_SAFETY_BUFFER`, `agent/context_governance.py:105`), tedy ~958k tokenů pro `glm`.
## Velká zpráva projde, `snip_history` krátí jen historii
@@ -1302,3 +1311,54 @@ per model. Ověřeno 2026-09-15.
→ Atribuce spotřeby na session jde jen **časovou korelací** (timestampy v `sessions/*.jsonl`
a `Processing message from …` v journalu) proti řadě vzorků z `db/ollama_usage.sqlite`.
Ověřeno 2026-09-15, zdroj: [[plans/ollama-usage-poller.md]] sekce Revize.
---
## Ollama session okno: 5 h kotvených prvním requestem, ne pevná mřížka
Okno **nezačíná na pevné mřížce** — začíná prvním requestem po vypršení
předchozího (jako 5h sessions v Claude Code). Naměřeno 2026-09-15: usage
zůstala na `0.077`/21 req přes 05:00 UTC (pevná mřížka by ji tam vynulovala
a poller by to zapsal) a resetovala se až s requestem v 06:00, po 93minutové
pauze. Rekonstrukce aktivity z `sessions/*.jsonl` dává souvislý řetězec
00:0005:00, pak 06:0011:00 UTC.
Délka 5 h je z primárního zdroje
[transparent-pricing](https://ollama.com/blog/transparent-pricing) („no 5-hour
or weekly limits" je to, co **nové** plány zrušily; tenhle klíč je pořád má).
Že jde o okno a ne rolling counter je taky měřené — pokles `0.077`/21 → `0.0`/`{}`
naráz, rolling by se rozpadal postupně.
**Praktický důsledek:** konec okna = poslední pozorovaný přechod v `samples`
+ 5 h. Za konec se nesmí extrapolovat — další okno začne až dalším requestem,
takže čas neexistuje, dokud request nepřijde. Detail: history 2026-09-15 10:15.
**Pozor na past:** crony nanobota běží na celé hodiny (Dream po 2 h atd.), takže
první request po pauze často padne na celou hodinu. Hranice pak *vypadají* jako
pevná mřížka, i když nejsou.
**Zamítnuto:** kalibrace fáze průnikem kandidátů modulo 300 min. Stála na
předpokladu pevné mřížky a navíc potřebovala víc pozorování, než začala hlásit
cokoli. Nahrazeno výše. Vedlejší poznatek, který z ní zbyl: **5 h nedělí 24 h**,
takže „mřížka 00/05/10/15/20 UTC" nemůže být stálá tak jako tak.
## `limits.session.usage` == procento na dashboardu; countdown na webu ne
API `2.3 %` sedělo přesně s tím, co uživatel viděl na webu. **Countdown na webu
je ale ke kalibraci nepoužitelný** — zaokrouhluje neznámo jak: v 06:58 UTC
ukazoval „4 hodiny" a v 07:05 „3 hodiny". Proto skill tiskne i konkrétní čas,
ne jen „resets in X".
## Zamítnuto: heartbeat v usage polleru
Nápad zapisovat vzorek i beze změny (aby mezera v `samples` znamenala výpadek)
je **zbytečný** — interval `(poslední před, první po]` kolem resetu je pravdivý
bez ohledu na to, jestli poller běžel; výpadek ho jen rozšíří. Uživatel ho
zamítl a měl pravdu: DB zůstává čistě změnová.
## `uv` není v PATH při neinteraktivním SSH
`ssh nanobot@nanobot.hell 'uv run …'` selže na `uv: command not found` — je
v `~/.local/bin`, který se nenačte. Crontab to řeší vlastním řádkem
`PATH=/home/nanobot/.local/bin:/usr/bin:/bin`. Při ručním spuštění přes SSH
předřadit `export PATH=/home/nanobot/.local/bin:$PATH`.

View File

@@ -31,3 +31,60 @@ Po každém commitu, který mění `knowledge.md`, `history.md` nebo `memory.md`
**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`).
## feedback: než navrhneš řešení, nejdřív se podívej na reálný stav souborů na serveru
Když uživatel navrhuje změnu/přidání do serverové konfigurace (skill, `SOUL.md`/`AGENTS.md`/ostatní workspace soubory, config), **nejdřív si stáhni a přečti aktuální serverovou verzi** a ověř, zda navrhovaná věc už neexistuje nebo není vyřešená jinak — teprve pak navrhuj postup.
**Why:** Uživatel 2026-06-07 — na otázku „má smysl zapsat nanobotovi reasoning anglicky?" jsem rovnou navrhl formulaci a celý deployment, ale pravidlo už v `SOUL.md` dávno bylo (napsal si ho Dream procesor sám 2026-05-27). Celý návrh byl zbytečný. Uživatel to označil za podstatnější poznatek než samotnou odpověď. Server se navíc mění autonomně (Dream), takže předpoklady z paměti/repa můžou být zastaralé.
**How to apply:** U čehokoli, co se týká serverového stavu, je první krok `rsync`/`ssh cat` reálného souboru + kontrola, jestli problém už není vyřešený. Návrh řešení až po ověření. Platí i pro „malé" změny, které vypadají triviálně.
## feedback: u bugu nejdřív najdi a dolož PŘÍČINU, neiteruj workaroundy
Než navrhnu jakýkoli fix chování (zvlášť rendering/UI bug), musím **nejdřív najít a doložit kořenovou příčinu** — přečíst reálný **nasazený** kód, který chování produkuje (ne upstream/podobnou verzi), a získat **přímý důkaz** (např. session log s tím, co model skutečně vrátil). Teprve s prokázanou příčinou navrhovat řešení.
**Why:** 2026-06-14 (`/note` + URL ve WebUI, viz [[plans/note-wrong-urls.md]]) jsem několik kol nasazoval kosmetické obezličky (linkify → backtick → odrážky → tučné číslo), všechny selhaly, a teprve pak našel příčinu: custom fork WebUI má `li` handler, co přebalí každou položku seznamu s odkazem na kartu. Kdybych nejdřív přečetl nasazený renderer a session log (důkaz, že model echovuje verbatim → chyba je v rendereru, ne v modelu/formátu), ušetřil bych celá kola deploy-test a autorovo zklamání. Nabízená „řešení" pak byly workaroundy, ne systémová oprava — autor je všechny zamítl jako „nesystémové".
**How to apply:** U bug reportu: (1) lokalizuj a přečti reálný nasazený kód zodpovědný za chování; (2) seženi přímý důkaz, kde přesně se to láme (logy, session transcript, raw výstup) a vyluč nesprávné hypotézy (model vs renderer apod.); (3) až pak navrhuj fix — a měř ho proti příčině: pokud neopravuje příčinu, řekni to nahlas a označ za workaround. Doplňuje [[memory.md]] „než navrhneš řešení, nejdřív se podívej na reálný stav souborů na serveru".
## feedback: skill nemá opakovat ani vysvětlovat to, co už je v system promptu
Do `SKILL.md` (nanobotího skillu) nepatří konvence a fakta o prostředí, která už žijí
v system promptu — `AGENTS.md` (nástroje, `uv`, temp soubory, exec guard, git) a `SOUL.md`
(osobnost, styl výstupu, jazyk reasoningu). Skill popisuje **svůj vlastní postup**, ne to,
jak se v tomhle prostředí obecně pracuje. Než něco takového do skillu napíšu, ověřit
`grep` v `AGENTS.md`/`SOUL.md`, jestli to tam už není.
**Why:** 2026-09-02 jsem při zkracování skillu `reflect` do STOP gate 1 *přidal* půlvětu
vysvětlující, proč se `uv` volá plnou cestou („`uv` není v `PATH` v neinteraktivním SSH;
uvnitř tahu stačí `uv run`"). Uživatel se zeptal, proč to tam vůbec je — `AGENTS.md`
celou sekci `## python — use uv`. Byla to dvojí chyba: environmentální meta-znalost ve
skillu, a druhá polovina věty navíc doslova opakovala, co `AGENTS.md` agentovi říká.
Zvlášť trapné v commitu, jehož cílem bylo skill **zkrátit**. Viz `history.md` 2026-09-02
14:15.
**How to apply:** Fakt o prostředí → `AGENTS.md`/`SOUL.md`. Vysvětlení „proč je ten příkaz
takhle" pro člověka → `README.md` skillu (nenačítá se do kontextu, takže nestojí tokeny).
Do `SKILL.md` jen to, co agent potřebuje k provedení **tohoto** postupu. A pozor na
asymetrii: přidat do system promptu se vyplatí jen tehdy, když to agent reálně potřebuje —
`PATH` gotcha se do `AGENTS.md` nakonec taky nepřidala, protože agentovi bare `uv run`
funguje a týkala se jen člověka v SSH. Souvisí s [[memory.md]] „než navrhneš řešení,
nejdřív se podívej na reálný stav souborů na serveru".
## feedback: YAML seznamy — víc položek nebo dlouhé stringy jdou do block stylu
Seznam s jedinou krátkou položkou zůstává flow (`include: ["*.md"]`, `paths: ["**"]`,
`exclude: []`). Jakmile má **víc položek** nebo jsou položky **dlouhé stringy** (typicky
cesty a globy), píše se **block stylem, každá položka na vlastním řádku**.
**Why:** 2026-09-09 jsem v plánu `final-wiki-hybrid-rag` napsal `exclude` jako flow seznam
zalomený přes dva řádky (`["**/node_modules/**", "**/vendor/**",` / `"**/.venv/**", …]`).
Uživatel to vrátil: zalomený flow seznam je nejhorší z obou světů — nevejde se na řádek,
nejde u položky mít komentář a diff jedné změněné položky přepíše celý blok.
**How to apply:** Platí pro jakýkoli YAML, který píšu nebo který generuje kód (configy
skillů, frontmatter, CI). Rozhoduj podle obsahu, ne podle délky výsledku: dva dlouhé globy
jdou do block stylu, i kdyby se na jeden řádek vešly. Block styl navíc umožní komentář
u konkrétní položky, což u whitelistů a excludů nese hodnotu. Souvisí s pravidlem
v `CLAUDE.md`, že config je YAML právě kvůli komentářům.

View File

@@ -7,44 +7,44 @@ trvalá změna defaultu = `agents.defaults.modelPreset` v `~/.nanobot/config.jso
## Nakonfigurované presety (`/model`)
Zdroj: `~/.nanobot/config.json``model_presets`. Všechny mají `maxTokens = 16384`,
`temperature = 0.1`, `provider = ollama`.
Zdroj: `~/.nanobot/config.json``model_presets`. Všechny mají `maxTokens = 16384`
a `temperature = 0.1`. Default je `glm`, fallback `kimi`.
| Preset | Model id | Kontext | reasoningEffort |
|---|---|---|---|
| `glm` *(default)* | `glm-5.3-flash:cloud` | 976 000 | high |
| `glm53` | `glm-5.3:cloud` | 976 000 | high |
| `glm52` *(legacy)* | `glm-5.2:cloud` | 976 000 | high |
| `glmi51` *(legacy)* | `glm-5.1:cloud` | 196 608 | — |
| `kimi` | `kimi-k2.6:cloud` | 262 144 | — |
| `kimi27` | `kimi-k2.7-code:cloud` | 262 144 | — |
| `kimi3` | `kimi-k3:cloud` | 1 020 000 | — |
| Preset | Provider | Model id | Kontext | reasoningEffort |
|---|---|---|---|---|
| `glm` *(default)* | ollama | `glm-5.3:cloud` | 976 000 | high |
| `flash` | ollama | `glm-5.3-flash:cloud` | 976 000 | high |
| `glm52` *(legacy)* | ollama | `glm-5.2:cloud` | 976 000 | high |
| `glm51` *(legacy)* | ollama | `glm-5.1:cloud` | 196 608 | — |
| `kimi` | ollama | `kimi-k2.7-code:cloud` | 262 144 | — |
| `kimi26` | ollama | `kimi-k2.6:cloud` | 262 144 | — |
| `kimi3` | ollama | `kimi-k3:cloud` | 1 020 000 | — |
| `sonnet` | openrouter | `anthropic/claude-sonnet-4.6` | 256 000 | — |
| `haiku` | openrouter | `anthropic/claude-haiku-4.5` | 200 000 | — |
| `gemini-flash` | openrouter | `google/gemini-3.5-flash` | 256 000 | — |
| `gemini-flash-lite` | openrouter | `google/gemini-3.1-flash-lite` | 256 000 | — |
`glmi51` a `glm52` jsou legacní — jen pro regression srovnání, k práci nepoužívat.
`glm51` a `glm52` jsou legacní — jen pro regression srovnání, k práci nepoužívat.
`kimi3` nepoužívat (viz níž), zvaž odstranění presetu z configu.
---
## Strategie modelů (potvrzeno uživatelem, 2026-02)
## Strategie modelů (potvrzeno uživatelem, 2026-09)
| Role | Model |
|---|---|
| **Daily driver** | `glm` — glm-5.3-flash |
| **Těžké úlohy** | `glm53` — glm-5.3 |
| **Kódování** | `kimi27` — kimi-k2.7-code (fallback: `kimi`) |
| **Běžné agentní práce na Kimi** | `kimi` — kimi-k2.6 |
| **Daily driver + těžké úlohy** | `glm` — glm-5.3 |
| **Kódování** | `kimi` — kimi-k2.7-code |
| **Běžné agentní práce na Kimi** | `kimi26` — kimi-k2.6 |
| **Levnější/rychlejší + multimodální vstup** | `flash` — glm-5.3-flash |
| **Nepoužívat** | `kimi3` — extra high usage, zasekávání v agentním modu (2026-02) |
### Kdy přepnout na glm53 („těžká úloha")
### Kdy sáhnout po `flash`
- Multi-step research / deep-research skill (30+ iterací, cross-checking, dlouhé syntézy).
- Multi-file refactor (5+ souborů, velký kontext).
- Dlouhý debug / root-cause analýza přes víc systémů.
- Wiki-compile s velkými vstupy (drain tisíců řádků).
- Prompt injection / security analýza.
Pravidlo: nejdřív `glm` (flash); když task „cuchne" (model ztrácí nit, opakuje tool
call, potřebuje přes 100 iterací), restartuj na `glm53`.
- **Multimodální vstup** — obrázek, screenshot, sken, video. `glm` (glm-5.3) je
**text-only**, flash je jediný GLM preset, co obrázky přijme (viz níž).
- Levné rutinní tahy, kde nejde o maximální kvalitu a záleží na rychlosti
(TTFT ~0,4 s vs ~0,7 s).
### GLM-5.3 řada — srovnání (deep research, 2026-06)

View File

@@ -142,3 +142,5 @@ Závěr uživatele: současný stav (modely pod 10 GB na této GPU instanci) nen
[obnovený zápis 1] Výuková session: uživatel chce rozumět hardwarové vrstvě své RTX 4060 Ti 16GB — výpočetní jednotky (SM/CUDA/tensor cores), data path RAM→VRAM→jednotky, rozdíl inference vs trénink, memory-bound decode. Motivace: lépe chápat chování Ollamy na nvidia.hell (OOM killy watchdogu, layer offload, limity tok/s dle bandwidth). Klíčové spojitosti: 288 GB/s bandwidth = horní limit decode tok/s, KV cache roste s kontextem (koreluje s watchdog killy). Artifact soubor později na žádost uživatele smazán.
[obnovený zápis 2] Zpětná vazba na výukovou session o RTX 4060 Ti: první verze byla odmítnuta — příliš "hop", hromada spec-čísel místo mechanistického vysvětlení. Uživatel chce proces: co se skutečně počítá v transformeru, jak interní jednotky (CUDA core vs tensor core mechanicky) fungují a kde se zapojují, detailní inference vs trénink průchod. Lesson: u výukových dotazů tohoto typu -- mechanistický průchod, ne spec tabulky.
- 2026-09-15: - Zkušenost s navrhováním skillů v nanobotu: výsledky jsou dost žalostné — kostra není špatná, ale nanobot nedohledá různé detaily a problémy tak, jak je to pak udělá Claude Code. To je důvod, proč všechny zásadní opravy a nové skilly dělá uživatel v Claude Code. Nanobot plánovač jde ale točit a ladit — sám odchytí plno chyb a doladí se k uživatelově spokojenosti (příklad: skill /usage). Uživatelův rozbor: není to úplně chyba modelu, který nanobot používá, a asi ani ne nanobota samotného — spíš to je dáno tím, jak je nanobot koncipovaný.
- 2026-09-15: - Rozbor, proč je navrhování skillů v nanobotu slabé (uživatel přímo): (1) nanobot je s řešením moc rychle hotov — žádný důsledkový průzkum po prvním návrhu, (2) chybí plánovací mód, kde uživatel může komentovat, co se mu nelíbí, dřív než se něco implementuje, (3) nanobot je málo kritický ke svým vlastním řešením. Odtud workflow: návrh/ladění plánu s nanobotem (iterativně, /usage jako důkaz, že to jde), finální implementace v Claude Code.

View File

@@ -22,6 +22,7 @@ https://blog.root.cz/tonda/llm-jako-virtualni-projektovy-tym-od-generovani-textu
- **Iterovaná oponentura** (A→B→A/C→B) — opakovat review po přepracování, ne jen jednou.
## Lessons learned
- Skill design: nanobot samotný navrhne jen kostru, nedohledá detaily a problémy jako Claude Code → zásadní opravy a nové skilly dělat v Claude Code; nanobot plánovač ale jde točit a ladit iterativně (příklad: /usage). Proč (rozbor uživatele): nanobot je s řešením moc rychle hotov (žádný důsledkový průzkum), chybí plánovací mód s možností komentovat, málo kritický k vlastním řešením. Příčina: podle uživatele to není úplně chyba modelu ani nanobota samotného, spíš koncept, jak je nanobot postavený.
- Review iterací: nová session na review = svěží pohled, ale opravy dělat v původní session s plnou historií rozhodnutí — jinak hrozí regrese (reverzy odsouhlasených rozhodnutí).
- Inline `python -c` s cestami blokuje exec guard i uvnitř workspace → write_file do tmp/ + `uv run` s working_dir.
- Před re-runem vždy rekonstruovat loop ze sessions/ — rozhodnout model vs. tooling chyba.

View File

@@ -0,0 +1,5 @@
# Phrases
| EN | CZ | poznámka |
|----|----|----------|
| a means to an end | prostředek k cíli | něco, co má smysl jen jako cesta k cíli, ne samo o sobě; work is a means to an end |

View File

@@ -0,0 +1,5 @@
# Vocabulary
| EN | CZ | poznámka |
|----|----|----------|
| culprit (!) | viník, původce (problému) | ten, kdo za něco může; the culprit behind the outage |

2
projects/en/memory.md Normal file
View File

@@ -0,0 +1,2 @@
- 2026-09-16: Vznikl projekt 'en' pro učení angličtiny a psaní různých poznámek. Uživatel schválil název 'en' (zvažovali jsme i 'english' / 'anglictina').
- 2026-09-16: Uživatel chce v rámci projektu sbírat anglická slovíčka, fráze a části vět. Některá slovíčka jsou pro něj opakovaný boj — ty je potřeba vidět a vracet se k nim.

13
projects/en/prompt.md Normal file
View File

@@ -0,0 +1,13 @@
# English (en)
## Účel
Projekt pro učení angličtiny a psaní si různých poznámek.
## Rozsah
- Poznámky k učení angličtiny: slovíčka, fráze, gramatika, pokroky
- Různé soukromé poznámky v angličtině (cvičení psaní, deník, …)
- Cokoliv, co uživatel do projektu uloží
## Jak pracovat
- Psát soubory do `artifacts/` s popisnými názvy
- Vysvětlovat v češtině, ukázky a cvičné texty v angličtině

0
projects/en/state.md Normal file
View File

View File

@@ -8,4 +8,5 @@ Pravidlo: vynechaný den není selhání. Pásmo: podlaha 2×/týden, strop denn
| 2026-09-12 | ✅ | opakování |
| 2026-09-13 | ❌ | |
| 2026-09-14 | ✅ | |
| 2026-09-15 | ✅ | + další cviky z vlastní sestavy; pravá kyčel táhne víc, kolena ztuha |
| 2026-09-15 | ✅ | + další cviky z vlastní sestavy; pravá kyčel táhne víc, kolena ztuha |
| 2026-09-16 | ✅ | + další cviky z vlastní sestavy |

View File

@@ -27,3 +27,4 @@
- 2026-09-15: Cvičil fyzio cviky — třetí tréninkový den za čtyři dny (11., 12., 14., 15.9.), v rámci zvoleného pásma.
- 2026-09-15: Stav kyčle po 4 dnech cvičení: asymetrie přetrvává — pravá strana táhne o dost víc, přitahování kolen jde obecně ztuha. K fyzio sestavě zařadil i další cviky ze své obvyklé sestavy. Rozhodnutí: nová návštěva fyzioterapeutky cca 34 týdny po první (10.9.), tedy začátek října — v souladu s klinickou praxí (reassessment 23 týdny po zahájení, pak 34 týdny; ROM adaptace trvá týdny). Na kontrole doladit počty opakování a výdrže u cviků.
- 2026-09-15: Upřesnění k rozšíření sestavy: další přidané cviky jsou taky protahovací — celý trénink tak zůstává čistě mobilizační, žádná zátěž navíc.
- 2026-09-16: Další tréninkový den (16.9.) — fyzio cviky plus další přidané protahovací cviky z vlastní obvyklé sestavy. Adherence drží: 5 tréninků za 6 dní, v rámci zvoleného pásma (podlaha 2×/týden, cíl denně).

View File

@@ -0,0 +1,66 @@
# compact-memory
Audit a zhuštění `memory/MEMORY.md` — zahazuje záznamy, které už neplatí, opakují se, jsou příliš
detailní nebo jednorázové, a slučuje související odrážky. Mění **jen** `memory/MEMORY.md`;
`USER.md`, `SOUL.md` a `keep.md` čte pouze pro detekci duplicit.
## Dva režimy
| | Interaktivní | Noční |
|---|---|---|
| Spouštěč | uživatel v chatu („compact memory") | crontab `0 2 * * *``scripts/compact_memory_auto.py` |
| Kdo aplikuje změnu | agent svými file tooly | skript (agent nesmí nic zapsat) |
| Výstup | číslovaný návrh v chatu, čeká na `apply` \| `keep <čísla>` \| `delete <čísla>` \| `cancel` | Telegram zpráva složená skriptem |
| Backup | ne (dohledatelnost přes `log/memory-clean.log`) | `backup/<YYYY-MM-DD_HHMM>_memory.backup.md` |
## Co se maže a slučuje
| Kategorie | Co znamená |
|---|---|
| `superseded` | překonáno novějším faktem, práce dokončená, už neplatí |
| `detail` | konkrétní příkazy, cesty, čísla, odkazy do kódu — patří do skillu nebo kódu, ne do paměti |
| `duplicate` | stejný fakt už je v `USER.md`, `SOUL.md` nebo `keep.md` |
| `ephemeral` | jednorázový debug, dočasný stav, vyřešený incident, průběh běhu |
| `stale-section` | celá `##`/`###` sekce, jejíž obsah je celý překonaný nebo jednorázový |
| `merge` | sloučení souvisejících odrážek do jedné — jen v rámci téže `###` podsekce |
## Noční běh krok za krokem
1. Skript spustí agenta přes `Nanobot.from_config(model_preset="kimi")` s vždy novým
`session_key` (`compact-memory-auto:<timestamp>`), aby noční běhy nekontaminovaly živý chat
ani sebe navzájem. Celý běh má timeout 10 min.
2. Agent provede audit a odpoví **jedním JSON blokem** se seznamem `changes`
(prázdný seznam = není co měnit). Prózu agenta nikdo nečte.
3. Skript change-set zvaliduje proti aktuálnímu obsahu `MEMORY.md` a ověří sha256, že agent
soubor sám nezměnil.
4. Odmítnutý change-set → retry ve stejné session s výpisem chyb validátoru, celkem max 3 pokusy.
5. Platný change-set → backup, přepis `MEMORY.md`, append do `log/memory-clean.log`, Telegram report
(počty + jeden řádek na změnu s důvodem).
Proč takhle: model rozhoduje *co* se má smazat, skript řeší *jak* — doručená zpráva tak nezávisí
na tom, jestli model dodržel formát výstupu.
## Validace
Skript change-set přijme jen když přesně sedí na aktuální soubor: každý blok v `original` musí být
zkopírovaný znak po znaku a v souboru se smí vyskytovat jen jednou, bloky se nesmí překrývat
a sloučený text musí být kratší než původní. Navíc platí limity na délku (`reason` je krátká česká
věta) a change-set nesmí smazat víc než polovinu souboru. Přesná čísla jsou v `SKILL.md`
a v konstantách skriptu.
## Cesty (relativně k workspace)
| Cesta | Role |
|---|---|
| `memory/MEMORY.md` | jediný soubor, který se mění |
| `backup/` | snapshot před nočním přepisem |
| `log/memory-clean.log` | append-only audit smazaných/sloučených položek |
| `log/compact_memory_auto_cron.log` | stdout/stderr cronu — odpověď modelu a důvod odmítnutí per pokus |
| `skills/compact-memory/scripts/compact_memory_auto.py` | noční runner |
## Když noční běh spadne
- Zpráva začínající `Memory compact: CHYBA —` znamená, že se **nic neaplikovalo**.
- Detail (odpovědi modelu, chyby validátoru) je v `log/compact_memory_auto_cron.log`.
- Ruční spuštění: `uv run skills/compact-memory/scripts/compact_memory_auto.py` — pozor, reálně
mění paměť a pošle Telegram zprávu.

View File

@@ -45,7 +45,7 @@ CLEAN_LOG_REL = "log/memory-clean.log"
FALLBACK_CHAT_ID = "8826147089"
TIMEOUT_SECONDS = 10 * 60
MAX_ATTEMPTS = 3
MODEL_PRESET = "kimi27"
MODEL_PRESET = "kimi"
DELETE_CATEGORIES = frozenset({"superseded", "detail", "duplicate", "ephemeral", "stale-section"})
DELETE_KEYS = frozenset({"op", "category", "original", "reason"})

View File

@@ -0,0 +1,425 @@
"""Tests for compact_memory_auto.py — change-set parsing, validation, applying and reporting.
The nanobot import is deferred inside _run, so importing the module needs no nanobot-ai install.
Nothing here touches the network or an LLM; every tested function is pure or writes into tmp_path.
"""
import json
import sys
from datetime import datetime
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import compact_memory_auto as cma
MEMORY_TEXT = """# MEMORY
## Infra
- Runs as a systemd user service `nanobot.service`
- Model switching via `my` tool needs `tools.my.allow_set = true`
- Telegram bot polls every 2 seconds
## Projects
### compact-memory
- Nightly job driven by the crontab
- Debug run 2026-07-25: output-format test in progress
- Change-set validated by the script
### remind
- Reminders live in a SQLite database
- Cron sends due reminders every minute
"""
DEBUG_LINE = "- Debug run 2026-07-25: output-format test in progress"
SYSTEMD_LINE = "- Runs as a systemd user service `nanobot.service`"
PRESET_LINE = "- Model switching via `my` tool needs `tools.my.allow_set = true`"
NOW = datetime(2026, 7, 27, 2, 5)
def delete_item(original, reason="ephemeral marker", category="ephemeral", **overrides):
item = {"op": "delete", "category": category, "original": original, "reason": reason}
item.update(overrides)
return item
def merge_item(original, new_text, reason="same subsection, one topic", **overrides):
item = {"op": "merge", "original": original, "new_text": new_text, "reason": reason}
item.update(overrides)
return item
def answer(*changes):
"""Wrap changes the way the agent does — one fenced json block."""
return "```json\n" + json.dumps({"changes": list(changes)}) + "\n```"
@pytest.fixture
def workspace(tmp_path):
memory = tmp_path / "memory"
memory.mkdir()
(memory / "MEMORY.md").write_text(MEMORY_TEXT, encoding="utf-8")
return tmp_path
# --- _extract_json ---------------------------------------------------------------------------
def test_extract_json_from_fenced_block():
assert cma._extract_json('```json\n{"changes": []}\n```') == {"changes": []}
def test_extract_json_from_bare_answer():
assert cma._extract_json('{"changes": []}') == {"changes": []}
def test_extract_json_takes_last_parseable_block():
content = '```json\n{"changes": [1]}\n```\ntext\n```json\n{"changes": [2]}\n```'
assert cma._extract_json(content) == {"changes": [2]}
def test_extract_json_without_json_raises():
with pytest.raises(cma.ChangeSetError, match="no parseable"):
cma._extract_json("I audited the file and found nothing.")
# --- parse_change_set: happy paths -----------------------------------------------------------
def test_parse_delete():
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
assert len(located) == 1
change = located[0].change
assert change.op == "delete"
assert change.category == "ephemeral"
assert change.original == (DEBUG_LINE,)
assert change.new_text == ()
def test_parse_merge():
merged = "- systemd service `nanobot.service`; model switching needs `tools.my.allow_set = true`"
located = cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])), MEMORY_TEXT)
assert len(located) == 1
assert located[0].change.op == "merge"
assert located[0].change.category == "merge"
assert located[0].change.new_text == (merged,)
def test_parse_empty_change_set():
assert cma.parse_change_set(answer(), MEMORY_TEXT) == []
def test_parse_strips_reason_whitespace():
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason=" stale ")), MEMORY_TEXT)
assert located[0].change.reason == "stale"
def test_parse_reports_span_of_matched_block():
lines = MEMORY_TEXT.splitlines()
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
assert located[0].start == lines.index(DEBUG_LINE)
assert located[0].end == located[0].start + 1
# --- parse_change_set: payload shape ---------------------------------------------------------
def test_payload_must_be_object():
with pytest.raises(cma.ChangeSetError, match="must be an object"):
cma.parse_change_set("```json\n[]\n```", MEMORY_TEXT)
def test_payload_needs_changes_list():
with pytest.raises(cma.ChangeSetError, match='"changes" list'):
cma.parse_change_set('```json\n{"items": []}\n```', MEMORY_TEXT)
def test_item_must_be_object():
with pytest.raises(cma.ChangeSetError, match="must be a JSON object"):
cma.parse_change_set(answer("delete everything"), MEMORY_TEXT)
# --- parse_change_set: field validation ------------------------------------------------------
def test_unknown_op_rejected():
with pytest.raises(cma.ChangeSetError, match='"op" must be'):
cma.parse_change_set(answer(delete_item([DEBUG_LINE], op="rewrite")), MEMORY_TEXT)
def test_unknown_field_rejected():
with pytest.raises(cma.ChangeSetError, match="unknown fields"):
cma.parse_change_set(answer(delete_item([DEBUG_LINE], note="extra")), MEMORY_TEXT)
def test_merge_field_on_delete_rejected():
with pytest.raises(cma.ChangeSetError, match="unknown fields"):
cma.parse_change_set(answer(delete_item([DEBUG_LINE], new_text=["x"])), MEMORY_TEXT)
def test_empty_reason_rejected():
with pytest.raises(cma.ChangeSetError, match='"reason" must be a non-empty string'):
cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason=" ")), MEMORY_TEXT)
def test_too_long_reason_rejected():
long_reason = "x" * (cma.MAX_REASON_CHARS + 1)
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_REASON_CHARS}"):
cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason=long_reason)), MEMORY_TEXT)
def test_original_must_be_non_empty_list():
with pytest.raises(cma.ChangeSetError, match='"original" must be a non-empty list'):
cma.parse_change_set(answer(delete_item([])), MEMORY_TEXT)
def test_original_must_be_list_of_strings():
with pytest.raises(cma.ChangeSetError, match='"original" must be a non-empty list'):
cma.parse_change_set(answer(delete_item([DEBUG_LINE, 42])), MEMORY_TEXT)
def test_too_many_original_lines_rejected():
block = [f"- line {i}" for i in range(cma.MAX_ORIGINAL_LINES + 1)]
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_ORIGINAL_LINES}"):
cma.parse_change_set(answer(delete_item(block)), MEMORY_TEXT)
def test_unknown_delete_category_rejected():
with pytest.raises(cma.ChangeSetError, match='"category" must be one of'):
cma.parse_change_set(answer(delete_item([DEBUG_LINE], category="obsolete")), MEMORY_TEXT)
def test_too_many_new_text_lines_rejected():
original = [SYSTEMD_LINE, PRESET_LINE, "- Telegram bot polls every 2 seconds"]
new_text = [f"- merged {i}" for i in range(cma.MAX_NEW_TEXT_LINES + 1)]
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_NEW_TEXT_LINES}"):
cma.parse_change_set(answer(merge_item(original, new_text)), MEMORY_TEXT)
def test_too_long_new_text_rejected():
new_text = ["- " + "x" * cma.MAX_NEW_TEXT_CHARS]
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_NEW_TEXT_CHARS}"):
cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], new_text)), MEMORY_TEXT)
def test_merge_must_shrink():
new_text = ["- one", "- two"]
with pytest.raises(cma.ChangeSetError, match="fewer lines"):
cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], new_text)), MEMORY_TEXT)
def test_all_item_errors_reported_at_once():
bad_op = delete_item([DEBUG_LINE], op="rewrite")
bad_reason = delete_item([SYSTEMD_LINE], reason="")
with pytest.raises(cma.ChangeSetError) as excinfo:
cma.parse_change_set(answer(bad_op, bad_reason), MEMORY_TEXT)
message = str(excinfo.value)
assert "item 1:" in message
assert "item 2:" in message
# --- locating blocks in the file -------------------------------------------------------------
def test_missing_block_rejected():
with pytest.raises(cma.ChangeSetError, match=r"does not appear in MEMORY\.md"):
cma.parse_change_set(answer(delete_item(["- this line was never in the file"])), MEMORY_TEXT)
def test_ambiguous_block_rejected():
text = "- duplicated bullet\n- something else\n- duplicated bullet\n"
with pytest.raises(cma.ChangeSetError, match="appears 2 times"):
cma.parse_change_set(answer(delete_item(["- duplicated bullet"])), text)
def test_trailing_whitespace_is_ignored_when_matching():
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE + " "])), MEMORY_TEXT)
assert located[0].change.original == (DEBUG_LINE + " ",)
def test_located_changes_are_sorted_by_position():
later = delete_item([DEBUG_LINE])
earlier = delete_item([SYSTEMD_LINE], category="detail", reason="code reference")
located = cma.parse_change_set(answer(later, earlier), MEMORY_TEXT)
assert [item.change.original[0] for item in located] == [SYSTEMD_LINE, DEBUG_LINE]
def test_overlapping_blocks_rejected():
first = delete_item([SYSTEMD_LINE, PRESET_LINE])
second = delete_item([PRESET_LINE, "- Telegram bot polls every 2 seconds"])
with pytest.raises(cma.ChangeSetError, match="overlapping items"):
cma.parse_change_set(answer(first, second), MEMORY_TEXT)
def test_removing_more_than_half_the_file_rejected():
text = "- one\n- two\n- three\n- four\n"
block = ["- one", "- two", "- three"]
with pytest.raises(cma.ChangeSetError, match="more than 50% of the file"):
cma.parse_change_set(answer(delete_item(block)), text)
# --- apply_change_set ------------------------------------------------------------------------
def test_apply_delete_removes_the_block(workspace):
memory = workspace / "memory" / "MEMORY.md"
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
lines_after = cma.apply_change_set(memory, located, workspace, NOW)
text = memory.read_text(encoding="utf-8")
assert DEBUG_LINE not in text
assert SYSTEMD_LINE in text
assert text.endswith("\n")
assert lines_after == len(MEMORY_TEXT.splitlines()) - 1
assert lines_after == len(text.splitlines())
def test_apply_merge_replaces_the_block(workspace):
memory = workspace / "memory" / "MEMORY.md"
merged = "- systemd service; model switching needs `tools.my.allow_set = true`"
located = cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])), MEMORY_TEXT)
lines_after = cma.apply_change_set(memory, located, workspace, NOW)
text = memory.read_text(encoding="utf-8")
assert merged in text
assert SYSTEMD_LINE not in text
assert PRESET_LINE not in text
assert lines_after == len(MEMORY_TEXT.splitlines()) - 1
def test_apply_writes_backup_with_the_original_content(workspace):
memory = workspace / "memory" / "MEMORY.md"
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
cma.apply_change_set(memory, located, workspace, NOW)
backup = workspace / "backup" / "2026-07-27_0205_memory.backup.md"
assert backup.read_text(encoding="utf-8") == MEMORY_TEXT
def test_apply_appends_to_the_clean_log(workspace):
memory = workspace / "memory" / "MEMORY.md"
clean_log = workspace / cma.CLEAN_LOG_REL
clean_log.parent.mkdir(parents=True)
clean_log.write_text('2026-07-26 02:05 DELETED [detail] "old entry" — earlier run\n', encoding="utf-8")
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
cma.apply_change_set(memory, located, workspace, NOW)
lines = clean_log.read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
assert lines[0].endswith("earlier run")
assert lines[1].startswith("2026-07-27 02:05 DELETED [ephemeral]")
def test_apply_logs_one_line_per_change(workspace):
memory = workspace / "memory" / "MEMORY.md"
merged = "- systemd service; model switching needs `tools.my.allow_set = true`"
located = cma.parse_change_set(
answer(delete_item([DEBUG_LINE]), merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])),
MEMORY_TEXT,
)
cma.apply_change_set(memory, located, workspace, NOW)
lines = (workspace / cma.CLEAN_LOG_REL).read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
assert any("MERGED [merge]" in line for line in lines)
assert any("DELETED [ephemeral]" in line for line in lines)
# --- reporting -------------------------------------------------------------------------------
def test_report_for_empty_change_set():
assert cma.format_report([], 42, 42) == "Memory compact: nothing to remove (MEMORY.md, 42 lines)."
def test_report_counts_deletes_and_merges():
merged = "- systemd service; model switching needs `tools.my.allow_set = true`"
located = cma.parse_change_set(
answer(delete_item([DEBUG_LINE]), merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])),
MEMORY_TEXT,
)
rows = cma.format_report(located, 20, 18).splitlines()
assert rows[0] == "Memory compact: deleted 1, merged 1 (20 → 18 lines)."
assert len(rows) == 3
assert any(row.startswith("- [merge] ") for row in rows[1:])
assert any(row.startswith("- [ephemeral] ") for row in rows[1:])
def test_report_quotes_reason_verbatim():
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason="task finished")), MEMORY_TEXT)
assert cma.format_report(located, 20, 19).endswith("— task finished")
def test_log_line_for_delete():
change = cma.Change(op="delete", category="detail", original=("- a path",), new_text=(), reason="belongs in code")
assert cma._log_line(change, "2026-07-27 02:05") == '2026-07-27 02:05 DELETED [detail] "- a path" — belongs in code'
def test_log_line_for_merge():
change = cma.Change(op="merge", category="merge", original=("- a", "- b"), new_text=("- ab",), reason="one topic")
assert cma._log_line(change, "2026-07-27 02:05") == '2026-07-27 02:05 MERGED [merge] "- a - b""- ab" — one topic'
# --- helpers ---------------------------------------------------------------------------------
def test_shorten_keeps_short_text():
assert cma._shorten("short", 10) == "short"
def test_shorten_truncates_with_ellipsis():
assert cma._shorten("abcdefghij", 5) == "abcd…"
def test_shorten_strips_before_the_ellipsis():
assert cma._shorten("ab cdefgh", 4) == "ab…"
def test_quote_joins_stripped_lines():
assert cma._quote((" - first ", "- second")) == "- first - second"
def test_quote_truncates_long_blocks():
quoted = cma._quote(tuple(f"- line {i}" for i in range(30)))
assert len(quoted) == cma.QUOTE_CHARS
assert quoted.endswith("")
# --- config ----------------------------------------------------------------------------------
def test_telegram_config_uses_first_allowed_chat():
config = {"channels": {"telegram": {"token": "tok", "allowFrom": [12345, 999]}}}
assert cma._telegram_config(config) == ("tok", "12345")
def test_telegram_config_falls_back_when_allow_from_is_empty():
config = {"channels": {"telegram": {"token": "tok", "allowFrom": []}}}
assert cma._telegram_config(config) == ("tok", cma.FALLBACK_CHAT_ID)
def test_telegram_config_falls_back_when_allow_from_is_missing():
config = {"channels": {"telegram": {"token": "tok"}}}
assert cma._telegram_config(config) == ("tok", cma.FALLBACK_CHAT_ID)
def test_workspace_from_config():
config = {"agents": {"defaults": {"workspace": "~/custom/workspace"}}}
assert cma._workspace(config) == Path.home() / "custom" / "workspace"
def test_workspace_falls_back_when_unset():
assert cma._workspace({}) == cma.WORKSPACE_FALLBACK

View File

@@ -60,7 +60,7 @@ FINDINGS_REL = "reflect/findings.jsonl"
RESULTS_REL = "results"
FALLBACK_CHAT_ID = "8826147089"
MODEL_PRESET = "glm53"
MODEL_PRESET = "glm"
# The soft deadline is what actually bounds a run: no new batch starts past it, and everything
# already analysed is on disk. TIMEOUT_SECONDS only catches a single batch that hangs.
DEFAULT_DEADLINE_MINUTES = 20