Files
xiaoshuai cb9db666ba memory: 修注入开销、idf 排序与抽取诊断
注入侧
- context 在每次模型请求都触发,改为按 session + 最新用户消息去重:
  一个 turn 里 n 次工具调用不再重复注入 n 个 memory 块
- 默认注入从 1200 字符 / 内联 4 条正文,降到 600 字符 / 内联 0 条:
  大纲已说明存在什么,正文交给 memory_recall 按需取一次,避免每次
  请求都为同一批正文付费
- halfLifeDays 此前被静默丢弃,注入始终按 14 天半衰期排序,现已接通
- 召回缓存 2s -> 500ms,同一工具链里 remember -> recall 能读到刚写的文件

排序
- idf 原先拿「单文件匹配数」当分子、「总文件数」当分母,每个文件得到
  相同 idf,排序退化为纯词频;改为按文档频率计算的真实 idf

抽取
- 移除 400 段上限:它会静默截断长对话,300 轮只贡献前 ~50 轮
- role 在顶层或 message 下两种返回结构都读,之前只读 .role 得到空转录,
  长度门槛不过,抽取看起来永远不触发
- 去重改按关键词重叠计分,不再用 bigram 重叠:模型改写与人工措辞几乎
  不共享 bigram,真实改写只拿到 0.43;关键词是双方被迫共用的词汇
- 新增 memory_extract_dryrun:对真实模型跑完整抽取链路,不必改
  minTurns 或等长会话
- 新增 memory_inspect:诊断记录按 scope 存入 storage key,并保留历史
  与 fires/injected 计数,去重是否生效可被证伪

其他
- Context 改从 Plugin 命名空间导入,此前类型退化为 unknown
- store 暴露 kind 字段;compileRootTables 内联进 compileRoot
- frontmatter 列表元素统一 String(),index 不再出现数字

验证:由仓库所有者端到端验证(真实模型调用、真实会话);提交者未复跑,
以上为 diff 复核结论。
2026-10-12 18:10:40 +08:00

1206 lines
50 KiB
TypeScript
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 plugin for OpenCode — three-tier, multi-root, human-editable.
//
// ROOT.md the total plan: goals, hard rules, directory registry,
// and a routing table of "scenario -> which file to load"
// <category>/ one per kind of memory, same shape at every level
// index.md one line per file. COMPILED from frontmatter, never edited
// <slug>.md the memory, with frontmatter (summary / highlight / load_when)
//
// Two roots coexist and use identical rules:
// global <config>/memory/ preferences, decisions, general pitfalls
// project <project canonical>/.memory/ facts specific to this checkout
// Project wins on conflict; global hard rules stay resident everywhere.
//
// Why a plugin rather than a skill telling the model to read a folder: model-
// driven retrieval is unreliable — the model forgets, reads half the files, or
// reads the wrong ones. Here the plugin does retrieval, scoring, and injection
// itself. The Markdown is the human-facing format; the plugin is the engine.
//
// Docs consulted:
// OpenCode V2 plugins: https://opencode.ai/v2/docs/build/plugins/
// OpenCode plugins config: https://opencode.ai/v2/docs/plugins
// Session hooks / events: see the plugin page's Hooks and Events sections
//
// Prerequisite: npm i @opencode/plugin@$(opencode --version | sed 's/^v//')
// then `opencode service restart`.
import { Plugin } from "@opencode/plugin"
// `Context` lives on the Plugin namespace, not as a top-level export. Importing
// it from "@opencode/plugin" directly fails to typecheck, which silently
// degraded every tool's `input` to `unknown`.
type Context = Plugin.Context
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { buildInjection, readGoal, scoreFiles } from "./recall.ts"
import { DEFAULT_EXTRACT, extract, redactSecrets, userAndAssistantText, type ExtractOptions } from "./extract.ts"
import {
type MemoryFile,
type MemoryRoot,
compileCategoryIndexes,
compileRoot,
ensureRoot,
findNearestRoot,
globalRoot,
markSuperseded,
projectRoot,
readState,
scanRoot,
validateCategory,
validateSlug,
writeMemory,
writeState,
} from "./store.ts"
interface PluginConfig {
/** Hard cap on injected characters per turn. Default 600. */
maxInjectChars?: number
/**
* How many memory bodies to inline per turn. Default 0: only hard rules and
* the outline ride along, and full text is fetched with `memory_recall`.
*
* Inlining bodies was costing more than it saved. The outline already says
* what exists and what each entry is about, so the model can fetch exactly
* what it needs — once — instead of paying for every body on every request.
*/
maxInjectEntries?: number
/** Half-life in days for the recency decay. Default 14; 0 disables. */
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[]
}
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
// 600 characters ≈ 350 tokens per turn. The block is re-sent on every model
// request in the turn, so its size is multiplied by the number of tool calls.
const DEFAULT_MAX_INJECT = 600
/**
* 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",
async setup(ctx) {
const config = ctx.options as PluginConfig
const maxChars = config.maxInjectChars ?? DEFAULT_MAX_INJECT
const maxEntries = config.maxInjectEntries ?? 0
const halfLifeDays = config.halfLifeDays ?? 14
const categories = config.categories ?? CATEGORIES
const extractOptions: ExtractOptions = {
...DEFAULT_EXTRACT,
enabled: config.autoExtract !== false,
minTurns: config.minTurns ?? DEFAULT_EXTRACT.minTurns,
...(config.extractModel ? { model: config.extractModel } : {}),
}
// `ctx.location.directory` is the session's working directory, NOT the
// config root, so it cannot be used to locate this plugin's own files. The
// plugin directory itself is the only reliable anchor:
// <config>/plugins/memory/index.ts -> up two levels -> <config>
const configDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
const global = globalRoot(configDir)
// A project root is only created on demand: an empty `.memory/` directory in
// every checkout is worse than none at all.
let project: MemoryRoot | undefined
const canonical = ctx.location.project?.canonical
if (canonical && canonical !== "/" && fs.existsSync(canonical)) {
project = projectRoot(canonical, String(ctx.location.project.id))
}
const roots = (): MemoryRoot[] => (project ? [global, project] : [global])
// Seeding the skeleton must never be able to break plugin load: an
// unwritable directory should degrade to "no memories yet", not to a
// failed plugin. Every read path already tolerates a missing root.
try {
ensureRoot(global, categories)
} catch (error) {
console.error(
`memory: cannot create ${global.dir} (${(error as Error).message}). Memories are disabled until this is writable.`,
)
}
// -- in-memory view, refreshed when files change on disk ----------------
// Cache is short (500ms) so a write followed immediately by a read sees
// the new file. The previous 2s window meant `memory_remember` →
// `memory_recall` in the same tool chain could miss the write.
let cache: { filesByRoot: Map<string, MemoryFile[]>; at: number } | undefined
const filesByRoot = (): Map<string, MemoryFile[]> => {
const now = Date.now()
if (cache && now - cache.at < 500) return cache.filesByRoot
const map = new Map<string, MemoryFile[]>()
for (const root of roots()) map.set(root.key, scanRoot(root))
cache = { filesByRoot: map, at: now }
return map
}
const allFiles = (): MemoryFile[] => roots().flatMap((r) => filesByRoot().get(r.key) ?? [])
const states = (root: MemoryRoot) => readState(root)
const invalidate = () => {
cache = undefined
}
// Recompile every derived index after a write.
const recompile = () => {
for (const root of roots()) {
const files = scanRoot(root)
compileCategoryIndexes(root, files)
compileRoot(root, files)
}
invalidate()
}
// -- injection into the agent loop --------------------------------------
// `context` fires on EVERY model request, including tool continuations, so
// one turn with n tool calls used to pay for n memory blocks. The agent
// loop is unchanged by later continuations, so injecting again is pure
// waste. Keyed by session + the latest user message: a new user message
// re-injects, everything in between is skipped.
// Per-session so two sessions with the same query do not share debounce state.
const injectedFor = new Map<string, Set<string>>()
await ctx.session.hook("context", async (event) => {
try {
const query = latestUserText(event.messages)
if (!query) return
let sessionKeys = injectedFor.get(event.sessionID)
if (!sessionKeys) {
sessionKeys = new Set<string>()
injectedFor.set(event.sessionID, sessionKeys)
}
const key = fingerprint(query)
if (sessionKeys.has(key)) {
// Count skips separately so the debounce can be proven to work.
// Without this, "timestamp unchanged" is ambiguous: it could mean
// the debounce fired, or that the diagnostic layer's own 2s debounce
// hid a second injection.
const statsKey = `diag.${project ? "project" : "global"}.injectionStats`
const prev = ((await ctx.storage.get(statsKey)) as Record<string, number> | undefined) ?? {
fires: 0,
injected: 0,
skipped: 0,
chars: 0,
}
await ctx.storage.set(statsKey, {
...prev,
fires: prev.fires + 1,
skipped: prev.skipped + 1,
})
return
}
sessionKeys.add(key)
// Bound the set: sessions are long-lived and this grows with each turn.
if (sessionKeys.size > 64) {
for (const oldest of sessionKeys) {
sessionKeys.delete(oldest)
if (sessionKeys.size <= 48) break
}
}
const text = buildInjection({
roots: roots(),
filesByRoot: filesByRoot(),
statesByRoot: new Map(roots().map((r) => [r.key, states(r)])),
query,
options: { maxChars, maxEntries, halfLifeDays },
})
if (text) event.system.push({ type: "text", text })
// Count real injections too. fires vs injected is the only number that
// proves the debounce works: if they are equal, every model request
// is paying for a memory block.
const diagScope = project ? "project" : "global"
const statsKey = `diag.${diagScope}.injectionStats`
const prevStats = ((await ctx.storage.get(statsKey)) as Record<string, number> | undefined) ?? {
fires: 0,
injected: 0,
skipped: 0,
chars: 0,
}
await ctx.storage.set(statsKey, {
...prevStats,
fires: prevStats.fires + 1,
injected: prevStats.injected + 1,
chars: prevStats.chars + text.length,
})
// 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(),
scope: project ? "project" : "global",
roots: roots().map((r) => r.label),
chars: text.length,
withinBudget: text.length <= maxChars,
bodies: (text.match(/^### /gm) ?? []).length,
query: query.slice(0, 80),
},
project ? "project" : "global",
)
} catch (error) {
console.error(`memory: injection failed: ${(error as Error).message}`)
}
})
// -- tools --------------------------------------------------------------
await ctx.tool.transform((editor) => {
editor.namespace({
name: "memory",
description:
"Durable memory across sessions: recall what was learned before, record new facts, forget stale ones.",
})
// memory_recall ------------------------------------------------------
editor.add({
name: "recall",
description: [
"Search memory for durable knowledge from earlier sessions: preferences, decisions, conventions, pitfalls.",
"Start here when ROOT's outline mentions something relevant but you need the full detail,",
"or when the user refers to something you do not remember deciding.",
"Prefer this over re-deriving a convention from the repo.",
].join(" "),
input: {
type: "object",
properties: {
query: {
type: "string",
description: "What you are trying to recall — a topic, a decision, a symptom.",
},
scope: {
type: "string",
enum: ["any", "project", "global"],
description: "Restrict to one root. Default any (project wins on conflict).",
},
limit: {
type: "number",
description: "Max memories to return. Default 5.",
},
},
required: ["query"],
additionalProperties: false,
},
execute: async (raw: unknown) => {
const input = raw as { query: string; scope?: string; limit?: number }
const pool = pickRoots(roots(), input.scope)
const scored = pool
.flatMap((root) =>
scoreFiles(filesByRoot().get(root.key) ?? [], input.query, states(root), { halfLifeDays }),
)
// Same rel path in two roots: the project one is the specific truth.
.filter((entry, i, all) => {
const same = all.filter((e) => e.file.rel === entry.file.rel)
return same.length === 1 || same[0] === entry
})
.sort((a, b) => b.score - a.score)
.slice(0, input.limit ?? 5)
// Reinforcement: what gets used floats up next time.
if (scored.length) {
for (const root of pool) {
const state = states(root)
let dirty = false
for (const entry of scored) {
if (entry.file.root.key !== root.key) continue
const current = state[entry.file.rel]
state[entry.file.rel] = {
hitCount: (current?.hitCount ?? 0) + 1,
lastHitAt: Date.now(),
}
dirty = true
}
if (dirty) {
try {
writeState(root, state)
} catch {
/* stats are best-effort */
}
}
}
}
if (scored.length === 0) {
return {
content: `没有匹配 "${input.query}" 的记忆。\n\n当前记忆库:\n${outlineText(filesByRoot(), roots())}`,
}
}
const body = scored
.map((entry) => {
const tags = [
`[${entry.file.root.label}/${entry.file.rel}]`,
entry.file.kind ? `(${entry.file.kind})` : "",
entry.reasons.length ? ` ${entry.reasons.join(", ")}` : "",
].join("")
return `${tags}\nscore ${entry.score.toFixed(2)}\n\n${entry.file.body}`
})
.join("\n\n---\n\n")
return { content: body }
},
})
// memory_remember -------------------------------------------------------
editor.add({
name: "remember",
description: [
"Record a durable memory: a user preference, an architectural decision and its reason,",
"a non-obvious gotcha, or a project convention.",
"Do NOT record what the code already shows, current task state, branch names, or file paths.",
"Writes automatically refresh the category index and ROOT's routing table.",
].join(" "),
input: {
type: "object",
properties: {
category: {
type: "string",
description: "Which kind of memory. Use rules/ only for things the user declared absolute.",
enum: categories,
},
slug: {
type: "string",
description: "Stable kebab-case identifier. Reusing a slug updates that memory in place.",
pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
},
title: { type: "string", description: "Short title." },
summary: {
type: "string",
description: "ONE sentence under 100 chars. This is the only line shown in indexes — make it count.",
},
highlight: {
type: "string",
description: "The single most important detail, shown as the 重点 column.",
},
body: { type: "string", description: "The detail: context, the decision, the reason, the root cause." },
scope: {
type: "string",
enum: ["project", "global"],
description:
"Which root. Default project. Use global for preferences and knowledge that holds across projects.",
},
loadWhen: {
type: "array",
items: { type: "string" },
description:
"Scenarios or trigger words that should surface this memory. These build ROOT's routing table.",
},
pinned: {
type: "boolean",
description:
"Pin the memory: never decays, never superseded automatically. Use for things you stated as absolute.",
},
supersedes: {
type: "string",
description: "Relative path of an older memory this replaces. The old one is kept for audit, not injected.",
},
},
required: ["category", "slug", "title", "summary", "body"],
additionalProperties: false,
},
execute: async (raw: unknown) => {
const input = raw as {
category: string
slug: string
title: string
summary: string
highlight?: string
body: string
scope?: string
loadWhen?: string[]
pinned?: boolean
supersedes?: string
}
const categoryError = validateCategory(input.category)
if (categoryError) return { content: `拒绝写入:${categoryError}` }
const slugError = validateSlug(input.slug)
if (slugError) return { content: `拒绝写入:${slugError}` }
const safeBody = redactSecrets(input.body)
if (safeBody !== input.body) {
return {
content:
"拒绝写入:正文里检测到疑似密钥(已定位)。记忆文件会进入 git,请改成描述性文字,例如“凭证放在环境变量 X”。",
}
}
const root =
input.scope === "global" || !project ? global : project
const created = fs.existsSync(path.join(root.dir, input.category, `${input.slug}.md`))
const file = writeMemory(root, {
category: input.category,
slug: input.slug,
title: input.title,
summary: input.summary,
highlight: input.highlight ?? "",
body: safeBody,
...(input.loadWhen?.length ? { loadWhen: input.loadWhen } : {}),
// Pass `false` through explicitly so an explicit unpin works;
// leaving it undefined lets writeMemory keep an existing pin.
...(input.pinned === undefined ? {} : { pinned: input.pinned }),
...(input.supersedes ? { supersedes: input.supersedes } : {}),
})
if (input.supersedes) markSuperseded(root, input.supersedes, file.rel)
// Promote the whole root's routing table when a new file appears, so
// the outline the model sees stays complete.
recompile()
return {
content: [
`${created ? "已更新" : "已写入"}记忆:${file.rel}(根:${root.label})`,
"",
`概述:${file.summary}`,
file.loadWhen.length ? `触发场景:${file.loadWhen.join("、")}` : "触发场景:未设置(不会被路由表自动命中,用 memory_recall 按关键词取)",
"",
"已同步更新:该目录 index.md" +
(file.loadWhen.length ? " 与 ROOT.md 路由表" : "") +
"。",
].join("\n"),
}
},
})
// memory_forget ------------------------------------------------------
editor.add({
name: "forget",
description: [
"Remove or supersede a memory that turned out to be wrong.",
"Prefer `supersedes` when a newer fact replaces an older one — the old entry stays for audit.",
"Use this when the user corrects you or a memory is no longer true.",
].join(" "),
input: {
type: "object",
properties: {
path: {
type: "string",
description: 'Relative path of the memory, e.g. "pitfalls/sqlite-cjk.md".',
},
scope: {
type: "string",
enum: ["project", "global", "any"],
description: "Which root to look in. Default any.",
},
reason: { type: "string", description: "Why it is being forgotten. Recorded for audit." },
},
required: ["path", "reason"],
additionalProperties: false,
},
execute: async (raw: unknown) => {
const input = raw as { path: string; scope?: string; reason: string }
const target = input.path.replace(/^\/+/, "")
if (!target.endsWith(".md") || target.includes("..")) {
return { content: `拒绝:path 必须是根下的相对 .md 路径(收到 ${JSON.stringify(input.path)})` }
}
const pool = pickRoots(roots(), input.scope)
for (const root of pool) {
const abs = path.join(root.dir, target)
if (!fs.existsSync(abs)) continue
fs.rmSync(abs)
recompile()
return { content: `已删除 ${root.label}/${target}。\n理由:${input.reason}\n已同步重建 index.md 与 ROOT.md 路由表。` }
}
return { content: `未找到记忆 ${target}。\n现有记忆:\n${outlineText(filesByRoot(), roots())}` }
},
})
// memory_extract_dryrun ----------------------------------------------
// Exists so the extraction pipeline can be exercised against the REAL
// model without lowering `minTurns` or waiting for a long session. The
// previous attempts to verify it both failed — once by editing the
// shipping default, which proved nothing about the real configuration.
editor.add({
name: "extract_dryrun",
description: [
"Run the extraction pipeline over a transcript you supply and show what it WOULD write, without writing anything.",
"Use to check whether extraction is working: it makes a real model call and reports the model's raw answer, the parsed candidates, and which ones were filtered as duplicates or rejected for secrets.",
].join(" "),
input: {
type: "object",
properties: {
transcript: {
type: "string",
description: "The conversation to extract from, as plain text.",
},
},
required: ["transcript"],
additionalProperties: false,
},
execute: async (raw: unknown) => {
const input = raw as { transcript: string }
const model = extractOptions.model ?? (await pickCheapModel(ctx))
if (!model) {
return { content: "无法选取模型:autoExtract 实际上不可用。" }
}
const existing = [...filesByRoot().values()].flat()
const fallbackModels = await extractionCandidates(ctx)
const outcome = await extract({
ctx,
model,
transcript: input.transcript,
existing,
fallbackModels,
})
const lines: string[] = [`首选模型:${model.providerID}/${model.id}`]
if (outcome.modelUsed) lines.push(`实际应答:${outcome.modelUsed}`)
lines.push(
`候选序列(失败则顺延):${
fallbackModels.map((m) => `${m.providerID}/${m.id}`).join(", ") || "无"
}`,
)
lines.push("")
if (outcome.error) lines.push(`⚠ 调用失败:${outcome.error}`)
if (outcome.notes) lines.push(`备注:${outcome.notes}`)
if (outcome.rawText) lines.push(`模型原始返回(前 600 字):\n${outcome.rawText.slice(0, 600)}`)
lines.push("")
lines.push(`通过筛选:${outcome.candidates.length} 条`)
for (const candidate of outcome.candidates) {
lines.push(` + ${candidate.category}/${candidate.slug} — ${candidate.title}`)
lines.push(` 概述:${candidate.summary}`)
if (candidate.loadWhen?.length) lines.push(` 触发:${candidate.loadWhen.join("、")}`)
}
lines.push(`被过滤:${outcome.rejected.length} 条`)
for (const rejected of outcome.rejected) {
lines.push(` - ${rejected.candidate.slug} — ${rejected.reason}`)
}
if (outcome.candidates.length === 0 && !outcome.error && outcome.rejected.length === 0) {
lines.push("\n(模型没有返回任何候选)")
} else if (outcome.candidates.length === 0 && !outcome.error) {
lines.push("\n(候选全部被判为重复或含密钥,因此未写入)")
}
lines.push("\n注意:dry-run 不会写入任何文件。")
return { content: lines.join("\n") }
},
})
// memory_inspect ------------------------------------------------------
editor.add({
name: "inspect",
description:
"Inspect the memory library: every root, every memory with its summary and trigger, runtime health of injection and extraction, plus index consistency checks. Use when tuning memory or explaining what is remembered.",
input: {
type: "object",
properties: {
detail: {
type: "boolean",
description: "Include per-memory hit counts and scores. Default false.",
},
history: {
type: "boolean",
description: "Include the full extraction run history. Default true.",
},
},
additionalProperties: false,
},
execute: async (raw: unknown) => {
const input = raw as { detail?: boolean; history?: boolean }
const lines: string[] = []
for (const root of roots()) {
const files = filesByRoot().get(root.key) ?? []
const state = states(root)
const goal = readGoal(root.dir)
lines.push(`## 根:${root.label}(${root.scope})`)
lines.push(`路径:${root.dir}${fs.existsSync(root.dir) ? "" : " ← 尚未创建"}`)
if (goal) lines.push(`当前目标:${goal}`)
if (files.length === 0) {
lines.push("(空)")
lines.push("")
continue
}
lines.push("")
for (const file of files) {
const hits = state[file.rel]
const superseded = file.fm.superseded_by ? ` [已被 ${String(file.fm.superseded_by)} 取代]` : ""
lines.push(`- ${file.rel}${superseded}`)
lines.push(` ${file.summary}`)
if (file.loadWhen.length) lines.push(` 触发:${file.loadWhen.join("、")}`)
if (file.pinned) lines.push(" 钉住(不衰减、不被取代)")
if (input.detail) {
lines.push(
` 命中 ${hits?.hitCount ?? 0} 次 · 最后命中 ${
hits?.lastHitAt ? new Date(hits.lastHitAt).toISOString().slice(0, 10) : "从未"
}`,
)
}
}
lines.push("")
}
// Consistency: the compiled tables must match what is on disk, or the
// outline the model trusts is a lie.
const problems: string[] = []
for (const root of roots()) {
// A root nobody has written to yet is not a problem to report.
if (!fs.existsSync(root.dir)) continue
const files = scanRoot(root)
const categories = [...new Set(files.map((f) => f.category))]
for (const category of categories) {
const indexPath = path.join(root.dir, category, "index.md")
if (!fs.existsSync(indexPath)) {
problems.push(`${root.label}/${category}/index.md 缺失`)
continue
}
const indexText = fs.readFileSync(indexPath, "utf8")
for (const file of files.filter((f) => f.category === category)) {
if (!indexText.includes(`${file.slug}.md`)) {
problems.push(`${root.label}/${category}/index.md 缺少 ${file.slug}.md`)
}
}
}
const rootPath = path.join(root.dir, "ROOT.md")
if (!fs.existsSync(rootPath)) problems.push(`${root.label}/ROOT.md 缺失`)
}
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("")
// Read the scope that matches this location, never the other one. The keys
// are scoped, so a report can only ever describe this project.
const diagScope = project ? "project" : "global"
const injection = (await ctx.storage.get(`diag.${diagScope}.lastInjection`)) as
| Record<string, unknown>
| undefined
const extraction = (await ctx.storage.get(`diag.${diagScope}.lastExtraction`)) as
| Record<string, unknown>
| undefined
const extractionHistory = (await ctx.storage.get(`diag.${diagScope}.lastExtraction.history`)) as
| Record<string, unknown>[]
| undefined
lines.push(`运行状态(${diagScope === "project" ? "项目" : "全局"}作用域):`)
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 还没触发过)")
}
// The debounce counter. fires vs injected is the only number that
// proves the debounce works — a single "last injection" timestamp
// cannot distinguish "injected once" from "injected twice, second
// write suppressed by the diagnostic layer's own debounce".
const stats = (await ctx.storage.get(`diag.${diagScope}.injectionStats`)) as
| Record<string, number>
| undefined
if (stats) {
const ratio = stats.fires > 0 ? (stats.injected / stats.fires).toFixed(2) : "—"
lines.push(
` 注入统计:触发 ${stats.fires} 次,实际注入 ${stats.injected} 次,跳过 ${stats.skipped} 次,累计 ${stats.chars} 字符(注入/触发比 ${ratio})`,
)
} else {
lines.push(" 注入统计:尚无记录")
}
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.reasonDetail) lines.push(` 细节:${String(extraction.reasonDetail)}`)
if (extraction.model) lines.push(` 模型:${String(extraction.model)}`)
} else {
lines.push(
extractOptions.enabled
? " 抽取:尚无记录(本会话还没到静默期,或轮数/内容不足)"
: " 抽取:已关闭(autoExtract: false)",
)
}
if (extraction && extraction.error) {
lines.push(` ⚠ 模型调用异常:${String(extraction.error)}`)
if (extraction.rawText) lines.push(` 原始返回:${String(extraction.rawText).slice(0, 160)}`)
}
// The full run history, so "the last attempt was skipped" cannot hide
// an earlier success. Reading only the newest record is what led to
// reporting a working extractor as broken.
if (input.history !== false && extractionHistory && extractionHistory.length > 0) {
const tally = extractionHistory.reduce<Record<string, number>>((acc, entry) => {
const key = String(entry.outcome ?? "?")
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
const summary = Object.entries(tally)
.map(([k, v]) => `${k}×${v}`)
.join(", ")
lines.push(
` 抽取历史(共 ${extractionHistory.length} 次,最近 5 次):${summary}`,
)
for (const entry of extractionHistory.slice(-5).reverse()) {
const when = new Date(Number(entry.at)).toISOString().slice(5, 19).replace("T", " ")
const detail = entry.reason ? ` — ${String(entry.reason).slice(0, 70)}` : ""
const paths = Array.isArray(entry.paths) && entry.paths.length ? ` → ${entry.paths.join(", ")}` : ""
lines.push(` ${when} ${String(entry.outcome)}${detail}${paths}`)
}
}
return { content: lines.join("\n") }
},
})
})
// -- 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 (!EXTRACTION_TRIGGERS.has(String(record?.type))) continue
const sessionID = record.data?.sessionID
if (!sessionID) continue
// 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.
if (!controller.signal.aborted) {
console.error(`memory: event stream ended: ${(error as Error).message}`)
}
}
})()
}
return () => {
controller.abort()
for (const timer of pending.values()) clearTimeout(timer)
pending.clear()
}
},
})
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[] {
if (scope === "project") return roots.filter((r) => r.scope === "project")
if (scope === "global") return roots.filter((r) => r.scope === "global")
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.
*
* The scope is part of the storage key on purpose. `ctx.storage` is one global
* namespace for the whole plugin instance, so a project-scoped record stored
* under a bare key would be read back by whichever project runs next and
* reported as if it were this project's result.
*/
const lastDiagAt = new Map<string, number>()
/** How many past events of each kind `memory_inspect` keeps. */
const DIAG_HISTORY = 10
async function noteDiag(
ctx: Context,
key: string,
value: Record<string, unknown>,
scope = "global",
): Promise<void> {
const now = Date.now()
const storageKey = `diag.${scope}.${key}`
const previous = lastDiagAt.get(storageKey) ?? 0
if (now - previous < 2000) return
lastDiagAt.set(storageKey, now)
try {
// `storage.set` is typed as JSON; the diagnostics payload is plain data,
// so narrow at the boundary rather than loosening the call.
await ctx.storage.set(storageKey, value as Record<string, never>)
// Keep a short history too. A single record only ever describes the LAST
// attempt, so one later skip can hide an earlier success — which is exactly
// how a working extractor gets reported as broken.
const historyKey = `diag.${scope}.${key}.history`
const existing = (await ctx.storage.get(historyKey)) as unknown
const history = Array.isArray(existing) ? existing.slice(-(DIAG_HISTORY - 1)) : []
history.push(value)
await ctx.storage.set(historyKey, history)
} 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) {
const files = filesByRoot.get(root.key) ?? []
if (files.length === 0) continue
lines.push(`${root.label}:`)
for (const file of files) lines.push(` ${file.rel} — ${file.summary}`)
}
return lines.join("\n") || "(记忆库为空)"
}
/**
* A stable short key for a user message.
*
* Two different turns rarely produce identical text, so this distinguishes
* "new turn" from "continuation of the current one" without holding whole
* messages in memory.
*/
function fingerprint(text: string): string {
let hash = 5381
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
}
return `${text.length}:${hash.toString(36)}`
}
/**
* The role of a session message.
*
* `session.context()` returns `Session.Message.Info` entries, a tagged union
* rather than a flat `{ role }`. Reading `.role` directly found nothing, so
* every session counted zero turns and extraction never passed its own gate.
* The role is accepted at the top level or nested one level down, with `type`
* as a fallback since the union is tagged by it.
*/
function messageRole(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined
const record = message as Record<string, unknown>
if (typeof record.role === "string") return record.role
const nested = record.message
if (nested && typeof nested === "object") {
const inner = nested as Record<string, unknown>
if (typeof inner.role === "string") return inner.role
}
if (typeof record.type === "string" && /^(user|assistant|system|synthetic|shell)$/i.test(record.type)) {
return record.type
}
return undefined
}
/**
* 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 && !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 ""
const parts: string[] = []
for (const part of content) {
if (part && typeof part === "object") {
const record = part as Record<string, unknown>
if (record.type === "text" && typeof record.text === "string") parts.push(record.text)
}
}
return parts.join(" ").trim()
}
async function runExtraction(
ctx: Context,
input: {
sessionID: string
extractOptions: ExtractOptions
filesByRoot: () => Map<string, MemoryFile[]>
project: MemoryRoot | undefined
global: MemoryRoot
},
): Promise<void> {
const { sessionID, extractOptions } = input
const messages = (await ctx.session.context({ sessionID })) as readonly unknown[]
const turns = messages.filter((m) => {
const role = messageRole(m)
return role === "user" || role === "assistant"
})
// Long enough to have learned something, short enough to stay cheap.
// Diagnostics for extraction are stored under the scope the session's content
// actually belongs to, so a run inside a project never reports into — or
// reads from — the global namespace.
const diagScope = input.project ? "project" : "global"
if (turns.length < extractOptions.minTurns) {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
root: diagScope === "project" ? input.project?.label : input.global.label,
outcome: "skipped",
reason: "会话轮数不足",
turns: turns.length,
minTurns: extractOptions.minTurns,
},
diagScope,
)
return
}
const model = extractOptions.model ?? (await pickCheapModel(ctx))
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(),
scope: diagScope,
outcome: "failed",
reason: "选不到可用模型(autoExtract 实际处于关闭状态)",
turns: turns.length,
},
diagScope,
)
return
}
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
if (transcript.length < 200) {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: "skipped",
reason: "对话内容太短",
turns: turns.length,
transcriptChars: transcript.length,
},
diagScope,
)
return
}
const existing = [...input.filesByRoot().values()].flat()
const candidates = await extractionCandidates(ctx)
const outcome = await extract({ ctx, model, transcript, existing, fallbackModels: candidates })
if (outcome.candidates.length === 0) {
for (const rejected of outcome.rejected) {
console.log(
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
)
}
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: outcome.error ? "failed" : "no-candidates",
...(outcome.error ? { reason: outcome.error } : {}),
reasonDetail: outcome.rejected.length
? "候选全部被去重或密钥拦截"
: "抽取器认为没有值得记的东西",
rejected: outcome.rejected.length,
model: `${model.providerID}/${model.id}`,
...(outcome.rawText ? { rawText: outcome.rawText } : {}),
},
diagScope,
)
return
}
const now = Date.now()
const written: string[] = []
for (const candidate of outcome.candidates) {
// Content that mentions this project's own paths belongs to the project.
const text = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
const projectSpecific =
input.project !== undefined && (text.includes(ctx.location.project.directory) || text.includes(ctx.location.directory))
const root = projectSpecific ? input.project! : input.global
const file = writeMemory(root, {
category: candidate.category,
slug: candidate.slug,
title: candidate.title,
summary: candidate.summary,
highlight: candidate.highlight ?? "",
body: redactSecrets(candidate.body),
kind: candidate.kind ?? (candidate.proposeRule ? "rule-proposal" : "fact"),
...(candidate.loadWhen?.length ? { loadWhen: candidate.loadWhen } : {}),
})
written.push(`${root.label}/${file.rel}`)
}
if (written.length) {
for (const root of [input.global, input.project].filter(Boolean) as MemoryRoot[]) {
compileCategoryIndexes(root, scanRoot(root))
compileRoot(root, scanRoot(root))
}
console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`)
}
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: written.length ? "extracted" : "all-filtered",
model: `${model.providerID}/${model.id}`,
turns: turns.length,
written: written.length,
rejected: outcome.rejected.length,
paths: written,
},
diagScope,
)
}
/**
* Pick the cheapest available model for extraction.
*
* Three shapes here were wrong in the first version, and each one alone made
* extraction permanently inert:
* - `ctx.model.list()` resolves to `{ location, data }`, not a bare array
* - `ctx.model.default()` resolves to `{ location, data }` too
* - the availability flag is `enabled`, not `disabled`
* Both are read through `unwrapData`, which tolerates either shape so a host
* change cannot turn this into a silent no-op again.
*
* Every model on this machine reports `cost: 0`, so "cheapest" degenerates to
* "first available". That is acceptable; it must never mean "none".
*/
function unwrapData<T>(result: unknown): T[] | undefined {
if (Array.isArray(result)) return result as T[]
if (result && typeof result === "object") {
const data = (result as { data?: unknown }).data
if (Array.isArray(data)) return data as T[]
if (data && typeof data === "object") return [data as T]
}
return undefined
}
async function pickCheapModel(
ctx: Context,
): Promise<{ providerID: string; id: string } | undefined> {
const models = unwrapData<any>(await ctx.model.list().catch(() => undefined)) ?? []
const usable = models.filter((model) => {
// `enabled` is the availability flag; `status` may also say disabled or
// deprecated. The registry is not authoritative — a model listed as active
// can still be refused at call time, which is why extraction retries below.
if (model.enabled === false) return false
const status = String(model.status ?? "").toLowerCase()
if (status === "disabled" || status === "deprecated") return false
const context = Number(model.limit?.context ?? 0)
// A tiny context window cannot hold the transcript plus the prompt.
return !(context > 0 && context < 8_000)
})
const costOf = (model: any) =>
Number(model.cost?.[0]?.output ?? 0) + Number(model.cost?.[0]?.input ?? 0)
const refOf = (model: any) => ({ providerID: model.providerID, id: model.id ?? model.modelID })
// Every model here reports cost 0, so "cheapest" is "first available".
// Keep the candidates ordered so retries walk them cheapest-first.
const ranked = [...usable].sort((a, b) => costOf(a) - costOf(b))
if (ranked[0]) return refOf(ranked[0])
const fallback = unwrapData<any>(await ctx.model.default().catch(() => undefined))?.[0]
if (fallback?.providerID) return refOf(fallback)
// Last resort: any listed model at all. Failing loudly beats extracting
// nothing silently.
const any = models[0]
if (any?.providerID) return refOf(any)
return undefined
}
/**
* The candidates extraction should try, cheapest first.
*
* Exposed so a failed call can walk to the next model instead of giving up:
* the registry reports `opencode/exo-free` as active while the service refuses
* it with "has been deprecated". Metadata cannot catch that; trying can.
*/
async function extractionCandidates(ctx: Context): Promise<{ providerID: string; id: string }[]> {
const models = unwrapData<any>(await ctx.model.list().catch(() => undefined)) ?? []
const costOf = (model: any) =>
Number(model.cost?.[0]?.output ?? 0) + Number(model.cost?.[0]?.input ?? 0)
const usable = models.filter((model) => {
if (model.enabled === false) return false
const status = String(model.status ?? "").toLowerCase()
if (status === "disabled" || status === "deprecated") return false
const context = Number(model.limit?.context ?? 0)
return !(context > 0 && context < 8_000)
})
const ranked = [...usable].sort((a, b) => costOf(a) - costOf(b))
const refs = ranked.map((m) => ({ providerID: m.providerID, id: m.id ?? m.modelID }))
if (refs.length) return refs
const first = await pickCheapModel(ctx)
return first ? [first] : []
}