diff --git a/README.md b/README.md index 31a6891..da6bdbe 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,11 @@ python3 dna.py memory maintain weekly --json python3 dna.py memory maintain monthly --json ``` +`memory value` 将真正待审的 `backlog.reviewable_proposals` 与来源指针、 +生命周期事件分开统计。`backlog.pending` 只是 `total_pending` 的兼容别名, +不能解释为待审记忆数量。`clients` 中的 `recall_hits` 和 `recall_share` 用于 +观察 Codex、Claude、Hermes 的主动召回采用情况;占比不代表召回质量。 + 当新证据明确使旧结论失效时,调用 `memory_remember` 并显式传入旧 ID: ```json diff --git a/README_EN.md b/README_EN.md index 2fe7de2..17b79e4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -158,6 +158,12 @@ python3 dna.py memory maintain weekly --json python3 dna.py memory maintain monthly --json ``` +`memory value` separates reviewable `backlog.reviewable_proposals` from +provenance and lifecycle events. `backlog.pending` is only a compatibility +alias for `total_pending`; it is not the number of memories awaiting review. +Per-client `recall_hits` and `recall_share` show active-recall adoption across +Codex, Claude, and Hermes. Recall share is not a quality score. + When a new verified fact invalidates an older one, pass exact old memory IDs in `supersedes`. Old Markdown remains available as history, while default recall returns active conclusions only. DNA Memory never infers replacement from type diff --git a/docs/mcp-and-client-adapters.md b/docs/mcp-and-client-adapters.md index f9f297b..36de935 100644 --- a/docs/mcp-and-client-adapters.md +++ b/docs/mcp-and-client-adapters.md @@ -229,6 +229,24 @@ python3 dna.py memory maintain monthly --json - weekly:daily + SQLite 在线备份、有限轮换和 `VACUUM`。 - monthly:weekly + `PRAGMA integrity_check` 和 Markdown 重建核对。 +`memory value` 的 backlog 字段按用途拆分: + +| 字段 | 含义 | +|---|---| +| `reviewable_proposals` | pending 的 `memory_proposal`,是真正待审认知候选 | +| `provenance_events` | 会话、轮次和来源指针 | +| `lifecycle_events` | 客户端启动、停止和关闭事件 | +| `other_pending` | 尚未显式分类的新事件类型 | +| `total_pending` | 所有 pending 事件之和 | +| `pending` | `total_pending` 的兼容别名 | +| `oldest_reviewable_at` | 最早待审候选时间 | + +客户端字段中的 `recall_attempts`、`recall_hits`、`useful`、`misleading` 和 +`recall_share` 用于观察各客户端是否实际进入召回闭环。`recall_share` 仅表示 +调用分布,不应作为质量评分。7 天和 30 天窗口兼容 SQLite 时间、ISO 8601 +`+0800`/`+08:00` 偏移和 Unix 秒;不可解析时间仍保留在全量统计中,但不进入 +时间窗口。 + 自动化可使用 macOS LaunchAgent、cron 或其他调度器。建议使用通用 label: ```text diff --git a/docs/skill-management.md b/docs/skill-management.md index 77d34c8..28cf20d 100644 --- a/docs/skill-management.md +++ b/docs/skill-management.md @@ -28,6 +28,15 @@ DNA Memory 将“长期记忆”和“行为 Skill”分开治理: 4. 记忆服务故障不阻塞主任务。 5. 不把完整 transcript、凭证或大型工具输出写入记忆。 +仓库中的 `dna-memory-loop` 是不含个人命名的公开模板。本机部署可以在 +`skill_registry` 中使用自定义的受管 Skill 名称,但 Codex、Claude Code 和 +Hermes 的目标目录必须链接到同一个 `skill_root` 真源。不要复制三份后分别 +修改,否则召回边界和隐私限制会随客户端漂移。 + +对依赖历史的实质任务,Memory Loop 是强制前置步骤;“继续之前的工作”、 +“按上次方案”、已有项目路径、用户长期偏好、已知错误和开放事项都属于触发 +条件。翻译、当前时间、一步格式化等完全自包含任务仍然跳过召回。 + ## 命令 ```bash diff --git a/docs/superpowers/plans/2026-07-16-memory-value-and-recall-adoption.md b/docs/superpowers/plans/2026-07-16-memory-value-and-recall-adoption.md new file mode 100644 index 0000000..d71c9cc --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-memory-value-and-recall-adoption.md @@ -0,0 +1,595 @@ +# DNA Memory Value and Recall Adoption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修正 DNA Memory 的时间窗口、backlog 和客户端采用率指标,并让共享 Memory Loop Skill 明确要求 Claude、Codex 与 Hermes 在历史相关任务前执行有界召回。 + +**Architecture:** 在 `scripts/memory_value.py` 内增加独立的时间规范化、backlog 分类和客户端采用率聚合函数,保持数据库只读且不迁移已有记录。行为侧复用现有 `dna skills sync` 单一真源分发机制,只强化已有 `dna-memory-loop` 的触发契约。 + +**Tech Stack:** Python 3 标准库、SQLite、PyYAML、pytest、Agent Skills、MCP、GitHub CLI。 + +--- + +## 文件边界 + +- Modify: `scripts/memory_value.py`:时间解析、窗口过滤、backlog 分类和客户端采用率指标。 +- Modify: `tests/test_memory_value.py`:真实时间格式、异常时间、backlog 分类、召回命中和占比测试。 +- Modify: `skills/dna-memory-loop/SKILL.md`:公开通用 Skill 的强制触发边界。 +- Modify: `tests/test_bundled_skills.py`:固定主动召回契约。 +- Modify: `docs/skill-management.md`:说明公开模板、本机真源和三端分发关系。 +- Modify: `docs/mcp-and-client-adapters.md`:记录新的 `memory value` 输出字段和验收命令。 +- Modify: `README.md`、`README_EN.md`:更新价值指标摘要和主动召回用法。 +- Deploy only, not committed: 用户级 Claude/Hermes/Codex 指令与共享 Skill 真源。 + +### Task 1: 兼容真实时间格式 + +**Files:** +- Modify: `tests/test_memory_value.py` +- Modify: `scripts/memory_value.py` + +- [ ] **Step 1: 写入真实格式的失败测试** + +在 `tests/test_memory_value.py` 增加: + +```python +def test_memory_value_windows_accept_iso_offsets_and_unix_seconds(tmp_path): + cfg = config(tmp_path) + store = UnifiedMemoryStore(cfg.database_path) + rows = [ + ("iso-basic", "2026-07-10T12:00:00+0800"), + ("iso-colon", "2026-07-10T12:00:00+08:00"), + ("sqlite", "2026-07-10 04:00:00"), + ("unix", 1783656000), + ("old", "2026-05-01T12:00:00+0800"), + ("invalid", "not-a-time"), + ] + store.connection.executemany( + "INSERT INTO memory_index " + "(memory_id,type,status,summary,content_hash,clients,created_at,updated_at,source_kind) " + "VALUES (?, 'fact', 'active', ?, ?, '[\"codex\"]', ?, ?, 'markdown')", + [(name, name, name, created_at, created_at) for name, created_at in rows], + ) + store.connection.commit() + store.close() + + payload = memory_value(cfg, now="2026-07-11T12:00:00+0800") + + assert payload["all_time"]["new_memories"] == 6 + assert payload["windows"]["7d"]["new_memories"] == 4 +``` + +- [ ] **Step 2: 运行测试并确认旧实现失败** + +Run: + +```bash +python3 -m pytest tests/test_memory_value.py::test_memory_value_windows_accept_iso_offsets_and_unix_seconds -q +``` + +Expected: FAIL,旧 SQL `datetime()` 无法把 `+0800` 和 Unix 秒正确计入窗口。 + +- [ ] **Step 3: 实现最小时间规范化** + +在 `scripts/memory_value.py` 中用 Python 标准库替代 `_window_clause`: + +```python +from datetime import datetime, timedelta, timezone +import re + + +OFFSET_WITHOUT_COLON = re.compile(r"([+-]\d{2})(\d{2})$") + + +def _parse_timestamp(value): + if value is None: + return None + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value), timezone.utc) + except (OverflowError, OSError, ValueError): + return None + text = str(value).strip() + if not text: + return None + try: + if text.replace(".", "", 1).isdigit(): + return datetime.fromtimestamp(float(text), timezone.utc) + if text.endswith("Z"): + text = text[:-1] + "+00:00" + text = OFFSET_WITHOUT_COLON.sub(r"\1:\2", text) + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + +def _in_window(value, days, now): + if days is None: + return True + created_at = _parse_timestamp(value) + current = _parse_timestamp(now) + if created_at is None or current is None: + return False + return current - timedelta(days=days) <= created_at <= current +``` + +把 `_metrics` 的三个聚合查询改为读取必要字段后用 `_in_window` 过滤。全量统计保留不可解析时间,7 天和 30 天窗口排除不可解析时间。 + +- [ ] **Step 4: 运行时间与现有价值测试** + +Run: + +```bash +python3 -m pytest tests/test_memory_value.py -q +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交时间兼容修复** + +```bash +git add scripts/memory_value.py tests/test_memory_value.py +git commit -m "fix: normalize memory value timestamps" +``` + +### Task 2: 拆分 backlog 口径 + +**Files:** +- Modify: `tests/test_memory_value.py` +- Modify: `scripts/memory_value.py` + +- [ ] **Step 1: 写入 backlog 分类失败测试** + +增加一个独立测试,向 `candidate_events` 写入以下 pending 类型: + +```python +def test_memory_value_classifies_pending_events(tmp_path): + cfg = config(tmp_path) + queue = CandidateEventQueue(cfg.database_path) + event_types = [ + "memory_proposal", "session_updated", "session_meta", "turn_context", + "SessionStart", "Stop", "SessionEnd", "session_closed", "task_complete", + ] + for index, event_type in enumerate(event_types): + queue.enqueue({ + "event_id": "event-{}".format(index), + "client": "codex", + "event_type": event_type, + }) + queue.connection.execute( + "UPDATE candidate_events SET created_at='2026-07-08 12:00:00'" + ) + queue.connection.execute( + "UPDATE candidate_events SET created_at='2026-07-09 12:00:00' " + "WHERE event_type='memory_proposal'" + ) + queue.connection.commit() + queue.connection.close() + + backlog = memory_value(cfg, now="2026-07-11 12:00:00")["backlog"] + + assert backlog == { + "reviewable_proposals": 1, + "provenance_events": 3, + "lifecycle_events": 4, + "other_pending": 1, + "total_pending": 9, + "pending": 9, + "oldest_pending_at": "2026-07-08 12:00:00", + "oldest_reviewable_at": "2026-07-09 12:00:00", + } +``` + +- [ ] **Step 2: 运行测试并确认字段缺失** + +Run: + +```bash +python3 -m pytest tests/test_memory_value.py::test_memory_value_classifies_pending_events -q +``` + +Expected: FAIL,当前 `backlog` 只有 `pending` 和 `oldest_pending_at`。 + +- [ ] **Step 3: 实现显式事件分类** + +在 `scripts/memory_value.py` 增加: + +```python +PROVENANCE_EVENT_TYPES = frozenset(( + "session_updated", "session_meta", "turn_context", +)) +LIFECYCLE_EVENT_TYPES = frozenset(( + "SessionStart", "Stop", "SessionEnd", "session_closed", +)) + + +def _empty_backlog(): + return { + "reviewable_proposals": 0, + "provenance_events": 0, + "lifecycle_events": 0, + "other_pending": 0, + "total_pending": 0, + "pending": 0, + "oldest_pending_at": None, + "oldest_reviewable_at": None, + } + + +def _backlog(connection): + result = _empty_backlog() + rows = connection.execute( + "SELECT event_type, created_at FROM candidate_events WHERE status='pending'" + ).fetchall() + reviewable_times = [] + pending_times = [] + for event_type, created_at in rows: + pending_times.append(created_at) + if event_type == "memory_proposal": + result["reviewable_proposals"] += 1 + reviewable_times.append(created_at) + elif event_type in PROVENANCE_EVENT_TYPES: + result["provenance_events"] += 1 + elif event_type in LIFECYCLE_EVENT_TYPES: + result["lifecycle_events"] += 1 + else: + result["other_pending"] += 1 + result["total_pending"] = len(rows) + result["pending"] = len(rows) + result["oldest_pending_at"] = min(pending_times) if pending_times else None + result["oldest_reviewable_at"] = min(reviewable_times) if reviewable_times else None + return result +``` + +让无数据库路径和无表路径都返回 `_empty_backlog()`。 + +- [ ] **Step 4: 更新旧断言并运行测试** + +Run: + +```bash +python3 -m pytest tests/test_memory_value.py -q +``` + +Expected: PASS,兼容字段 `pending == total_pending`。 + +- [ ] **Step 5: 提交 backlog 分类** + +```bash +git add scripts/memory_value.py tests/test_memory_value.py +git commit -m "feat: classify memory candidate backlog" +``` + +### Task 3: 增加客户端召回命中与占比 + +**Files:** +- Modify: `tests/test_memory_value.py` +- Modify: `scripts/memory_value.py` + +- [ ] **Step 1: 写入客户端采用率失败断言** + +在现有聚合测试中加入: + +```python +assert payload["clients"]["codex"]["recall_hits"] == 1 +assert payload["clients"]["claude"]["recall_hits"] == 0 +assert payload["clients"]["hermes"]["recall_hits"] == 1 +assert payload["clients"]["codex"]["recall_share"] == 1 / 3 +assert payload["clients"]["claude"]["recall_share"] == 1 / 3 +assert payload["clients"]["hermes"]["recall_share"] == 1 / 3 +``` + +在无数据库测试中加入: + +```python +assert payload["clients"]["codex"]["recall_hits"] == 0 +assert payload["clients"]["codex"]["recall_share"] == 0.0 +``` + +- [ ] **Step 2: 运行测试并确认字段缺失** + +Run: + +```bash +python3 -m pytest tests/test_memory_value.py -q +``` + +Expected: FAIL with `KeyError: 'recall_hits'`。 + +- [ ] **Step 3: 实现客户端采用率字段** + +修改字段和聚合查询: + +```python +CLIENT_FIELDS = ( + "candidate_events", "recall_attempts", "recall_hits", + "returned_memories", "useful", "misleading", "new_memories", + "recall_share", +) +``` + +```python +for client, attempts, hits, returned in connection.execute( + "SELECT client, COUNT(*), " + "COALESCE(SUM(CASE WHEN result_count > 0 THEN 1 ELSE 0 END), 0), " + "COALESCE(SUM(result_count), 0) " + "FROM memory_recall_events GROUP BY client" +): + add(client, "recall_attempts", attempts) + add(client, "recall_hits", hits) + add(client, "returned_memories", returned) +``` + +聚合完成后计算: + +```python +total_attempts = sum(item["recall_attempts"] for item in clients.values()) +for item in clients.values(): + item["recall_share"] = ( + item["recall_attempts"] / total_attempts if total_attempts else 0.0 + ) +``` + +`add()` 只处理计数;`recall_share` 直接赋浮点值。 + +- [ ] **Step 4: 运行价值测试** + +Run: + +```bash +python3 -m pytest tests/test_memory_value.py tests/test_memory_cli.py -q +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交客户端采用率** + +```bash +git add scripts/memory_value.py tests/test_memory_value.py +git commit -m "feat: report client recall adoption" +``` + +### Task 4: 强化主动召回 Skill 契约 + +**Files:** +- Modify: `tests/test_bundled_skills.py` +- Modify: `skills/dna-memory-loop/SKILL.md` +- Modify: `docs/skill-management.md` + +- [ ] **Step 1: 写入强制触发契约失败测试** + +在 `tests/test_bundled_skills.py` 增加: + +```python +def test_memory_loop_requires_recall_for_history_dependent_work(): + body = (ROOT / "skills/dna-memory-loop/SKILL.md").read_text(encoding="utf-8") + + assert "MUST recall before relying on history" in body + for trigger in ("prior", "continue", "last time", "same plan"): + assert trigger in body + assert "one to four distinctive terms" in body + assert "simple, self-contained" in body + assert "Memory failure must not block" in body +``` + +- [ ] **Step 2: 运行测试并确认强制文案缺失** + +Run: + +```bash +python3 -m pytest tests/test_bundled_skills.py::test_memory_loop_requires_recall_for_history_dependent_work -q +``` + +Expected: FAIL,因为现有 Skill 描述了流程但没有明确 MUST 边界和触发词。 + +- [ ] **Step 3: 最小修改 Skill** + +在 `## Before substantive work` 开头增加: + +```markdown +You MUST recall before relying on history. Treat a task as history-dependent +when it refers to prior work, asks to continue, mentions last time or the same +plan, names an existing project or path, or depends on a durable preference, +known error, workflow, project state, or open loop. +``` + +保留现有 1 至 4 个关键词、5 条记忆、2,000 tokens、当前证据优先和 degraded mode 限制。 + +- [ ] **Step 4: 更新 Skill 管理文档** + +在 `docs/skill-management.md` 明确:公开仓库模板名是 `dna-memory-loop`;个人部署可在共享真源使用自定义受管名称;三端目标必须是指向同一共享目录的链接,不能手工维护三个副本。 + +- [ ] **Step 5: 运行 Skill 与同步测试** + +Run: + +```bash +python3 -m pytest tests/test_bundled_skills.py tests/test_skill_manager.py tests/test_skills_cli.py -q +``` + +Expected: PASS。 + +- [ ] **Step 6: 提交 Skill 契约** + +```bash +git add skills/dna-memory-loop/SKILL.md tests/test_bundled_skills.py docs/skill-management.md +git commit -m "feat: require bounded recall for substantive tasks" +``` + +### Task 5: 更新公开用法与输出说明 + +**Files:** +- Modify: `README.md` +- Modify: `README_EN.md` +- Modify: `docs/mcp-and-client-adapters.md` + +- [ ] **Step 1: 更新中文用法** + +记录以下稳定接口: + +```text +backlog.reviewable_proposals +backlog.provenance_events +backlog.lifecycle_events +backlog.other_pending +backlog.total_pending +clients.codex.recall_hits / clients.claude.recall_hits / clients.hermes.recall_hits +clients.codex.recall_share / clients.claude.recall_share / clients.hermes.recall_share +``` + +明确 `pending` 只是 `total_pending` 的兼容别名,不能解释为待审记忆数量。 + +- [ ] **Step 2: 同步英文说明** + +英文 README 只翻译产品行为,不写入本机路径、真实客户端计数或会话标识。 + +- [ ] **Step 3: 文档脱敏扫描** + +Run: + +```bash +python3 scripts/check_public_safety.py +``` + +Expected: 无本次新增的个人绝对路径、真实记忆 ID 或真实会话 ID。 + +- [ ] **Step 4: 提交文档** + +```bash +git add README.md README_EN.md docs/mcp-and-client-adapters.md +git commit -m "docs: explain memory value adoption metrics" +``` + +### Task 6: 全量验证、公开发布与本机部署 + +**Files:** +- Verify: repository test suite +- Deploy: live DNA Memory installation and user-level shared Skill + +- [ ] **Step 1: 运行全量测试和静态检查** + +Run: + +```bash +git diff --check origin/main...HEAD +python3 -m pytest -q +``` + +Expected: `155` 个基线测试加新增测试全部 PASS,`git diff --check` 无输出。 + +- [ ] **Step 2: 用临时数据库验证 CLI JSON** + +Run: + +```bash +python3 dna.py memory value --json +``` + +Expected: JSON 含新 backlog 和客户端字段,不输出记忆摘要、数据库路径或凭证。 + +- [ ] **Step 3: 推送公开分支并创建 PR** + +```bash +git push -u origin codex/memory-value-recall-adoption +PR_URL=$(gh pr create \ + --repo AIPMAndy/dna-memory \ + --base main \ + --head codex/memory-value-recall-adoption \ + --title "Fix memory value metrics and strengthen active recall" \ + --body $'## Summary\n- normalize real-world memory timestamps\n- split reviewable backlog from provenance events\n- report per-client recall adoption\n- strengthen bounded recall guidance\n\n## Verification\n- python3 -m pytest -q') +PR_NUMBER=${PR_URL##*/} +``` + +Expected: PR 指向公开仓库,不包含本机路径、真实计数、真实记忆或会话数据。 + +- [ ] **Step 4: 检查 CI 并合并** + +```bash +gh pr checks --repo AIPMAndy/dna-memory "$PR_NUMBER" --watch +gh pr merge --repo AIPMAndy/dna-memory "$PR_NUMBER" --squash --delete-branch +``` + +Expected: 必需检查通过,PR squash merge 到 `main`。 + +- [ ] **Step 5: 部署到 live 目录且保留现有差异** + +先列出 live 目录已有修改,再只更新本功能涉及的文件。不得 reset、checkout 或覆盖无关的 README/importer 差异。 + +```bash +DNA_MEMORY_LIVE="$HOME/.cc-switch/skills/dna-memory" +DNA_MEMORY_DB="$HOME/.local/share/dna-memory/memory.db" +git -C "$DNA_MEMORY_LIVE" status --short +git -C "$DNA_MEMORY_LIVE" diff -- scripts/memory_value.py tests/test_memory_value.py +``` + +只应用本功能涉及的文件: + +```bash +git diff 320b751..HEAD -- \ + scripts/memory_value.py tests/test_memory_value.py \ + skills/dna-memory-loop/SKILL.md tests/test_bundled_skills.py \ + docs/skill-management.md docs/mcp-and-client-adapters.md \ + README.md README_EN.md \ + | git -C "$DNA_MEMORY_LIVE" apply --3way +``` + +若命令报告冲突,停止自动应用并读取冲突文件,使用 `apply_patch` 把本功能块合入 live 内容;不得回退用户已有修改。 + +- [ ] **Step 6: 强化本机单一 Skill 真源并同步三端** + +在用户级共享 `dna-memory-loop/SKILL.md` 写入公开 Skill 的 MUST 触发段,然后运行: + +```bash +python3 dna.py skills sync --apply --json +python3 dna.py skills doctor --json +``` + +Expected: Codex、Claude、Hermes 的 `dna-memory-loop` 均指向同一共享真源,无 conflict 或 broken link。 + +- [ ] **Step 7: 运行 live 价值核对** + +```bash +python3 dna.py memory value --json +sqlite3 -readonly "$DNA_MEMORY_DB" \ + "SELECT event_type, COUNT(*) FROM candidate_events WHERE status='pending' GROUP BY event_type ORDER BY event_type;" +``` + +Expected: 7 天/30 天新增数不再因 `+0800` 变为零;`reviewable_proposals` 等于 pending 的 `memory_proposal`;分类之和等于 `total_pending`。 + +- [ ] **Step 8: 新会话验证 Claude Code 主动召回** + +先记录 `memory_recall_events` 最新行号,再运行一个明确依赖历史、但不直接命令调用 MCP 的新会话: + +```bash +claude -p --output-format json \ + "继续之前的 DNA Memory 价值统计工作。先恢复相关历史,再只报告你使用了哪些记忆。" +``` + +Expected: 会话实际调用 `memory_recall`,数据库新增 `client` 属于 Claude 的召回事件;只输出结果不算通过。 + +- [ ] **Step 9: 新会话验证 Hermes 及子 Agent 可发现性** + +```bash +hermes skills list --source local --enabled-only | rg 'dna-memory-loop' +hermes -z "继续之前的 DNA Memory 价值统计工作。先恢复相关历史,再只报告你使用了哪些记忆。" +``` + +Expected: Hermes 能发现 Skill,实际调用 `memory_recall`,数据库新增 Hermes 召回事件。随后用 Hermes 的委派能力启动一个子 Agent 执行同类历史任务,并确认子 Agent 也产生 Hermes 召回事件。 + +- [ ] **Step 10: 故障降级验收** + +用仅本次进程有效的无效 MCP 配置启动一个新客户端会话,要求完成自包含任务。不得改全局配置。 + +Expected: 客户端报告一次记忆降级但仍完成主任务,不声称召回成功。 + +- [ ] **Step 11: 最终状态确认** + +```bash +git status --short --branch +python3 -m pytest -q +python3 dna.py memory status --json +``` + +Expected: 开发 worktree 干净、全量测试通过、live 数据库 `state: ok` 且可写。 diff --git a/docs/superpowers/specs/2026-07-16-memory-value-and-recall-adoption-design.md b/docs/superpowers/specs/2026-07-16-memory-value-and-recall-adoption-design.md new file mode 100644 index 0000000..900aeb8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-memory-value-and-recall-adoption-design.md @@ -0,0 +1,211 @@ +# DNA Memory 价值统计与跨客户端主动召回设计 + +日期:2026-07-16 + +## 1. 背景 + +DNA Memory 已能从 Codex、Claude Code、Claude Desktop 和 Hermes 捕获有界会话事件,也能通过 MCP 提供召回、反馈和写回能力。当前瓶颈不再是“是否采集到数据”,而是两类价值闭环问题: + +1. `memory value` 对真实时间格式的窗口统计不准确,导致近期新增记忆显示为零。 +2. backlog 把待审记忆候选和来源、生命周期事件合并为一个数字,容易误判积压规模。 +3. 不同客户端的主动召回采用率差异较大,仅配置 MCP 并不能保证 Agent 在依赖历史的任务中使用它。 + +本设计不引入新的原始对话仓库,不恢复无界 transcript 存储,也不批量改写已有 Markdown 记忆。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 让 7 天、30 天和全量价值指标兼容现有真实时间格式。 +- 把“待审认知候选”和“会话来源事件”拆成可解释的 backlog 指标。 +- 分客户端展示召回尝试、命中、有效反馈和召回占比。 +- 让 Codex、Claude Code 和 Hermes 在依赖历史的实质任务开始前执行有界召回。 +- 让跨客户端记忆规则只维护一个 Skill 真源,降低副本漂移。 +- 保持记忆服务故障不阻塞主任务。 + +### 2.2 非目标 + +- 不保存完整 transcript、完整提示词或大段工具输出。 +- 不为每个简单、自包含请求强制召回。 +- 不创建基于多个弱指标拼接的“总价值分数”。 +- 不迁移或重写现有记忆文件中的时间字段。 +- 不让 hook 代替 Agent 进行语义检索决策。 + +## 3. 方案选择 + +### 3.1 备选方案 + +方案 A:只修复统计。 + +- 优点:改动最小,运行风险低。 +- 缺点:无法改善 Claude 和 Hermes 的实际召回采用率。 + +方案 B:修复统计,同时强化全局指令与共享 Skill。 + +- 优点:能同时修复可观测性和使用行为;Agent 可以根据任务语义选择关键词;简单任务不增加延迟。 +- 缺点:仍依赖 Agent 遵循指令,需要通过客户端指标持续观察。 + +方案 C:由 hook 对每个请求自动调用 MCP。 + +- 优点:调用覆盖率最高。 +- 缺点:hook 缺少完整任务语义,容易产生无效关键词、噪声和固定延迟,也会把“调用次数”误当作“产生价值”。 + +### 3.2 决策 + +采用方案 B。只有依赖历史的实质任务强制召回;简单、自包含任务跳过。记忆服务不可用时降级继续主任务。 + +## 4. 总体架构 + +改动分成三个独立单元: + +1. **时间规范化单元**:把数据库中的多种时间表示转换为可比较的 UTC 时间。 +2. **价值聚合单元**:按窗口、backlog 类型和客户端输出可解释指标。 +3. **召回采用单元**:通过共享 Skill 和客户端全局指令约束任务前召回行为。 + +三个单元边界如下: + +- 时间规范化只负责解析和比较,不理解业务事件。 +- 价值聚合只读取数据库,不修改记忆或候选队列。 +- 召回采用只规定何时以及如何调用现有 MCP,不改变 MCP 协议。 + +## 5. 时间窗口设计 + +### 5.1 支持格式 + +统一解析函数必须兼容: + +- SQLite 标准时间:`YYYY-MM-DD HH:MM:SS` +- ISO 8601 无冒号偏移:`YYYY-MM-DDTHH:MM:SS+0800` +- ISO 8601 有冒号偏移:`YYYY-MM-DDTHH:MM:SS+08:00` +- Unix 秒:整数或可安全转换的数字字符串 + +无法解析的值不计入时间窗口,但仍计入全量指标。统计命令不得因单条脏数据失败。 + +### 5.2 比较方式 + +- 将 `now` 和记录时间规范化为带时区的 UTC `datetime`。 +- 窗口采用闭区间:`now - days <= created_at <= now`。 +- 全量统计不应用时间过滤。 +- 数据量当前较小,优先采用清晰、可测试的 Python 规范化逻辑;不为避免一次轻量遍历引入数据库迁移。 + +## 6. Backlog 分类设计 + +`backlog` 输出以下字段: + +- `reviewable_proposals`:`event_type = memory_proposal` 且状态为 `pending`,代表真正需要提炼或审阅的认知候选。 +- `provenance_events`:事件类型为 `session_updated`、`session_meta` 或 `turn_context` 的会话与来源指针。 +- `lifecycle_events`:事件类型为 `SessionStart`、`Stop`、`SessionEnd` 或 `session_closed` 的客户端生命周期事件。匹配保持现有大小写语义。 +- `other_pending`:无法归入以上类别的 pending 事件,防止静默丢失新类型。 +- `total_pending`:以上分类之和。 +- `oldest_pending_at`:全部 pending 事件中的最早时间,保持运维可见性。 +- `oldest_reviewable_at`:最早待审候选时间,用于判断真正的认知积压。 + +分类逻辑集中在一个函数或常量映射中。新增事件类型时必须显式决定类别,并通过测试固定行为。 + +为减少现有调用方破坏,首个版本保留 `pending` 作为 `total_pending` 的兼容别名,并在文档中标记为兼容字段。 + +## 7. 客户端采用率设计 + +每个客户端族输出: + +- `recall_attempts`:召回调用次数。 +- `recall_hits`:至少返回一条记忆的召回次数。 +- `returned_memories`:返回记忆总条数。 +- `useful`:明确标记有效的反馈数。 +- `misleading`:明确标记误导的反馈数。 +- `recall_share`:该客户端召回次数占所有客户端召回次数的比例。 +- `new_memories`:该客户端参与形成的长期记忆数。 +- `candidate_events`:该客户端进入候选队列的事件数。 + +`recall_share` 只表示采用分布,不代表质量。系统不生成综合价值分数,避免伪精确。 + +客户端族继续归并为 `codex`、`claude`、`hermes`;未知客户端保留独立键,避免数据消失。 + +## 8. 主动召回规则 + +### 8.1 触发条件 + +以下任一条件成立时,任务视为依赖历史的实质任务: + +- 用户提及“之前”“继续”“上次”“按原方案”等历史指代。 +- 请求包含已有项目、仓库、模块、长期工作流或明确路径。 +- 决策可能依赖用户偏好、既有约束、已知故障或开放事项。 +- 当前任务是跨会话恢复、复盘、验收或状态判断。 + +翻译、当前时间、单句改写和一步格式化等完全自包含请求跳过召回。 + +### 8.2 执行约束 + +- 从请求、项目路径、错误或预期结果中提取 1 至 4 个高辨识度关键词。 +- 每个关键词单独调用 `memory_recall`,不得把所有关键词拼成一句宽泛查询。 +- 按记忆 ID 去重,最多注入 5 条、约 2,000 tokens。 +- 当前文件、进程、远端状态和测试证据优先于历史记忆。 +- 只有实际影响或确认任务的记忆才记录 `useful`;把工作引向错误方向时记录 `misleading`。 + +### 8.3 单一 Skill 真源 + +本机部署的 `dna-memory-loop` 以用户级共享 Agent Skills 目录为唯一真源。部署时由用户配置的 Skill 注册表决定实际受管名称。各客户端采用引用或受管安装,不维护手工分叉副本: + +- Codex:从共享 Skills 目录发现 Skill,并由全局指令要求实质任务使用。 +- Claude Code:从共享 Skills 目录发现 Skill,并由全局 `CLAUDE.md` 明确“必须召回”的触发边界。 +- Hermes:通过现有 `dna skills sync` 在 Hermes 原生 Skill 目录创建受管链接,并在使用的 profile 中保留 DNA Memory MCP 配置;只有使用非原生目录时才配置 `skills.external_dirs`。 + +部署检查必须覆盖默认配置和实际启用的 Hermes profiles。配置脚本应幂等:重复运行不产生重复目录项或重复规则块。 + +## 9. 数据流 + +1. 客户端收到任务并判断是否依赖历史。 +2. 若不依赖历史,直接执行主任务。 +3. 若依赖历史,Agent 选取关键词并调用 DNA Memory MCP。 +4. Agent 去重并应用少量相关记忆,以当前证据校验。 +5. 验证完成后,Agent对真正使用的记忆写反馈;仅把可复用结论写回长期记忆。 +6. `memory value` 从只读数据库聚合窗口、backlog 和客户端采用指标。 + +## 10. 错误处理 + +- 时间值不可解析:忽略该记录的窗口归属,保留全量统计。 +- 表不存在:沿用现有零值降级行为。 +- MCP 不可用或超时:继续主任务,只报告一次降级状态。 +- 客户端 Skill 不可发现:部署验收失败,不宣称跨客户端召回已生效。 +- Hermes profile 未启用 MCP:仅报告具体 profile,不修改无关 profile。 +- 指标分母为零:比例返回 `0.0`,不返回 NaN 或异常。 + +## 11. 测试策略 + +严格采用测试先行: + +1. 增加真实 `+0800`、`+08:00`、SQLite 时间和 Unix 秒的窗口测试,先证明旧实现失败。 +2. 增加不可解析时间不会中断统计的测试。 +3. 增加 backlog 四类事件及兼容 `pending` 字段测试。 +4. 增加客户端 `recall_hits` 与 `recall_share` 测试,包括零分母。 +5. 增加共享 Skill 的强制触发契约测试,并复用现有 Skill 同步幂等测试。 +6. 运行全量测试,确保现有 MCP、导入、提炼和 CLI 行为不回归。 + +## 12. 验收标准 + +实现只有在以下条件全部满足时才算完成: + +- 7 天和 30 天 `new_memories` 与同一数据库的独立时间核对结果一致。 +- `reviewable_proposals` 等于真正 pending 的 `memory_proposal` 数量。 +- backlog 各分类之和等于 `total_pending`,`pending` 与其一致。 +- 每个客户端的 `recall_attempts`、`recall_hits` 和 `recall_share` 可由数据库独立复算。 +- 新启动的 Claude Code 会话在历史相关任务中实际调用 `memory_recall`。 +- 新启动的 Hermes 会话或子 Agent 在历史相关任务中实际调用 `memory_recall`。 +- 简单、自包含任务不会因本设计固定增加 MCP 调用。 +- DNA Memory 不可用时,Claude Code 和 Hermes 仍能完成主任务。 +- 全量自动化测试通过,数据库完整性检查通过。 + +## 13. 发布与隐私 + +- 开发从公开 `main` 的独立 worktree 进行,不直接在 live 部署目录开发。 +- 先完成测试和公开仓库提交,再部署到 live 目录进行本机验收。 +- 公共提交不得包含个人绝对路径、真实记忆 ID、真实会话 ID、数据库、完整摘要或客户端凭证。 +- 本机配置修改只保留在部署步骤,不提交到公开仓库。 + +## 14. 风险与控制 + +- **规则存在但 Agent 不执行**:用分客户端召回指标和新会话烟雾测试验证,不以配置文件存在代替实际调用。 +- **召回增加延迟**:仅对依赖历史的实质任务执行,限制查询数和注入量。 +- **事件分类随版本漂移**:保留 `other_pending`,新增事件类型必须补测试。 +- **Skill 多副本漂移**:坚持单一真源和幂等引用,不复制后手工修改。 +- **历史数据格式继续扩展**:解析失败采用有界降级,并让测试覆盖新增格式后再声明支持。 diff --git a/scripts/memory_value.py b/scripts/memory_value.py index e859d1f..f716b9a 100644 --- a/scripts/memory_value.py +++ b/scripts/memory_value.py @@ -2,16 +2,26 @@ """Privacy-bounded value metrics for the cross-client memory loop.""" import json +import re import sqlite3 -from datetime import datetime +from datetime import datetime, timedelta, timezone from urllib.parse import quote CLIENT_FIELDS = ( - "candidate_events", "recall_attempts", "returned_memories", - "useful", "misleading", "new_memories", + "candidate_events", "recall_attempts", "recall_hits", + "returned_memories", "useful", "misleading", "new_memories", + "recall_share", ) +OFFSET_WITHOUT_COLON = re.compile(r"([+-]\d{2})(\d{2})$") +PROVENANCE_EVENT_TYPES = frozenset(( + "session_updated", "session_meta", "turn_context", +)) +LIFECYCLE_EVENT_TYPES = frozenset(( + "SessionStart", "Stop", "SessionEnd", "session_closed", +)) + def _family(client): value = str(client or "unknown").strip().casefold() @@ -30,14 +40,46 @@ def _table_exists(connection, table): ).fetchone() is not None -def _window_clause(column, days, now): +def _parse_timestamp(value): + if value is None: + return None + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value), timezone.utc) + except (OverflowError, OSError, ValueError): + return None + text = str(value).strip() + if not text: + return None + try: + if text.replace(".", "", 1).isdigit(): + return datetime.fromtimestamp(float(text), timezone.utc) + if text.endswith("Z"): + text = text[:-1] + "+00:00" + text = OFFSET_WITHOUT_COLON.sub(r"\1:\2", text) + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + +def _in_window(value, days, now): if days is None: - return "", () - return ( - " WHERE datetime({}) >= datetime(?, ?) " - "AND datetime({}) <= datetime(?)".format(column, column), - (now, "-{} days".format(days), now), - ) + return True + created_at = _parse_timestamp(value) + current = _parse_timestamp(now) + if created_at is None or current is None: + return False + return current - timedelta(days=days) <= created_at <= current + + +def _windowed_rows(connection, table, columns, days, now): + rows = connection.execute( + "SELECT {} FROM {}".format(", ".join(columns), table) + ).fetchall() + return [row for row in rows if _in_window(row[-1], days, now)] def _metrics(connection, days, now): @@ -47,34 +89,29 @@ def _metrics(connection, days, now): "unfeedback": 0, "new_memories": 0, } if _table_exists(connection, "memory_recall_events"): - clause, params = _window_clause("created_at", days, now) - row = connection.execute( - "SELECT COUNT(*), " - "COALESCE(SUM(CASE WHEN result_count > 0 THEN 1 ELSE 0 END), 0), " - "COALESCE(SUM(result_count), 0) FROM memory_recall_events" + clause, - params, - ).fetchone() - metrics["recall_attempts"] = row[0] - metrics["recall_hits"] = row[1] - metrics["returned_memories"] = row[2] + rows = _windowed_rows( + connection, "memory_recall_events", ("result_count", "created_at"), + days, now, + ) + metrics["recall_attempts"] = len(rows) + metrics["recall_hits"] = sum(1 for result_count, _ in rows if result_count > 0) + metrics["returned_memories"] = sum(result_count for result_count, _ in rows) if _table_exists(connection, "memory_feedback"): - clause, params = _window_clause("created_at", days, now) - rows = connection.execute( - "SELECT outcome, COUNT(*) FROM memory_feedback" + clause + - " GROUP BY outcome", - params, - ).fetchall() - outcomes = {row[0]: row[1] for row in rows} + rows = _windowed_rows( + connection, "memory_feedback", ("outcome", "created_at"), days, now, + ) + outcomes = {} + for outcome, _ in rows: + outcomes[outcome] = outcomes.get(outcome, 0) + 1 metrics["useful"] = outcomes.get("useful", 0) metrics["misleading"] = outcomes.get("misleading", 0) if _table_exists(connection, "memory_index"): - clause, params = _window_clause("created_at", days, now) - prefix = " WHERE" if not clause else " AND" - metrics["new_memories"] = connection.execute( - "SELECT COUNT(*) FROM memory_index" + clause + - prefix + " source_kind='markdown'", - params, - ).fetchone()[0] + rows = _windowed_rows( + connection, "memory_index", ("source_kind", "created_at"), days, now, + ) + metrics["new_memories"] = sum( + 1 for source_kind, _ in rows if source_kind == "markdown" + ) attempts = metrics["recall_attempts"] metrics["hit_rate"] = metrics["recall_hits"] / attempts if attempts else 0.0 metrics["unfeedback"] = max( @@ -101,11 +138,14 @@ def add(client, field, count): ): add(client, "candidate_events", count) if _table_exists(connection, "memory_recall_events"): - for client, attempts, returned in connection.execute( - "SELECT client, COUNT(*), COALESCE(SUM(result_count), 0) " + for client, attempts, hits, returned in connection.execute( + "SELECT client, COUNT(*), " + "COALESCE(SUM(CASE WHEN result_count > 0 THEN 1 ELSE 0 END), 0), " + "COALESCE(SUM(result_count), 0) " "FROM memory_recall_events GROUP BY client" ): add(client, "recall_attempts", attempts) + add(client, "recall_hits", hits) add(client, "returned_memories", returned) if _table_exists(connection, "memory_feedback"): for client, outcome, count in connection.execute( @@ -124,9 +164,52 @@ def add(client, field, count): values = [] for client in set(values or ["unknown"]): add(client, "new_memories", 1) + total_attempts = sum(item["recall_attempts"] for item in clients.values()) + for item in clients.values(): + item["recall_share"] = ( + item["recall_attempts"] / total_attempts if total_attempts else 0.0 + ) return clients +def _empty_backlog(): + return { + "reviewable_proposals": 0, + "provenance_events": 0, + "lifecycle_events": 0, + "other_pending": 0, + "total_pending": 0, + "pending": 0, + "oldest_pending_at": None, + "oldest_reviewable_at": None, + } + + +def _backlog(connection): + result = _empty_backlog() + rows = connection.execute( + "SELECT event_type, created_at FROM candidate_events WHERE status='pending'" + ).fetchall() + reviewable_times = [] + pending_times = [] + for event_type, created_at in rows: + pending_times.append(created_at) + if event_type == "memory_proposal": + result["reviewable_proposals"] += 1 + reviewable_times.append(created_at) + elif event_type in PROVENANCE_EVENT_TYPES: + result["provenance_events"] += 1 + elif event_type in LIFECYCLE_EVENT_TYPES: + result["lifecycle_events"] += 1 + else: + result["other_pending"] += 1 + result["total_pending"] = len(rows) + result["pending"] = len(rows) + result["oldest_pending_at"] = min(pending_times) if pending_times else None + result["oldest_reviewable_at"] = min(reviewable_times) if reviewable_times else None + return result + + def _storage(config): database_bytes = config.database_path.stat().st_size if config.database_path.is_file() else 0 backup_bytes = 0 @@ -138,7 +221,7 @@ def _storage(config): def memory_value(config, now=None): - now = now or datetime.now().strftime("%Y-%m-%d %H:%M:%S") + now = now or datetime.now().astimezone().isoformat(timespec="seconds") payload = { "all_time": _metrics_without_connection(), "windows": { @@ -149,7 +232,7 @@ def memory_value(config, now=None): name: {field: 0 for field in CLIENT_FIELDS} for name in ("codex", "claude", "hermes") }, - "backlog": {"pending": 0, "oldest_pending_at": None}, + "backlog": _empty_backlog(), "storage": _storage(config), } if not config.database_path.is_file(): @@ -162,13 +245,7 @@ def memory_value(config, now=None): payload["windows"]["30d"] = _metrics(connection, 30, now) payload["clients"] = _client_metrics(connection) if _table_exists(connection, "candidate_events"): - row = connection.execute( - "SELECT COUNT(*), MIN(created_at) FROM candidate_events " - "WHERE status='pending'" - ).fetchone() - payload["backlog"] = { - "pending": row[0], "oldest_pending_at": row[1] - } + payload["backlog"] = _backlog(connection) return payload finally: connection.close() diff --git a/skills/dna-memory-loop/SKILL.md b/skills/dna-memory-loop/SKILL.md index 0665641..7e861a7 100644 --- a/skills/dna-memory-loop/SKILL.md +++ b/skills/dna-memory-loop/SKILL.md @@ -10,6 +10,11 @@ history as a transcript source, not as a competing durable memory store. ## Before substantive work +You MUST recall before relying on history. Treat a task as history-dependent +when it refers to prior work, asks to continue, mentions last time or the same plan, +names an existing project or path, or depends on a durable preference, known error, +workflow, project state, or open loop. + 1. Extract one to four distinctive terms from the request, project, error, or expected result. 2. Call `memory_recall` separately for each term. Include the real client and session ID when available. 3. Deduplicate by memory ID. Inject at most five memories and about 2,000 tokens total. diff --git a/tests/test_bundled_skills.py b/tests/test_bundled_skills.py index 8e8261b..0681e33 100644 --- a/tests/test_bundled_skills.py +++ b/tests/test_bundled_skills.py @@ -35,3 +35,14 @@ def test_memory_loop_has_bounded_active_use_gates(): assert "simple, self-contained" in body assert "supersedes" in body assert "Never infer replacement" in body + + +def test_memory_loop_requires_recall_for_history_dependent_work(): + body = (ROOT / "skills/dna-memory-loop/SKILL.md").read_text(encoding="utf-8") + + assert "MUST recall before relying on history" in body + for trigger in ("prior", "continue", "last time", "same plan"): + assert trigger in body + assert "one to four distinctive terms" in body + assert "simple, self-contained" in body + assert "Memory failure must not block" in body diff --git a/tests/test_memory_value.py b/tests/test_memory_value.py index c275940..e9c386c 100644 --- a/tests/test_memory_value.py +++ b/tests/test_memory_value.py @@ -96,8 +96,21 @@ def test_memory_value_aggregates_windows_clients_backlog_and_storage(tmp_path): assert payload["clients"]["hermes"]["misleading"] == 1 assert payload["clients"]["claude"]["recall_attempts"] == 1 assert payload["clients"]["codex"]["new_memories"] == 1 + assert payload["clients"]["codex"]["recall_hits"] == 1 + assert payload["clients"]["claude"]["recall_hits"] == 0 + assert payload["clients"]["hermes"]["recall_hits"] == 1 + assert payload["clients"]["codex"]["recall_share"] == 1 / 3 + assert payload["clients"]["claude"]["recall_share"] == 1 / 3 + assert payload["clients"]["hermes"]["recall_share"] == 1 / 3 assert payload["backlog"] == { - "pending": 2, "oldest_pending_at": "2026-07-08 12:00:00" + "reviewable_proposals": 0, + "provenance_events": 2, + "lifecycle_events": 0, + "other_pending": 0, + "total_pending": 2, + "pending": 2, + "oldest_pending_at": "2026-07-08 12:00:00", + "oldest_reviewable_at": None, } assert payload["storage"]["database_bytes"] > 0 assert payload["storage"]["backup_bytes"] == 6 @@ -111,4 +124,69 @@ def test_memory_value_returns_zeros_without_a_database(tmp_path): assert payload["all_time"]["recall_attempts"] == 0 assert payload["clients"]["codex"]["candidate_events"] == 0 + assert payload["clients"]["codex"]["recall_hits"] == 0 + assert payload["clients"]["codex"]["recall_share"] == 0.0 assert payload["backlog"]["oldest_pending_at"] is None + + +def test_memory_value_windows_accept_iso_offsets_and_unix_seconds(tmp_path): + cfg = config(tmp_path) + store = UnifiedMemoryStore(cfg.database_path) + rows = [ + ("iso-basic", "2026-07-10T12:00:00+0800"), + ("iso-colon", "2026-07-10T12:00:00+08:00"), + ("sqlite", "2026-07-10 04:00:00"), + ("unix", 1783656000), + ("old", "2026-05-01T12:00:00+0800"), + ("invalid", "not-a-time"), + ] + store.connection.executemany( + "INSERT INTO memory_index " + "(memory_id,type,status,summary,content_hash,clients,created_at,updated_at,source_kind) " + "VALUES (?, 'fact', 'active', ?, ?, '[\"codex\"]', ?, ?, 'markdown')", + [(name, name, name, created_at, created_at) for name, created_at in rows], + ) + store.connection.commit() + store.close() + + payload = memory_value(cfg, now="2026-07-11T12:00:00+0800") + + assert payload["all_time"]["new_memories"] == 6 + assert payload["windows"]["7d"]["new_memories"] == 4 + + +def test_memory_value_classifies_pending_events(tmp_path): + cfg = config(tmp_path) + queue = CandidateEventQueue(cfg.database_path) + event_types = [ + "memory_proposal", "session_updated", "session_meta", "turn_context", + "SessionStart", "Stop", "SessionEnd", "session_closed", "task_complete", + ] + for index, event_type in enumerate(event_types): + queue.enqueue({ + "event_id": "event-{}".format(index), + "client": "codex", + "event_type": event_type, + }) + queue.connection.execute( + "UPDATE candidate_events SET created_at='2026-07-08 12:00:00'" + ) + queue.connection.execute( + "UPDATE candidate_events SET created_at='2026-07-09 12:00:00' " + "WHERE event_type='memory_proposal'" + ) + queue.connection.commit() + queue.connection.close() + + backlog = memory_value(cfg, now="2026-07-11 12:00:00")["backlog"] + + assert backlog == { + "reviewable_proposals": 1, + "provenance_events": 3, + "lifecycle_events": 4, + "other_pending": 1, + "total_pending": 9, + "pending": 9, + "oldest_pending_at": "2026-07-08 12:00:00", + "oldest_reviewable_at": "2026-07-09 12:00:00", + }