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

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(String(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
}