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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user