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
+539
View File
@@ -0,0 +1,539 @@
// Memory store: roots, files, and the compiled indexes.
//
// The layout is deliberately the same at every level, so a project-local
// `.memory/` and the global root are interchangeable:
//
// ROOT.md total plan: goals, hard rules, registry, routing table
// <category>/
// index.md one line per file — COMPILED, never hand-edited
// <slug>.md the memory itself, with frontmatter
//
// Two rules keep the indexes honest:
//
// 1. `index.md` tables and ROOT's registry/routing tables are generated from
// frontmatter. A hand-maintained index drifts from reality; a generated
// one cannot.
// 2. Hand-written regions in ROOT.md live outside `<!-- auto:begin -->`
// markers and are never touched by the compiler.
import fs from "node:fs"
import path from "node:path"
import { type Frontmatter, parseFrontmatter, stringifyFrontmatter } from "./frontmatter.ts"
export type RootScope = "global" | "project"
export interface MemoryRoot {
readonly scope: RootScope
/** Stable identity used in state keys: "global" or "project:<id>". */
readonly key: string
/** Human label shown in injected context. */
readonly label: string
readonly dir: string
/** Project wins ties; global is the fallback. */
readonly rank: number
}
export interface MemoryFile {
readonly root: MemoryRoot
/** Path relative to the root, e.g. "pitfalls/sqlite-cjk.md". */
readonly rel: string
readonly abs: string
readonly category: string
readonly slug: string
readonly fm: Frontmatter
readonly body: string
readonly title: string
/** One-line description shown in indexes and injected summaries. */
readonly summary: string
/** The "重点" column in index.md. */
readonly highlight: string
/** Scenarios that should pull this file in. Drives ROOT's routing table. */
readonly loadWhen: string[]
readonly pinned: boolean
readonly created: number
readonly updated: number
readonly mtime: number
}
export interface FileState {
hitCount: number
lastHitAt: number
}
const AUTO_BEGIN = "<!-- auto:begin"
const AUTO_END = "<!-- auto:end"
const STATE_FILE = ".state.json"
/** Categories whose files are hard rules: always resident, never decayed. */
const HARD_RULES_CATEGORY = "rules"
export function isHardRule(file: MemoryFile): boolean {
if (file.category === HARD_RULES_CATEGORY) return true
return String(file.fm.kind ?? "").toLowerCase() === "rule"
}
// ---------------------------------------------------------------------------
// Roots
// ---------------------------------------------------------------------------
export function globalRoot(configDir: string): MemoryRoot {
return {
scope: "global",
key: "global",
label: "全局",
dir: path.join(configDir, "memory"),
rank: 10,
}
}
export function projectRoot(canonical: string, projectID: string): MemoryRoot {
return {
scope: "project",
key: `project:${projectID}`,
label: path.basename(canonical) || "项目",
dir: path.join(canonical, ".memory"),
rank: 20,
}
}
/**
* Walk up from `dir` looking for a `.memory/` directory. This is what makes the
* feature work in a monorepo or a nested checkout, where the session's
* canonical project root may be several levels above the file being edited.
*/
export function findNearestRoot(start: string, depth = 4): string | undefined {
let current = path.resolve(start)
for (let i = 0; i <= depth; i++) {
const candidate = path.join(current, ".memory")
if (fs.existsSync(path.join(candidate, "ROOT.md"))) return candidate
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return undefined
}
// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------
export function scanRoot(root: MemoryRoot): MemoryFile[] {
const out: MemoryFile[] = []
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(root.dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue
const category = entry.name
const dir = path.join(root.dir, category)
let files: fs.Dirent[]
try {
files = fs.readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const file of files) {
// `index.md` is compiled output; ROOT.md lives at the root, not here.
if (!file.isFile() || !file.name.endsWith(".md")) continue
if (file.name === "index.md" || file.name === "ROOT.md") continue
const abs = path.join(dir, file.name)
const parsed = readMemoryFile(root, abs, category, file.name)
if (parsed) out.push(parsed)
}
}
return out
}
function readMemoryFile(root: MemoryRoot, abs: string, category: string, name: string): MemoryFile | undefined {
let raw: string
let stat: fs.Stats
try {
raw = fs.readFileSync(abs, "utf8")
stat = fs.statSync(abs)
} catch {
return undefined
}
const { data, body } = parseFrontmatter(raw)
const slug = name.replace(/\.md$/, "")
return {
root,
rel: path.posix.join(category, name),
abs,
category,
slug,
fm: data,
body,
title: String(data.title ?? firstHeading(body) ?? slug),
summary: String(data.summary ?? deriveSummary(body, slug)),
highlight: String(data.highlight ?? ""),
loadWhen: toStringArray(data.load_when ?? data.loadWhen),
pinned: data.pinned === true || data.pinned === "true",
created: toNumber(data.created) ?? stat.mtimeMs,
updated: toNumber(data.updated) ?? stat.mtimeMs,
mtime: stat.mtimeMs,
}
}
function firstHeading(body: string): string | undefined {
const m = /^#{1,3}\s+(.+)$/m.exec(body)
return m?.[1]?.trim()
}
/**
* Fallback summary when frontmatter omits one: the first real paragraph,
* flattened to a single line and clipped. Falls back to the slug so the index
* never has an empty cell.
*/
function deriveSummary(body: string, slug: string): string {
const lines = body.split(/\r?\n/)
const buf: string[] = []
for (const line of lines) {
const text = line.trim()
if (!text || text.startsWith("#") || text.startsWith("```")) continue
buf.push(text)
if (buf.join(" ").length > 90) break
}
const text = buf.join(" ").replace(/\s+/g, " ").trim()
if (!text) return slug
return text.length > 140 ? `${text.slice(0, 139)}…` : text
}
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean)
if (typeof value === "string" && value.trim()) {
return value
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean)
}
return []
}
function toNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") {
const n = Number(value)
if (Number.isFinite(n)) return n
const parsed = Date.parse(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
// ---------------------------------------------------------------------------
// State (hit counts) — derived, never the source of truth
// ---------------------------------------------------------------------------
export function readState(root: MemoryRoot): Record<string, FileState> {
try {
const raw = fs.readFileSync(path.join(root.dir, STATE_FILE), "utf8")
const parsed = JSON.parse(raw)
return typeof parsed === "object" && parsed ? (parsed as Record<string, FileState>) : {}
} catch {
return {}
}
}
/** Write state atomically so a crash mid-write cannot truncate the file. */
export function writeState(root: MemoryRoot, state: Record<string, FileState>): void {
fs.mkdirSync(root.dir, { recursive: true })
const target = path.join(root.dir, STATE_FILE)
const tmp = `${target}.tmp-${process.pid}`
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8")
fs.renameSync(tmp, target)
}
// ---------------------------------------------------------------------------
// Writing memories
// ---------------------------------------------------------------------------
export interface WriteInput {
category: string
slug: string
title?: string
summary?: string
highlight?: string
body: string
kind?: string
loadWhen?: string[]
pinned?: boolean
supersedes?: string
}
const CATEGORY_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
export function validateCategory(category: string): string | undefined {
if (!CATEGORY_RE.test(category)) {
return `category must be lowercase [a-z0-9._-] (got ${JSON.stringify(category)})`
}
return undefined
}
export function validateSlug(slug: string): string | undefined {
if (!SLUG_RE.test(slug)) {
return `slug must match [A-Za-z0-9][A-Za-z0-9._-]* (got ${JSON.stringify(slug)})`
}
return undefined
}
export function writeMemory(root: MemoryRoot, input: WriteInput, now = Date.now()): MemoryFile {
const dir = path.join(root.dir, input.category)
fs.mkdirSync(dir, { recursive: true })
const abs = path.join(dir, `${input.slug}.md`)
const rel = path.posix.join(input.category, `${input.slug}.md`)
const existing = fs.existsSync(abs) ? readMemoryFile(root, abs, input.category, `${input.slug}.md`) : undefined
const data: Frontmatter = {
id: rel,
// A file under `rules/` is a hard rule regardless of what it was labelled
// before, so the category-derived value wins over a stale stored one. An
// explicit `kind` from the caller still wins over both.
kind: input.kind ?? (input.category === "rules" ? "rule" : String(existing?.fm.kind ?? "fact")),
summary: input.summary ?? String(existing?.fm.summary ?? ""),
highlight: input.highlight ?? String(existing?.fm.highlight ?? ""),
created: existing?.created ?? now,
updated: now,
}
if (input.loadWhen?.length) data.load_when = input.loadWhen
else if (existing?.loadWhen.length) data.load_when = existing.loadWhen
if (input.pinned ?? existing?.pinned) data.pinned = true
if (input.supersedes) data.supersedes = input.supersedes
const body = input.body.trim() || String(existing?.body ?? "")
fs.writeFileSync(abs, stringifyFrontmatter(data, body), "utf8")
return readMemoryFile(root, abs, input.category, `${input.slug}.md`)!
}
/** Mark `supersedes` (a rel path) as replaced, keeping the row for audit. */
export function markSuperseded(root: MemoryRoot, target: string, byRel: string, now = Date.now()): void {
const abs = path.join(root.dir, target)
if (!fs.existsSync(abs) || !target.endsWith(".md")) return
const { data, body } = parseFrontmatter(fs.readFileSync(abs, "utf8"))
data.superseded_by = byRel
data.updated = now
fs.writeFileSync(abs, stringifyFrontmatter(data, body), "utf8")
}
// ---------------------------------------------------------------------------
// Compiled indexes
// ---------------------------------------------------------------------------
/**
* Replace the region belonging to one specific `<!-- auto:begin <label> -->`.
*
* The label must be part of the match. Matching on a bare `auto:begin` pairs
* the first marker with the *next* marker regardless of which region was meant,
* which silently swaps two unrelated tables.
*/
export function replaceAutoRegion(text: string, label: string, generated: string): string {
const re = new RegExp(
`(${escapeRe(`${AUTO_BEGIN} ${label}`)}[^\\n]*\\n)[\\s\\S]*?(${escapeRe(AUTO_END)})`,
)
if (!re.test(text)) return text
return text.replace(re, (_m, open: string, close: string) => `${open}${generated}${close}`)
}
function escapeRe(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function cell(value: string): string {
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim() || "—"
}
export interface CategoryIndex {
category: string
files: MemoryFile[]
}
export function groupByCategory(files: MemoryFile[]): CategoryIndex[] {
const map = new Map<string, MemoryFile[]>()
for (const file of files) {
const list = map.get(file.category) ?? []
list.push(file)
map.set(file.category, list)
}
return [...map.entries()]
.map(([category, list]) => ({
category,
files: list.sort((a, b) => a.slug.localeCompare(b.slug)),
}))
.sort((a, b) => a.category.localeCompare(b.category))
}
/** Rewrite `<root>/<category>/index.md` for every category that has files. */
export function compileCategoryIndexes(root: MemoryRoot, files: MemoryFile[]): string[] {
const written: string[] = []
for (const { category, files: list } of groupByCategory(files)) {
const heading = `# ${category} 索引`
const rows = list.map((f) => `| ${cell(f.slug + ".md")} | ${cell(f.summary)} | ${cell(f.highlight)} |`)
const table = [
"| 文件 | 概述 | 重点 |",
"|---|---|---|",
...rows,
"",
].join("\n")
const generated = `\n| 文件 | 概述 | 重点 |\n|---|---|---|\n${rows.join("\n")}\n`
// Create the directory before writing: a root can be compiled before anything
// was ever written into it (e.g. the global root was seeded but the project
// root has no memories yet), and the compiler must not depend on that.
fs.mkdirSync(path.join(root.dir, category), { recursive: true })
const target = path.join(root.dir, category, "index.md")
let existing = ""
try {
existing = fs.readFileSync(target, "utf8")
} catch {
/* first build */
}
const next = existing.includes(AUTO_BEGIN)
? replaceAutoRegion(existing, category, generated)
: `${heading}\n\n${AUTO_BEGIN} ${category}\n${generated}${AUTO_END}\n`
// Skip the write when nothing changed: index.md is regenerated often and a
// stable mtime keeps file watchers (and git) quiet.
if (existing !== next) fs.writeFileSync(target, next, "utf8")
written.push(path.join(category, "index.md"))
}
// Prune indexes whose category lost every memory. A stale index listing
// deleted files is exactly the drift this whole design exists to prevent, so
// it must not be left behind.
pruneEmptyIndexes(root, files)
return written
}
function pruneEmptyIndexes(root: MemoryRoot, files: MemoryFile[]): void {
const populated = new Set(files.map((f) => f.category))
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(root.dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue
if (populated.has(entry.name)) continue
// A hand-written index in an empty category is the user's content; only
// remove one this compiler produced.
const target = path.join(root.dir, entry.name, "index.md")
try {
const text = fs.readFileSync(target, "utf8")
if (text.includes(AUTO_BEGIN)) fs.rmSync(target)
} catch {
/* nothing to prune */
}
}
}
export interface CompiledRoot {
registry: string
routing: string
}
export function compileRootTables(root: MemoryRoot, files: MemoryFile[]): CompiledRoot {
const categories = groupByCategory(files)
const registryRows = categories.map(({ category, files: list }) => {
const pinned = list.filter((f) => f.pinned).length
const desc = describeCategory(category, list)
return `| ${cell(category + "/")} | ${cell(desc)} | ${list.length}${pinned ? ` (${pinned} 钉住)` : ""} |`
})
const registry = `\n| 目录 | 概述 | 条目 |\n|---|---|---|\n${registryRows.join("\n")}\n`
// The routing table is the union of every file's `load_when`, so it stays
// correct without anyone maintaining it.
const routingRows: string[] = []
for (const { category, files: list } of categories) {
for (const file of list) {
for (const trigger of file.loadWhen) {
routingRows.push(`| ${cell(trigger)} | ${cell(file.rel)} | ${cell(file.kind ?? "fact")} |`)
}
}
}
routingRows.sort((a, b) => a.localeCompare(b))
const routing = routingRows.length
? `\n| 场景 / 触发 | 读哪个文件 | 类型 |\n|---|---|---|\n${routingRows.join("\n")}\n`
: `\n_暂无 load_when 标记的记忆。给文件加 frontmatter 的 \`load_when:\` 即可自动进入路由表。_\n`
return { registry, routing }
}
function describeCategory(category: string, files: MemoryFile[]): string {
const pinned = files.find((f) => isHardRule(f))
if (pinned && category === HARD_RULES_CATEGORY) return "不可违反的硬规则,常驻"
const first = files[0]
if (first?.fm.category_desc) return String(first.fm.category_desc)
return first ? first.summary.slice(0, 40) : "(空)"
}
/** Recompile ROOT.md's generated regions and write it if it does not exist. */
export function compileRoot(root: MemoryRoot, files: MemoryFile[]): string {
// Same reason as the category index: ROOT.md may be the first thing written
// into a root that does not exist yet.
fs.mkdirSync(root.dir, { recursive: true })
const target = path.join(root.dir, "ROOT.md")
const { registry, routing } = compileRootTables(root, files)
let existing: string | undefined
try {
existing = fs.readFileSync(target, "utf8")
} catch {
/* first build */
}
const text = existing ?? defaultRootTemplate(root)
// Labeled regions, replaced in order. Each call is independent because the
// label is part of the match — see replaceAutoRegion.
let next = replaceAutoRegion(text, "registry", registry)
next = replaceAutoRegion(next, "routing", routing)
if (next !== existing) fs.writeFileSync(target, next, "utf8")
return next
}
export function defaultRootTemplate(root: MemoryRoot): string {
const where =
root.scope === "global"
? "跨项目通用记忆:你的偏好、技术决策、通用约定、踩过的坑。"
: "本项目专属记忆:项目目标、事实、决策、坑。优先级高于全局根。"
return `# 记忆总纲 — ${root.label}
> ${where}
> 由 memory 插件维护。\`auto:begin\` / \`auto:end\` 之间的表格是编译产物,不要手改;
> 其余区域随便写。
## 当前目标
<!-- 手写:现在在做什么。插件不会碰这里。 -->
## 铁规则(常驻,永不衰减)
<!-- 手写:只写硬性要求。rules/ 目录下的文件会被自动常驻注入。 -->
## 目录登记
${AUTO_BEGIN} registry
${AUTO_END}
## 加载路由表
${AUTO_BEGIN} routing
${AUTO_END}
`
}
/** Create the skeleton (ROOT.md + category dirs) for a root that does not exist. */
export function ensureRoot(root: MemoryRoot, categories: string[]): void {
fs.mkdirSync(root.dir, { recursive: true })
for (const category of categories) fs.mkdirSync(path.join(root.dir, category), { recursive: true })
if (!fs.existsSync(path.join(root.dir, "ROOT.md"))) {
fs.writeFileSync(path.join(root.dir, "ROOT.md"), defaultRootTemplate(root), "utf8")
}
}