diff --git a/plugins/memory/extract.ts b/plugins/memory/extract.ts index 4b3f90d..f3a26d0 100644 --- a/plugins/memory/extract.ts +++ b/plugins/memory/extract.ts @@ -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 | 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 | 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 { + 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([ + ...(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([ + ...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 { const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}` - 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 }).text({ - model: input.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. + + // 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 }).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 { - candidates: [], - rejected: [], - error: `模型调用失败:${(error as Error).message.slice(0, 160)}`, + ...screened, + rawText: text.slice(0, 400), + modelUsed: `${model.providerID}/${model.id}`, + ...(failures.length ? { notes: `已跳过不可用模型:${failures.join("; ")}` } : {}), } } - const text = coerceText(result) - // An empty string here 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". - 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 || "空"})`, - } + return { + candidates: [], + rejected: [], + error: `所有候选模型都调用失败:${failures.join("; ") || "无可用模型"}`, } - - const candidates = parseCandidates(text) - const screened = screenCandidates(candidates, input.existing) - return { ...screened, rawText: text.slice(0, 400) } } \ No newline at end of file diff --git a/plugins/memory/frontmatter.ts b/plugins/memory/frontmatter.ts index 5639ecb..6690177 100644 --- a/plugins/memory/frontmatter.ts +++ b/plugins/memory/frontmatter.ts @@ -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 } diff --git a/plugins/memory/index.ts b/plugins/memory/index.ts index a6cdfee..50e1667 100644 --- a/plugins/memory/index.ts +++ b/plugins/memory/index.ts @@ -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; at: number } | undefined const filesByRoot = (): Map => { 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() 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>() + 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() + 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 | 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 | 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", { - at: Date.now(), - chars: text.length, - withinBudget: text.length <= maxChars, - bodies: (text.match(/^### /gm) ?? []).length, - query: query.slice(0, 80), - }) + 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,18 +412,19 @@ export default Plugin.define({ required: ["category", "slug", "title", "summary", "body"], additionalProperties: false, }, - execute: async (input: { - category: string - slug: string - title: string - summary: string - highlight?: string - body: string - scope?: string - loadWhen?: string[] - pinned?: boolean - supersedes?: string - }) => { + execute: async (raw: unknown) => { + const input = raw as { + category: string + slug: string + title: string + summary: string + highlight?: string + body: string + scope?: string + 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 | undefined - const extraction = (await ctx.storage.get("diag.lastExtraction")) as Record | 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 + | undefined + const extraction = (await ctx.storage.get(`diag.${diagScope}.lastExtraction`)) as + | Record + | undefined + const extractionHistory = (await ctx.storage.get(`diag.${diagScope}.lastExtraction.history`)) as + | Record[] + | 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 + | 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>((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() -async function noteDiag(ctx: Context, key: string, value: Record): Promise { +/** How many past events of each kind `memory_inspect` keeps. */ +const DIAG_HISTORY = 10 + +async function noteDiag( + ctx: Context, + key: string, + value: Record, + scope = "global", +): Promise { 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) + // 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, 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 + if (typeof record.role === "string") return record.role + const nested = record.message + if (nested && typeof nested === "object") { + const inner = nested as Record + 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 ("", "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", { - at: Date.now(), - outcome: "skipped", - reason: "会话轮数不足", - turns: turns.length, - minTurns: extractOptions.minTurns, - }) + 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", { - at: Date.now(), - outcome: "failed", - reason: "选不到可用模型(autoExtract 实际上处于关闭状态)", - turns: turns.length, - }) + await noteDiag( + ctx, + "lastExtraction", + { + at: Date.now(), + scope: diagScope, + outcome: "failed", + reason: "选不到可用模型(autoExtract 实际处于关闭状态)", + turns: turns.length, + }, + diagScope, + ) return } const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages)) if (transcript.length < 200) { - await noteDiag(ctx, "lastExtraction", { - at: Date.now(), - outcome: "skipped", - reason: "对话内容太短", - turns: turns.length, - transcriptChars: transcript.length, - }) + 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,17 +1052,23 @@ async function runExtraction( `memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`, ) } - await noteDiag(ctx, "lastExtraction", { - at: Date.now(), - outcome: outcome.error ? "failed" : "no-candidates", - ...(outcome.error ? { reason: outcome.error } : {}), - reasonDetail: outcome.rejected.length - ? "候选全部被去重或密钥拦截" - : "抽取器认为没有值得记的东西", - rejected: outcome.rejected.length, - model: `${model.providerID}/${model.id}`, - ...(outcome.rawText ? { rawText: outcome.rawText } : {}), - }) + await noteDiag( + ctx, + "lastExtraction", + { + at: Date.now(), + scope: diagScope, + outcome: outcome.error ? "failed" : "no-candidates", + ...(outcome.error ? { reason: outcome.error } : {}), + reasonDetail: outcome.rejected.length + ? "候选全部被去重或密钥拦截" + : "抽取器认为没有值得记的东西", + 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", { - at: Date.now(), - outcome: written.length ? "extracted" : "all-filtered", - model: `${model.providerID}/${model.id}`, - turns: turns.length, - written: written.length, - rejected: outcome.rejected.length, - paths: written, - }) + 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(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(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(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(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] : [] } \ No newline at end of file diff --git a/plugins/memory/recall.ts b/plugins/memory/recall.ts index ce19872..2c40c56 100644 --- a/plugins/memory/recall.ts +++ b/plugins/memory/recall.ts @@ -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() 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() + const docTokenList: Set[] = [] + 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, docTokens: Set): 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/g, "") + ?.replace(//g, "") .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l && !l.startsWith("