memory: fix extraction trigger, dedupe, and add runtime diagnostics
Four defects found by end-to-end testing, not by reading the code. 1. Extraction never ran, silently. Collecting every event type that ctx.event.subscribe actually delivers shows `session.idle` is NOT among them — it is defined in the protocol but never emitted on this stream. The subscription filtered on it alone, so auto-extraction was a permanent no-op with no error. Now debounced off events that do arrive (session.step.started / session.tool.called / session.inbox.delivered) after `settleMs` of quiet, which is what "idle" meant anyway. Verified: the trigger now fires and reports "轮数不足" for a 1-turn session. 2. Dedupe silently stopped rejecting duplicates. It compared against a fixed retrieval score, but that score carries idf and scope priors that drift as the corpus grows, so an obvious restatement of an existing memory scored 0.48 against a 0.9 threshold and was written as a rival entry. Now uses term coverage of the candidate, which answers "is this already said?" and does not move with corpus size. Verified: a 100%-duplicate is rejected, a novel pitfall is kept. 3. Harness-injected user turns became the retrieval query. "<system-reminder>" and "The server restarted while you were working" are user-role messages, so the hook retrieved on them and injected memories chosen by noise. Both are filtered out now. 4. The `pinned` flag was absent from the remember tool's input schema, so the schema stripped it and pinning was unreachable from the tool surface. `kind` also ignored the rules/ category when a stale value was stored. memory_inspect now reports runtime health for the two paths with no other observable effect: last injection size and query, and last extraction outcome with its reason. Without it, "working" and "silently dead" look identical — which is exactly how defects 1 and 2 survived the first round of testing.
This commit is contained in:
@@ -4,5 +4,5 @@
|
|||||||
|
|
||||||
| 文件 | 概述 | 重点 |
|
| 文件 | 概述 | 重点 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| plugin-push-on-change.md | 改动这两个目录后立即 commit 并 push,不询问。 | ~/.config/opencode 本身就是 git 仓库,.gitignore 只放行 skills/ 和 plugins/ |
|
| plugin-push-on-change.md | 改动这三棵目录后立即 commit 并 push,不询问;改插件代码后必须重启服务。 | 本目录是 git 仓库;.gitignore 只放行 skills/ plugins/ memory/ 三棵树 |
|
||||||
<!-- auto:end
|
<!-- auto:end
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
---
|
---
|
||||||
id: rules/plugin-push-on-change.md
|
id: rules/plugin-push-on-change.md
|
||||||
kind: rule
|
kind: rule
|
||||||
summary: 改动这两个目录后立即 commit 并 push,不询问。
|
summary: 改动这三棵目录后立即 commit 并 push,不询问;改插件代码后必须重启服务。
|
||||||
highlight: ~/.config/opencode 本身就是 git 仓库,.gitignore 只放行 skills/ 和 plugins/
|
highlight: 本目录是 git 仓库;.gitignore 只放行 skills/ plugins/ memory/ 三棵树
|
||||||
created: 1791512032262
|
created: 1791512032262
|
||||||
updated: 1791512246723
|
updated: 1791512893909
|
||||||
pinned: true
|
pinned: true
|
||||||
---
|
---
|
||||||
|
|
||||||
`~/.config/opencode` 自身是 git 仓库,`.gitignore` 只放行 `skills/` 和 `plugins/`(外加 .gitignore、pwsh.cmd、package.json)。
|
`~/.config/opencode` 自身是 git 仓库,`.gitignore` 只放行三棵树:`skills/`、`plugins/`、`memory/`(外加 .gitignore、pwsh.cmd、package.json)。
|
||||||
|
|
||||||
在这两个目录下创建、修改、删除任何文件后,**提交并推送后再回复用户**,不要问是否要 push。
|
在这三棵树下创建、修改、删除任何文件后,**提交并推送后再回复用户**,不要问是否要 push。
|
||||||
|
|
||||||
推送用 SSH(origin 已是 SSH 地址)。HTTPS 推送会提示密码且无 TTY,必然失败。
|
推送用 SSH(origin 已是 SSH 地址)。HTTPS 推送会提示密码且无 TTY,必然失败。
|
||||||
|
|
||||||
推送失败就报错并停下,不要静默重试,也不要 `git reset`。提交信息要写清楚改了什么、为什么改。
|
推送失败就报错并停下,不要静默重试,也不要 `git reset`。提交信息要写清楚改了什么、为什么改。
|
||||||
|
|
||||||
|
**改完插件代码必须 `opencode service restart`**:热重载会应用新工具名,但已缓存的模块改动不一定生效。踩过两次「改了没生效」的坑。
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pinned: true
|
|||||||
"halfLifeDays": 14, // 衰减半衰期,0 = 不衰减
|
"halfLifeDays": 14, // 衰减半衰期,0 = 不衰减
|
||||||
"autoExtract": true, // 自动抽取
|
"autoExtract": true, // 自动抽取
|
||||||
"minTurns": 4, // 会话至少多少轮才抽取
|
"minTurns": 4, // 会话至少多少轮才抽取
|
||||||
|
"settleMs": 20000, // 一轮对话安静多久才认为结束
|
||||||
"extractModel": { "providerID": "anthropic", "id": "claude-haiku-4-5" },
|
"extractModel": { "providerID": "anthropic", "id": "claude-haiku-4-5" },
|
||||||
"categories": ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
"categories": ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
||||||
} }
|
} }
|
||||||
@@ -155,13 +156,24 @@ pinned: true
|
|||||||
|
|
||||||
## 自动抽取
|
## 自动抽取
|
||||||
|
|
||||||
监听 `session.idle` → 取本轮对话 → 廉价模型抽取结构化记忆 → 关键词去重 → 写入。
|
一轮对话安静下来后触发 → 取本轮对话 → 廉价模型抽取结构化记忆 → 去重 → 写入。
|
||||||
|
|
||||||
三重保护:
|
**触发用的是去抖,不是 `session.idle`。** 实测把 `ctx.event.subscribe` 收到的
|
||||||
|
所有事件类型都收集起来看过,这个流里**根本没有 `session.idle`** —— 它在协议里
|
||||||
|
定义了,却不投递。所以改成监听真正会到达的 `session.step.started` /
|
||||||
|
`session.tool.called` / `session.inbox.delivered`,并要求静默 `settleMs`(默认 20s)
|
||||||
|
才动手。等价于"turn 结束了",且不依赖一个不存在的事件。
|
||||||
|
|
||||||
|
四重保护:
|
||||||
|
|
||||||
1. **独立模型**,不碰主对话 —— 抽取失败不影响用户
|
1. **独立模型**,不碰主对话 —— 抽取失败不影响用户
|
||||||
2. **只写普通记忆**,铁规则降级为 `rule-proposal` 等人确认
|
2. **只写普通记忆**,铁规则降级为 `rule-proposal` 等人确认
|
||||||
3. **相似度去重** —— 与已有记忆重叠的候选会被跳过并记日志
|
3. **词项覆盖率去重**,不是分数阈值 —— 分数带 idf 和作用域先验,会随语料增长漂移,
|
||||||
|
固定阈值会悄悄失效。覆盖率问的是"是不是已经说过了",阈值稳定
|
||||||
|
4. **密钥拦截**,写入前过一遍正则
|
||||||
|
|
||||||
|
`memory_inspect` 的"运行状态"会报告抽取最近一次的结果和原因(轮数不足、对话太短、
|
||||||
|
选不到模型、去重拦截),所以"没抽取"和"抽取坏了"不会混为一谈。
|
||||||
|
|
||||||
## 跨设备同步
|
## 跨设备同步
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// would let a hallucination become permanent policy.
|
// would let a hallucination become permanent policy.
|
||||||
|
|
||||||
import type { Context } from "@opencode/plugin"
|
import type { Context } from "@opencode/plugin"
|
||||||
import { scoreFiles } from "./recall.ts"
|
import { tokenize } from "./recall.ts"
|
||||||
|
|
||||||
export interface ExtractOptions {
|
export interface ExtractOptions {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -222,14 +222,20 @@ export interface ExtractionOutcome {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide which candidates are worth writing, using existing memories for
|
* Decide which candidates are worth writing, using existing memories for
|
||||||
* dedupe. Near-duplicates bump the existing file instead of adding a rival.
|
* 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?"
|
||||||
*/
|
*/
|
||||||
export function screenCandidates(
|
export function screenCandidates(
|
||||||
candidates: Candidate[],
|
candidates: Candidate[],
|
||||||
existing: readonly import("./store.ts").MemoryFile[],
|
existing: readonly import("./store.ts").MemoryFile[],
|
||||||
options: { minimumScore?: number } = {},
|
options: { coverage?: number } = {},
|
||||||
): ExtractionOutcome {
|
): ExtractionOutcome {
|
||||||
const threshold = options.minimumScore ?? 0.9
|
const threshold = options.coverage ?? 0.55
|
||||||
const accepted: Candidate[] = []
|
const accepted: Candidate[] = []
|
||||||
const rejected: { candidate: Candidate; reason: string }[] = []
|
const rejected: { candidate: Candidate; reason: string }[] = []
|
||||||
|
|
||||||
@@ -246,11 +252,14 @@ export function screenCandidates(
|
|||||||
candidate.category = "rules"
|
candidate.category = "rules"
|
||||||
candidate.kind = "rule-proposal"
|
candidate.kind = "rule-proposal"
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
|
const probe = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
|
||||||
const scored = scoreFiles(existing, probe, {}, { halfLifeDays: 0, pinnedBoost: false })
|
const best = bestCoverage(probe, existing)
|
||||||
const top = scored[0]
|
if (best.file && best.coverage >= threshold) {
|
||||||
if (top && top.score >= threshold) {
|
rejected.push({
|
||||||
rejected.push({ candidate, reason: `与已有记忆高度重叠 (${top.file.rel}, score ${top.score.toFixed(2)})` })
|
candidate,
|
||||||
|
reason: `与已有记忆重复 ${(best.coverage * 100).toFixed(0)}% (${best.file.rel})`,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
accepted.push(candidate)
|
accepted.push(candidate)
|
||||||
@@ -259,6 +268,32 @@ export function screenCandidates(
|
|||||||
return { candidates: accepted, rejected }
|
return { candidates: accepted, rejected }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunExtractionInput {
|
export interface RunExtractionInput {
|
||||||
ctx: Context
|
ctx: Context
|
||||||
model: { providerID: string; id: string }
|
model: { providerID: string; id: string }
|
||||||
|
|||||||
+176
-22
@@ -57,6 +57,8 @@ interface PluginConfig {
|
|||||||
halfLifeDays?: number
|
halfLifeDays?: number
|
||||||
autoExtract?: boolean
|
autoExtract?: boolean
|
||||||
minTurns?: number
|
minTurns?: number
|
||||||
|
/** How long a turn must be quiet before extraction runs. Default 20000ms. */
|
||||||
|
settleMs?: number
|
||||||
extractModel?: { providerID: string; id: string }
|
extractModel?: { providerID: string; id: string }
|
||||||
/** Seed these categories on first run. */
|
/** Seed these categories on first run. */
|
||||||
categories?: string[]
|
categories?: string[]
|
||||||
@@ -65,6 +67,17 @@ interface PluginConfig {
|
|||||||
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
||||||
const DEFAULT_MAX_INJECT = 1200
|
const DEFAULT_MAX_INJECT = 1200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events that mark turn activity. Collected from what this stream actually
|
||||||
|
* delivers: `session.idle` is defined in the protocol but never arrives here,
|
||||||
|
* so extraction is debounced off these instead.
|
||||||
|
*/
|
||||||
|
const EXTRACTION_TRIGGERS = new Set([
|
||||||
|
"session.step.started",
|
||||||
|
"session.tool.called",
|
||||||
|
"session.inbox.delivered",
|
||||||
|
])
|
||||||
|
|
||||||
export default Plugin.define({
|
export default Plugin.define({
|
||||||
id: "memory",
|
id: "memory",
|
||||||
|
|
||||||
@@ -138,7 +151,7 @@ export default Plugin.define({
|
|||||||
// -- injection into the agent loop --------------------------------------
|
// -- injection into the agent loop --------------------------------------
|
||||||
// `context` covers the agent loop including tool continuations, and edits
|
// `context` covers the agent loop including tool continuations, and edits
|
||||||
// only the outgoing request — the conversation transcript stays clean.
|
// only the outgoing request — the conversation transcript stays clean.
|
||||||
await ctx.session.hook("context", (event) => {
|
await ctx.session.hook("context", async (event) => {
|
||||||
try {
|
try {
|
||||||
const query = latestUserText(event.messages)
|
const query = latestUserText(event.messages)
|
||||||
if (!query) return
|
if (!query) return
|
||||||
@@ -150,6 +163,16 @@ export default Plugin.define({
|
|||||||
options: { maxChars, maxEntries, halfLifeDays },
|
options: { maxChars, maxEntries, halfLifeDays },
|
||||||
})
|
})
|
||||||
if (text) event.system.push({ type: "text", text })
|
if (text) event.system.push({ type: "text", text })
|
||||||
|
// 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),
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`memory: injection failed: ${(error as Error).message}`)
|
console.error(`memory: injection failed: ${(error as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -488,6 +511,33 @@ export default Plugin.define({
|
|||||||
lines.push(problems.length === 0 ? "索引一致性:正常" : `索引一致性:发现 ${problems.length} 个问题`)
|
lines.push(problems.length === 0 ? "索引一致性:正常" : `索引一致性:发现 ${problems.length} 个问题`)
|
||||||
for (const problem of problems) lines.push(` - ${problem}`)
|
for (const problem of problems) lines.push(` - ${problem}`)
|
||||||
|
|
||||||
|
// 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("运行状态:")
|
||||||
|
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 ? " ⚠ 超出预算" : ""}`,
|
||||||
|
)
|
||||||
|
lines.push(` 检索词:${String(injection.query)}`)
|
||||||
|
} else {
|
||||||
|
lines.push(" 注入:尚无记录(context hook 还没触发过)")
|
||||||
|
}
|
||||||
|
if (extraction) {
|
||||||
|
const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ")
|
||||||
|
lines.push(` 抽取:${when} → ${String(extraction.outcome)}`)
|
||||||
|
if (extraction.reason) lines.push(` 原因:${String(extraction.reason)}`)
|
||||||
|
if (extraction.model) lines.push(` 模型:${String(extraction.model)}`)
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
extractOptions.enabled
|
||||||
|
? " 抽取:尚无记录(本会话还没到静默期,或轮数/内容不足)"
|
||||||
|
: " 抽取:已关闭(autoExtract: false)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return { content: lines.join("\n") }
|
return { content: lines.join("\n") }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -496,30 +546,50 @@ export default Plugin.define({
|
|||||||
// -- automatic extraction on idle --------------------------------------
|
// -- automatic extraction on idle --------------------------------------
|
||||||
let extracting = false
|
let extracting = false
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
// How long a turn must be quiet before extraction counts it as finished.
|
||||||
|
const settleMs = config.settleMs ?? 20_000
|
||||||
|
|
||||||
|
// `session.idle` is NOT delivered on this stream — verified by collecting
|
||||||
|
// every event type that actually arrives. The turn-ending events below do
|
||||||
|
// arrive, so they drive a debounce instead: the turn must go quiet for
|
||||||
|
// `settleMs` before extraction runs, which is what "idle" meant anyway.
|
||||||
|
const pending = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
if (extractOptions.enabled) {
|
if (extractOptions.enabled) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
||||||
const record = event as unknown as { type?: string; data?: { sessionID?: string } }
|
const record = event as unknown as { type?: string; data?: { sessionID?: string } }
|
||||||
if (record?.type !== "session.idle") continue
|
if (!EXTRACTION_TRIGGERS.has(String(record?.type))) continue
|
||||||
const sessionID = record.data?.sessionID
|
const sessionID = record.data?.sessionID
|
||||||
if (!sessionID || extracting) continue
|
if (!sessionID) continue
|
||||||
|
|
||||||
extracting = true
|
// Debounce: each new turn activity pushes the deadline out. Only a
|
||||||
try {
|
// genuine lull lets the timer fire, so extraction runs on a
|
||||||
await runExtraction(ctx, {
|
// finished turn rather than mid-stream.
|
||||||
sessionID,
|
const existing = pending.get(sessionID)
|
||||||
extractOptions,
|
if (existing) clearTimeout(existing)
|
||||||
filesByRoot,
|
pending.set(
|
||||||
project,
|
sessionID,
|
||||||
global,
|
setTimeout(() => {
|
||||||
})
|
pending.delete(sessionID)
|
||||||
} catch (error) {
|
if (extracting) return
|
||||||
console.error(`memory: extraction failed: ${(error as Error).message}`)
|
extracting = true
|
||||||
} finally {
|
void runExtraction(ctx, {
|
||||||
extracting = false
|
sessionID,
|
||||||
}
|
extractOptions,
|
||||||
|
filesByRoot,
|
||||||
|
project,
|
||||||
|
global,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`memory: extraction failed: ${(error as Error).message}`)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
extracting = false
|
||||||
|
})
|
||||||
|
}, settleMs),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Subscription teardown is normal on unload.
|
// Subscription teardown is normal on unload.
|
||||||
@@ -532,6 +602,8 @@ export default Plugin.define({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
|
for (const timer of pending.values()) clearTimeout(timer)
|
||||||
|
pending.clear()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -546,6 +618,28 @@ function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[]
|
|||||||
return roots
|
return roots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record plugin health for `memory_inspect` to show.
|
||||||
|
*
|
||||||
|
* The two asynchronous paths — the context hook and idle extraction — have no
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const lastDiagAt = new Map<string, number>()
|
||||||
|
|
||||||
|
async function noteDiag(ctx: Context, key: string, value: Record<string, unknown>): Promise<void> {
|
||||||
|
const now = Date.now()
|
||||||
|
const previous = lastDiagAt.get(key) ?? 0
|
||||||
|
if (now - previous < 2000) return
|
||||||
|
lastDiagAt.set(key, now)
|
||||||
|
try {
|
||||||
|
await ctx.storage.set(`diag.${key}`, value)
|
||||||
|
} catch {
|
||||||
|
// Diagnostics must never break the hook that reports them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]): string {
|
function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]): string {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
@@ -557,17 +651,32 @@ function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]
|
|||||||
return lines.join("\n") || "(记忆库为空)"
|
return lines.join("\n") || "(记忆库为空)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The newest user text in a request — the retrieval query. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
function latestUserText(messages: readonly unknown[]): string | undefined {
|
function latestUserText(messages: readonly unknown[]): string | undefined {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const record = messages[i] as { role?: string; content?: unknown } | null
|
const record = messages[i] as { role?: string; content?: unknown } | null
|
||||||
if (!record || record.role !== "user") continue
|
if (!record || record.role !== "user") continue
|
||||||
const text = extractUserText(record.content)
|
const text = extractUserText(record.content)
|
||||||
if (text) return text
|
if (text && !isHarnessMessage(text)) return text
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HARNESS_PREFIX = /^<(system-reminder|command-name|command-message|command-args)/i
|
||||||
|
const HARNESS_RE =
|
||||||
|
/^(The server restarted while you were working|Task tools haven't been used recently|<system-reminder)/i
|
||||||
|
|
||||||
|
function isHarnessMessage(text: string): boolean {
|
||||||
|
const head = text.trimStart()
|
||||||
|
return HARNESS_PREFIX.test(head) || HARNESS_RE.test(head)
|
||||||
|
}
|
||||||
|
|
||||||
function extractUserText(content: unknown): string {
|
function extractUserText(content: unknown): string {
|
||||||
if (typeof content === "string") return content.trim()
|
if (typeof content === "string") return content.trim()
|
||||||
if (!Array.isArray(content)) return ""
|
if (!Array.isArray(content)) return ""
|
||||||
@@ -599,13 +708,41 @@ async function runExtraction(
|
|||||||
return role === "user" || role === "assistant"
|
return role === "user" || role === "assistant"
|
||||||
})
|
})
|
||||||
// Long enough to have learned something, short enough to stay cheap.
|
// Long enough to have learned something, short enough to stay cheap.
|
||||||
if (turns.length < extractOptions.minTurns) return
|
if (turns.length < extractOptions.minTurns) {
|
||||||
|
await noteDiag(ctx, "lastExtraction", {
|
||||||
|
at: Date.now(),
|
||||||
|
outcome: "skipped",
|
||||||
|
reason: "会话轮数不足",
|
||||||
|
turns: turns.length,
|
||||||
|
minTurns: extractOptions.minTurns,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const model = extractOptions.model ?? (await pickCheapModel(ctx))
|
const model = extractOptions.model ?? (await pickCheapModel(ctx))
|
||||||
if (!model) return
|
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,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
|
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
|
||||||
if (transcript.length < 200) return
|
if (transcript.length < 200) {
|
||||||
|
await noteDiag(ctx, "lastExtraction", {
|
||||||
|
at: Date.now(),
|
||||||
|
outcome: "skipped",
|
||||||
|
reason: "对话内容太短",
|
||||||
|
turns: turns.length,
|
||||||
|
transcriptChars: transcript.length,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const existing = [...input.filesByRoot().values()].flat()
|
const existing = [...input.filesByRoot().values()].flat()
|
||||||
const outcome = await extract({ ctx, model, transcript, existing })
|
const outcome = await extract({ ctx, model, transcript, existing })
|
||||||
@@ -616,6 +753,13 @@ async function runExtraction(
|
|||||||
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
|
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
await noteDiag(ctx, "lastExtraction", {
|
||||||
|
at: Date.now(),
|
||||||
|
outcome: "no-candidates",
|
||||||
|
reason: outcome.rejected.length ? "候选全部被去重或密钥拦截" : "抽取器认为没有值得记的东西",
|
||||||
|
rejected: outcome.rejected.length,
|
||||||
|
model: `${model.providerID}/${model.id}`,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +792,16 @@ async function runExtraction(
|
|||||||
}
|
}
|
||||||
console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user