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` (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 路径不受影响)
Expand Down
75 changes: 73 additions & 2 deletions emrg/memory.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ─────────────────────────────────────────────────────


Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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"
Expand DownExpand Up@@ -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)
41 changes: 39 additions & 2 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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 "
Expand All@@ -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"
Expand DownExpand Up@@ -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}/` "
Expand Down
1 change: 1 addition & 0 deletions emrg/server/evolution_prompt.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions emrg/server/open_source_prompt.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)

---
Expand Down
6 changes: 6 additions & 0 deletions emrg/server/prompts/system.j2
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
65 changes: 65 additions & 0 deletions tests/test_memory.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):
Expand DownExpand Up@@ -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):
Expand Down
Loading