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:
+468
-105
@@ -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", {
|
||||
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<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", {
|
||||
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<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] : []
|
||||
}
|
||||
Reference in New Issue
Block a user