// 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" // / one per kind of memory, same shape at every level // index.md one line per file. COMPILED from frontmatter, never edited // .md the memory, with frontmatter (summary / highlight / load_when) // // Two roots coexist and use identical rules: // global /memory/ preferences, decisions, general pitfalls // project /.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: // /plugins/memory/index.ts -> up two levels -> 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; at: number } | undefined const filesByRoot = (): Map => { const now = Date.now() if (cache && now - cache.at < 500) return cache.filesByRoot const map = new Map() 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>() 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() 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 | 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 | 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 | undefined const extraction = (await ctx.storage.get(`diag.${diagScope}.lastExtraction`)) as | Record | undefined const extractionHistory = (await ctx.storage.get(`diag.${diagScope}.lastExtraction.history`)) as | Record[] | 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 | 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>((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>() 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() /** How many past events of each kind `memory_inspect` keeps. */ const DIAG_HISTORY = 10 async function noteDiag( ctx: Context, key: string, value: Record, scope = "global", ): Promise { 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) // 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, 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 if (typeof record.role === "string") return record.role const nested = record.message if (nested && typeof nested === "object") { const inner = nested as Record 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 ("", "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| 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 project: MemoryRoot | undefined global: MemoryRoot }, ): Promise { 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(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(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(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(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] : [] }