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

128 lines
4.2 KiB
TypeScript

// Minimal YAML-subset frontmatter, enough for memory files.
//
// A full YAML parser is deliberately avoided: memory files only need scalars
// and string arrays, and every added dependency is one more thing that can fail
// to load on someone else's machine. This reads what we write and tolerates
// what humans type.
export interface Frontmatter {
[key: string]: string | number | boolean | string[] | undefined
}
/** Parse a leading `---` block. Returns empty data when there is none. */
export function parseFrontmatter(raw: string): { data: Frontmatter; body: string } {
// Tolerate a UTF-8 BOM and leading blank lines before the fence.
const text = raw.replace(/^\uFEFF/, "")
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text)
if (!match) return { data: {}, body: text.trim() }
const data: Frontmatter = {}
let currentKey: string | undefined
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim() || line.trim().startsWith("#")) continue
// `- item` continues the array opened by the previous `key:`.
const item = /^\s*-\s+(.*)$/.exec(line)
if (item && currentKey) {
const prev = data[currentKey]
const list = Array.isArray(prev) ? prev : []
list.push(scalar(item[1]))
data[currentKey] = list
continue
}
const kv = /^([A-Za-z_][\w.-]*)\s*:\s*(.*)$/.exec(line)
if (!kv) continue
currentKey = kv[1]
const value = kv[2].trim()
if (value === "") {
// Either an empty scalar or the head of a block list; the `- ` branch
// above fills it in. Mark it as an empty array so round-tripping works.
data[currentKey] = []
continue
}
data[currentKey] = value.startsWith("[") ? parseInlineList(value) : scalar(value)
}
return { data, body: text.slice(match[0].length).trim() }
}
/** Serialize data + body back to a memory file. Key order is preserved. */
export function stringifyFrontmatter(data: Frontmatter, body: string): string {
const lines: string[] = []
for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`)
continue
}
// Arrays are written one item per line: readable in a diff, and immune
// to quoting problems that inline JSON lists hit with Chinese text.
lines.push(`${key}:`)
for (const entry of value) lines.push(` - ${quoteScalar(entry)}`)
continue
}
lines.push(`${key}: ${quoteScalar(value)}`)
}
return `---\n${lines.join("\n")}\n---\n\n${body.trim()}\n`
}
function scalar(raw: string): string | number | boolean {
const value = unquote(raw.trim())
if (value === "true") return true
if (value === "false") return false
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value)
return value
}
function parseInlineList(raw: string): string[] {
const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "")
if (!inner.trim()) return []
return splitTopLevel(inner).map((part) => String(unquote(part.trim())))
}
/** Split on commas that are not inside quotes. */
function splitTopLevel(input: string): string[] {
const parts: string[] = []
let buf = ""
let quote: string | undefined
for (const ch of input) {
if (quote) {
if (ch === quote) quote = undefined
else buf += ch
continue
}
if (ch === '"' || ch === "'") {
quote = ch
continue
}
if (ch === ",") {
parts.push(buf)
buf = ""
continue
}
buf += ch
}
if (buf.trim()) parts.push(buf)
return parts
}
function unquote(value: string): string {
if (value.length >= 2 && ((value[0] === '"' && value.endsWith('"')) || (value[0] === "'" && value.endsWith("'")))) {
return value.slice(1, -1)
}
return value
}
/** Quote only when the value would otherwise not round-trip. */
function quoteScalar(value: string | number | boolean): string {
if (typeof value === "number" || typeof value === "boolean") return String(value)
// A bare `:` or a leading marker would be read back as YAML structure.
if (/^[\s>|*&!%@`#-]|[:#]\s|^$/.test(value)) {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
}
return value
}