This commit is contained in:
lachtan
2026-09-16 08:56:39 +02:00
parent 5407931bdc
commit 6d43517a69
11 changed files with 521 additions and 2 deletions

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 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. [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. - **Iterovaná oponentura** (A→B→A/C→B) — opakovat review po přepracování, ne jen jednou.
## Lessons learned ## 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í). - 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. - 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. - 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

@@ -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" FALLBACK_CHAT_ID = "8826147089"
TIMEOUT_SECONDS = 10 * 60 TIMEOUT_SECONDS = 10 * 60
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3
MODEL_PRESET = "kimi27" MODEL_PRESET = "kimi"
DELETE_CATEGORIES = frozenset({"superseded", "detail", "duplicate", "ephemeral", "stale-section"}) DELETE_CATEGORIES = frozenset({"superseded", "detail", "duplicate", "ephemeral", "stale-section"})
DELETE_KEYS = frozenset({"op", "category", "original", "reason"}) 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" RESULTS_REL = "results"
FALLBACK_CHAT_ID = "8826147089" 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 # 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. # already analysed is on disk. TIMEOUT_SECONDS only catches a single batch that hangs.
DEFAULT_DEADLINE_MINUTES = 20 DEFAULT_DEADLINE_MINUTES = 20