92c612b94c
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.
203 lines
8.2 KiB
Markdown
203 lines
8.2 KiB
Markdown
# 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
|