Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
29 changes: 27 additions & 2 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1348,22 +1348,47 @@ 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}

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

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
Expand Down
6 changes: 5 additions & 1 deletion emrg/server/evolution_prompt.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-<ts>.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.
Expand Down
53 changes: 53 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ───────────────────────────────────────


Expand Down
Loading