Files
oc/plugins/memory/index.ts
T
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

834 lines
33 KiB
TypeScript
Raw 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, type Context } from "@opencode/plugin"
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 model request. Default 1200. */
maxInjectChars?: number
/** How many memory bodies to inline per request. Default 4. */
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"]
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",
async setup(ctx) {
const config = ctx.options as PluginConfig
const maxChars = config.maxInjectChars ?? DEFAULT_MAX_INJECT
const maxEntries = config.maxInjectEntries ?? 4
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 ----------------
let cache: { filesByRoot: Map<string, MemoryFile[]>; at: number } | undefined
const filesByRoot = (): Map<string, MemoryFile[]> => {
const now = Date.now()
if (cache && now - cache.at < 2000) 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` covers the agent loop including tool continuations, and edits
// only the outgoing request — the conversation transcript stays clean.
await ctx.session.hook("context", async (event) => {
try {
const query = latestUserText(event.messages)
if (!query) return
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 })
// 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}`)
}
})
// -- 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 (input: { 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 (input: {
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 (input: { 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_inspect ------------------------------------------------------
editor.add({
name: "inspect",
description:
"Inspect the memory library: every root, every memory with its summary and trigger, 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.",
},
},
additionalProperties: false,
},
execute: async (input: { detail?: 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("")
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") }
},
})
})
// -- 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.
*/
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) {
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") || "(记忆库为空)"
}
/**
* 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 = (m as { role?: string }).role
return role === "user" || role === "assistant"
})
// Long enough to have learned something, short enough to stay cheap.
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) {
// 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) {
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 })
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(),
outcome: "no-candidates",
reason: outcome.rejected.length ? "候选全部被去重或密钥拦截" : "抽取器认为没有值得记的东西",
rejected: outcome.rejected.length,
model: `${model.providerID}/${model.id}`,
})
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(),
outcome: written.length ? "extracted" : "all-filtered",
model: `${model.providerID}/${model.id}`,
turns: turns.length,
written: written.length,
rejected: outcome.rejected.length,
paths: written,
})
}
/**
* Pick the cheapest available model for extraction. Falls back to the session
* default when cost metadata is unavailable, and disables extraction entirely
* when neither is available.
*/
async function pickCheapModel(ctx: Context): Promise<{ providerID: string; id: string } | undefined> {
try {
const models = await ctx.model.list()
let best: { ref: { providerID: string; id: string }; cost: number } | undefined
for (const model of models) {
if (model.disabled) continue
// A reasoning-only or tiny-context model cannot do the job reliably.
if ((model.limit?.context ?? 0) > 0 && model.limit.context < 8_000) continue
const output = model.cost?.[0]?.output ?? 0
const input = model.cost?.[0]?.input ?? 0
const cost = Number(output) + Number(input)
if (!best || cost < best.cost) {
best = { ref: { providerID: model.providerID, id: model.id }, cost }
}
}
if (best) return best.ref
const fallback = await ctx.model.default()
if (fallback) return { providerID: fallback.providerID, id: fallback.modelID }
} catch {
return undefined
}
return undefined
}