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 复核结论。
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
// 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(new RegExp(CJK.source, "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 []
|
|
if (files.length === 0) return []
|
|
|
|
const querySet = new Map<string, number>()
|
|
for (const token of queryTokens) querySet.set(token, (querySet.get(token) ?? 0) + 1)
|
|
|
|
// Document frequency for idf: how many files contain each term.
|
|
// The previous version passed a per-file match count into a total-files
|
|
// denominator, which gave every file the same idf and reduced ranking to
|
|
// raw term frequency.
|
|
const docFreq = new Map<string, number>()
|
|
const docTokenList: Set<string>[] = []
|
|
for (const file of files) {
|
|
const haystack = [file.title, file.summary, file.highlight, file.loadWhen.join(" "), file.body].join("\n")
|
|
const docTokens = new Set(tokenize(haystack))
|
|
docTokenList.push(docTokens)
|
|
for (const token of docTokens) docFreq.set(token, (docFreq.get(token) ?? 0) + 1)
|
|
}
|
|
|
|
const results: ScoredFile[] = []
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i]
|
|
// 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 docTokens = docTokenList[i]
|
|
|
|
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
|
|
|
|
// Inverse document frequency: a term appearing in every file is useless.
|
|
let idfSum = 0
|
|
let idfCount = 0
|
|
for (const token of querySet.keys()) {
|
|
if (!docTokens.has(token)) continue
|
|
const df = docFreq.get(token) ?? 1
|
|
idfSum += Math.log(1 + files.length / df)
|
|
idfCount++
|
|
}
|
|
if (idfCount === 0) continue
|
|
const idf = idfSum / idfCount
|
|
|
|
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)
|
|
}
|
|
|
|
export interface InjectionOptions {
|
|
/** Hard cap on the injected block, in characters. */
|
|
maxChars?: number
|
|
/** Max number of memory bodies to inline. */
|
|
maxEntries?: number
|
|
/**
|
|
* Half-life in days for the recency decay. 0 disables decay.
|
|
* Declared here because the caller passes it: it was silently dropped when
|
|
* this option was missing, so injection ranked with the default 14-day
|
|
* half-life regardless of configuration.
|
|
*/
|
|
halfLifeDays?: 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 ?? 600
|
|
const maxEntries = input.options?.maxEntries ?? 0
|
|
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.
|
|
// Body first line only: the title usually restates it, and this block is
|
|
// re-sent on every model request.
|
|
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
|
|
const firstLine = file.body.split(/\r?\n/).find((l) => l.trim())?.trim() ?? ""
|
|
if (firstLine) ruleLines.push(`- [${root.label}] ${firstLine}`)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Goals only. The slug list was the single most expensive part of the block
|
|
// and the model does not need it: `memory_recall` discovers files by query.
|
|
const outline: string[] = []
|
|
for (const root of roots) {
|
|
const goals = readGoal(root.dir)
|
|
if (goals) outline.push(`目标(${root.label}): ${goals}`)
|
|
}
|
|
|
|
// 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")
|
|
// Match until the next section header or auto marker. The previous regex
|
|
// stopped at any `##` or `<!--`, which truncated content containing
|
|
// sub-headings or HTML comments inside the goals section.
|
|
const m = /^##\s*当前目标\s*\n([\s\S]*?)(?=\n##\s|\n<!-- auto:begin|$)/m.exec(text)
|
|
const body = m?.[1]
|
|
?.replace(/<!--(?!auto:)[\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
|
|
}
|
|
}
|