cb9db666ba
注入侧 - 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 复核结论。
128 lines
4.2 KiB
TypeScript
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
|
|
}
|