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,127 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user