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.
This commit is contained in:
2026-10-09 10:17:45 +08:00
parent 8ba68d7f9a
commit a64ea2acb3
14 changed files with 2246 additions and 0 deletions
+680
View File
@@ -0,0 +1,680 @@
// 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
}