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 复核结论。
466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
// Automatic extraction: turn a finished turn into candidate memories.
|
||
//
|
||
// Runs on `session.idle`. The point is that memory capture must not depend on
|
||
// the model remembering to call `memory_write` — models forget. This is the
|
||
// safety net.
|
||
//
|
||
// Two deliberate limits keep it from becoming a garbage generator:
|
||
//
|
||
// 1. A separate, cheap model does the extraction. It never touches the main
|
||
// conversation, so a bad extraction cannot derail the user's work.
|
||
// 2. Hard rules are never written automatically. The extractor may *propose*
|
||
// one, but only a human confirms it. Letting a model write "iron rules"
|
||
// would let a hallucination become permanent policy.
|
||
|
||
type Context = import("@opencode/plugin").Plugin.Context
|
||
import { tokenize } from "./recall.ts"
|
||
|
||
export interface ExtractOptions {
|
||
enabled: boolean
|
||
/** Minimum turns in a session before extraction kicks in. */
|
||
minTurns: number
|
||
/** Cap on how many messages are handed to the extractor. */
|
||
maxMessages: number
|
||
model?: { providerID: string; id: string }
|
||
}
|
||
|
||
export const DEFAULT_EXTRACT: ExtractOptions = {
|
||
enabled: true,
|
||
minTurns: 4,
|
||
maxMessages: 24,
|
||
}
|
||
|
||
export interface Candidate {
|
||
category: string
|
||
slug: string
|
||
title: string
|
||
summary: string
|
||
highlight?: string
|
||
body: string
|
||
kind?: string
|
||
loadWhen?: string[]
|
||
/** Rules the model believes should be hard rules, needing human confirmation. */
|
||
proposeRule?: boolean
|
||
}
|
||
|
||
const SYSTEM = `You extract durable memories from a coding conversation.
|
||
|
||
Return ONLY a JSON array. Each element:
|
||
{
|
||
"category": "preferences|decisions|pitfalls|facts",
|
||
"slug": "kebab-case-identifier",
|
||
"title": "short title",
|
||
"summary": "ONE sentence, under 100 chars. This is what shows in an index.",
|
||
"highlight": "the single most important detail",
|
||
"body": "markdown detail, 2-6 sentences",
|
||
"loadWhen": ["trigger words or situations where this is relevant"],
|
||
"proposeRule": false
|
||
}
|
||
|
||
Only record what stays true after the session ends:
|
||
GOOD A stated preference, an architectural decision and its reason,
|
||
a non-obvious gotcha and its root cause, a project convention.
|
||
BAD What the code currently looks like, task state, git branch names,
|
||
file paths, anything already obvious from the repo, one-off chatter.
|
||
|
||
NEVER set proposeRule to true unless the user stated an absolute rule about how
|
||
to behave ("always", "never", "必须", "禁止"). Doubtful -> omit the memory.
|
||
|
||
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) return out
|
||
if (typeof value === "string") {
|
||
if (value.trim()) out.push(value)
|
||
return out
|
||
}
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) collectText(item, depth + 1, out)
|
||
return out
|
||
}
|
||
if (value && typeof value === "object") {
|
||
const record = value as Record<string, unknown>
|
||
// Prefer a part's own text over its metadata noise.
|
||
if (typeof record.text === "string" && record.text.trim()) out.push(record.text)
|
||
for (const [key, child] of Object.entries(record)) {
|
||
if (key === "text") continue
|
||
if (key === "synthetic" || key === "cache" || key === "providerMetadata") continue
|
||
collectText(child, depth + 1, out)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
export function userAndAssistantText(messages: readonly unknown[]): string {
|
||
const parts: string[] = []
|
||
for (const message of messages) {
|
||
const record = message as Record<string, unknown> | null
|
||
if (!record) continue
|
||
// 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 ?? nested)
|
||
const joined = texts.join("\n").trim()
|
||
if (joined) parts.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${joined}`)
|
||
}
|
||
return parts.join("\n\n")
|
||
}
|
||
|
||
/** `generate.text` is documented as returning text; accept a few shapes. */
|
||
function coerceText(result: unknown): string {
|
||
if (typeof result === "string") return result
|
||
if (result && typeof result === "object") {
|
||
const record = result as Record<string, unknown>
|
||
for (const key of ["text", "content", "output", "result"]) {
|
||
const value = record[key]
|
||
if (typeof value === "string") return value
|
||
if (Array.isArray(value)) {
|
||
const joined = collectText(value).join("\n")
|
||
if (joined) return joined
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
/** Parse the extractor's JSON array, tolerating prose and code fences. */
|
||
export function parseCandidates(raw: string): Candidate[] {
|
||
const text = raw.trim()
|
||
if (!text) return []
|
||
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
|
||
const body = fenced?.[1] ?? text
|
||
const start = body.indexOf("[")
|
||
const end = body.lastIndexOf("]")
|
||
if (start < 0 || end <= start) return []
|
||
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(body.slice(start, end + 1))
|
||
} catch {
|
||
return []
|
||
}
|
||
if (!Array.isArray(parsed)) return []
|
||
|
||
const out: Candidate[] = []
|
||
for (const entry of parsed) {
|
||
if (!entry || typeof entry !== "object") continue
|
||
const record = entry as Record<string, unknown>
|
||
const title = str(record.title)
|
||
const bodyText = str(record.body)
|
||
if (!title || !bodyText) continue
|
||
|
||
const category = str(record.category).toLowerCase() || "facts"
|
||
const slug =
|
||
slugify(str(record.slug)) || slugify(title)
|
||
if (!slug) continue
|
||
|
||
out.push({
|
||
category: category.replace(/[^a-z0-9._-]/g, "") || "facts",
|
||
slug,
|
||
title,
|
||
summary: str(record.summary) || title,
|
||
highlight: str(record.highlight) || undefined,
|
||
body: bodyText,
|
||
kind: str(record.kind) || undefined,
|
||
loadWhen: Array.isArray(record.loadWhen)
|
||
? record.loadWhen.map((v) => str(v)).filter(Boolean).slice(0, 8)
|
||
: undefined,
|
||
proposeRule: record.proposeRule === true,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function str(value: unknown): string {
|
||
return typeof value === "string" ? value.trim() : ""
|
||
}
|
||
|
||
export function slugify(input: string): string {
|
||
const ascii = input
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 48)
|
||
if (ascii) return ascii
|
||
// CJK titles have no ASCII to work with; fall back to a hash-free stable id.
|
||
// `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. */
|
||
const SECRET_PATTERNS: RegExp[] = [
|
||
/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
|
||
/\bgh[pousr]_[A-Za-z0-9]{16,}/g,
|
||
/\bAKIA[0-9A-Z]{16}\b/g,
|
||
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
||
/\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 {
|
||
for (const pattern of SECRET_PATTERNS) {
|
||
const match = pattern.exec(text)
|
||
if (match) return match[0].slice(0, 24)
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
export function redactSecrets(text: string): string {
|
||
let out = text
|
||
for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, "[REDACTED]")
|
||
return out
|
||
}
|
||
|
||
export interface ExtractionOutcome {
|
||
candidates: Candidate[]
|
||
rejected: { candidate: Candidate; reason: string }[]
|
||
/**
|
||
* 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.
|
||
*/
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Decide which candidates are worth writing, using existing memories for
|
||
* dedupe.
|
||
*
|
||
* Dedupe uses term COVERAGE of the candidate, not the retrieval score. The
|
||
* score carries idf and scope priors that shift as the corpus grows, so a fixed
|
||
* score threshold silently stops rejecting duplicates as the library fills up.
|
||
* 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.5
|
||
const accepted: Candidate[] = []
|
||
const rejected: { candidate: Candidate; reason: string }[] = []
|
||
|
||
for (const candidate of candidates) {
|
||
const secret = hasSecret(`${candidate.title}\n${candidate.summary}\n${candidate.body}`)
|
||
if (secret) {
|
||
rejected.push({ candidate, reason: `含疑似密钥 (${secret}…)` })
|
||
continue
|
||
}
|
||
if (candidate.proposeRule) {
|
||
// Not rejected outright — a proposal is surfaced for a human, which is
|
||
// handled by the caller. Flag it here so it is written as a normal
|
||
// memory under `proposals` rather than promoted to policy.
|
||
candidate.category = "rules"
|
||
candidate.kind = "rule-proposal"
|
||
}
|
||
|
||
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.shared} 项重合,${best.file.rel})`,
|
||
})
|
||
continue
|
||
}
|
||
accepted.push(candidate)
|
||
}
|
||
|
||
return { candidates: accepted, rejected }
|
||
}
|
||
|
||
/**
|
||
* 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 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,
|
||
}
|
||
|
||
for (const file of existing) {
|
||
if (file.fm.superseded_by) continue
|
||
|
||
// 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 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* One model round-trip. Returns screened candidates and why things were dropped.
|
||
*
|
||
* A transport failure is reported in `error` rather than collapsed into an
|
||
* empty result: the caller's only other signal would be "no candidates", which
|
||
* is also what a correct answer looks like when nothing was worth keeping.
|
||
*/
|
||
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: (i: unknown) => Promise<unknown> }).text({
|
||
model,
|
||
prompt,
|
||
...(input.signal ? { signal: input.signal } : {}),
|
||
})
|
||
} catch (error) {
|
||
failures.push(`${model.providerID}/${model.id}: ${(error as Error).message.slice(0, 90)}`)
|
||
continue
|
||
}
|
||
|
||
const text = coerceText(result)
|
||
// 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 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, 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),
|
||
modelUsed: `${model.providerID}/${model.id}`,
|
||
...(failures.length ? { notes: `已跳过不可用模型:${failures.join("; ")}` } : {}),
|
||
}
|
||
}
|
||
|
||
return {
|
||
candidates: [],
|
||
rejected: [],
|
||
error: `所有候选模型都调用失败:${failures.join("; ") || "无可用模型"}`,
|
||
}
|
||
} |