From 6d43517a69d6742b51e92bf3a19800a09df69191 Mon Sep 17 00:00:00 2001 From: lachtan Date: Wed, 16 Sep 2026 08:56:39 +0200 Subject: [PATCH] runtime --- projects/ai/memory.md | 2 + projects/ai/state.md | 1 + projects/en/artifacts/phrases.md | 5 + projects/en/artifacts/vocab.md | 5 + projects/en/memory.md | 2 + projects/en/prompt.md | 13 + projects/en/state.md | 0 skills/compact-memory/README.md | 66 +++ .../scripts/compact_memory_auto.py | 2 +- .../tests/test_compact_memory_auto.py | 425 ++++++++++++++++++ skills/reflect/scripts/reflect_auto.py | 2 +- 11 files changed, 521 insertions(+), 2 deletions(-) create mode 100644 projects/en/artifacts/phrases.md create mode 100644 projects/en/artifacts/vocab.md create mode 100644 projects/en/memory.md create mode 100644 projects/en/prompt.md create mode 100644 projects/en/state.md create mode 100644 skills/compact-memory/README.md create mode 100644 skills/compact-memory/tests/test_compact_memory_auto.py diff --git a/projects/ai/memory.md b/projects/ai/memory.md index 9b6b798..0a77379 100644 --- a/projects/ai/memory.md +++ b/projects/ai/memory.md @@ -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. diff --git a/projects/ai/state.md b/projects/ai/state.md index 8c49089..b8bb475 100644 --- a/projects/ai/state.md +++ b/projects/ai/state.md @@ -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. diff --git a/projects/en/artifacts/phrases.md b/projects/en/artifacts/phrases.md new file mode 100644 index 0000000..1a76db5 --- /dev/null +++ b/projects/en/artifacts/phrases.md @@ -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 | \ No newline at end of file diff --git a/projects/en/artifacts/vocab.md b/projects/en/artifacts/vocab.md new file mode 100644 index 0000000..77283b6 --- /dev/null +++ b/projects/en/artifacts/vocab.md @@ -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 | \ No newline at end of file diff --git a/projects/en/memory.md b/projects/en/memory.md new file mode 100644 index 0000000..00e9a33 --- /dev/null +++ b/projects/en/memory.md @@ -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. diff --git a/projects/en/prompt.md b/projects/en/prompt.md new file mode 100644 index 0000000..2130bc5 --- /dev/null +++ b/projects/en/prompt.md @@ -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ě \ No newline at end of file diff --git a/projects/en/state.md b/projects/en/state.md new file mode 100644 index 0000000..e69de29 diff --git a/skills/compact-memory/README.md b/skills/compact-memory/README.md new file mode 100644 index 0000000..cf5d3e7 --- /dev/null +++ b/skills/compact-memory/README.md @@ -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/_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:`), 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. diff --git a/skills/compact-memory/scripts/compact_memory_auto.py b/skills/compact-memory/scripts/compact_memory_auto.py index 9d121c4..b56a650 100644 --- a/skills/compact-memory/scripts/compact_memory_auto.py +++ b/skills/compact-memory/scripts/compact_memory_auto.py @@ -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"}) diff --git a/skills/compact-memory/tests/test_compact_memory_auto.py b/skills/compact-memory/tests/test_compact_memory_auto.py new file mode 100644 index 0000000..a0fef75 --- /dev/null +++ b/skills/compact-memory/tests/test_compact_memory_auto.py @@ -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 diff --git a/skills/reflect/scripts/reflect_auto.py b/skills/reflect/scripts/reflect_auto.py index 7033a4a..7cd445e 100644 --- a/skills/reflect/scripts/reflect_auto.py +++ b/skills/reflect/scripts/reflect_auto.py @@ -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