a64ea2acb3
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.
540 lines
19 KiB
TypeScript
540 lines
19 KiB
TypeScript
// 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")
|
||
}
|
||
}
|