Files
oc/plugins/memory/store.ts
T
xiaoshuai cb9db666ba memory: 修注入开销、idf 排序与抽取诊断
注入侧
- context 在每次模型请求都触发,改为按 session + 最新用户消息去重:
  一个 turn 里 n 次工具调用不再重复注入 n 个 memory 块
- 默认注入从 1200 字符 / 内联 4 条正文,降到 600 字符 / 内联 0 条:
  大纲已说明存在什么,正文交给 memory_recall 按需取一次,避免每次
  请求都为同一批正文付费
- halfLifeDays 此前被静默丢弃,注入始终按 14 天半衰期排序,现已接通
- 召回缓存 2s -> 500ms,同一工具链里 remember -> recall 能读到刚写的文件

排序
- idf 原先拿「单文件匹配数」当分子、「总文件数」当分母,每个文件得到
  相同 idf,排序退化为纯词频;改为按文档频率计算的真实 idf

抽取
- 移除 400 段上限:它会静默截断长对话,300 轮只贡献前 ~50 轮
- role 在顶层或 message 下两种返回结构都读,之前只读 .role 得到空转录,
  长度门槛不过,抽取看起来永远不触发
- 去重改按关键词重叠计分,不再用 bigram 重叠:模型改写与人工措辞几乎
  不共享 bigram,真实改写只拿到 0.43;关键词是双方被迫共用的词汇
- 新增 memory_extract_dryrun:对真实模型跑完整抽取链路,不必改
  minTurns 或等长会话
- 新增 memory_inspect:诊断记录按 scope 存入 storage key,并保留历史
  与 fires/injected 计数,去重是否生效可被证伪

其他
- Context 改从 Plugin 命名空间导入,此前类型退化为 unknown
- store 暴露 kind 字段;compileRootTables 内联进 compileRoot
- frontmatter 列表元素统一 String(),index 不再出现数字

验证:由仓库所有者端到端验证(真实模型调用、真实会话);提交者未复跑,
以上为 diff 复核结论。
2026-10-12 18:10:40 +08:00

533 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
/** Highlight column in index.md. */
readonly highlight: string
/** `kind` from frontmatter, e.g. "fact", "rule", "rule-proposal". */
readonly kind?: 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 ?? ""),
kind: data.kind === undefined ? undefined : String(data.kind),
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 */
}
}
}
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 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`
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")
}
}