diff --git a/Agent.md b/Agent.md index b9551d76..0d3dfdfa 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1009) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1013) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index e7532c4c..bfa25840 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1348,6 +1348,31 @@ def _collect_project_context(self, session: Session) -> list[dict[str, str]]: return found + def _cap_memory_index(self, path) -> str: + """Cap a MEMORY.md embedded into the system prompt (defense in depth). + + Rant 2026-08-23T11:00:31: #941's write-time guards only fire on + memory_store API writes, but agents append MEMORY.md rows directly + (evolution_prompt §6) and bypass them — the index once reached + 787KB/2931 lines = 77% of a 452,972-char prompt (~250K all-miss + tokens per request). Cap what gets embedded; the full index and + cycle-archive-*.md stay readable on disk via the read tool. + """ + limit = 50 * 1024 # match memory.INDEX_SIZE_WARN (50KB) + text = path.read_text(encoding="utf-8") + if len(text) <= limit: + return text + cut = text.rfind("\n", 0, limit) + if cut <= 0: + cut = limit + head = text[:cut] + over = len(text) - len(head) + return head + ( + f"\n… [truncated {over} chars — MEMORY.md exceeds the 50KB embed " + "cap; older cycle rows live in cycle-archive-*.md, readable via " + "the read tool]" + ) + def _collect_memory_data(self, session: Session) -> dict[str, Any] | None: """Read memory indexes, return structured data for template.""" data: dict[str, Any] = {"has_memories": False} @@ -1355,7 +1380,7 @@ def _collect_memory_data(self, session: Session) -> dict[str, Any] | None: project_dir = session.cwd / ".emrg" / "memory" pindex_path = project_dir / "MEMORY.md" if pindex_path.exists(): - data["project_memory_index"] = pindex_path.read_text(encoding="utf-8") + data["project_memory_index"] = self._cap_memory_index(pindex_path) data["project_memory_dir"] = str(project_dir) data["project_memory_index_path"] = str(pindex_path) data["has_memories"] = True @@ -1363,7 +1388,7 @@ def _collect_memory_data(self, session: Session) -> dict[str, Any] | None: smem_dir = session.memory_dir sindex_path = smem_dir / "MEMORY.md" if sindex_path.exists(): - data["session_memory_index"] = sindex_path.read_text(encoding="utf-8") + data["session_memory_index"] = self._cap_memory_index(sindex_path) data["session_memory_dir"] = str(smem_dir) data["session_memory_index_path"] = str(sindex_path) data["has_memories"] = True diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 767cd513..b922f192 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -378,7 +378,11 @@ Create a **cycle memory entry** (rant 2026-08-12T18:03:26 — no more standalone - `type: task`, `scope: project`, `status: active` (cycle in progress) or `completed` (final) - Body: findings, changes, verification results, expected effects (same content as before, just a memory file) - Update the `MEMORY.md` index in the same directory (add one row, id linked to the filename) — this is the **single index** for cycle records -- ⚡ **Index-line norm** (rant 2026-08-23T08:04:26): each MEMORY.md index line is a **short one-line summary** (title ≤512 chars, single line) — never duplicated content. Update entries in place rather than appending when a record refines a previous one. If the index exceeds ~50 entries, consolidate (merge/archive) instead of growing it. +- ⚡ **Index hygiene protocol** (rants 2026-08-23T08:04:26 + 11:00:31 — the daemon embeds MEMORY.md into the system prompt raw, and evolution's direct file writes bypass memory_store's guards; an unbounded index once reached 787KB/2931 lines = 77% of a 452,972-char prompt, ~250K all-miss tokens per request). Apply to **every MEMORY.md you maintain** (evolution-level, source-project-level, session-level). Rules: + - **Title-only rows**: each index row is a **one-line summary ≤512 chars** (id linked to the filename). **Never embed a cycle's summary/NTE text into the index row** — that text lives in the `cycle-.md` detail file only. + - **Hard cap: keep at most the 50 most recent cycle rows** in each MEMORY.md. Before adding a row that would exceed 50: append the oldest cycle rows to `cycle-archive-YYYYMMDD.md` **in the same directory** (create-if-missing, append-only, never rewrite or dedupe), then remove those rows from MEMORY.md. **Detail files (`cycle-*.md`) are never deleted** — only index rows move. + - **Archive files are excluded from the system prompt** (the daemon embeds only `MEMORY.md`): never reference `cycle-archive-*.md` in MEMORY.md rows, never re-add archived rows to the index, never paste archive content into MEMORY.md or the prompt. Archived rows stay readable via the `read` tool. + - **Row-cap check**: if a MEMORY.md already exceeds 50 cycle rows (e.g. after a missed cleanup), archive down to the latest 50 this cycle before adding the new row. - Keep the file format identical to other memory entries (frontmatter + Markdown body) > Transition note: legacy `evolution-cycle-*.md` files remain in place (readable, never deleted); only new records use the memory entry path. diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 5df00428..b15c2cbe 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -334,6 +334,59 @@ def test_context_section_manifesto(tmp_path): assert "Keep it simple" in result[0]["content"] +# ── _cap_memory_index / _collect_memory_data ────────────────────── + + +def test_cap_memory_index_small_file(tmp_path): + """Index under the 50KB cap is embedded as-is.""" + server = _make_server() + idx = tmp_path / "MEMORY.md" + idx.write_text("- [row](cycle-1.md) — title\n" * 50, encoding="utf-8") # well under 50KB + assert server._cap_memory_index(idx) == idx.read_text(encoding="utf-8") + + +def test_cap_memory_index_large_file(tmp_path): + """Index over 50KB is truncated at a line boundary with a notice. + + Rant 2026-08-23T11:00:31: evolution agents append MEMORY.md rows + directly (bypassing memory_store's write-time guards), so the embedded + index must be capped at render time — defense in depth. + """ + server = _make_server() + idx = tmp_path / "MEMORY.md" + line = "- [cyc00000000-000000](cycle-20260823-000000.md) — " + "x" * 100 + "\n" + idx.write_text(line * 600, encoding="utf-8") # ~64KB > 50KB cap + capped = server._cap_memory_index(idx) + assert len(capped) <= 50 * 1024 + 200 # head + notice + assert "truncated" in capped + assert "cycle-archive" in capped + # truncation lands on a line boundary (no half-cut index row) + last_line = capped.rsplit("\n", 1)[1] + assert last_line.startswith("… [truncated") + + +def test_collect_memory_data_caps_index(tmp_path): + """_collect_memory_data embeds a capped index (never the raw giant).""" + server = _make_server() + (tmp_path / ".emrg" / "memory").mkdir(parents=True) + idx = tmp_path / ".emrg" / "memory" / "MEMORY.md" + line = "- [cyc00000000-000000](cycle-20260823-000000.md) — " + "y" * 100 + "\n" + idx.write_text(line * 600, encoding="utf-8") # ~64KB + session = Session.create_with_id("mem-cap", tmp_path) + data = server._collect_memory_data(session) + assert data["has_memories"] is True + assert data["project_memory_index_path"] == str(idx) + assert "truncated" in data["project_memory_index"] + assert len(data["project_memory_index"]) <= 50 * 1024 + 200 + + +def test_collect_memory_data_no_index(tmp_path): + """No MEMORY.md files → has_memories stays False.""" + server = _make_server() + session = Session.create_with_id("mem-none", tmp_path) + assert server._collect_memory_data(session) is None + + # ── _count_chars_for_tokens ───────────────────────────────────────