From a64ea2acb3d3404d8198cbe6b8b6d56342b6ad5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timeless=E5=B0=8F=E5=B8=85?= <1018008088@qq.com> Date: Fri, 9 Oct 2026 10:17:45 +0800 Subject: [PATCH] Add memory plugin: three-tier markdown store with plugin-driven retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A durable memory layer for OpenCode. Structure: ROOT.md total plan: goals, hard rules, directory registry, and a scenario -> file routing table / index.md one line per file, COMPILED from frontmatter .md the memory itself Two roots, same shape: /memory/ (global) and /.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. --- .gitignore | 7 + memory/ROOT.md | 41 ++ memory/decisions/index.md | 8 + memory/decisions/memory-store-format.md | 26 + memory/pitfalls/index.md | 8 + memory/pitfalls/sqlite-cjk-tokenizer.md | 23 + memory/rules/index.md | 8 + memory/rules/plugin-push-on-change.md | 17 + plugins/memory/README.md | 190 +++++++ plugins/memory/extract.ts | 289 ++++++++++ plugins/memory/frontmatter.ts | 127 +++++ plugins/memory/index.ts | 680 ++++++++++++++++++++++++ plugins/memory/recall.ts | 283 ++++++++++ plugins/memory/store.ts | 539 +++++++++++++++++++ 14 files changed, 2246 insertions(+) create mode 100644 memory/ROOT.md create mode 100644 memory/decisions/index.md create mode 100644 memory/decisions/memory-store-format.md create mode 100644 memory/pitfalls/index.md create mode 100644 memory/pitfalls/sqlite-cjk-tokenizer.md create mode 100644 memory/rules/index.md create mode 100644 memory/rules/plugin-push-on-change.md create mode 100644 plugins/memory/README.md create mode 100644 plugins/memory/extract.ts create mode 100644 plugins/memory/frontmatter.ts create mode 100644 plugins/memory/index.ts create mode 100644 plugins/memory/recall.ts create mode 100644 plugins/memory/store.ts diff --git a/.gitignore b/.gitignore index d64f961..ddaaa19 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # # ~/.config/opencode/skills/ -> agent skills # ~/.config/opencode/plugins/ -> server plugins (auto-discovered) +# ~/.config/opencode/memory/ -> memory store written by the memory plugin # # A .git directory inside either of those gets picked up as a plugin and fails # to load, so the repository lives one level up, here, and everything that is @@ -17,8 +18,14 @@ /* !/skills/ !/plugins/ +!/memory/ !/.gitignore +# Per-memory hit counters. Derived data: regenerating it costs one recall, and +# committing it would rewrite the file on every retrieval and create merge +# conflicts across devices. +memory/**/.state.json + # UTF-8 shell launcher (see https://www.xsinfo.vip/windows/01-opencode-utf8.html). # Portable: it only calls chcp and pwsh.exe from PATH, so it is safe to share. !/pwsh.cmd diff --git a/memory/ROOT.md b/memory/ROOT.md new file mode 100644 index 0000000..04ec271 --- /dev/null +++ b/memory/ROOT.md @@ -0,0 +1,41 @@ +# 记忆总纲 — 全局 + +> 跨项目通用记忆:你的偏好、技术决策、通用约定、踩过的坑。 +> 由 memory 插件维护。`auto:begin` / `auto:end` 之间的表格是编译产物,不要手改; +> 其余区域随便写。 + +## 当前目标 + + + +## 铁规则(常驻,永不衰减) + + + +## 目录登记 + +/g, "") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("` +// 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:". */ + 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 = "`. + * + * 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() + 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 `//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\` 之间的表格是编译产物,不要手改; +> 其余区域随便写。 + +## 当前目标 + + + +## 铁规则(常驻,永不衰减) + + + +## 目录登记 + +${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") + } +}