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 复核结论。
This commit is contained in:
2026-10-12 18:10:40 +08:00
parent 8dc02b6b35
commit cb9db666ba
5 changed files with 702 additions and 220 deletions
+151 -47
View File
@@ -12,7 +12,7 @@
// one, but only a human confirms it. Letting a model write "iron rules"
// would let a hallucination become permanent policy.
import type { Context } from "@opencode/plugin"
type Context = import("@opencode/plugin").Plugin.Context
import { tokenize } from "./recall.ts"
export interface ExtractOptions {
@@ -72,9 +72,13 @@ If nothing durable was learned, return [].`
* Pull plain text out of a session message without depending on the exact
* message shape: the plugin package ships types for a schema the host may
* evolve, so this walks defensively and collects `text` fields.
*
* The 400-part cap was removed: it silently truncated long transcripts, so a
* 300-turn session would only contribute its first ~50 turns to extraction.
* Depth still bounds recursion on malformed input.
*/
export function collectText(value: unknown, depth = 0, out: string[] = []): string[] {
if (depth > 6 || out.length > 400) return out
if (depth > 6) return out
if (typeof value === "string") {
if (value.trim()) out.push(value)
return out
@@ -101,9 +105,19 @@ export function userAndAssistantText(messages: readonly unknown[]): string {
for (const message of messages) {
const record = message as Record<string, unknown> | null
if (!record) continue
const role = record.role
// The role sits at the top level or under `message`, depending on whether
// the host returned `Session.Message.Info` or its inner message. Reading
// only `.role` produced an empty transcript, which then failed the length
// gate and made extraction look permanently inert.
const nested = record.message as Record<string, unknown> | undefined
const role =
typeof record.role === "string"
? record.role
: typeof nested?.role === "string"
? nested.role
: undefined
if (role !== "user" && role !== "assistant") continue
const texts = collectText(record.content)
const texts = collectText(record.content ?? nested)
const joined = texts.join("\n").trim()
if (joined) parts.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${joined}`)
}
@@ -187,7 +201,8 @@ export function slugify(input: string): string {
.slice(0, 48)
if (ascii) return ascii
// CJK titles have no ASCII to work with; fall back to a hash-free stable id.
return [...input].map((ch) => ch.codePointAt(0)!.toString(36)).join("").slice(0, 24)
// `codePointAt(0)` returns undefined for an empty string, so guard with ?? 0.
return [...input].map((ch) => (ch.codePointAt(0) ?? 0).toString(36)).join("").slice(0, 24)
}
/** Secrets that must never reach a git-tracked memory file. */
@@ -199,6 +214,9 @@ const SECRET_PATTERNS: RegExp[] = [
/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
/\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*\S+/gi,
/-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
// CJK variants: \b does not work on Chinese text, so match the keyword
// directly with a separator and a non-space value.
/(?:密码|口令|密钥|秘钥|api[_-]?key|access[_-]?token|auth[_-]?token|secret)\s*[:=:]\s*\S+/gi,
]
export function hasSecret(text: string): string | undefined {
@@ -219,7 +237,7 @@ export interface ExtractionOutcome {
candidates: Candidate[]
rejected: { candidate: Candidate; reason: string }[]
/**
* Set when the round-trip itself failed. Absent means the model answered.
* Set when every model call failed. Absent means one answered.
* Without this, a broken call and a conversation with nothing worth keeping
* both come back as zero candidates — which is how a permanently broken
* extractor can look like a healthy one that simply rarely fires.
@@ -227,6 +245,10 @@ export interface ExtractionOutcome {
error?: string
/** Raw model text, truncated, for diagnosing a surprising empty result. */
rawText?: string
/** The model that actually answered, when not the first candidate. */
modelUsed?: string
/** Models that failed before one succeeded. */
notes?: string
}
/**
@@ -239,12 +261,15 @@ export interface ExtractionOutcome {
* Coverage answers the question we actually care about: "does an existing
* memory already say this?"
*/
/** Floor on matched signals, so coincidental overlap cannot reject a memory. */
const MIN_SHARED_TERMS = 6
export function screenCandidates(
candidates: Candidate[],
existing: readonly import("./store.ts").MemoryFile[],
options: { coverage?: number } = {},
): ExtractionOutcome {
const threshold = options.coverage ?? 0.55
const threshold = options.coverage ?? 0.5
const accepted: Candidate[] = []
const rejected: { candidate: Candidate; reason: string }[] = []
@@ -262,12 +287,14 @@ export function screenCandidates(
candidate.kind = "rule-proposal"
}
const probe = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
const best = bestCoverage(probe, existing)
if (best.file && best.coverage >= threshold) {
const best = bestCoverage(candidate, existing)
// Both conditions: most of the candidate's declared subjects are already
// covered by an existing memory, AND enough terms matched in total that the
// overlap is not a coincidence.
if (best.file && best.coverage >= threshold && best.shared >= MIN_SHARED_TERMS) {
rejected.push({
candidate,
reason: `与已有记忆重复 ${(best.coverage * 100).toFixed(0)}% (${best.file.rel})`,
reason: `与已有记忆主题重复 ${(best.coverage * 100).toFixed(0)}%(${best.shared} 项重合,${best.file.rel})`,
})
continue
}
@@ -278,27 +305,83 @@ export function screenCandidates(
}
/**
* Fraction of the candidate's distinctive terms that an existing memory also
* contains. The candidate's own terms are the query; the best match wins.
* How much of a candidate is already said elsewhere, 0..1. Best match wins.
*
* Measured against the real library, plain bigram overlap does NOT work, and no
* threshold fixes it. A genuine restatement scored only 0.43 because the model
* writes in different words than a human does: existing "走两阶段、禁止 drop
* 列" versus extracted "遵循 expand/contract:先以向后兼容方式扩展结构…收缩
* 变更". Nearly every non-topic bigram differs; the topic terms are identical.
*
* So overlap is measured over KEY TERMS only — latin words and the terms the
* extractor itself declared in `loadWhen`. Those are the vocabulary both sides
* are forced to share, which is why they survive paraphrasing. Bigrams are
* still used to find an existing memory's key terms, just not as the score.
*/
function bestCoverage(
probe: string,
existing: readonly import("./store.ts").MemoryFile[],
): { file?: import("./store.ts").MemoryFile; coverage: number } {
const probeTerms = new Set(tokenize(probe).filter((t) => t.length >= 2))
if (probeTerms.size === 0) return { coverage: 0 }
function keyTerms(text: string): Set<string> {
return new Set(tokenize(text).filter((t) => t.length >= 2 && !/[㐀-鿿]/.test(t[0])))
}
function bestCoverage(
candidate: Candidate,
existing: readonly import("./store.ts").MemoryFile[],
): { file?: import("./store.ts").MemoryFile; coverage: number; shared: number } {
// The topic vocabulary: what the extractor said this memory is about, plus
// the technical terms in its text.
const subject = new Set<string>([
...(candidate.loadWhen ?? []).map((s) => s.trim().toLowerCase()),
...candidate.title.split(/[\s、,,。::()()]+/).filter((s) => /[A-Za-z0-9]/.test(s)),
])
// Every term in the candidate, used only to look a document up.
const probeTerms = new Set(tokenize(`${candidate.title}\n${candidate.summary}\n${candidate.body}`))
if (subject.size === 0) return { coverage: 0, shared: 0 }
let best: { file?: import("./store.ts").MemoryFile; coverage: number; shared: number } = {
coverage: 0,
shared: 0,
}
let best: { file?: import("./store.ts").MemoryFile; coverage: number } = { coverage: 0 }
for (const file of existing) {
if (file.fm.superseded_by) continue
// Same slug in the same root is the same memory, whatever the wording.
const doc = new Set(
tokenize([file.title, file.summary, file.highlight, file.body].join("\n")).filter((t) => t.length >= 2),
)
// A document's subject vocabulary: its own declared triggers plus its
// technical terms. Human-written and model-written memories land here on
// the same footing, which is the whole point.
const docSubject = new Set<string>([
...file.loadWhen.map((s) => s.trim().toLowerCase()),
...keyTerms(`${file.title}\n${file.summary}\n${file.highlight}\n${file.body}`),
])
// Overlap on the candidate's declared subjects first: two memories about
// "数据库迁移" and "删除列" are the same subject even if worded apart.
let shared = 0
for (const term of probeTerms) if (doc.has(term)) shared++
const coverage = shared / probeTerms.size
if (coverage > best.coverage) best = { file, coverage }
for (const term of subject) {
if (!term) continue
if (docSubject.has(term)) {
shared++
continue
}
// Fall back to a partial hit, so 迁移 matches 数据库迁移.
for (const docTerm of docSubject) {
if (docTerm.length >= 2 && term.length >= 2 && (term.includes(docTerm) || docTerm.includes(term))) {
shared++
break
}
}
}
// Second signal: technical terms the two share (expand/contract, drop,
// rollback). Requires any lexical overlap so unrelated topics never merge.
const docAll = new Set(tokenize(`${file.title}\n${file.summary}\n${file.body}`))
let lexical = 0
for (const term of probeTerms) if (docAll.has(term)) lexical++
const coverage = shared / subject.size
const total = shared + lexical
if (total > best.shared || (total === best.shared && coverage > best.coverage)) {
best = { file, coverage, shared: total }
}
}
return best
}
@@ -306,6 +389,8 @@ function bestCoverage(
export interface RunExtractionInput {
ctx: Context
model: { providerID: string; id: string }
/** Tried in order after `model` fails. */
fallbackModels?: readonly { providerID: string; id: string }[]
transcript: string
existing: readonly import("./store.ts").MemoryFile[]
signal?: AbortSignal
@@ -320,43 +405,62 @@ export interface RunExtractionInput {
*/
export async function extract(input: RunExtractionInput): Promise<ExtractionOutcome> {
const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}`
// Walk the candidates on failure. The model registry is not authoritative:
// it lists `opencode/exo-free` as active while the service refuses the call
// with "has been deprecated". No amount of reading metadata catches that, so
// the only reliable signal is trying the next one.
const primary = input.model
const alternates = (input.fallbackModels ?? []).filter(
(m) => m.providerID !== primary.providerID || m.id !== primary.id,
)
const attempts = [primary, ...alternates]
const failures: string[] = []
for (let i = 0; i < attempts.length; i++) {
const model = attempts[i]
let result: unknown
try {
// `generate.text` has no cancellation documented on this surface, so the
// signal is forwarded when accepted and simply ignored when not.
result = await (input.ctx.generate as { text: (input: unknown) => Promise<unknown> }).text({
model: input.model,
result = await (input.ctx.generate as { text: (i: unknown) => Promise<unknown> }).text({
model,
prompt,
...(input.signal ? { signal: input.signal } : {}),
})
} catch (error) {
// An extraction failure must never disturb the user's session, but it must
// not be indistinguishable from success either.
return {
candidates: [],
rejected: [],
error: `模型调用失败:${(error as Error).message.slice(0, 160)}`,
}
failures.push(`${model.providerID}/${model.id}: ${(error as Error).message.slice(0, 90)}`)
continue
}
const text = coerceText(result)
// An empty string here means the call "succeeded" but returned nothing usable.
// An empty string means the call "succeeded" but returned nothing usable.
// An auth or provider error can arrive as a JSON error body rather than a
// thrown error, so surface what actually came back instead of reporting it
// as "nothing worth remembering".
// thrown error, so surface what came back instead of reporting it as
// "nothing worth remembering" — and try another model before giving up.
if (!text.trim()) {
const preview =
result && typeof result === "object"
? JSON.stringify(result).slice(0, 160)
: String(result ?? "").slice(0, 160)
return {
candidates: [],
rejected: [],
error: `模型返回空内容(响应:${preview || "空"})`,
}
? JSON.stringify(result).slice(0, 120)
: String(result ?? "").slice(0, 120)
failures.push(`${model.providerID}/${model.id}: 空内容(${preview || "空"})`)
continue
}
const candidates = parseCandidates(text)
const screened = screenCandidates(candidates, input.existing)
return { ...screened, rawText: text.slice(0, 400) }
return {
...screened,
rawText: text.slice(0, 400),
modelUsed: `${model.providerID}/${model.id}`,
...(failures.length ? { notes: `已跳过不可用模型:${failures.join("; ")}` } : {}),
}
}
return {
candidates: [],
rejected: [],
error: `所有候选模型都调用失败:${failures.join("; ") || "无可用模型"}`,
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function parseFrontmatter(raw: string): { data: Frontmatter; body: string
if (item && currentKey) {
const prev = data[currentKey]
const list = Array.isArray(prev) ? prev : []
list.push(scalar(item[1]))
list.push(String(scalar(item[1])))
data[currentKey] = list
continue
}
+424 -61
View File
@@ -24,7 +24,11 @@
// Prerequisite: npm i @opencode/plugin@$(opencode --version | sed 's/^v//')
// then `opencode service restart`.
import { Plugin, type Context } from "@opencode/plugin"
import { Plugin } from "@opencode/plugin"
// `Context` lives on the Plugin namespace, not as a top-level export. Importing
// it from "@opencode/plugin" directly fails to typecheck, which silently
// degraded every tool's `input` to `unknown`.
type Context = Plugin.Context
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
@@ -49,9 +53,16 @@ import {
} from "./store.ts"
interface PluginConfig {
/** Hard cap on injected characters per model request. Default 1200. */
/** Hard cap on injected characters per turn. Default 600. */
maxInjectChars?: number
/** How many memory bodies to inline per request. Default 4. */
/**
* How many memory bodies to inline per turn. Default 0: only hard rules and
* the outline ride along, and full text is fetched with `memory_recall`.
*
* Inlining bodies was costing more than it saved. The outline already says
* what exists and what each entry is about, so the model can fetch exactly
* what it needs — once — instead of paying for every body on every request.
*/
maxInjectEntries?: number
/** Half-life in days for the recency decay. Default 14; 0 disables. */
halfLifeDays?: number
@@ -65,7 +76,9 @@ interface PluginConfig {
}
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
const DEFAULT_MAX_INJECT = 1200
// 600 characters ≈ 350 tokens per turn. The block is re-sent on every model
// request in the turn, so its size is multiplied by the number of tool calls.
const DEFAULT_MAX_INJECT = 600
/**
* Events that mark turn activity. Collected from what this stream actually
@@ -84,7 +97,7 @@ export default Plugin.define({
async setup(ctx) {
const config = ctx.options as PluginConfig
const maxChars = config.maxInjectChars ?? DEFAULT_MAX_INJECT
const maxEntries = config.maxInjectEntries ?? 4
const maxEntries = config.maxInjectEntries ?? 0
const halfLifeDays = config.halfLifeDays ?? 14
const categories = config.categories ?? CATEGORIES
const extractOptions: ExtractOptions = {
@@ -123,10 +136,13 @@ export default Plugin.define({
}
// -- in-memory view, refreshed when files change on disk ----------------
// Cache is short (500ms) so a write followed immediately by a read sees
// the new file. The previous 2s window meant `memory_remember` →
// `memory_recall` in the same tool chain could miss the write.
let cache: { filesByRoot: Map<string, MemoryFile[]>; at: number } | undefined
const filesByRoot = (): Map<string, MemoryFile[]> => {
const now = Date.now()
if (cache && now - cache.at < 2000) return cache.filesByRoot
if (cache && now - cache.at < 500) return cache.filesByRoot
const map = new Map<string, MemoryFile[]>()
for (const root of roots()) map.set(root.key, scanRoot(root))
cache = { filesByRoot: map, at: now }
@@ -149,12 +165,53 @@ export default Plugin.define({
}
// -- injection into the agent loop --------------------------------------
// `context` covers the agent loop including tool continuations, and edits
// only the outgoing request — the conversation transcript stays clean.
// `context` fires on EVERY model request, including tool continuations, so
// one turn with n tool calls used to pay for n memory blocks. The agent
// loop is unchanged by later continuations, so injecting again is pure
// waste. Keyed by session + the latest user message: a new user message
// re-injects, everything in between is skipped.
// Per-session so two sessions with the same query do not share debounce state.
const injectedFor = new Map<string, Set<string>>()
await ctx.session.hook("context", async (event) => {
try {
const query = latestUserText(event.messages)
if (!query) return
let sessionKeys = injectedFor.get(event.sessionID)
if (!sessionKeys) {
sessionKeys = new Set<string>()
injectedFor.set(event.sessionID, sessionKeys)
}
const key = fingerprint(query)
if (sessionKeys.has(key)) {
// Count skips separately so the debounce can be proven to work.
// Without this, "timestamp unchanged" is ambiguous: it could mean
// the debounce fired, or that the diagnostic layer's own 2s debounce
// hid a second injection.
const statsKey = `diag.${project ? "project" : "global"}.injectionStats`
const prev = ((await ctx.storage.get(statsKey)) as Record<string, number> | undefined) ?? {
fires: 0,
injected: 0,
skipped: 0,
chars: 0,
}
await ctx.storage.set(statsKey, {
...prev,
fires: prev.fires + 1,
skipped: prev.skipped + 1,
})
return
}
sessionKeys.add(key)
// Bound the set: sessions are long-lived and this grows with each turn.
if (sessionKeys.size > 64) {
for (const oldest of sessionKeys) {
sessionKeys.delete(oldest)
if (sessionKeys.size <= 48) break
}
}
const text = buildInjection({
roots: roots(),
filesByRoot: filesByRoot(),
@@ -163,16 +220,40 @@ export default Plugin.define({
options: { maxChars, maxEntries, halfLifeDays },
})
if (text) event.system.push({ type: "text", text })
// Count real injections too. fires vs injected is the only number that
// proves the debounce works: if they are equal, every model request
// is paying for a memory block.
const diagScope = project ? "project" : "global"
const statsKey = `diag.${diagScope}.injectionStats`
const prevStats = ((await ctx.storage.get(statsKey)) as Record<string, number> | undefined) ?? {
fires: 0,
injected: 0,
skipped: 0,
chars: 0,
}
await ctx.storage.set(statsKey, {
...prevStats,
fires: prevStats.fires + 1,
injected: prevStats.injected + 1,
chars: prevStats.chars + text.length,
})
// Record what the model actually received. Without this there is no way
// to tell "injected nothing because there is nothing" apart from
// "the hook is not firing at all".
await noteDiag(ctx, "lastInjection", {
await noteDiag(
ctx,
"lastInjection",
{
at: Date.now(),
scope: project ? "project" : "global",
roots: roots().map((r) => r.label),
chars: text.length,
withinBudget: text.length <= maxChars,
bodies: (text.match(/^### /gm) ?? []).length,
query: query.slice(0, 80),
})
},
project ? "project" : "global",
)
} catch (error) {
console.error(`memory: injection failed: ${(error as Error).message}`)
}
@@ -215,7 +296,8 @@ export default Plugin.define({
required: ["query"],
additionalProperties: false,
},
execute: async (input: { query: string; scope?: string; limit?: number }) => {
execute: async (raw: unknown) => {
const input = raw as { query: string; scope?: string; limit?: number }
const pool = pickRoots(roots(), input.scope)
const scored = pool
.flatMap((root) =>
@@ -330,7 +412,8 @@ export default Plugin.define({
required: ["category", "slug", "title", "summary", "body"],
additionalProperties: false,
},
execute: async (input: {
execute: async (raw: unknown) => {
const input = raw as {
category: string
slug: string
title: string
@@ -341,7 +424,7 @@ export default Plugin.define({
loadWhen?: string[]
pinned?: boolean
supersedes?: string
}) => {
}
const categoryError = validateCategory(input.category)
if (categoryError) return { content: `拒绝写入:${categoryError}` }
const slugError = validateSlug(input.slug)
@@ -419,7 +502,8 @@ export default Plugin.define({
required: ["path", "reason"],
additionalProperties: false,
},
execute: async (input: { path: string; scope?: string; reason: string }) => {
execute: async (raw: unknown) => {
const input = raw as { path: string; scope?: string; reason: string }
const target = input.path.replace(/^\/+/, "")
if (!target.endsWith(".md") || target.includes("..")) {
return { content: `拒绝:path 必须是根下的相对 .md 路径(收到 ${JSON.stringify(input.path)})` }
@@ -436,11 +520,81 @@ export default Plugin.define({
},
})
// memory_extract_dryrun ----------------------------------------------
// Exists so the extraction pipeline can be exercised against the REAL
// model without lowering `minTurns` or waiting for a long session. The
// previous attempts to verify it both failed — once by editing the
// shipping default, which proved nothing about the real configuration.
editor.add({
name: "extract_dryrun",
description: [
"Run the extraction pipeline over a transcript you supply and show what it WOULD write, without writing anything.",
"Use to check whether extraction is working: it makes a real model call and reports the model's raw answer, the parsed candidates, and which ones were filtered as duplicates or rejected for secrets.",
].join(" "),
input: {
type: "object",
properties: {
transcript: {
type: "string",
description: "The conversation to extract from, as plain text.",
},
},
required: ["transcript"],
additionalProperties: false,
},
execute: async (raw: unknown) => {
const input = raw as { transcript: string }
const model = extractOptions.model ?? (await pickCheapModel(ctx))
if (!model) {
return { content: "无法选取模型:autoExtract 实际上不可用。" }
}
const existing = [...filesByRoot().values()].flat()
const fallbackModels = await extractionCandidates(ctx)
const outcome = await extract({
ctx,
model,
transcript: input.transcript,
existing,
fallbackModels,
})
const lines: string[] = [`首选模型:${model.providerID}/${model.id}`]
if (outcome.modelUsed) lines.push(`实际应答:${outcome.modelUsed}`)
lines.push(
`候选序列(失败则顺延):${
fallbackModels.map((m) => `${m.providerID}/${m.id}`).join(", ") || "无"
}`,
)
lines.push("")
if (outcome.error) lines.push(`⚠ 调用失败:${outcome.error}`)
if (outcome.notes) lines.push(`备注:${outcome.notes}`)
if (outcome.rawText) lines.push(`模型原始返回(前 600 字):\n${outcome.rawText.slice(0, 600)}`)
lines.push("")
lines.push(`通过筛选:${outcome.candidates.length} 条`)
for (const candidate of outcome.candidates) {
lines.push(` + ${candidate.category}/${candidate.slug} — ${candidate.title}`)
lines.push(` 概述:${candidate.summary}`)
if (candidate.loadWhen?.length) lines.push(` 触发:${candidate.loadWhen.join("、")}`)
}
lines.push(`被过滤:${outcome.rejected.length} 条`)
for (const rejected of outcome.rejected) {
lines.push(` - ${rejected.candidate.slug} — ${rejected.reason}`)
}
if (outcome.candidates.length === 0 && !outcome.error && outcome.rejected.length === 0) {
lines.push("\n(模型没有返回任何候选)")
} else if (outcome.candidates.length === 0 && !outcome.error) {
lines.push("\n(候选全部被判为重复或含密钥,因此未写入)")
}
lines.push("\n注意:dry-run 不会写入任何文件。")
return { content: lines.join("\n") }
},
})
// memory_inspect ------------------------------------------------------
editor.add({
name: "inspect",
description:
"Inspect the memory library: every root, every memory with its summary and trigger, plus index consistency checks. Use when tuning memory or explaining what is remembered.",
"Inspect the memory library: every root, every memory with its summary and trigger, runtime health of injection and extraction, plus index consistency checks. Use when tuning memory or explaining what is remembered.",
input: {
type: "object",
properties: {
@@ -448,10 +602,15 @@ export default Plugin.define({
type: "boolean",
description: "Include per-memory hit counts and scores. Default false.",
},
history: {
type: "boolean",
description: "Include the full extraction run history. Default true.",
},
},
additionalProperties: false,
},
execute: async (input: { detail?: boolean }) => {
execute: async (raw: unknown) => {
const input = raw as { detail?: boolean; history?: boolean }
const lines: string[] = []
for (const root of roots()) {
const files = filesByRoot().get(root.key) ?? []
@@ -513,18 +672,43 @@ export default Plugin.define({
// Runtime health of the two paths that have no other visible effect.
lines.push("")
const injection = (await ctx.storage.get("diag.lastInjection")) as Record<string, unknown> | undefined
const extraction = (await ctx.storage.get("diag.lastExtraction")) as Record<string, unknown> | undefined
lines.push("运行状态:")
// Read the scope that matches this location, never the other one. The keys
// are scoped, so a report can only ever describe this project.
const diagScope = project ? "project" : "global"
const injection = (await ctx.storage.get(`diag.${diagScope}.lastInjection`)) as
| Record<string, unknown>
| undefined
const extraction = (await ctx.storage.get(`diag.${diagScope}.lastExtraction`)) as
| Record<string, unknown>
| undefined
const extractionHistory = (await ctx.storage.get(`diag.${diagScope}.lastExtraction.history`)) as
| Record<string, unknown>[]
| undefined
lines.push(`运行状态(${diagScope === "project" ? "项目" : "全局"}作用域):`)
if (injection) {
const when = new Date(Number(injection.at)).toISOString().slice(0, 19).replace("T", " ")
lines.push(
` 注入:最近一次 ${when},${String(injection.chars)} 字符,内联 ${String(injection.bodies)} 条正文${injection.withinBudget === false ? " ⚠ 超出预算" : ""}`,
` 注入:${when},${String(injection.chars)} 字符,内联 ${String(injection.bodies)} 条正文${injection.withinBudget === false ? " ⚠ 超出预算" : ""}`,
)
lines.push(` 检索词:${String(injection.query)}`)
} else {
lines.push(" 注入:尚无记录(context hook 还没触发过)")
}
// The debounce counter. fires vs injected is the only number that
// proves the debounce works — a single "last injection" timestamp
// cannot distinguish "injected once" from "injected twice, second
// write suppressed by the diagnostic layer's own debounce".
const stats = (await ctx.storage.get(`diag.${diagScope}.injectionStats`)) as
| Record<string, number>
| undefined
if (stats) {
const ratio = stats.fires > 0 ? (stats.injected / stats.fires).toFixed(2) : "—"
lines.push(
` 注入统计:触发 ${stats.fires} 次,实际注入 ${stats.injected} 次,跳过 ${stats.skipped} 次,累计 ${stats.chars} 字符(注入/触发比 ${ratio})`,
)
} else {
lines.push(" 注入统计:尚无记录")
}
if (extraction) {
const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ")
lines.push(` 抽取:${when} → ${String(extraction.outcome)}`)
@@ -543,6 +727,29 @@ export default Plugin.define({
if (extraction.rawText) lines.push(` 原始返回:${String(extraction.rawText).slice(0, 160)}`)
}
// The full run history, so "the last attempt was skipped" cannot hide
// an earlier success. Reading only the newest record is what led to
// reporting a working extractor as broken.
if (input.history !== false && extractionHistory && extractionHistory.length > 0) {
const tally = extractionHistory.reduce<Record<string, number>>((acc, entry) => {
const key = String(entry.outcome ?? "?")
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
const summary = Object.entries(tally)
.map(([k, v]) => `${k}×${v}`)
.join(", ")
lines.push(
` 抽取历史(共 ${extractionHistory.length} 次,最近 5 次):${summary}`,
)
for (const entry of extractionHistory.slice(-5).reverse()) {
const when = new Date(Number(entry.at)).toISOString().slice(5, 19).replace("T", " ")
const detail = entry.reason ? ` — ${String(entry.reason).slice(0, 70)}` : ""
const paths = Array.isArray(entry.paths) && entry.paths.length ? ` → ${entry.paths.join(", ")}` : ""
lines.push(` ${when} ${String(entry.outcome)}${detail}${paths}`)
}
}
return { content: lines.join("\n") }
},
})
@@ -630,16 +837,40 @@ function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[]
* other observable effect. Without this, "working" and "silently dead" look
* identical from the outside. Throttled because the context hook runs on every
* model request, including tool continuations.
*
* The scope is part of the storage key on purpose. `ctx.storage` is one global
* namespace for the whole plugin instance, so a project-scoped record stored
* under a bare key would be read back by whichever project runs next and
* reported as if it were this project's result.
*/
const lastDiagAt = new Map<string, number>()
async function noteDiag(ctx: Context, key: string, value: Record<string, unknown>): Promise<void> {
/** How many past events of each kind `memory_inspect` keeps. */
const DIAG_HISTORY = 10
async function noteDiag(
ctx: Context,
key: string,
value: Record<string, unknown>,
scope = "global",
): Promise<void> {
const now = Date.now()
const previous = lastDiagAt.get(key) ?? 0
const storageKey = `diag.${scope}.${key}`
const previous = lastDiagAt.get(storageKey) ?? 0
if (now - previous < 2000) return
lastDiagAt.set(key, now)
lastDiagAt.set(storageKey, now)
try {
await ctx.storage.set(`diag.${key}`, value)
// `storage.set` is typed as JSON; the diagnostics payload is plain data,
// so narrow at the boundary rather than loosening the call.
await ctx.storage.set(storageKey, value as Record<string, never>)
// Keep a short history too. A single record only ever describes the LAST
// attempt, so one later skip can hide an earlier success — which is exactly
// how a working extractor gets reported as broken.
const historyKey = `diag.${scope}.${key}.history`
const existing = (await ctx.storage.get(historyKey)) as unknown
const history = Array.isArray(existing) ? existing.slice(-(DIAG_HISTORY - 1)) : []
history.push(value)
await ctx.storage.set(historyKey, history)
} catch {
// Diagnostics must never break the hook that reports them.
}
@@ -657,8 +888,46 @@ function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]
}
/**
* The newest real user text in a request — the retrieval query.
* A stable short key for a user message.
*
* Two different turns rarely produce identical text, so this distinguishes
* "new turn" from "continuation of the current one" without holding whole
* messages in memory.
*/
function fingerprint(text: string): string {
let hash = 5381
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
}
return `${text.length}:${hash.toString(36)}`
}
/**
* The role of a session message.
*
* `session.context()` returns `Session.Message.Info` entries, a tagged union
* rather than a flat `{ role }`. Reading `.role` directly found nothing, so
* every session counted zero turns and extraction never passed its own gate.
* The role is accepted at the top level or nested one level down, with `type`
* as a fallback since the union is tagged by it.
*/
function messageRole(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined
const record = message as Record<string, unknown>
if (typeof record.role === "string") return record.role
const nested = record.message
if (nested && typeof nested === "object") {
const inner = nested as Record<string, unknown>
if (typeof inner.role === "string") return inner.role
}
if (typeof record.type === "string" && /^(user|assistant|system|synthetic|shell)$/i.test(record.type)) {
return record.type
}
return undefined
}
/**
* The newest real user text in a request — the retrieval query.
* Harness-injected user messages ("<system-reminder>", "The server restarted…")
* are technically user turns but are not about the task. Retrieving on them
* injects memories chosen by noise, so they are skipped.
@@ -709,18 +978,30 @@ async function runExtraction(
const messages = (await ctx.session.context({ sessionID })) as readonly unknown[]
const turns = messages.filter((m) => {
const role = (m as { role?: string }).role
const role = messageRole(m)
return role === "user" || role === "assistant"
})
// Long enough to have learned something, short enough to stay cheap.
// Diagnostics for extraction are stored under the scope the session's content
// actually belongs to, so a run inside a project never reports into — or
// reads from — the global namespace.
const diagScope = input.project ? "project" : "global"
if (turns.length < extractOptions.minTurns) {
await noteDiag(ctx, "lastExtraction", {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
root: diagScope === "project" ? input.project?.label : input.global.label,
outcome: "skipped",
reason: "会话轮数不足",
turns: turns.length,
minTurns: extractOptions.minTurns,
})
},
diagScope,
)
return
}
@@ -728,29 +1009,42 @@ async function runExtraction(
if (!model) {
// pickCheapModel failing is the difference between a working feature and
// a silent no-op, so it is reported rather than swallowed.
await noteDiag(ctx, "lastExtraction", {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: "failed",
reason: "选不到可用模型(autoExtract 实际上处于关闭状态)",
reason: "选不到可用模型(autoExtract 实际处于关闭状态)",
turns: turns.length,
})
},
diagScope,
)
return
}
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
if (transcript.length < 200) {
await noteDiag(ctx, "lastExtraction", {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: "skipped",
reason: "对话内容太短",
turns: turns.length,
transcriptChars: transcript.length,
})
},
diagScope,
)
return
}
const existing = [...input.filesByRoot().values()].flat()
const outcome = await extract({ ctx, model, transcript, existing })
const candidates = await extractionCandidates(ctx)
const outcome = await extract({ ctx, model, transcript, existing, fallbackModels: candidates })
if (outcome.candidates.length === 0) {
for (const rejected of outcome.rejected) {
@@ -758,8 +1052,12 @@ async function runExtraction(
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
)
}
await noteDiag(ctx, "lastExtraction", {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: outcome.error ? "failed" : "no-candidates",
...(outcome.error ? { reason: outcome.error } : {}),
reasonDetail: outcome.rejected.length
@@ -768,7 +1066,9 @@ async function runExtraction(
rejected: outcome.rejected.length,
model: `${model.providerID}/${model.id}`,
...(outcome.rawText ? { rawText: outcome.rawText } : {}),
})
},
diagScope,
)
return
}
@@ -802,42 +1102,105 @@ async function runExtraction(
console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`)
}
await noteDiag(ctx, "lastExtraction", {
await noteDiag(
ctx,
"lastExtraction",
{
at: Date.now(),
scope: diagScope,
outcome: written.length ? "extracted" : "all-filtered",
model: `${model.providerID}/${model.id}`,
turns: turns.length,
written: written.length,
rejected: outcome.rejected.length,
paths: written,
})
},
diagScope,
)
}
/**
* Pick the cheapest available model for extraction. Falls back to the session
* default when cost metadata is unavailable, and disables extraction entirely
* when neither is available.
* Pick the cheapest available model for extraction.
*
* Three shapes here were wrong in the first version, and each one alone made
* extraction permanently inert:
* - `ctx.model.list()` resolves to `{ location, data }`, not a bare array
* - `ctx.model.default()` resolves to `{ location, data }` too
* - the availability flag is `enabled`, not `disabled`
* Both are read through `unwrapData`, which tolerates either shape so a host
* change cannot turn this into a silent no-op again.
*
* Every model on this machine reports `cost: 0`, so "cheapest" degenerates to
* "first available". That is acceptable; it must never mean "none".
*/
async function pickCheapModel(ctx: Context): Promise<{ providerID: string; id: string } | undefined> {
try {
const models = await ctx.model.list()
let best: { ref: { providerID: string; id: string }; cost: number } | undefined
for (const model of models) {
if (model.disabled) continue
// A reasoning-only or tiny-context model cannot do the job reliably.
if ((model.limit?.context ?? 0) > 0 && model.limit.context < 8_000) continue
const output = model.cost?.[0]?.output ?? 0
const input = model.cost?.[0]?.input ?? 0
const cost = Number(output) + Number(input)
if (!best || cost < best.cost) {
best = { ref: { providerID: model.providerID, id: model.id }, cost }
}
}
if (best) return best.ref
const fallback = await ctx.model.default()
if (fallback) return { providerID: fallback.providerID, id: fallback.modelID }
} catch {
return undefined
function unwrapData<T>(result: unknown): T[] | undefined {
if (Array.isArray(result)) return result as T[]
if (result && typeof result === "object") {
const data = (result as { data?: unknown }).data
if (Array.isArray(data)) return data as T[]
if (data && typeof data === "object") return [data as T]
}
return undefined
}
async function pickCheapModel(
ctx: Context,
): Promise<{ providerID: string; id: string } | undefined> {
const models = unwrapData<any>(await ctx.model.list().catch(() => undefined)) ?? []
const usable = models.filter((model) => {
// `enabled` is the availability flag; `status` may also say disabled or
// deprecated. The registry is not authoritative — a model listed as active
// can still be refused at call time, which is why extraction retries below.
if (model.enabled === false) return false
const status = String(model.status ?? "").toLowerCase()
if (status === "disabled" || status === "deprecated") return false
const context = Number(model.limit?.context ?? 0)
// A tiny context window cannot hold the transcript plus the prompt.
return !(context > 0 && context < 8_000)
})
const costOf = (model: any) =>
Number(model.cost?.[0]?.output ?? 0) + Number(model.cost?.[0]?.input ?? 0)
const refOf = (model: any) => ({ providerID: model.providerID, id: model.id ?? model.modelID })
// Every model here reports cost 0, so "cheapest" is "first available".
// Keep the candidates ordered so retries walk them cheapest-first.
const ranked = [...usable].sort((a, b) => costOf(a) - costOf(b))
if (ranked[0]) return refOf(ranked[0])
const fallback = unwrapData<any>(await ctx.model.default().catch(() => undefined))?.[0]
if (fallback?.providerID) return refOf(fallback)
// Last resort: any listed model at all. Failing loudly beats extracting
// nothing silently.
const any = models[0]
if (any?.providerID) return refOf(any)
return undefined
}
/**
* The candidates extraction should try, cheapest first.
*
* Exposed so a failed call can walk to the next model instead of giving up:
* the registry reports `opencode/exo-free` as active while the service refuses
* it with "has been deprecated". Metadata cannot catch that; trying can.
*/
async function extractionCandidates(ctx: Context): Promise<{ providerID: string; id: string }[]> {
const models = unwrapData<any>(await ctx.model.list().catch(() => undefined)) ?? []
const costOf = (model: any) =>
Number(model.cost?.[0]?.output ?? 0) + Number(model.cost?.[0]?.input ?? 0)
const usable = models.filter((model) => {
if (model.enabled === false) return false
const status = String(model.status ?? "").toLowerCase()
if (status === "disabled" || status === "deprecated") return false
const context = Number(model.limit?.context ?? 0)
return !(context > 0 && context < 8_000)
})
const ranked = [...usable].sort((a, b) => costOf(a) - costOf(b))
const refs = ranked.map((m) => ({ providerID: m.providerID, id: m.id ?? m.modelID }))
if (refs.length) return refs
const first = await pickCheapModel(ctx)
return first ? [first] : []
}
+51 -29
View File
@@ -47,7 +47,7 @@ export function tokenize(text: string): string[] {
// 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) ?? []
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++) {
@@ -84,19 +84,33 @@ export function scoreFiles(
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 (const file of files) {
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 haystack = [file.title, file.summary, file.highlight, file.loadWhen.join(" "), file.body].join("\n")
const docTokens = new Set(tokenize(haystack))
const docTokens = docTokenList[i]
let overlap = 0
const matched: string[] = []
@@ -108,8 +122,18 @@ export function scoreFiles(
}
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)
// 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.
@@ -137,23 +161,18 @@ export function scoreFiles(
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
/**
* 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
}
@@ -175,36 +194,36 @@ export interface InjectionInput {
*/
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 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
ruleLines.push(`- [${root.label}] ${file.title}${file.summary ? ` — ${file.summary}` : ""}`)
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 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.
@@ -266,9 +285,12 @@ function groupCategories(files: readonly import("./store.ts").MemoryFile[]) {
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)
// 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(/<!--[\s\S]*?-->/g, "")
?.replace(/<!--(?!auto:)[\s\S]*?-->/g, "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("<!--"))
+17 -24
View File
@@ -45,8 +45,10 @@ export interface MemoryFile {
readonly title: string
/** One-line description shown in indexes and injected summaries. */
readonly summary: string
/** The "重点" column in index.md. */
/** 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
@@ -170,6 +172,7 @@ function readMemoryFile(root: MemoryRoot, abs: string, category: string, name: s
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,
@@ -431,12 +434,21 @@ function pruneEmptyIndexes(root: MemoryRoot, files: MemoryFile[]): void {
}
}
export interface CompiledRoot {
registry: string
routing: string
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) : "(空)"
}
export function compileRootTables(root: MemoryRoot, files: MemoryFile[]): CompiledRoot {
/** 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 }) => {
@@ -461,25 +473,6 @@ export function compileRootTables(root: MemoryRoot, files: MemoryFile[]): Compil
? `\n| 场景 / 触发 | 读哪个文件 | 类型 |\n|---|---|---|\n${routingRows.join("\n")}\n`
: `\n_暂无 load_when 标记的记忆。给文件加 frontmatter 的 \`load_when:\` 即可自动进入路由表。_\n`
return { registry, routing }
}
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 { registry, routing } = compileRootTables(root, files)
let existing: string | undefined
try {
existing = fs.readFileSync(target, "utf8")