memory: fix extraction trigger, dedupe, and add runtime diagnostics

Four defects found by end-to-end testing, not by reading the code.

1. Extraction never ran, silently. Collecting every event type that
   ctx.event.subscribe actually delivers shows `session.idle` is NOT among
   them — it is defined in the protocol but never emitted on this stream.
   The subscription filtered on it alone, so auto-extraction was a permanent
   no-op with no error. Now debounced off events that do arrive
   (session.step.started / session.tool.called / session.inbox.delivered)
   after `settleMs` of quiet, which is what "idle" meant anyway. Verified:
   the trigger now fires and reports "轮数不足" for a 1-turn session.

2. Dedupe silently stopped rejecting duplicates. It compared against a fixed
   retrieval score, but that score carries idf and scope priors that drift as
   the corpus grows, so an obvious restatement of an existing memory scored
   0.48 against a 0.9 threshold and was written as a rival entry. Now uses
   term coverage of the candidate, which answers "is this already said?" and
   does not move with corpus size. Verified: a 100%-duplicate is rejected, a
   novel pitfall is kept.

3. Harness-injected user turns became the retrieval query. "<system-reminder>"
   and "The server restarted while you were working" are user-role messages,
   so the hook retrieved on them and injected memories chosen by noise. Both
   are filtered out now.

4. The `pinned` flag was absent from the remember tool's input schema, so the
   schema stripped it and pinning was unreachable from the tool surface.
   `kind` also ignored the rules/ category when a stale value was stored.

memory_inspect now reports runtime health for the two paths with no other
observable effect: last injection size and query, and last extraction outcome
with its reason. Without it, "working" and "silently dead" look identical —
which is exactly how defects 1 and 2 survived the first round of testing.
This commit is contained in:
2026-10-09 10:28:34 +08:00
parent a64ea2acb3
commit 92c612b94c
5 changed files with 242 additions and 39 deletions
+15 -3
View File
@@ -144,6 +144,7 @@ pinned: true
"halfLifeDays": 14, // 衰减半衰期,0 = 不衰减
"autoExtract": true, // 自动抽取
"minTurns": 4, // 会话至少多少轮才抽取
"settleMs": 20000, // 一轮对话安静多久才认为结束
"extractModel": { "providerID": "anthropic", "id": "claude-haiku-4-5" },
"categories": ["rules", "preferences", "decisions", "pitfalls", "facts"]
} }
@@ -155,13 +156,24 @@ pinned: true
## 自动抽取
监听 `session.idle` → 取本轮对话 → 廉价模型抽取结构化记忆 → 关键词去重 → 写入。
一轮对话安静下来后触发 → 取本轮对话 → 廉价模型抽取结构化记忆 → 去重 → 写入。
三重保护:
**触发用的是去抖,不是 `session.idle`。** 实测把 `ctx.event.subscribe` 收到的
所有事件类型都收集起来看过,这个流里**根本没有 `session.idle`** —— 它在协议里
定义了,却不投递。所以改成监听真正会到达的 `session.step.started` /
`session.tool.called` / `session.inbox.delivered`,并要求静默 `settleMs`(默认 20s)
才动手。等价于"turn 结束了",且不依赖一个不存在的事件。
四重保护:
1. **独立模型**,不碰主对话 —— 抽取失败不影响用户
2. **只写普通记忆**,铁规则降级为 `rule-proposal` 等人确认
3. **相似度去重** —— 与已有记忆重叠的候选会被跳过并记日志
3. **词项覆盖率去重**,不是分数阈值 —— 分数带 idf 和作用域先验,会随语料增长漂移,
固定阈值会悄悄失效。覆盖率问的是"是不是已经说过了",阈值稳定
4. **密钥拦截**,写入前过一遍正则
`memory_inspect` 的"运行状态"会报告抽取最近一次的结果和原因(轮数不足、对话太短、
选不到模型、去重拦截),所以"没抽取"和"抽取坏了"不会混为一谈。
## 跨设备同步