Files
oc/plugins/memory/store.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

540 lines
19 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 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")
}
}