Files
xiaoshuai 92c612b94c 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.
2026-10-09 10:28:34 +08:00

203 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# memory 插件
给 OpenCode 加一套跨会话记忆。三级结构、多根并存、纯文本可手改,检索和注入由插件自己完成。
## 核心思想:ROOT 是索引,不是记忆
模型每次不需要读完所有记忆。读一份小的 `ROOT.md` 就知道"我有什么、什么时候该取哪一块",
需要细节时再用工具深挖。
```
ROOT.md 总纲:当前目标 / 铁规则 / 目录登记 / 加载路由表
<category>/
index.md 每个文件一句话 —— 编译产物,不要手改
<slug>.md 记忆本体,带 frontmatter
```
注入分三层,预算递减:
| 层 | 内容 | 何时进上下文 |
|---|---|---|
| 铁规则 | 用户声明的硬性要求 | **每次必带**,不受相关性影响 |
| 记忆大纲 | 有什么、每个文件一句话、触发场景 | 每次必带(很便宜) |
| 记忆正文 | 命中最强的几条 | 每次最多 4 条,受字符预算限制 |
剩下的靠 `memory_recall` 按需取。
## 双根:全局 + 项目
两个根结构完全相同,可同时存在:
```
~/.config/opencode/memory/ 全局:偏好、决策、通用坑
<项目 canonical>/.memory/ 项目:项目事实、专属决策
```
- 冲突时**项目级赢** —— 更具体的说法更有用
- **全局铁规则始终常驻**,不能被项目覆盖;项目铁规则追加在后面
- 项目根不存在就自动创建,不存在时静默跳过
项目根取自 `ctx.location.project.canonical`,也就是项目根目录(不是会话工作目录 ——
两者在 worktree 里不同)。
## 为什么不是 skill + 文件夹
原来的做法是"写个 skill 告诉模型去读 `.memory/`"。这个方案的致命缺陷是**检索依赖模型自觉**:
模型会忘记读、只读一半、或读错文件。
这里把职责拆开:
- **Markdown 是人类面向的格式** —— 纯文本、可手改、git 可 diff
- **插件是检索引擎** —— 解析、评分、注入全部由代码保证
记忆会自动抽取(模型忘了写也有兜底),但**铁规则永远只能由人写** ——
允许模型生成"铁规则"等于让幻觉变成永久策略。
## 中文分词:实测结论,不是推测
这是本插件唯一必须自己动手的地方。在 SQLite 3.53.4 上实测字符串
`部署脚本用 pnpm 而不是 npm,因为 lockfile 不兼容`:
| FTS5 分词器 | 查"部署" | 查"部署脚本" | 原因 |
|---|---|---|---|
| `unicode61`(默认) | **0 命中** | **0 命中** | 整句只产生 7 个词条,把 `部署脚本用` 当**一个 token** |
| `trigram` | **0 命中** | 1 命中 | 要求查询 ≥3 字符,词表是「不兼」「而不」这种三字滑窗 |
中文里「部署」「配置」「依赖」「接口」这类**两字词占比极高**,trigram 漏掉的正好是最常用的一批。
所以分词在插件里做,不在 SQL 里做:CJK 切**字符二元组**,拉丁切整词。两字中文能精确命中,
多出来的噪声词交给 idf 自动降权。
(也因此没有用 SQLite 索引 —— 记忆是千级条目,全表扫描足够,索引换不来收益。)
## 评分
```
score = 词项重叠 × idf × 作用域先验 × 时间衰减 × 复述加成
```
- **作用域先验**:项目 1.0 / 全局 0.5 —— 同分时更具体的赢
- **时间衰减**:半衰期 14 天(`halfLifeDays: 0` 关闭)。**不删除**,只降权
- **复述加成**:`memory_recall` 命中过的记忆会累加计数,越常用越靠前
- **钉住**:`pinned: true` 的条目不衰减、不被自动取代
- **取代**:新记忆的 `supersedes` 指向旧的,旧的标 `superseded_by` 保留可追溯,
但**不再参与检索**
## 工具
| 工具 | 作用 |
|---|---|
| `memory_recall` | 搜记忆。返回全文 + 命中理由(匹配了哪些词、命中几次) |
| `memory_remember` | 写记忆。自动同步目录 index.md 和 ROOT 路由表 |
| `memory_forget` | 删记忆。路径穿越会被拒绝 |
| `memory_inspect` | 看当前所有记忆 + **索引一致性自检** —— 调优全靠它 |
> 工具刻意不叫 `write` / `read` / `status`:实测发现这类通用名会**遮蔽 harness 自带的
> 同名工具**(`write` 曾直接让本会话失去建文件能力)。叶子名必须避开通用词。
## 记忆文件格式
```markdown
---
id: pitfalls/sqlite-cjk-tokenizer
kind: pitfall
summary: unicode61 把整段中文当一个 token,trigram 漏掉两字词。
highlight: 实测:查"部署"两种分词器都是 0 命中
created: 1791512014824
updated: 1791512014824
load_when:
- SQLite
- FTS5
- 分词
pinned: true
---
正文:上下文、决策、原因、根因。
```
`load_when` 是**路由表的唯一来源** —— 插件扫所有文件的这个字段编译出 ROOT 的路由表,
所以表永远不会和实际内容对不上。
`index.md` 和 ROOT 的两张表同理,都是从 frontmatter 编译的。手写索引一定会漂移。
## 该记什么
| 该记 | 不该记 |
|---|---|
| 用户偏好 | 代码里已经能看到的东西 |
| 架构决策 + 原因 | 当前任务状态 |
| 非显然的坑 + 根因 | git 分支名、文件路径 |
| 项目约定 | 一次性对话 |
插件会在写入前拦截疑似密钥(记忆会进 git)。
## 配置
默认零配置。放在 `opencode.jsonc` 的 `plugins` 里可调:
```jsonc
{
"plugins": [
{ "package": "./plugins/memory", "options": {
"maxInjectChars": 1200, // 注入字符硬上限
"maxInjectEntries": 4, // 每次内联几条正文
"halfLifeDays": 14, // 衰减半衰期,0 = 不衰减
"autoExtract": true, // 自动抽取
"minTurns": 4, // 会话至少多少轮才抽取
"settleMs": 20000, // 一轮对话安静多久才认为结束
"extractModel": { "providerID": "anthropic", "id": "claude-haiku-4-5" },
"categories": ["rules", "preferences", "decisions", "pitfalls", "facts"]
} }
]
}
```
不指定 `extractModel` 时自动挑**最便宜的可用模型**(按 cost 排序,并过滤掉上下文窗口 < 8k 的)。
## 自动抽取
一轮对话安静下来后触发 → 取本轮对话 → 廉价模型抽取结构化记忆 → 去重 → 写入。
**触发用的是去抖,不是 `session.idle`。** 实测把 `ctx.event.subscribe` 收到的
所有事件类型都收集起来看过,这个流里**根本没有 `session.idle`** —— 它在协议里
定义了,却不投递。所以改成监听真正会到达的 `session.step.started` /
`session.tool.called` / `session.inbox.delivered`,并要求静默 `settleMs`(默认 20s)
才动手。等价于"turn 结束了",且不依赖一个不存在的事件。
四重保护:
1. **独立模型**,不碰主对话 —— 抽取失败不影响用户
2. **只写普通记忆**,铁规则降级为 `rule-proposal` 等人确认
3. **词项覆盖率去重**,不是分数阈值 —— 分数带 idf 和作用域先验,会随语料增长漂移,
固定阈值会悄悄失效。覆盖率问的是"是不是已经说过了",阈值稳定
4. **密钥拦截**,写入前过一遍正则
`memory_inspect` 的"运行状态"会报告抽取最近一次的结果和原因(轮数不足、对话太短、
选不到模型、去重拦截),所以"没抽取"和"抽取坏了"不会混为一谈。
## 跨设备同步
`memory/` 已被 git 跟踪,`.state.json`(命中计数)被忽略 —— 否则每次检索都改文件,
跨设备必然冲突。
**判据是陈旧风险**:偏好、决策、坑、约定可以同步;当前分支、本地路径、任务状态不同步。
同步错的事实比漏同步更糟。
项目级 `.memory/` 不在全局仓库里,需要哪个项目同步,就在那个项目仓库里提交它。
## 排查
```bash
opencode service restart # 改完插件代码后
opencode plugin list # 确认 memory 已加载
```
- 加载失败看 `~/.local/share/opencode/log/opencode.log`,过滤 `role=server`
- 索引漂移用 `memory_inspect` 检查(它会核对 index.md 是否漏了文件)
- 想知道"模型现在到底看到了什么",看日志里的抽取日志
## 已知边界
- 记忆到**万条量级**时全表扫描会变慢,那时再换 SQLite 索引(正文是纯文本,迁移成本低)
- 多设备同时同步同一份记忆会有 git 冲突,需要手动 rebase