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,283 @@
|
||||
// Retrieval: tokenization, scoring, and the block that gets injected.
|
||||
//
|
||||
// Tokenization note — this is the part that had to be measured, not guessed.
|
||||
//
|
||||
// FTS5's two built-in tokenizers both fail on the memory corpus this plugin
|
||||
// exists to hold. Measured on SQLite 3.53.4 with the string
|
||||
// "部署脚本用 pnpm 而不是 npm,因为 lockfile 不兼容":
|
||||
//
|
||||
// unicode61 (FTS5 default) query "部署" -> 0 hits
|
||||
// query "部署脚本" -> 0 hits
|
||||
// It emits 7 terms for the whole sentence, treating 部署脚本用 as ONE
|
||||
// token, so no substring can ever match.
|
||||
//
|
||||
// trigram query "部署" -> 0 hits (needs >= 3 chars)
|
||||
// query "部署脚" -> 1 hit
|
||||
// Its vocabulary is sliding 3-char windows (不兼 / 而不 / m,因), so every
|
||||
// two-character Chinese word — 部署, 配置, 依赖, 接口 — is unreachable.
|
||||
//
|
||||
// So tokenization happens here, not in SQL: CJK becomes character bigrams and
|
||||
// latin becomes words. Two-character Chinese then matches exactly, and the
|
||||
// extra noise bigrams introduce is harmless because rarer terms score lower.
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const CJK =
|
||||
/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/
|
||||
const LATIN_WORD = /[A-Za-z0-9][A-Za-z0-9_+.#-]*/g
|
||||
|
||||
/** Stopwords that would otherwise dominate a scoring pass. */
|
||||
const STOPWORDS = new Set([
|
||||
"the", "a", "an", "and", "or", "of", "to", "in", "is", "are", "was", "were",
|
||||
"be", "for", "on", "at", "by", "with", "as", "it", "this", "that", "from",
|
||||
"我", "的", "了", "是", "在", "和", "有", "就", "不", "也", "你",
|
||||
])
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
if (!text) return []
|
||||
const tokens: string[] = []
|
||||
|
||||
// Latin / digits: whole words, lowercased.
|
||||
for (const match of text.toLowerCase().matchAll(LATIN_WORD)) {
|
||||
const word = match[0]
|
||||
if (word.length < 2 || STOPWORDS.has(word)) continue
|
||||
tokens.push(word)
|
||||
}
|
||||
|
||||
// CJK: unigrams plus bigrams. Bigrams carry the matching power; unigrams
|
||||
// rescue one-character queries that have no bigram at all.
|
||||
const runs = text.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]+/g) ?? []
|
||||
for (const run of runs) {
|
||||
const chars = [...run]
|
||||
for (let i = 0; i < chars.length - 1; i++) {
|
||||
const bigram = chars[i] + chars[i + 1]
|
||||
if (!STOPWORDS.has(bigram)) tokens.push(bigram)
|
||||
}
|
||||
if (chars.length === 1 && !STOPWORDS.has(chars[0])) tokens.push(chars[0])
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
export interface ScoredFile {
|
||||
file: import("./store.ts").MemoryFile
|
||||
score: number
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Score memories against a query.
|
||||
*
|
||||
* Ranking is scope-first on purpose: a project-specific fact about how *this*
|
||||
* repo deploys should beat a global preference when both match, because the
|
||||
* narrower claim is the more useful one. Ties inside one root fall back to
|
||||
* decay and hit count.
|
||||
*/
|
||||
export function scoreFiles(
|
||||
files: readonly import("./store.ts").MemoryFile[],
|
||||
query: string,
|
||||
states: Record<string, import("./store.ts").FileState>,
|
||||
options: { now?: number; halfLifeDays?: number; pinnedBoost?: boolean } = {},
|
||||
): ScoredFile[] {
|
||||
const now = options.now ?? Date.now()
|
||||
const halfLife = (options.halfLifeDays ?? 14) * 86_400_000
|
||||
const queryTokens = tokenize(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
|
||||
const querySet = new Map<string, number>()
|
||||
for (const token of queryTokens) querySet.set(token, (querySet.get(token) ?? 0) + 1)
|
||||
|
||||
const results: ScoredFile[] = []
|
||||
|
||||
for (const file of files) {
|
||||
// A superseded memory stays on disk for audit but must never be injected.
|
||||
if (file.fm.superseded_by) continue
|
||||
|
||||
const state = states[file.rel]
|
||||
const haystack = [file.title, file.summary, file.highlight, file.loadWhen.join(" "), file.body].join("\n")
|
||||
const docTokens = new Set(tokenize(haystack))
|
||||
|
||||
let overlap = 0
|
||||
const matched: string[] = []
|
||||
for (const [token, weight] of querySet) {
|
||||
if (!docTokens.has(token)) continue
|
||||
// Diminishing returns: a term repeated in the query should not dominate.
|
||||
overlap += weight * (1 / (1 + Math.log2(weight)))
|
||||
if (matched.length < 4) matched.push(token)
|
||||
}
|
||||
if (overlap <= 0) continue
|
||||
|
||||
// Relative term rarity across the corpus: a term in every file is useless.
|
||||
const idf = idfFor(tokenScore(querySet, docTokens), files.length)
|
||||
let score = overlap * idf
|
||||
|
||||
// Scope prior.
|
||||
score *= file.root.rank === 20 ? 1 : 0.5
|
||||
|
||||
// Recency decay, unless pinned or a hard rule.
|
||||
const hard = file.pinned || String(file.fm.kind ?? "").toLowerCase() === "rule" || file.category === "rules"
|
||||
if (!hard && halfLife > 0) {
|
||||
const age = Math.max(0, now - (state?.lastHitAt ?? file.updated))
|
||||
score *= Math.pow(0.5, age / halfLife)
|
||||
}
|
||||
|
||||
// Reinforcement: memories that keep proving useful float up.
|
||||
score *= 1 + Math.log1p(state?.hitCount ?? 0) * 0.25
|
||||
|
||||
if (options.pinnedBoost !== false && hard) score *= 1.15
|
||||
|
||||
const reasons = [...matched]
|
||||
if (hard) reasons.push("铁规则")
|
||||
if ((state?.hitCount ?? 0) > 0) reasons.push(`命中 ${state?.hitCount} 次`)
|
||||
|
||||
results.push({ file, score, reasons })
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
function tokenScore(querySet: Map<string, number>, docTokens: Set<string>): number {
|
||||
let n = 0
|
||||
for (const token of querySet.keys()) if (docTokens.has(token)) n++
|
||||
return n
|
||||
}
|
||||
|
||||
/** Inverse document frequency over the candidate set. */
|
||||
function idfFor(matchedTerms: number, total: number): number {
|
||||
if (matchedTerms <= 0 || total <= 0) return 0
|
||||
return Math.log(1 + total / matchedTerms)
|
||||
}
|
||||
|
||||
export interface InjectionOptions {
|
||||
/** Hard cap on the injected block, in characters. */
|
||||
maxChars?: number
|
||||
/** Max number of memory bodies to inline. */
|
||||
maxEntries?: number
|
||||
now?: number
|
||||
}
|
||||
|
||||
export interface InjectionInput {
|
||||
roots: readonly import("./store.ts").MemoryRoot[]
|
||||
filesByRoot: Map<string, import("./store.ts").MemoryFile[]>
|
||||
statesByRoot: Map<string, Record<string, import("./store.ts").FileState>>
|
||||
query: string
|
||||
options?: InjectionOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the text injected into the agent loop.
|
||||
*
|
||||
* Two tiers on purpose:
|
||||
* - outlines are cheap and always accurate, so they always ride along
|
||||
* - full bodies are expensive, so only the best few ride along
|
||||
* A `memory_recall` call reaches the rest.
|
||||
*/
|
||||
export function buildInjection(input: InjectionInput): string {
|
||||
const { roots, filesByRoot, statesByRoot, query } = input
|
||||
const maxChars = input.options?.maxChars ?? 1200
|
||||
const maxEntries = input.options?.maxEntries ?? 4
|
||||
const now = input.options?.now ?? Date.now()
|
||||
|
||||
const blocks: string[] = []
|
||||
|
||||
// Tier 1 — hard rules. Resident regardless of query: these are the user's
|
||||
// non-negotiables and must never be outranked by relevance.
|
||||
const ruleLines: string[] = []
|
||||
for (const root of roots) {
|
||||
for (const file of filesByRoot.get(root.key) ?? []) {
|
||||
const isRule = file.category === "rules" || String(file.fm.kind ?? "").toLowerCase() === "rule"
|
||||
if (!isRule || file.fm.superseded_by) continue
|
||||
ruleLines.push(`- [${root.label}] ${file.title}${file.summary ? ` — ${file.summary}` : ""}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 — the outline: what exists, and when each part is worth loading.
|
||||
// This is the ROOT.md idea: one small file answers "what do I know and when
|
||||
// do I need it", instead of dumping every memory into every request.
|
||||
const outline: string[] = []
|
||||
for (const root of roots) {
|
||||
const files = filesByRoot.get(root.key) ?? []
|
||||
if (files.length === 0) continue
|
||||
const goals = readGoal(root.dir)
|
||||
if (goals) outline.push(`目标(${root.label}): ${goals}`)
|
||||
for (const [category, list] of groupCategories(files)) {
|
||||
const bits = list.map((f) => `${f.slug}(${f.summary.slice(0, 28)})`)
|
||||
outline.push(`${root.label}/${category}: ${bits.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 — full bodies for the top matches.
|
||||
const bodyLines: string[] = []
|
||||
let budget = maxChars - ruleLines.join("\n").length - outline.join("\n").length
|
||||
const all: ScoredFile[] = []
|
||||
for (const root of roots) {
|
||||
const scored = scoreFiles(filesByRoot.get(root.key) ?? [], query, statesByRoot.get(root.key) ?? {}, { now })
|
||||
for (const entry of scored) {
|
||||
// Project wins over global on the same file path.
|
||||
if (all.some((a) => a.file.rel === entry.file.rel && a.file.root.rank < entry.file.root.rank)) continue
|
||||
all.push(entry)
|
||||
}
|
||||
}
|
||||
for (const entry of all) {
|
||||
if (bodyLines.length >= maxEntries) break
|
||||
if (entry.score <= 0) continue
|
||||
const body = entry.file.body.slice(0, 400)
|
||||
const line = `\n### ${entry.file.root.label}/${entry.file.rel}\n${body}`
|
||||
if (budget - line.length < 0) break
|
||||
budget -= line.length
|
||||
bodyLines.push(line)
|
||||
}
|
||||
|
||||
const sections: string[] = []
|
||||
if (ruleLines.length) {
|
||||
sections.push(`## 铁规则(必须遵守)\n${ruleLines.join("\n")}`)
|
||||
}
|
||||
if (outline.length) {
|
||||
sections.push(`## 记忆大纲(按需深挖,用 memory_recall 取全文)\n${outline.join("\n")}`)
|
||||
}
|
||||
if (bodyLines.length) {
|
||||
sections.push(`## 与当前任务相关的记忆\n${bodyLines.join("\n")}`)
|
||||
}
|
||||
if (sections.length === 0) return ""
|
||||
|
||||
const header = "# 记忆上下文(来自 memory 插件;如需更多记忆用 memory_recall 工具)"
|
||||
let text = `${header}\n\n${sections.join("\n\n")}`
|
||||
if (text.length > maxChars) {
|
||||
// Truncate from the least important tier: keep rules, drop the tail of the
|
||||
// outline, then the bodies.
|
||||
const keep = sections.slice(0, Math.max(1, sections.length - 1)).join("\n\n")
|
||||
text = `${header}\n\n${keep}`.slice(0, maxChars)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function groupCategories(files: readonly import("./store.ts").MemoryFile[]) {
|
||||
const map = new Map<string, import("./store.ts").MemoryFile[]>()
|
||||
for (const file of files) {
|
||||
const list = map.get(file.category) ?? []
|
||||
list.push(file)
|
||||
map.set(file.category, list)
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}
|
||||
|
||||
/** Read the hand-written "当前目标" block from ROOT.md. */
|
||||
export function readGoal(rootDir: string): string | undefined {
|
||||
try {
|
||||
const text = fs.readFileSync(path.join(rootDir, "ROOT.md"), "utf8")
|
||||
const m = /^##\s*当前目标\s*\n([\s\S]*?)(?=\n##\s|\n<!--|$)/m.exec(text)
|
||||
const body = m?.[1]
|
||||
?.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("<!--"))
|
||||
.join(" ")
|
||||
.trim()
|
||||
return body || undefined
|
||||
} catch {
|
||||
// A root nobody has written to yet has no goals. This runs on every
|
||||
// injection, so a missing ROOT.md is a quiet outcome, not a failure.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user