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:
@@ -4,5 +4,5 @@
|
||||
|
||||
| 文件 | 概述 | 重点 |
|
||||
|---|---|---|
|
||||
| plugin-push-on-change.md | 改动这两个目录后立即 commit 并 push,不询问。 | ~/.config/opencode 本身就是 git 仓库,.gitignore 只放行 skills/ 和 plugins/ |
|
||||
| plugin-push-on-change.md | 改动这三棵目录后立即 commit 并 push,不询问;改插件代码后必须重启服务。 | 本目录是 git 仓库;.gitignore 只放行 skills/ plugins/ memory/ 三棵树 |
|
||||
<!-- auto:end
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
---
|
||||
id: rules/plugin-push-on-change.md
|
||||
kind: rule
|
||||
summary: 改动这两个目录后立即 commit 并 push,不询问。
|
||||
highlight: ~/.config/opencode 本身就是 git 仓库,.gitignore 只放行 skills/ 和 plugins/
|
||||
summary: 改动这三棵目录后立即 commit 并 push,不询问;改插件代码后必须重启服务。
|
||||
highlight: 本目录是 git 仓库;.gitignore 只放行 skills/ plugins/ memory/ 三棵树
|
||||
created: 1791512032262
|
||||
updated: 1791512246723
|
||||
updated: 1791512893909
|
||||
pinned: true
|
||||
---
|
||||
|
||||
`~/.config/opencode` 自身是 git 仓库,`.gitignore` 只放行 `skills/` 和 `plugins/`(外加 .gitignore、pwsh.cmd、package.json)。
|
||||
`~/.config/opencode` 自身是 git 仓库,`.gitignore` 只放行三棵树:`skills/`、`plugins/`、`memory/`(外加 .gitignore、pwsh.cmd、package.json)。
|
||||
|
||||
在这两个目录下创建、修改、删除任何文件后,**提交并推送后再回复用户**,不要问是否要 push。
|
||||
在这三棵树下创建、修改、删除任何文件后,**提交并推送后再回复用户**,不要问是否要 push。
|
||||
|
||||
推送用 SSH(origin 已是 SSH 地址)。HTTPS 推送会提示密码且无 TTY,必然失败。
|
||||
|
||||
推送失败就报错并停下,不要静默重试,也不要 `git reset`。提交信息要写清楚改了什么、为什么改。
|
||||
|
||||
**改完插件代码必须 `opencode service restart`**:热重载会应用新工具名,但已缓存的模块改动不一定生效。踩过两次「改了没生效」的坑。
|
||||
|
||||
@@ -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` 的"运行状态"会报告抽取最近一次的结果和原因(轮数不足、对话太短、
|
||||
选不到模型、去重拦截),所以"没抽取"和"抽取坏了"不会混为一谈。
|
||||
|
||||
## 跨设备同步
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// would let a hallucination become permanent policy.
|
||||
|
||||
import type { Context } from "@opencode/plugin"
|
||||
import { scoreFiles } from "./recall.ts"
|
||||
import { tokenize } from "./recall.ts"
|
||||
|
||||
export interface ExtractOptions {
|
||||
enabled: boolean
|
||||
@@ -222,14 +222,20 @@ export interface ExtractionOutcome {
|
||||
|
||||
/**
|
||||
* Decide which candidates are worth writing, using existing memories for
|
||||
* dedupe. Near-duplicates bump the existing file instead of adding a rival.
|
||||
* dedupe.
|
||||
*
|
||||
* Dedupe uses term COVERAGE of the candidate, not the retrieval score. The
|
||||
* score carries idf and scope priors that shift as the corpus grows, so a fixed
|
||||
* score threshold silently stops rejecting duplicates as the library fills up.
|
||||
* Coverage answers the question we actually care about: "does an existing
|
||||
* memory already say this?"
|
||||
*/
|
||||
export function screenCandidates(
|
||||
candidates: Candidate[],
|
||||
existing: readonly import("./store.ts").MemoryFile[],
|
||||
options: { minimumScore?: number } = {},
|
||||
options: { coverage?: number } = {},
|
||||
): ExtractionOutcome {
|
||||
const threshold = options.minimumScore ?? 0.9
|
||||
const threshold = options.coverage ?? 0.55
|
||||
const accepted: Candidate[] = []
|
||||
const rejected: { candidate: Candidate; reason: string }[] = []
|
||||
|
||||
@@ -246,11 +252,14 @@ export function screenCandidates(
|
||||
candidate.category = "rules"
|
||||
candidate.kind = "rule-proposal"
|
||||
}
|
||||
|
||||
const probe = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
|
||||
const scored = scoreFiles(existing, probe, {}, { halfLifeDays: 0, pinnedBoost: false })
|
||||
const top = scored[0]
|
||||
if (top && top.score >= threshold) {
|
||||
rejected.push({ candidate, reason: `与已有记忆高度重叠 (${top.file.rel}, score ${top.score.toFixed(2)})` })
|
||||
const best = bestCoverage(probe, existing)
|
||||
if (best.file && best.coverage >= threshold) {
|
||||
rejected.push({
|
||||
candidate,
|
||||
reason: `与已有记忆重复 ${(best.coverage * 100).toFixed(0)}% (${best.file.rel})`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
accepted.push(candidate)
|
||||
@@ -259,6 +268,32 @@ export function screenCandidates(
|
||||
return { candidates: accepted, rejected }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraction of the candidate's distinctive terms that an existing memory also
|
||||
* contains. The candidate's own terms are the query; the best match wins.
|
||||
*/
|
||||
function bestCoverage(
|
||||
probe: string,
|
||||
existing: readonly import("./store.ts").MemoryFile[],
|
||||
): { file?: import("./store.ts").MemoryFile; coverage: number } {
|
||||
const probeTerms = new Set(tokenize(probe).filter((t) => t.length >= 2))
|
||||
if (probeTerms.size === 0) return { coverage: 0 }
|
||||
|
||||
let best: { file?: import("./store.ts").MemoryFile; coverage: number } = { coverage: 0 }
|
||||
for (const file of existing) {
|
||||
if (file.fm.superseded_by) continue
|
||||
// Same slug in the same root is the same memory, whatever the wording.
|
||||
const doc = new Set(
|
||||
tokenize([file.title, file.summary, file.highlight, file.body].join("\n")).filter((t) => t.length >= 2),
|
||||
)
|
||||
let shared = 0
|
||||
for (const term of probeTerms) if (doc.has(term)) shared++
|
||||
const coverage = shared / probeTerms.size
|
||||
if (coverage > best.coverage) best = { file, coverage }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface RunExtractionInput {
|
||||
ctx: Context
|
||||
model: { providerID: string; id: string }
|
||||
|
||||
+176
-22
@@ -57,6 +57,8 @@ interface PluginConfig {
|
||||
halfLifeDays?: number
|
||||
autoExtract?: boolean
|
||||
minTurns?: number
|
||||
/** How long a turn must be quiet before extraction runs. Default 20000ms. */
|
||||
settleMs?: number
|
||||
extractModel?: { providerID: string; id: string }
|
||||
/** Seed these categories on first run. */
|
||||
categories?: string[]
|
||||
@@ -65,6 +67,17 @@ interface PluginConfig {
|
||||
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
||||
const DEFAULT_MAX_INJECT = 1200
|
||||
|
||||
/**
|
||||
* Events that mark turn activity. Collected from what this stream actually
|
||||
* delivers: `session.idle` is defined in the protocol but never arrives here,
|
||||
* so extraction is debounced off these instead.
|
||||
*/
|
||||
const EXTRACTION_TRIGGERS = new Set([
|
||||
"session.step.started",
|
||||
"session.tool.called",
|
||||
"session.inbox.delivered",
|
||||
])
|
||||
|
||||
export default Plugin.define({
|
||||
id: "memory",
|
||||
|
||||
@@ -138,7 +151,7 @@ export default Plugin.define({
|
||||
// -- injection into the agent loop --------------------------------------
|
||||
// `context` covers the agent loop including tool continuations, and edits
|
||||
// only the outgoing request — the conversation transcript stays clean.
|
||||
await ctx.session.hook("context", (event) => {
|
||||
await ctx.session.hook("context", async (event) => {
|
||||
try {
|
||||
const query = latestUserText(event.messages)
|
||||
if (!query) return
|
||||
@@ -150,6 +163,16 @@ export default Plugin.define({
|
||||
options: { maxChars, maxEntries, halfLifeDays },
|
||||
})
|
||||
if (text) event.system.push({ type: "text", text })
|
||||
// Record what the model actually received. Without this there is no way
|
||||
// to tell "injected nothing because there is nothing" apart from
|
||||
// "the hook is not firing at all".
|
||||
await noteDiag(ctx, "lastInjection", {
|
||||
at: Date.now(),
|
||||
chars: text.length,
|
||||
withinBudget: text.length <= maxChars,
|
||||
bodies: (text.match(/^### /gm) ?? []).length,
|
||||
query: query.slice(0, 80),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`memory: injection failed: ${(error as Error).message}`)
|
||||
}
|
||||
@@ -488,6 +511,33 @@ export default Plugin.define({
|
||||
lines.push(problems.length === 0 ? "索引一致性:正常" : `索引一致性:发现 ${problems.length} 个问题`)
|
||||
for (const problem of problems) lines.push(` - ${problem}`)
|
||||
|
||||
// Runtime health of the two paths that have no other visible effect.
|
||||
lines.push("")
|
||||
const injection = (await ctx.storage.get("diag.lastInjection")) as Record<string, unknown> | undefined
|
||||
const extraction = (await ctx.storage.get("diag.lastExtraction")) as Record<string, unknown> | undefined
|
||||
lines.push("运行状态:")
|
||||
if (injection) {
|
||||
const when = new Date(Number(injection.at)).toISOString().slice(0, 19).replace("T", " ")
|
||||
lines.push(
|
||||
` 注入:最近一次 ${when},${String(injection.chars)} 字符,内联 ${String(injection.bodies)} 条正文${injection.withinBudget === false ? " ⚠ 超出预算" : ""}`,
|
||||
)
|
||||
lines.push(` 检索词:${String(injection.query)}`)
|
||||
} else {
|
||||
lines.push(" 注入:尚无记录(context hook 还没触发过)")
|
||||
}
|
||||
if (extraction) {
|
||||
const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ")
|
||||
lines.push(` 抽取:${when} → ${String(extraction.outcome)}`)
|
||||
if (extraction.reason) lines.push(` 原因:${String(extraction.reason)}`)
|
||||
if (extraction.model) lines.push(` 模型:${String(extraction.model)}`)
|
||||
} else {
|
||||
lines.push(
|
||||
extractOptions.enabled
|
||||
? " 抽取:尚无记录(本会话还没到静默期,或轮数/内容不足)"
|
||||
: " 抽取:已关闭(autoExtract: false)",
|
||||
)
|
||||
}
|
||||
|
||||
return { content: lines.join("\n") }
|
||||
},
|
||||
})
|
||||
@@ -496,30 +546,50 @@ export default Plugin.define({
|
||||
// -- automatic extraction on idle --------------------------------------
|
||||
let extracting = false
|
||||
const controller = new AbortController()
|
||||
// How long a turn must be quiet before extraction counts it as finished.
|
||||
const settleMs = config.settleMs ?? 20_000
|
||||
|
||||
// `session.idle` is NOT delivered on this stream — verified by collecting
|
||||
// every event type that actually arrives. The turn-ending events below do
|
||||
// arrive, so they drive a debounce instead: the turn must go quiet for
|
||||
// `settleMs` before extraction runs, which is what "idle" meant anyway.
|
||||
const pending = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
if (extractOptions.enabled) {
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
||||
const record = event as unknown as { type?: string; data?: { sessionID?: string } }
|
||||
if (record?.type !== "session.idle") continue
|
||||
if (!EXTRACTION_TRIGGERS.has(String(record?.type))) continue
|
||||
const sessionID = record.data?.sessionID
|
||||
if (!sessionID || extracting) continue
|
||||
if (!sessionID) continue
|
||||
|
||||
extracting = true
|
||||
try {
|
||||
await runExtraction(ctx, {
|
||||
sessionID,
|
||||
extractOptions,
|
||||
filesByRoot,
|
||||
project,
|
||||
global,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`memory: extraction failed: ${(error as Error).message}`)
|
||||
} finally {
|
||||
extracting = false
|
||||
}
|
||||
// Debounce: each new turn activity pushes the deadline out. Only a
|
||||
// genuine lull lets the timer fire, so extraction runs on a
|
||||
// finished turn rather than mid-stream.
|
||||
const existing = pending.get(sessionID)
|
||||
if (existing) clearTimeout(existing)
|
||||
pending.set(
|
||||
sessionID,
|
||||
setTimeout(() => {
|
||||
pending.delete(sessionID)
|
||||
if (extracting) return
|
||||
extracting = true
|
||||
void runExtraction(ctx, {
|
||||
sessionID,
|
||||
extractOptions,
|
||||
filesByRoot,
|
||||
project,
|
||||
global,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`memory: extraction failed: ${(error as Error).message}`)
|
||||
})
|
||||
.finally(() => {
|
||||
extracting = false
|
||||
})
|
||||
}, settleMs),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
// Subscription teardown is normal on unload.
|
||||
@@ -532,6 +602,8 @@ export default Plugin.define({
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
for (const timer of pending.values()) clearTimeout(timer)
|
||||
pending.clear()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -546,6 +618,28 @@ function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[]
|
||||
return roots
|
||||
}
|
||||
|
||||
/**
|
||||
* Record plugin health for `memory_inspect` to show.
|
||||
*
|
||||
* The two asynchronous paths — the context hook and idle extraction — have no
|
||||
* other observable effect. Without this, "working" and "silently dead" look
|
||||
* identical from the outside. Throttled because the context hook runs on every
|
||||
* model request, including tool continuations.
|
||||
*/
|
||||
const lastDiagAt = new Map<string, number>()
|
||||
|
||||
async function noteDiag(ctx: Context, key: string, value: Record<string, unknown>): Promise<void> {
|
||||
const now = Date.now()
|
||||
const previous = lastDiagAt.get(key) ?? 0
|
||||
if (now - previous < 2000) return
|
||||
lastDiagAt.set(key, now)
|
||||
try {
|
||||
await ctx.storage.set(`diag.${key}`, value)
|
||||
} catch {
|
||||
// Diagnostics must never break the hook that reports them.
|
||||
}
|
||||
}
|
||||
|
||||
function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]): string {
|
||||
const lines: string[] = []
|
||||
for (const root of roots) {
|
||||
@@ -557,17 +651,32 @@ function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]
|
||||
return lines.join("\n") || "(记忆库为空)"
|
||||
}
|
||||
|
||||
/** The newest user text in a request — the retrieval query. */
|
||||
/**
|
||||
* The newest real user text in a request — the retrieval query.
|
||||
*
|
||||
* Harness-injected user messages ("<system-reminder>", "The server restarted…")
|
||||
* are technically user turns but are not about the task. Retrieving on them
|
||||
* injects memories chosen by noise, so they are skipped.
|
||||
*/
|
||||
function latestUserText(messages: readonly unknown[]): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const record = messages[i] as { role?: string; content?: unknown } | null
|
||||
if (!record || record.role !== "user") continue
|
||||
const text = extractUserText(record.content)
|
||||
if (text) return text
|
||||
if (text && !isHarnessMessage(text)) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const HARNESS_PREFIX = /^<(system-reminder|command-name|command-message|command-args)/i
|
||||
const HARNESS_RE =
|
||||
/^(The server restarted while you were working|Task tools haven't been used recently|<system-reminder)/i
|
||||
|
||||
function isHarnessMessage(text: string): boolean {
|
||||
const head = text.trimStart()
|
||||
return HARNESS_PREFIX.test(head) || HARNESS_RE.test(head)
|
||||
}
|
||||
|
||||
function extractUserText(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim()
|
||||
if (!Array.isArray(content)) return ""
|
||||
@@ -599,13 +708,41 @@ async function runExtraction(
|
||||
return role === "user" || role === "assistant"
|
||||
})
|
||||
// Long enough to have learned something, short enough to stay cheap.
|
||||
if (turns.length < extractOptions.minTurns) return
|
||||
if (turns.length < extractOptions.minTurns) {
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "skipped",
|
||||
reason: "会话轮数不足",
|
||||
turns: turns.length,
|
||||
minTurns: extractOptions.minTurns,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = extractOptions.model ?? (await pickCheapModel(ctx))
|
||||
if (!model) return
|
||||
if (!model) {
|
||||
// pickCheapModel failing is the difference between a working feature and
|
||||
// a silent no-op, so it is reported rather than swallowed.
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "failed",
|
||||
reason: "选不到可用模型(autoExtract 实际上处于关闭状态)",
|
||||
turns: turns.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
|
||||
if (transcript.length < 200) return
|
||||
if (transcript.length < 200) {
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "skipped",
|
||||
reason: "对话内容太短",
|
||||
turns: turns.length,
|
||||
transcriptChars: transcript.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const existing = [...input.filesByRoot().values()].flat()
|
||||
const outcome = await extract({ ctx, model, transcript, existing })
|
||||
@@ -616,6 +753,13 @@ async function runExtraction(
|
||||
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
|
||||
)
|
||||
}
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "no-candidates",
|
||||
reason: outcome.rejected.length ? "候选全部被去重或密钥拦截" : "抽取器认为没有值得记的东西",
|
||||
rejected: outcome.rejected.length,
|
||||
model: `${model.providerID}/${model.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -648,6 +792,16 @@ async function runExtraction(
|
||||
}
|
||||
console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`)
|
||||
}
|
||||
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: written.length ? "extracted" : "all-filtered",
|
||||
model: `${model.providerID}/${model.id}`,
|
||||
turns: turns.length,
|
||||
written: written.length,
|
||||
rejected: outcome.rejected.length,
|
||||
paths: written,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user