Files
oc/plugins/memory/index.ts
T
xiaoshuai a64ea2acb3 Add memory plugin: three-tier markdown store with plugin-driven retrieval
A durable memory layer for OpenCode. Structure:

  ROOT.md          total plan: goals, hard rules, directory registry,
                   and a scenario -> file routing table
  <category>/
    index.md       one line per file, COMPILED from frontmatter
    <slug>.md      the memory itself

Two roots, same shape: <config>/memory/ (global) and
<project canonical>/.memory/ (project). Project wins on conflict; global
hard rules stay resident everywhere.

Why a plugin rather than a skill that tells the model to read a folder:
model-driven retrieval is unreliable — the model forgets, reads half the
files, or reads the wrong ones. Markdown is the human-facing format; the
plugin is the engine that parses, scores, and injects.

Measured, not assumed: FTS5's two built-in tokenizers both fail on this
corpus. On SQLite 3.53.4 with "部署脚本用 pnpm 而不是 npm...": unicode61
emits 7 terms for the whole sentence and matches 0 hits for "部署";
trigram needs >= 3 characters and also returns 0 for that two-character
word. So tokenization happens in the plugin (CJK bigrams, latin words)
and no SQL index is used — a thousand memories are fine to scan.

index.md and ROOT's two tables are compiled, never hand-edited: a
hand-maintained index drifts, a generated one cannot. Hit counters live
in .state.json so cross-device sync does not conflict on every recall.

Tools are named recall/remember/forget/inspect rather than
write/read/status: generic names were observed shadowing the harness's
own built-in tools.
2026-10-09 10:17:45 +08:00

680 lines
27 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
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
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", (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 })
} 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}`)
return { content: lines.join("\n") }
},
})
})
// -- automatic extraction on idle --------------------------------------
let extracting = false
const controller = new AbortController()
if (extractOptions.enabled) {
void (async () => {
try {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
const record = event as unknown as { type?: string; data?: { sessionID?: string } }
if (record?.type !== "session.idle") continue
const sessionID = record.data?.sessionID
if (!sessionID || extracting) continue
extracting = true
try {
await runExtraction(ctx, {
sessionID,
extractOptions,
filesByRoot,
project,
global,
})
} catch (error) {
console.error(`memory: extraction failed: ${(error as Error).message}`)
} finally {
extracting = false
}
}
} 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()
}
},
})
// ---------------------------------------------------------------------------
// 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
}
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 user text in a request — the retrieval query. */
function latestUserText(messages: readonly unknown[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const record = messages[i] as { role?: string; content?: unknown } | null
if (!record || record.role !== "user") continue
const text = extractUserText(record.content)
if (text) return text
}
return undefined
}
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) return
const model = extractOptions.model ?? (await pickCheapModel(ctx))
if (!model) return
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
if (transcript.length < 200) 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}`,
)
}
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(", ")}`)
}
}
/**
* 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
}