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.
834 lines
33 KiB
TypeScript
834 lines
33 KiB
TypeScript
// 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
|
||
} |