// 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 // / // index.md one line per file — COMPILED, never hand-edited // .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 `` // 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") } }