diff --git a/Agent.md b/Agent.md index 3125fb76..06d3c554 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` (1003) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1007) — 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/memory.py b/emrg/memory.py index e66db9c3..755bed95 100644 --- a/emrg/memory.py +++ b/emrg/memory.py @@ -63,12 +63,34 @@ def _short_date(iso_str: str) -> str: return iso_str[:10] +def _truncate_index_title(title: str, max_len: int | None = None) -> str: + """Truncate an index title to keep MEMORY.md lines bounded. + + The filename (detail file path) is rendered separately in the index line, + so the full content stays reachable via ``read`` even after truncation. + """ + if max_len is None: + max_len = INDEX_TITLE_MAX_CHARS + if len(title) <= max_len: + return title + return title[: max_len - 1] + "…" + + # ── Constants ────────────────────────────────────────────────────── VALID_TYPES = {"user", "feedback", "project", "reference", "decision", "task"} VALID_SCOPES = {"session", "project"} VALID_STATUSES = {"active", "superseded", "merged"} +# Rant 2026-08-23T08:04:26 — memory index governance: keep MEMORY.md lines +# bounded so the embedded index can't bloat the system prompt (413 / cost / +# cache invalidation). Write-time truncation is primary; render-time +# truncation is a fallback for legacy dirty data. Consolidation is +# LLM-driven; these are soft guards (warn/truncate, never auto-delete). +INDEX_TITLE_MAX_CHARS = 512 # max chars for one index title / line +INDEX_COUNT_WARN = 100 # >N memory files → consolidation recommended +INDEX_SIZE_WARN = 50 * 1024 # >50KB MEMORY.md → consolidation recommended + # ── MemoryFile ───────────────────────────────────────────────────── @@ -309,9 +331,14 @@ def add_entry(self, mem: MemoryFile) -> None: # Remove existing entry with same filename self.entries = [e for e in self.entries if e.filename != mem.filename] + # Rant 2026-08-23T08:04:26 — write-time truncation (primary guard): + # keep the stored index title bounded; the full title lives in the + # standalone .md frontmatter, and the filename stays in the index line. + title = _truncate_index_title(mem.display_title) + self.entries.append( _IndexEntry( - title=mem.display_title, + title=title, filename=mem.filename, type=mem.type, status=mem.status, @@ -349,7 +376,24 @@ def to_markdown(self) -> str: rec = f"rec: {_short_date(e.created_at)}" if e.created_at else "" evt = f"evt: {_short_date(e.event_at)}" if e.event_at else "" date_part = ", ".join(p for p in [rec, evt] if p) - lines.append(f"- [{e.title}]({e.filename}){status_tag} — {date_part}") + raw_line = f"- [{e.title}]({e.filename}){status_tag} — {date_part}" + if len(raw_line) > INDEX_TITLE_MAX_CHARS: + # Rant 2026-08-23T08:04:26 — render-time fallback for legacy + # dirty index data (write-time truncation wasn't in place). + # Keep the filename so the detail file stays reachable. + logger.warning( + "memory index line exceeds %d chars (title=%d chars) — truncating", + INDEX_TITLE_MAX_CHARS, len(e.title), + ) + other = len(f"- []({e.filename}){status_tag} — {date_part}") + budget = max(1, INDEX_TITLE_MAX_CHARS - other) + line = ( + f"- [{_truncate_index_title(e.title, budget)}]" + f"({e.filename}){status_tag} — {date_part}" + ) + else: + line = raw_line + lines.append(line) lines.append("") return "\n".join(lines).strip() + "\n" @@ -788,3 +832,30 @@ class SessionMemoryStore(MemoryStore): def __init__(self, session_dir: Path): super().__init__(session_dir / "memory", scope="session") + + # Rant 2026-08-23T08:04:26 — soft guard only: warn when the session index + # crosses thresholds. Actual consolidation (merge/trim to ≤50) is + # LLM-driven via reflection/consolidation prompts — never auto-delete here. + def _save_index(self, index: MemoryIndex, source: str = "") -> None: + super()._save_index(index, source) + self._warn_index_thresholds() + + def _warn_index_thresholds(self) -> None: + """Log a warning when the session index exceeds soft thresholds.""" + try: + if self.count > INDEX_COUNT_WARN: + logger.warning( + "memory index ≥ threshold — consolidation recommended: " + "count=%d > %d (%s)", + self.count, INDEX_COUNT_WARN, self.index_path, + ) + if self.index_path.exists(): + size = self.index_path.stat().st_size + if size > INDEX_SIZE_WARN: + logger.warning( + "memory index ≥ threshold — consolidation recommended: " + "size=%d bytes > %d (%s)", + size, INDEX_SIZE_WARN, self.index_path, + ) + except OSError: + logger.debug("memory index threshold check skipped", exc_info=True) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 53d593df..c274abb8 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -44,7 +44,12 @@ resolve_git_gh, ) from emrg.server.tool_types import ToolResult -from emrg.memory import ProjectMemoryStore, SessionMemoryStore +from emrg.memory import ( + INDEX_COUNT_WARN, + INDEX_SIZE_WARN, + ProjectMemoryStore, + SessionMemoryStore, +) from emrg.protocol import ( EvolutionLog, InstanceIdentity, @@ -3547,6 +3552,30 @@ async def _reflect(): for m in existing ) if existing else "(none yet)" + # Rant 2026-08-23T08:04:26 — index governance: when the session + # index crosses soft thresholds, steer the reflection LLM toward + # consolidation instead of append-only growth. + hygiene_note = "" + try: + index_size = ( + store.index_path.stat().st_size + if store.index_path.exists() else 0 + ) + except OSError: + index_size = 0 + if store.count > INDEX_COUNT_WARN or index_size > INDEX_SIZE_WARN: + hygiene_note = ( + f"\n## ⚠️ Memory hygiene (index: {store.count} entries, " + f"{index_size} bytes)\n" + "- MEMORY.md must stay a **pure index**: one short line per " + "entry (title ≤512 chars), never duplicated content.\n" + "- Prefer **updating existing entries in place** over creating " + "new ones when the new info refines an existing memory.\n" + "- If the index has grown past ~50 entries, consolidate: merge " + "redundant memories (mark old files `status: superseded` or " + "`merged`) and keep only the most relevant entries in the index.\n" + ) + prompt = ( "You are the memory reflection module of EMRG. " "Review the following exchange and decide if anything " @@ -3569,6 +3598,9 @@ async def _reflect(): "- If nothing worth remembering happened, just reply 'no new memories' briefly\n" "- Prefer session-scope for tentative/evolving knowledge; " "project-scope for stable, cross-session facts\n" + "- Keep MEMORY.md a pure index: one short line per entry " + "(title ≤512 chars); update entries in place rather than appending\n" + f"{hygiene_note}" "\n" "Memory format (YAML frontmatter + Markdown):\n" "```\n" @@ -3715,7 +3747,12 @@ async def _consolidate_session_memories( f"(`{cwd}/.emrg/memory/`), update `scope` to `project`, and update " "both MEMORY.md indexes\n" "3. **Clean**: mark completed tasks as `status: superseded`\n" - "4. **Skip**: if everything looks fine, just reply 'no consolidation needed'\n" + "4. **Index cap** (rant 2026-08-23T08:04:26): the MEMORY.md index must " + "converge to **≤50 entries** — merge/archive redundant memories and keep " + "the index a pure index (one short line per entry, title ≤512 chars). " + "Detail .md files are the source of truth and may exceed 50; only the " + "index needs trimming.\n" + "5. **Skip**: if everything looks fine, just reply 'no consolidation needed'\n" "\n" f"Use the read/edit/write tools to make these changes. " f"Session memory dir: `{store.directory}/` " diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 437eca49..767cd513 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -378,6 +378,7 @@ 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. - 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/emrg/server/open_source_prompt.md b/emrg/server/open_source_prompt.md index e7a5d50f..3399a5a5 100644 --- a/emrg/server/open_source_prompt.md +++ b/emrg/server/open_source_prompt.md @@ -485,6 +485,7 @@ At the end of every cycle: 1. **Update the state file** `{{ evolution_cwd }}/open_source_{{ owner }}_{{ repo }}_state.md` 2. **Record key findings** in `{{ evolution_cwd }}/memory/` (if there are important lessons or insights) + - ⚡ **Memory hygiene** (rant 2026-08-23T08:04:26): keep MEMORY.md a **pure index** — one short line per entry (title ≤512 chars, never duplicated content), update entries in place; if the index exceeds ~50 entries, merge/consolidate instead of appending. 3. **The state file itself does not need git commits** (it's a local work record, lives in EMRG's evolution directory) --- diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index 5a4fdac2..df4f7503 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -64,6 +64,12 @@ Index: `{{ session_memory_index_path }}` **To read a memory**: use the `read` tool with the full path. **To create/update a memory**: use `write`/`edit` tools to write the .md file, then update MEMORY.md index. **To clean up**: mark stale memories as `status: superseded` rather than deleting them. + +**Memory Hygiene** (rant 2026-08-23T08:04:26 — keep index small so the embedded index never bloats requests): +- MEMORY.md must stay a **pure index**: one short line per entry (title ≤512 chars), never duplicated content. +- Prefer **updating existing entries in place** over appending new ones when new info refines an existing memory. +- If a memory index exceeds ~50 entries, **consolidate**: merge redundant memories (mark old files `status: superseded` / `merged`) and keep only the most relevant entries in the index. +- Detail `.md` files are the source of truth and may exceed 50; only the index needs trimming. {% else %} ## Memory diff --git a/tests/test_memory.py b/tests/test_memory.py index d6283035..571352a4 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -161,6 +161,42 @@ def test_from_text_parses_entries(self): assert idx.entries[1].type == "decision" assert idx.entries[1].status == "superseded" + def test_add_entry_truncates_long_title(self): + """Rant 2026-08-23T08:04:26 — write-time truncation keeps the stored + index title bounded so MEMORY.md lines can't bloat the system prompt.""" + idx = MemoryIndex() + long_title = "决策" + "x" * 600 + mem = MemoryFile( + id="abc123", + type="task", + title=long_title, + created_at="2026-07-14T15:30:42Z", + ) + idx.add_entry(mem) + entry = idx.entries[0] + assert len(entry.title) <= 512 + assert entry.title.endswith("…") + # Filename (reachable detail file) is preserved un-truncated + assert entry.filename == mem.filename + + def test_to_markdown_truncates_legacy_long_line(self): + """Rant 2026-08-23T08:04:26 — render-time fallback for legacy dirty + index data written before write-time truncation existed.""" + long_title = "旧记录" + "y" * 700 + text = ( + "# Memory Index\n\n" + f"## task\n" + f"- [{long_title}](task-long.md) — rec: 2026-07-14, evt: 2026-07-10\n" + ) + idx = MemoryIndex.from_text(text) + md = idx.to_markdown() + # Every rendered index line stays bounded… + for line in md.splitlines(): + if line.startswith("- ["): + assert len(line) <= 512, f"line too long ({len(line)}): {line[:80]}…" + # …and the detail filename stays reachable. + assert "task-long.md" in md + class TestProjectMemoryStore: def test_create_creates_file_and_index(self, project_store): @@ -233,6 +269,35 @@ def test_promote_to_project(self, session_store, temp_cwd): original = session_store.get(mem.id) assert original.status == "merged" + def test_session_index_soft_guard_silent_below_threshold( + self, session_store, caplog + ): + """Rant 2026-08-23T08:04:26 — no warning below the soft thresholds.""" + import logging + + with caplog.at_level(logging.WARNING, logger="emrg.memory"): + for i in range(5): + session_store.create("task", f"T{i}", "body") + assert not any( + "consolidation recommended" in r.message for r in caplog.records + ) + + def test_session_index_soft_guard_warns_at_threshold( + self, session_store, caplog + ): + """Rant 2026-08-23T08:04:26 — soft guard warns (never deletes) when the + session index crosses the count threshold.""" + import logging + + with caplog.at_level(logging.WARNING, logger="emrg.memory"): + for i in range(101): + session_store.create("task", f"T{i}", "body") + assert any( + "consolidation recommended" in r.message for r in caplog.records + ) + # Guard is non-destructive: no memory file was removed. + assert len(session_store.list()) == 101 + class TestMemoryIndexFileRoundtrip: def test_save_and_load(self, temp_cwd):