diff --git a/README.md b/README.md index 8380913d..461547ee 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -270,7 +270,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 2654 个测试 +npm test # 2660 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -550,7 +550,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 2654 tests +npm test # 2660 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 6ef98ffb..b65be5be 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -49,6 +49,14 @@ - **sleep 的冲突相位在输入侧没有任何预算:全部候选对一次塞进一个 prompt、判 `keep` 不留痕,单请求实测 163,054 token(整池 163,840),失败后按冷却重排同一输入(#529,报告与实测由 @chengxinshengglj-png 提供)**:报告者的现场是一台 `sleepActionSet` 默认档的库——`sleep_conflict` 审计 131 行(46 success / 85 error),`input_tokens` 峰值 163,054,而服务端 `--ctx-size` 是 163,840(**占 99.5%**),`pairs` 443–449 而单轮 `applied` 只有 3–7;10-26 12:55 到 10-29 11:11 连续 **23 轮 llm failed**,`conflict_pending` 堆到 841 行。三件事互相咬合,缺一条都治不好:**①** 输入侧根本没有预算——`samePairs` 是**全部**同 scope 相似对,`listText` 一次拼完,模型只能看到前几对就撞上下文;**②** 判 `keep` 是彻底的 no-op(`applyDecisions` 只往进程内 `committed` 里 push 一下,不写任何东西),于是下一轮候选池逐字节相同、同一对每个冷却周期被重新问一遍;**③** 失败轮不带 `pairs`——131 行审计里 85 条 error 全是「送了个寂寞」,事后连送了多少对都查不到。四处一起补:**A 候选预算**:新增 `sleepConflictMaxPairs`(每批上限,默认 12)与 `sleepConflictMaxPairsPerRun`(每轮总量上限,默认 60),两者都 0 = 不限、回退旧行为;分批之后每批独立一次 LLM 往返 + 独立 `validateDecisions`/`applyDecisions`,**坏批只让这一批失败并计入 `degraded`,其余批照常落地**,只有一批都没成功才整轮 `failed`——此前任何一处理由(撞上下文、输出截断、JSON 坏掉)都是整轮失败。**B 裁决留痕**:新增表 `conflict_reviewed`(`memory_a`/`memory_b`/`judged_at`/`outcome`/`run_id`,`PRIMARY KEY (memory_a, memory_b)`)记「这段输入被看过」,`pair` 按字典序归一化 + `ON CONFLICT DO UPDATE` 幂等;预筛按「两侧 `updated_at` 都不晚于 `judged_at`」剔出候选池,任一字段更新即重新入池(与 `conflict_pending` 复核项 4 同一条宽松代理)。记账位置在每批 `validateDecisions` 之后,对本批**全部送审对**统一记、不只记被裁决成冲突的那几对——判 `keep` 的正是下一轮重复送审的元凶。**刻意不复用 `conflict_pending`**:那张表是人工待裁决队列,面板徽标数就是 `countConflictPending()`(`resolved_at IS NULL` 的行数),机器每判一对写一行会把这个数字淹掉,人就再也看不出「有哪些待我裁决」;台账零界面,不进 `listConflictPending`。**记账也不写进 `applyDecisions`**:那个函数 dream 与 sleep 共用,写进去会污染日间路径。**C 续排与退避**:新增 opt-in 的 `sleepFailureBackoff`(默认关),有效间隔 = `sleepMinIntervalHours` × 2^连败数、成功清零、封顶 `max(sleepMinIntervalHours, 24h)`(不是硬 24h:基数允许配到 168 小时,退避只放大间隔、不压缩用户自己设的基数)。**原提案的 `snapshot_hash` 判据用不了**:`runSleep` 里那个 `snapshotHash` 是 `createHash("sha256").update(JSON.stringify(phases))`——跑完之后对**结果**算的哈希,不是输入快照,拿它判「同一份输入又失败」会把不同的结果认成不同的输入;改用连续失败计数,与 `autoDream` 的失败退避(#292)同构。判据看 `status === "failed"` 而不是 `!ok`:`noop`(LLM 通路正常、只是没活干)与用户活动打断都给 `ok:false`,当失败会让退避在自己工作良好的库里越退越长。`armNextWindow()` 排下一次闹钟必须与 `shouldRun()` 用同一个函数——否则退避期闹钟会在基数到点响、被否、再重排,白转一轮。**D 审计补字段**:失败轮同样返回 `pairs`/`batches`(另加 `ledgerSkipped`/`perRunSkipped` 两个读数),失败批的 stream 层原文(`describeStreamFailure` 的 abort/error)进 `logger.warn`——只写 `llm failed` 的话运维分不清是上下文超限、模型拒收还是用户活动打断。**顺带修掉一个同类的谎**:分批之后「批间被用户活动打断」原本会落进 `noop`,而 `noop` 的语义是"看过、没冲突"——被打断的那几批根本没送出去。相位层改用 `aborted`(与入口处"一开始就 aborted"同一个词),`deriveStatus` 也补认它,否则整轮层会把相位层的 `aborted` 静默折回 `noop`,审计行照样记成"跑过、没什么事"。**报告者本地打了三键之后的效果**:单请求 806–2,011 token、连续失败 23 轮 → 0 轮、每轮台账跳过 32–53 对。**刻意不加 `sleepMinIntervalMinutes`**:`sleepMinIntervalHours` 早就在(`z.natural().min(1).max(168).default(8)`),再做一个「传 0 则回退小时键」的分钟键,面板上会出现两个都叫「最小间隔」的调节项。**不在本批**:报告者那 79 条 `input_tokens = 0` 的审计行属 #104 / #250 的传输族(流以错误结束、根本没有用量),与本条的输入预算无关,另账。回归测试 +16(`test/sleep.test.js` 6 条:分批上限、坏批只 degraded、判 `keep` 留痕且不重送 + 改一侧后重送、每轮总上限、**批间被用户活动打断时报 `aborted` 而不是 `noop`**(`noop` 的语义是「看过、没冲突」,而被打断的批根本没送出去)、失败轮仍带 `pairs`/`batches`;`test/conflict-reviewed.test.js` 4 条:键归一化、重复记账是 upsert、**记账不进人工队列**、按 `judged_at` 倒序;`test/sleep-failure-backoff.test.js` 6 条:默认关不翻倍、连败指数推迟、成功清零、封顶 max(基数, 24h)、**noop 不算失败**、抛错也计入),每条都做过变异自证。**评审补三处(CodeRabbit)**:① **台账预筛原先排在去重之后**——先按相似度占位、再把已审对摘掉,于是一条已审的记忆会把自己的配对名额占住,而它真正的对手(另一条没审过的记忆)因此进不了这一轮;现在预筛前移到去重之前,跨 scope 对不筛(它们要停车进人工队列)、`conflictFreezeEnabled` 档不筛(那一档只停车不送 LLM),`ledgerSkipped` 一次数清不再二次过滤。② **`reflectionUpdateMaxPerRun` 分批后退化成每批上限**——校验器不跨批记忆,N 批就是 N 倍上限;改为额度由分批循环持有、每批只领剩余额度、按本批**通过校验的** `update` 数扣减(`validateDecisions` 已把非法条目就地 splice 掉,数到的都真会落地),额度耗尽的那批若还含 `update` 被整单拒绝记 `failed`,其余批继续。③ **台账会把没落地的裁决记成已审**——原实现对整批送审对无条件记账且 `judged_at` 取落地之后,于是 `failures`/`conflicts`(CAS)/`skipped` 里那些没生效的对、以及 LLM 往返期间被外部改动的对,下一轮都会被 `alreadyJudged` 判成已审而永久压制;现在 `judged_at` 取**发送本批之前**的时刻,且本批 `failures`/`conflicts`/`skipped` 里出现过的 id,其所在对不记账。回归测试 +5(去重前预筛、跨批 update 额度、未落地对不记账、`judged_at` 的本批时钟域、单批抛错不带走整轮),五条都做过变异自证。**④ `judged_at` 还必须落在 store 的时钟域里**:`updated_at` 出自 `src/store.js` 的单调 `nowIso`(同一毫秒内连续写入逐次 +1ms),可能领先墙钟几毫秒;直接取墙钟会记出一个早于被测行自身版本的 `judged_at`,`alreadyJudged` 于是把「刚判过」读成「判过之后又被改过」,判 `keep` 的死循环原样复活(CI 的 `test-host-legacy` 腿抓到过这次真 flake)。改为取 `max(墙钟, 本批两侧最新 updated_at)`——两列同源才可比。**⑤ 一批抛异常会带走整轮**:`adjudicateBatch` 里 LLM 通路自己会把失败变成返回值,但 `applyDecisions` 之外的环节(台账写入的 `saveConflictReviewed` 之外,还有 `maintainIndexAfterDream`、以及未来任何新加的读写)一旦抛,循环就直接退出,前面的批次汇总全丢、后面还没送的批次一个不送,整轮被 `runSleep` 覆盖成失败相位。现在分批循环里加 `try/catch`,只放行 `AbortError`(与 `src/dream.js` 的 `if (error?.name === "AbortError") throw error;` 同款中断语义),其余折成这一批的 `failed` 结果继续下一批。**⑤ 的变异自证踩过一个坑,值得记**:最初把桩打在 `service.update` 上,用例是绿的但删掉 `try/catch` 也还是绿的——`applyDecisions`(`src/dream/decisions.js:532-540`)本来就把 `service` 抛出的任何非 CAS 异常吞进 `failures` 返回,根本打不到循环那一层。真正的注入点必须在 `applyDecisions` 的上游:改成让 `ctx.llm.stream` 在第一次 `sleep-conflict` 调用时直接 `throw`,删掉 `try/catch` 后用例立刻红。**教训:给「异常被兜住」的守卫写回归,桩必须打在兜底之外的那一层**,否则用例是自证循环——它证明的只是「被兜住的异常被兜住了」。 +- **#493 的两条补充:失败计数落库(带读侧失效)、围栏断链抽成一份共享实现(#493 的报告者 `@strange2000` 指出的两个洞,均无配置开关)**:主链路(围栏 + 角色声明 + 失败留证 + 失败护栏)在 0.9.3/0.9.4 已经修完,这两条是他复核后追加的两处「治了症状但没治守卫」的遗留。 + + **① 失败计数只在进程里**:`summarizeParseFailureMaxRetries` 的判据 `parseFailureStreak` 原来是 `createSummarizer` 闭包里的一个 `Map`——重启即清零,而这道护栏要防的恰恰是「确定性失败被反复重试」,重启一次就绕过去了(两个宿主共用一个库时也各算各的)。新增表 `distill_parse_failures (session_id PRIMARY KEY, streak, updated_at)`,`store` 三个函数 `getDistillParseFailureStreak` / `bumpDistillParseFailureStreak` / `clearDistillParseFailureStreak`,`service` 三个 bookkeeping passthrough。**刻意不并进 `distill_cursors`**,三条理由:写 streak 不必先有游标行(窗口可能还没成功消费过任何 seq);「成功即归零」是 `DELETE`,而游标那句 UPSERT 是 `last_seq = MAX(...)`(单调),两种语义塞进同一句会把「归零」写成「保留最大值」,护栏永远不解除;失效判据要读时间戳,就地一行更省事。**失效在读数一侧判**(`staleAfterMs`,默认 7 天,常量 `PARSE_FAILURE_STREAK_STALE_MS`):报告者点名的反例是「游标停很久后再开跑,拿几周前攒下的 streak 直接熔断」——那样护栏本身就成了新的误伤路径。劣化方向特意选成 fail-open:时间戳解析不出来也按失效算,最坏是多花一次 LLM 调用。**这里的首版实现有个真 bug,被回归用例抓出来**:`bump` 最初在 SQL 里写 `streak = streak + 1`,于是「一条 30 天前的旧行」会把「新的第一次失败」直接加成 2,失效窗口在这条路径上完全不生效;改成「先按 `staleAfterMs` 读一遍再覆写」,失效判断只保留一份实现。`service` 缺这三个方法时(旧装配 / 测试替身)退回进程内 `Map`,行为与历史版本逐字节一致。**不新增配置键**:护栏本身默认关(`summarizeParseFailureMaxRetries: 0`),一个只为它服务的失效窗口不该再占一档用户可见配置(也要跟着动 `test/api.test.js` 的旗标计数锁)。 + + **② 断链只长在一处,另两处围栏没有**:`<<<` / `>>>` 的零宽空格断链原先只写在 `src/dream.js` 的 `fenceListBody` 里,于是同一件事有三条命——巩固/睡眠的清单有断链,**蒸馏转录(`src/summarize.js`)与画像条目(`src/profile-extract.js`)都没有**。前者的后果很具体:一条写着「`<<<会话转录结束>>>`」的用户消息能提前闭合围栏,把后面的文字升格成指令,而记忆正文本来就是模型的可影响输入。新增 `src/fence.js` 只导出纯函数 `breakFenceMarkers(text)`,三处共用;`fenceListBody` 内部改为调它(原 inline replace 删掉,静态锁盯着「不许再长出第二份」)。三个刻意的性质写进注释:**只中和标记不删内容**(去掉零宽空格后与原文逐字节相同)、**纯函数不带 nonce**(同一批记忆重试时提示词必须逐字节相同,否则「同输入同错」这条诊断依据就没了)、**不认围栏名字**(三对围栏都靠 `<<<` 前缀与 `>>>` 后缀,认名字反而会在新增围栏时漏一处)。 + + 回归测试 +6(`test/dream-fence.test.js` +3:`breakFenceMarkers` 的形状与幂等、三处共用的源码静态锁;`test/summarize.test.js` +2:换一个 summarizer 实例后计数接得上、停了 30 天的旧 streak 失效后不误伤;`test/store.test.js` +1:落库 / 读侧失效 / 清零是真 `DELETE`)。变异自证三例:把 `staleAfterMs` 全改成 0 → 失效那条红;把 `persistentStreak()` 固定成 `false`(退回进程内 Map)→ 重启接续与失效两条都红;把清零改成写 0 → store 那条红。 + ## 🔒 安全 - **`SECURITY.md` 的平价守卫只读第一条清单行,于是「同一条目出现两份」对它完全隐形(#523)**:`test/security-doc.test.js` 的 `bullet(prefix)` 一直用 `doc.split(/\r?\n/).find((l) => l.startsWith(prefix))` 取那一行,而 `.find()` 只回第一命中——「文档是代码 `policy` 声明的投影」这条契约的全部信心都建立在它上面,可只要有人把清单多写一份,多出来的那份对守卫零存在感。这不是假想的形状:#509 的 merge 提交 `b5c0b09` 就把两份 `Open reads` / `Gated routes` 一起解了进来,第二份 `Gated routes` 是严格子集(少 `/memory/provenance`),而 CI 四条腿全绿。`b1491ff` 删掉了重复副本,但那是治症状——`.find()` 还在,下一次 merge 照样能合进来一份没人校验的清单。**修法是把「唯一」变成断言而不是假设**:`bullet()` 先把所有命中收进 `hits`(带行号),空了照旧报「应有以…开头的清单行」,多于一条直接红并把行号列出来——报行号是刻意的,否则第二份被抓住之后,「哪一份才算数」会变成下一个要人来回翻文件才能回答的问题。四个 prefix 全走这条路(`- **Gated routes` / `- **受门控的路由` / `- **Open reads` / `- **开放读`),内容侧那两条 `missing` / `stale` 反向断言一字未动:这次补的是「有没有第二份」,不是「这一份对不对」。**为什么不改成去重后校验合并结果**:把两份并起来再比,等于默许拆分清单(一份写一半),而这张表是给人读的一份东西,拆开之后没有任何一处能读出全貌。回归测试 +1(`test("重复的清单行会被守卫抓住(#523)")`):逐字相同的两份、以及第二份把 `/memory/provenance` 换成陈旧值的两份,都必须报出「必须恰好一条,实际 2 条」并带行号;另加「只有一份时不能误伤」。两条都做过变异自证:把 `assert.equal(hits.length, 1, …)` 换成 `assert.equal(hits.length, hits.length, …)`,新用例立刻红(6 pass / 1 fail),恢复后 7/7 绿。`bullet()` 多出的 `source` 参数只为可测——喂一份合成文档就能验唯一性,不必真去改 `SECURITY.md`。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index ef51fa9d..0661e773 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-2654%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-2660%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -720,7 +720,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 2654 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 2660 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -729,7 +729,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 2654 个测试 +npm test # 运行 2660 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index 9fb704c1..ad857b74 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -14,6 +14,9 @@ import { STR, langOf } from "./lang.js"; // service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在 // 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。 import { isImportedRow } from "./memory-source.js"; +// 断链是围栏的共同底子:三处围栏(巩固/睡眠清单、蒸馏转录、画像条目)共用这一份, +// 不许各写一条(#493 补充 §③)。 +import { breakFenceMarkers } from "./fence.js"; export { validateDecisions, applyDecisions, withEffortFallback, withMaxTokensFallback, parseMaxTokensCap, describeStreamFailure, resolveDreamEffort, resolveRoute, streamText, runAuditedLlm }; // Extract the first JSON array from LLM output, tolerating markdown fences, @@ -333,7 +336,7 @@ export function fenceListBody(language, text, shape = "json") { const close = STR.memoryFenceClose[language] ?? STR.memoryFenceClose.en; const table = shape === "text" ? STR.dreamTextRoleReminder : STR.dreamRoleReminder; const reminder = table[language] ?? table.en; - const body = String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`); + const body = breakFenceMarkers(text); return `${reminder}\n\n${open}\n${body}\n${close}`; } diff --git a/dsh-mneme/lib/fence.js b/dsh-mneme/lib/fence.js new file mode 100644 index 00000000..bef6fa14 --- /dev/null +++ b/dsh-mneme/lib/fence.js @@ -0,0 +1,24 @@ +// dsh-mneme/src/fence.js +// 围栏正文的「断链」——把内容里自带的 `<<<` / `>>>` 拆散,使它无法提前闭合我们围出来的围栏。 +// +// 为什么单独成模块(#493 的补充,报告者 §③):断链原先只长在 dream.js 的 fenceListBody +// 里,于是同一件事在三处各有一条命:dream/sleep 的清单(有断链)、蒸馏转录(没有)、 +// 画像提取的条目(也没有)。两处一旦分叉,下次出问题就得分别查一遍——#494 与 #501/PR +// #503 的重复劳动就是这么来的。判定逻辑只能有一份实现,这条纪律在本仓已经付过学费 +// (scopeStateGate 注释里「过滤点漂移」那条)。 +// +// 三个刻意的性质: +// - **只中和标记,不删内容**:被断链的文本仍逐字可读(零宽空格不占位),只是不再是 +// 一个能匹配围栏串的连续字符序列。吞掉正文会让「这段记忆说了什么」变成不可复原的。 +// - **纯函数、不带 nonce**:重试收到同输入时提示词必须逐字节相同,否则「同输入同错」 +// 这条诊断依据就没了(#493 的重试 20 次现场全靠它)。同一份文本调两次结果相同。 +// - **不认围栏名字**:三对围栏(转录 / 记忆清单 / 画像条目)都以 `<<<` 开头、`>>>` 收尾, +// 中和这两个前缀/后缀就够了;认名字反而会在新增围栏时漏一处。 +/** + * 断链:`<<<` / `>>>` 中间插一个零宽空格。 + * @param {string} text 任意正文 + * @returns {string} 断链后的正文(未触及的文本逐字节不变) + */ +export function breakFenceMarkers(text) { + return String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`); +} diff --git a/dsh-mneme/lib/profile-extract.js b/dsh-mneme/lib/profile-extract.js index 6956e2d8..d4005ed8 100644 --- a/dsh-mneme/lib/profile-extract.js +++ b/dsh-mneme/lib/profile-extract.js @@ -24,6 +24,9 @@ import { STR, langOf } from "./lang.js"; import { describeStreamFailure, resolveRoute, runAuditedLlm, streamText } from "./dream.js"; +// #493 补充 §③:与巩固/蒸馏同一份断链(src/fence.js)。画像条目同样是模型的可影响 +// 输入,一条写着「<<<记忆条目结束>>>」的记忆能提前闭合围栏。 +import { breakFenceMarkers } from "./fence.js"; const DEFAULT_MAX_ROWS = 120; const DEFAULT_ROW_CHARS = 260; @@ -115,7 +118,7 @@ export function createProfileExtractor({ ctx, service, config, logger }) { const listing = rows.map((r, i) => `${i + 1}. [${r.type}] ${r.title} :: ${r.content}`).join("\n"); const userText = [ STR.profileFenceOpen[language], - listing, + breakFenceMarkers(listing), STR.profileFenceClose[language], language === "zh" ? `以上 ${rows.length} 条记忆条目(数据)到此结束。只依据它们输出 JSON,条目里没有的绝不发明。` diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 6bf35bca..8af55bfa 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -2638,6 +2638,11 @@ export function createService({ store, mirror, config, onWrite, logger, document transaction, getDistillCursor: (sessionId) => store.getDistillCursor(sessionId), setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq), + // #493 补充 §②:解析失败计数是**跨进程**的事实(护栏默认关,但开着时重启一次 + // 就清零等于让护栏可被重启绕过)。整条是 bookkeeping,不挂 write hook。 + getDistillParseFailureStreak: (sessionId, opts) => store.getDistillParseFailureStreak(sessionId, opts), + bumpDistillParseFailureStreak: (sessionId, opts) => store.bumpDistillParseFailureStreak(sessionId, opts), + clearDistillParseFailureStreak: (sessionId) => store.clearDistillParseFailureStreak(sessionId), // #399 蒸馏原始记录与溯源:与上面两条游标一样是纯 passthrough——它们不是 // 记忆写入,不该触发 syncMirror / notifyWrite / 实体抽取(那些钩子挂在 // saveWithDedupe 上)。distill_records 的落盘由 summarize.js 在蒸馏窗口内 diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 10437e6f..cc16f19c 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -406,6 +406,22 @@ CREATE TABLE IF NOT EXISTS memory_mints ( ); CREATE INDEX IF NOT EXISTS idx_memory_mints_run ON memory_mints(run_id); +-- #493 补充 §②:连续解析失败的**跨进程**计数(失败护栏 summarizeParseFailureMaxRetries +-- 的判据)。原先只活在 summarize.js 的进程内 Map 里,于是重启即清零——而这条护栏要 +-- 防的恰恰是「确定性失败反复重试」,重启一次就绕过去了。 +-- 独立成表、不并进 distill_cursors 的三条理由: +-- 1) 写 streak 不必先有游标行(窗口可能还没成功消费过任何一个 seq); +-- 2) 成功即归零是 DELETE,而游标那句 UPSERT 是 last_seq = MAX(...)(单调), +-- 两种语义塞进同一句会把「归零」写成「保留最大值」,护栏永远不解除; +-- 3) 失效判据要读时间戳,就地一行更省事。 +-- 失效由读侧判(streakStaleAfterMs):超过窗口没更新就当作 0,**不在写侧定时清**—— +-- 停了几周的会话再开跑时,若拿旧 streak 直接触发熔断,护栏本身就成了新的误伤路径。 +CREATE TABLE IF NOT EXISTS distill_parse_failures ( + session_id TEXT PRIMARY KEY, + streak INTEGER NOT NULL, + updated_at TEXT NOT NULL +); + -- entity gene (v0.3.0): named entities mentioned across memories, with -- time-boxed attributes (valid_from → valid_until) and typed relations. -- Attributes follow the snapshot style: saveAttr invalidates the previous @@ -1237,6 +1253,59 @@ export function createStore(path) { return getDistillCursor(sessionId); } + /** + * 读指定会话的连续解析失败计数。 + * + * `staleAfterMs` 是**读侧**失效窗口(#493 补充 §②):超过这么久没更新就把计数 + * 当作 0,而不是拿几周前攒下的值去触发熔断。劣化方向选得很明确——宁可多试一次 + * (多花一次 LLM 调用),也不要因为「会话停了很久」而误判一个其实健康的窗口。 + * 传 0 或负数表示不判失效(照原样返回),测试用它构造边界。 + * @returns {number} 0 表示没有在途失败(含「已失效」与「从未记录」两种) + */ + function getDistillParseFailureStreak(sessionId, { staleAfterMs = 0, nowMs = Date.now() } = {}) { + if (typeof sessionId !== "string" || sessionId.length === 0) return 0; + const row = db.prepare( + "SELECT streak, updated_at FROM distill_parse_failures WHERE session_id = ?" + ).get(sessionId); + if (!row) return 0; + if (staleAfterMs > 0) { + const stamp = Date.parse(row.updated_at); + // 时间戳读不出来时按「已失效」处理:一个解析不了的 updated_at 不能当作「刚刚 + // 失败过」,否则坏数据会永久钉住一道护栏(fail-open,与整条护栏的默认关一致)。 + if (!Number.isFinite(stamp) || nowMs - stamp > staleAfterMs) return 0; + } + return Number(row.streak) || 0; + } + + /** + * 记一次解析失败(+1 并刷新时间戳)。返回写后的计数。 + * + * 先按 staleAfterMs 读一遍再**覆写**,而不是在 SQL 里做 streak + 1——失效判断只 + * 许有一份(就是上面那个读函数)。写成 + 1 的话,一条几周前的旧行会把「新的第一 + * 次失败」直接加成 2,等于失效窗口在这条路径上完全不生效(本块实现的首版就是 + * 这么写的,被回归用例抓出来)。 + */ + function bumpDistillParseFailureStreak(sessionId, { staleAfterMs = 0, nowMs = Date.now() } = {}) { + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new TypeError("bumpDistillParseFailureStreak: sessionId must be a non-empty string"); + } + const next = getDistillParseFailureStreak(sessionId, { staleAfterMs, nowMs }) + 1; + db.prepare(` + INSERT INTO distill_parse_failures (session_id, streak, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + streak = excluded.streak, + updated_at = excluded.updated_at + `).run(sessionId, next, nowIso()); + return next; + } + + /** 清零(不是写 0):没有在途失败时不该留一行,否则「有没有失败过」变得不可区分。 */ + function clearDistillParseFailureStreak(sessionId) { + if (typeof sessionId !== "string" || sessionId.length === 0) return 0; + return db.prepare("DELETE FROM distill_parse_failures WHERE session_id = ?").run(sessionId).changes; + } + function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) { const clauses = []; const params = []; @@ -4066,6 +4135,11 @@ export function createStore(path) { annotateLlmAudit, getDistillCursor, setDistillCursor, + // #493 补充 §②:解析失败计数跨进程持久化(读侧带失效窗口)。单独成表, + // 不并进 distill_cursors——那句 UPSERT 是单调的,归零语义会打架。 + getDistillParseFailureStreak, + bumpDistillParseFailureStreak, + clearDistillParseFailureStreak, listLlmAudits, countLlmAudits, getLlmAuditStats, diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index fa7ddad9..f9e30000 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -6,6 +6,9 @@ import { STR, langOf } from "./lang.js"; import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js"; // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。 import { createScopeResolver } from "./scope.js"; +// #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时 +// 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。 +import { breakFenceMarkers } from "./fence.js"; // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它—— // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。 import { randomUUID } from "node:crypto"; @@ -21,6 +24,12 @@ import { randomUUID } from "node:crypto"; // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。 // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。 const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000; +// #493 补充 §②:持久化的解析失败计数超过这么久没更新就按 0 算(读侧失效,见 +// store.getDistillParseFailureStreak)。7 天:够长,能跨一次长假/一次停机;够短, +// 不至于让「停了很久的会话」拿几周前的 streak 直接熔断一个其实健康的窗口。 +// 刻意不做成配置键:护栏本身默认关(summarizeParseFailureMaxRetries=0),一个只为 +// 护栏服务的失效窗口不该再占一档用户可见配置(AGENTS.md 旗标计数锁也要跟着动)。 +const PARSE_FAILURE_STREAK_STALE_MS = 7 * 24 * 60 * 60 * 1000; function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) { if (typeof raw !== "string") return undefined; const text = raw.trim(); @@ -469,7 +478,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是 // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。 // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。 - text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}` + text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${breakFenceMarkers(transcriptBody)}\n${STR.transcriptFenceClose[language]}` }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })] @@ -606,9 +615,54 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。 const lastDistilledSeq = new Map(); // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性 - // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即 - // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。 - const parseFailureStreak = new Map(); + // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即清零。 + // + // #493 补充 §②:**落库**,不再是进程内 Map。原先的 Map 有两个洞:重启即清零 + // (护栏可被重启绕过,而它防的正是「确定性失败反复重试」),以及进程内无法跨 + // 两个宿主共享同一个库时保持一致。库里读出来的值带读侧失效窗口(见 + // PARSE_FAILURE_STREAK_STALE_MS),停了很久的会话不会被旧 streak 误伤。 + // service 没这一步(旧装配 / 测试替身)时退回进程内 Map,行为与历史版本一致。 + const parseFailureStreakFallback = new Map(); + const persistentStreak = () => typeof service?.getDistillParseFailureStreak === "function" + && typeof service?.bumpDistillParseFailureStreak === "function" + && typeof service?.clearDistillParseFailureStreak === "function"; + function readParseFailureStreak(sessionId) { + if (!persistentStreak()) return parseFailureStreakFallback.get(sessionId) ?? 0; + try { + return Number(service.getDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })) || 0; + } catch (error) { + // 读数坏了不许把蒸馏本身拖下水:退回进程内计数(最坏是多试一次)。 + ctx.logger?.warn?.(`dsh-mneme: parse failure streak read failed: ${String(error)}`); + return parseFailureStreakFallback.get(sessionId) ?? 0; + } + } + function bumpParseFailureStreak(sessionId) { + if (!persistentStreak()) { + const next = (parseFailureStreakFallback.get(sessionId) ?? 0) + 1; + parseFailureStreakFallback.set(sessionId, next); + return next; + } + try { + // 失效判断由 store 那一侧负责(同一个 staleAfterMs),这里不再自己算一遍—— + // 算两遍就等于有两份失效口径,迟早分叉。 + const written = Number(service.bumpDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })); + if (Number.isFinite(written) && written > 0) return written; + } catch (error) { + ctx.logger?.warn?.(`dsh-mneme: parse failure streak write failed: ${String(error)}`); + } + const next = readParseFailureStreak(sessionId) + 1; + parseFailureStreakFallback.set(sessionId, next); + return next; + } + function clearParseFailureStreak(sessionId) { + parseFailureStreakFallback.delete(sessionId); + if (!persistentStreak()) return; + try { + service.clearDistillParseFailureStreak(sessionId); + } catch (error) { + ctx.logger?.warn?.(`dsh-mneme: parse failure streak clear failed: ${String(error)}`); + } + } let disposed = false; /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */ @@ -982,8 +1036,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何 // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。 const raw = text || assembledText || ""; - const streak = (parseFailureStreak.get(session.id) ?? 0) + 1; - parseFailureStreak.set(session.id, streak); + const streak = bumpParseFailureStreak(session.id); // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。 // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随 // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与 @@ -997,7 +1050,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = if (skipWindow) { // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。 commitCursor(session.id, nextSeq); - parseFailureStreak.delete(session.id); + clearParseFailureStreak(session.id); } // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法, // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。 @@ -1019,7 +1072,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = } return; } - parseFailureStreak.delete(session.id); + clearParseFailureStreak(session.id); const parsed = parsedResult.entries; // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进 // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。 @@ -1257,7 +1310,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = inFlight.clear(); lastRunAt.clear(); lastDistilledSeq.clear(); - parseFailureStreak.clear(); + parseFailureStreakFallback.clear(); } }; } diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index 9fb704c1..ad857b74 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -14,6 +14,9 @@ import { STR, langOf } from "./lang.js"; // service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在 // 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。 import { isImportedRow } from "./memory-source.js"; +// 断链是围栏的共同底子:三处围栏(巩固/睡眠清单、蒸馏转录、画像条目)共用这一份, +// 不许各写一条(#493 补充 §③)。 +import { breakFenceMarkers } from "./fence.js"; export { validateDecisions, applyDecisions, withEffortFallback, withMaxTokensFallback, parseMaxTokensCap, describeStreamFailure, resolveDreamEffort, resolveRoute, streamText, runAuditedLlm }; // Extract the first JSON array from LLM output, tolerating markdown fences, @@ -333,7 +336,7 @@ export function fenceListBody(language, text, shape = "json") { const close = STR.memoryFenceClose[language] ?? STR.memoryFenceClose.en; const table = shape === "text" ? STR.dreamTextRoleReminder : STR.dreamRoleReminder; const reminder = table[language] ?? table.en; - const body = String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`); + const body = breakFenceMarkers(text); return `${reminder}\n\n${open}\n${body}\n${close}`; } diff --git a/dsh-mneme/src/fence.js b/dsh-mneme/src/fence.js new file mode 100644 index 00000000..bef6fa14 --- /dev/null +++ b/dsh-mneme/src/fence.js @@ -0,0 +1,24 @@ +// dsh-mneme/src/fence.js +// 围栏正文的「断链」——把内容里自带的 `<<<` / `>>>` 拆散,使它无法提前闭合我们围出来的围栏。 +// +// 为什么单独成模块(#493 的补充,报告者 §③):断链原先只长在 dream.js 的 fenceListBody +// 里,于是同一件事在三处各有一条命:dream/sleep 的清单(有断链)、蒸馏转录(没有)、 +// 画像提取的条目(也没有)。两处一旦分叉,下次出问题就得分别查一遍——#494 与 #501/PR +// #503 的重复劳动就是这么来的。判定逻辑只能有一份实现,这条纪律在本仓已经付过学费 +// (scopeStateGate 注释里「过滤点漂移」那条)。 +// +// 三个刻意的性质: +// - **只中和标记,不删内容**:被断链的文本仍逐字可读(零宽空格不占位),只是不再是 +// 一个能匹配围栏串的连续字符序列。吞掉正文会让「这段记忆说了什么」变成不可复原的。 +// - **纯函数、不带 nonce**:重试收到同输入时提示词必须逐字节相同,否则「同输入同错」 +// 这条诊断依据就没了(#493 的重试 20 次现场全靠它)。同一份文本调两次结果相同。 +// - **不认围栏名字**:三对围栏(转录 / 记忆清单 / 画像条目)都以 `<<<` 开头、`>>>` 收尾, +// 中和这两个前缀/后缀就够了;认名字反而会在新增围栏时漏一处。 +/** + * 断链:`<<<` / `>>>` 中间插一个零宽空格。 + * @param {string} text 任意正文 + * @returns {string} 断链后的正文(未触及的文本逐字节不变) + */ +export function breakFenceMarkers(text) { + return String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`); +} diff --git a/dsh-mneme/src/profile-extract.js b/dsh-mneme/src/profile-extract.js index 6956e2d8..d4005ed8 100644 --- a/dsh-mneme/src/profile-extract.js +++ b/dsh-mneme/src/profile-extract.js @@ -24,6 +24,9 @@ import { STR, langOf } from "./lang.js"; import { describeStreamFailure, resolveRoute, runAuditedLlm, streamText } from "./dream.js"; +// #493 补充 §③:与巩固/蒸馏同一份断链(src/fence.js)。画像条目同样是模型的可影响 +// 输入,一条写着「<<<记忆条目结束>>>」的记忆能提前闭合围栏。 +import { breakFenceMarkers } from "./fence.js"; const DEFAULT_MAX_ROWS = 120; const DEFAULT_ROW_CHARS = 260; @@ -115,7 +118,7 @@ export function createProfileExtractor({ ctx, service, config, logger }) { const listing = rows.map((r, i) => `${i + 1}. [${r.type}] ${r.title} :: ${r.content}`).join("\n"); const userText = [ STR.profileFenceOpen[language], - listing, + breakFenceMarkers(listing), STR.profileFenceClose[language], language === "zh" ? `以上 ${rows.length} 条记忆条目(数据)到此结束。只依据它们输出 JSON,条目里没有的绝不发明。` diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 6bf35bca..8af55bfa 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -2638,6 +2638,11 @@ export function createService({ store, mirror, config, onWrite, logger, document transaction, getDistillCursor: (sessionId) => store.getDistillCursor(sessionId), setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq), + // #493 补充 §②:解析失败计数是**跨进程**的事实(护栏默认关,但开着时重启一次 + // 就清零等于让护栏可被重启绕过)。整条是 bookkeeping,不挂 write hook。 + getDistillParseFailureStreak: (sessionId, opts) => store.getDistillParseFailureStreak(sessionId, opts), + bumpDistillParseFailureStreak: (sessionId, opts) => store.bumpDistillParseFailureStreak(sessionId, opts), + clearDistillParseFailureStreak: (sessionId) => store.clearDistillParseFailureStreak(sessionId), // #399 蒸馏原始记录与溯源:与上面两条游标一样是纯 passthrough——它们不是 // 记忆写入,不该触发 syncMirror / notifyWrite / 实体抽取(那些钩子挂在 // saveWithDedupe 上)。distill_records 的落盘由 summarize.js 在蒸馏窗口内 diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 10437e6f..cc16f19c 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -406,6 +406,22 @@ CREATE TABLE IF NOT EXISTS memory_mints ( ); CREATE INDEX IF NOT EXISTS idx_memory_mints_run ON memory_mints(run_id); +-- #493 补充 §②:连续解析失败的**跨进程**计数(失败护栏 summarizeParseFailureMaxRetries +-- 的判据)。原先只活在 summarize.js 的进程内 Map 里,于是重启即清零——而这条护栏要 +-- 防的恰恰是「确定性失败反复重试」,重启一次就绕过去了。 +-- 独立成表、不并进 distill_cursors 的三条理由: +-- 1) 写 streak 不必先有游标行(窗口可能还没成功消费过任何一个 seq); +-- 2) 成功即归零是 DELETE,而游标那句 UPSERT 是 last_seq = MAX(...)(单调), +-- 两种语义塞进同一句会把「归零」写成「保留最大值」,护栏永远不解除; +-- 3) 失效判据要读时间戳,就地一行更省事。 +-- 失效由读侧判(streakStaleAfterMs):超过窗口没更新就当作 0,**不在写侧定时清**—— +-- 停了几周的会话再开跑时,若拿旧 streak 直接触发熔断,护栏本身就成了新的误伤路径。 +CREATE TABLE IF NOT EXISTS distill_parse_failures ( + session_id TEXT PRIMARY KEY, + streak INTEGER NOT NULL, + updated_at TEXT NOT NULL +); + -- entity gene (v0.3.0): named entities mentioned across memories, with -- time-boxed attributes (valid_from → valid_until) and typed relations. -- Attributes follow the snapshot style: saveAttr invalidates the previous @@ -1237,6 +1253,59 @@ export function createStore(path) { return getDistillCursor(sessionId); } + /** + * 读指定会话的连续解析失败计数。 + * + * `staleAfterMs` 是**读侧**失效窗口(#493 补充 §②):超过这么久没更新就把计数 + * 当作 0,而不是拿几周前攒下的值去触发熔断。劣化方向选得很明确——宁可多试一次 + * (多花一次 LLM 调用),也不要因为「会话停了很久」而误判一个其实健康的窗口。 + * 传 0 或负数表示不判失效(照原样返回),测试用它构造边界。 + * @returns {number} 0 表示没有在途失败(含「已失效」与「从未记录」两种) + */ + function getDistillParseFailureStreak(sessionId, { staleAfterMs = 0, nowMs = Date.now() } = {}) { + if (typeof sessionId !== "string" || sessionId.length === 0) return 0; + const row = db.prepare( + "SELECT streak, updated_at FROM distill_parse_failures WHERE session_id = ?" + ).get(sessionId); + if (!row) return 0; + if (staleAfterMs > 0) { + const stamp = Date.parse(row.updated_at); + // 时间戳读不出来时按「已失效」处理:一个解析不了的 updated_at 不能当作「刚刚 + // 失败过」,否则坏数据会永久钉住一道护栏(fail-open,与整条护栏的默认关一致)。 + if (!Number.isFinite(stamp) || nowMs - stamp > staleAfterMs) return 0; + } + return Number(row.streak) || 0; + } + + /** + * 记一次解析失败(+1 并刷新时间戳)。返回写后的计数。 + * + * 先按 staleAfterMs 读一遍再**覆写**,而不是在 SQL 里做 streak + 1——失效判断只 + * 许有一份(就是上面那个读函数)。写成 + 1 的话,一条几周前的旧行会把「新的第一 + * 次失败」直接加成 2,等于失效窗口在这条路径上完全不生效(本块实现的首版就是 + * 这么写的,被回归用例抓出来)。 + */ + function bumpDistillParseFailureStreak(sessionId, { staleAfterMs = 0, nowMs = Date.now() } = {}) { + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new TypeError("bumpDistillParseFailureStreak: sessionId must be a non-empty string"); + } + const next = getDistillParseFailureStreak(sessionId, { staleAfterMs, nowMs }) + 1; + db.prepare(` + INSERT INTO distill_parse_failures (session_id, streak, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + streak = excluded.streak, + updated_at = excluded.updated_at + `).run(sessionId, next, nowIso()); + return next; + } + + /** 清零(不是写 0):没有在途失败时不该留一行,否则「有没有失败过」变得不可区分。 */ + function clearDistillParseFailureStreak(sessionId) { + if (typeof sessionId !== "string" || sessionId.length === 0) return 0; + return db.prepare("DELETE FROM distill_parse_failures WHERE session_id = ?").run(sessionId).changes; + } + function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) { const clauses = []; const params = []; @@ -4066,6 +4135,11 @@ export function createStore(path) { annotateLlmAudit, getDistillCursor, setDistillCursor, + // #493 补充 §②:解析失败计数跨进程持久化(读侧带失效窗口)。单独成表, + // 不并进 distill_cursors——那句 UPSERT 是单调的,归零语义会打架。 + getDistillParseFailureStreak, + bumpDistillParseFailureStreak, + clearDistillParseFailureStreak, listLlmAudits, countLlmAudits, getLlmAuditStats, diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index fa7ddad9..f9e30000 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -6,6 +6,9 @@ import { STR, langOf } from "./lang.js"; import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js"; // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。 import { createScopeResolver } from "./scope.js"; +// #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时 +// 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。 +import { breakFenceMarkers } from "./fence.js"; // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它—— // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。 import { randomUUID } from "node:crypto"; @@ -21,6 +24,12 @@ import { randomUUID } from "node:crypto"; // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。 // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。 const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000; +// #493 补充 §②:持久化的解析失败计数超过这么久没更新就按 0 算(读侧失效,见 +// store.getDistillParseFailureStreak)。7 天:够长,能跨一次长假/一次停机;够短, +// 不至于让「停了很久的会话」拿几周前的 streak 直接熔断一个其实健康的窗口。 +// 刻意不做成配置键:护栏本身默认关(summarizeParseFailureMaxRetries=0),一个只为 +// 护栏服务的失效窗口不该再占一档用户可见配置(AGENTS.md 旗标计数锁也要跟着动)。 +const PARSE_FAILURE_STREAK_STALE_MS = 7 * 24 * 60 * 60 * 1000; function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) { if (typeof raw !== "string") return undefined; const text = raw.trim(); @@ -469,7 +478,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是 // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。 // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。 - text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}` + text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${breakFenceMarkers(transcriptBody)}\n${STR.transcriptFenceClose[language]}` }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })] @@ -606,9 +615,54 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。 const lastDistilledSeq = new Map(); // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性 - // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即 - // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。 - const parseFailureStreak = new Map(); + // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即清零。 + // + // #493 补充 §②:**落库**,不再是进程内 Map。原先的 Map 有两个洞:重启即清零 + // (护栏可被重启绕过,而它防的正是「确定性失败反复重试」),以及进程内无法跨 + // 两个宿主共享同一个库时保持一致。库里读出来的值带读侧失效窗口(见 + // PARSE_FAILURE_STREAK_STALE_MS),停了很久的会话不会被旧 streak 误伤。 + // service 没这一步(旧装配 / 测试替身)时退回进程内 Map,行为与历史版本一致。 + const parseFailureStreakFallback = new Map(); + const persistentStreak = () => typeof service?.getDistillParseFailureStreak === "function" + && typeof service?.bumpDistillParseFailureStreak === "function" + && typeof service?.clearDistillParseFailureStreak === "function"; + function readParseFailureStreak(sessionId) { + if (!persistentStreak()) return parseFailureStreakFallback.get(sessionId) ?? 0; + try { + return Number(service.getDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })) || 0; + } catch (error) { + // 读数坏了不许把蒸馏本身拖下水:退回进程内计数(最坏是多试一次)。 + ctx.logger?.warn?.(`dsh-mneme: parse failure streak read failed: ${String(error)}`); + return parseFailureStreakFallback.get(sessionId) ?? 0; + } + } + function bumpParseFailureStreak(sessionId) { + if (!persistentStreak()) { + const next = (parseFailureStreakFallback.get(sessionId) ?? 0) + 1; + parseFailureStreakFallback.set(sessionId, next); + return next; + } + try { + // 失效判断由 store 那一侧负责(同一个 staleAfterMs),这里不再自己算一遍—— + // 算两遍就等于有两份失效口径,迟早分叉。 + const written = Number(service.bumpDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })); + if (Number.isFinite(written) && written > 0) return written; + } catch (error) { + ctx.logger?.warn?.(`dsh-mneme: parse failure streak write failed: ${String(error)}`); + } + const next = readParseFailureStreak(sessionId) + 1; + parseFailureStreakFallback.set(sessionId, next); + return next; + } + function clearParseFailureStreak(sessionId) { + parseFailureStreakFallback.delete(sessionId); + if (!persistentStreak()) return; + try { + service.clearDistillParseFailureStreak(sessionId); + } catch (error) { + ctx.logger?.warn?.(`dsh-mneme: parse failure streak clear failed: ${String(error)}`); + } + } let disposed = false; /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */ @@ -982,8 +1036,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何 // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。 const raw = text || assembledText || ""; - const streak = (parseFailureStreak.get(session.id) ?? 0) + 1; - parseFailureStreak.set(session.id, streak); + const streak = bumpParseFailureStreak(session.id); // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。 // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随 // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与 @@ -997,7 +1050,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = if (skipWindow) { // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。 commitCursor(session.id, nextSeq); - parseFailureStreak.delete(session.id); + clearParseFailureStreak(session.id); } // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法, // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。 @@ -1019,7 +1072,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = } return; } - parseFailureStreak.delete(session.id); + clearParseFailureStreak(session.id); const parsed = parsedResult.entries; // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进 // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。 @@ -1257,7 +1310,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = inFlight.clear(); lastRunAt.clear(); lastDistilledSeq.clear(); - parseFailureStreak.clear(); + parseFailureStreakFallback.clear(); } }; } diff --git a/dsh-mneme/test/dream-fence.test.js b/dsh-mneme/test/dream-fence.test.js index e9c73ddd..ce15d932 100644 --- a/dsh-mneme/test/dream-fence.test.js +++ b/dsh-mneme/test/dream-fence.test.js @@ -14,6 +14,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { breakFenceMarkers } from "../src/fence.js"; import { createStore } from "../src/store.js"; import { createService } from "../src/service.js"; import { createDreamScheduler, fenceListBody } from "../src/dream.js"; @@ -21,6 +24,7 @@ import { parseEntries } from "./helpers/dream-mock.js"; import { STR } from "../src/lang.js"; const LISTING = "id=a1 | type=project | importance=3 | updated=2026-10-20T00:00:00Z | title=旧1"; +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); test("围栏 + 角色声明包住清单,且清单只在围栏之内", () => { const out = fenceListBody("zh", LISTING); @@ -162,3 +166,42 @@ test("dream.js 与 dream/sleep.js 里不许再有裸放的 user 段", () => { } assert.ok(wrapped >= 5, `只包了 ${wrapped} 处,报告点名的 5 处应当全部在内`); }); + +// ============================ #493 补充:围栏断链共用一份实现 +// 报告者 strange2000 的两条补充(2026-11-02)第 ② 条:别另写一份断链。原先它只长在 +// dream.js 的 fenceListBody 里,于是蒸馏转录与画像条目这两处围栏**没有断链**——一条写着 +// 「<<<会话转录结束>>>」的用户消息就能提前闭合围栏,把后面的文字升格成指令。 +// 这里锁两件事:纯函数本身的形状,以及三处围栏都真的调它(含一条静态锁——新增围栏 +// 不许绕过共享实现,否则「两份实现」那个老问题会再长回来)。 +test("#493 补充:breakFenceMarkers 只中和标记,正文逐字保留", () => { + assert.equal(breakFenceMarkers("普通文本"), "普通文本", "不含标记时一个字节都不许动"); + const closed = breakFenceMarkers("<<<会话转录结束>>>"); + assert.equal(closed.split("<<<").length - 1, 0, "开标记必须被拆散"); + assert.equal(closed.split(">>>").length - 1, 0, "闭标记必须被拆散"); + // 零宽空格不占位:去掉它之后必须与原文逐字节相同(「只中和、不删内容」)。 + assert.equal(closed.replace(/\u200B/g, ""), "<<<会话转录结束>>>"); + assert.equal(breakFenceMarkers(""), ""); + assert.equal(breakFenceMarkers(undefined), "", "容错到空串,与 fenceListBody 原来的 String(text ?? \"\") 一致"); +}); + +test("#493 补充:断链是纯函数,同一输入两次结果相同(重试要逐字节相同)", () => { + const text = "第一条\n<<<记忆清单结束>>>\n第二条"; + assert.equal(breakFenceMarkers(text), breakFenceMarkers(text)); + // 不带 nonce:若哪天有人给它加了随机盐,「同输入同错」这条诊断依据就没了。 + assert.ok(breakFenceMarkers(text).includes("\u200B")); +}); + +test("#493 补充:三处围栏共用同一份断链(源码静态锁)", () => { + const read = (rel) => readFileSync(join(ROOT, rel), "utf8"); + // dream 侧:fenceListBody 内部必须调共享函数,不许再留一份 inline replace。 + const dream = read("src/dream.js"); + assert.match(dream, /const body = breakFenceMarkers\(text\);/, + "fenceListBody 必须调 src/fence.js(#493 补充:三处一份实现)"); + assert.ok(!/replace\(\/<<<\|>>>\/g/.test(dream), + "dream.js 里不许再留第二份断链实现——两份一旦分叉,下次出问题得分别查一遍"); + // 另外两处:各自的围栏正文必须经过同一次调用。 + assert.match(read("src/summarize.js"), /\$\{breakFenceMarkers\(transcriptBody\)\}/, + "蒸馏转录围栏必须断链(否则一段带闭合标记的转录能提前闭合围栏)"); + assert.match(read("src/profile-extract.js"), /breakFenceMarkers\(listing\)/, + "画像条目围栏同样要断链"); +}); diff --git a/dsh-mneme/test/store.test.js b/dsh-mneme/test/store.test.js index 07cfa2b1..f2ea69c3 100644 --- a/dsh-mneme/test/store.test.js +++ b/dsh-mneme/test/store.test.js @@ -689,3 +689,36 @@ test("#512 memory_mints:同一 (记忆, run) 只记一次,删记忆时显式 ); store.close(); }); + +// ============================ #493 补充:失败计数持久化(读侧失效) +// 这张表只服务一道默认关的护栏(summarizeParseFailureMaxRetries),但它的语义有三处 +// 容易写错、且错法都不可见:计数不落库(重启即清零)、失效不在读侧(几周前的失败 +// 直接熔断当前窗口)、清零写成 0(「从没失败过」与「刚清零」变得不可区分)。 +test("#493 补充:解析失败计数持久化、读侧带失效窗口、清零是真 DELETE", () => { + const store = openMemory(); + const sid = "s-streak"; + assert.equal(store.getDistillParseFailureStreak(sid), 0, "没有记录就是 0,不是 undefined"); + + assert.equal(store.bumpDistillParseFailureStreak(sid), 1); + assert.equal(store.bumpDistillParseFailureStreak(sid), 2, "累计加一"); + assert.equal(store.getDistillParseFailureStreak(sid), 2); + + // 读侧失效:时间戳挪到 30 天前,7 天窗口下必须当 0(不判失效时它仍是 2)。 + const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, sid); + assert.equal(store.getDistillParseFailureStreak(sid), 2, "staleAfterMs 缺省不判失效(调用方显式要才判)"); + assert.equal(store.getDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0, + "过期的 streak 必须读成 0:拿几周前的失败去熔断当前窗口,护栏就成了新的误伤路径"); + + // 失效之后再加一,必须从 1 起(不是在旧值上 +1)——这条是本块实现的首版 bug, + // 被 summarize.test.js 的端到端用例抓出来:SQL 里写 streak + 1 会让失效判断失效。 + assert.equal(store.bumpDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 1, + "过期的旧行不许把「新的第一次失败」加成 2"); + + // 清零是真删除:没有任何在途失败时不该留一行 0。 + assert.equal(store.clearDistillParseFailureStreak(sid), 1); + assert.equal(store.getDistillParseFailureStreak(sid), 0); + assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_parse_failures").get().c, 0, + "清零是 DELETE,不是写 0——写 0 会让「从没失败过」与「刚清零」不可区分"); + store.close(); +}); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index 1f457936..b7bf17ba 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -2127,3 +2127,99 @@ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", asy summarizer.dispose(); store.close(); }); + +// ============================ #493 补充:失败计数落库(不再只在进程内) +// 报告者 strange2000(2026-11-02)第 ① 条:别只存裸计数。原先 streak 活在 +// createSummarizer 闭包里的一个 Map,重启即清零——而这道护栏要防的恰恰是「确定性 +// 失败反复重试」,重启一次就绕过去了。两条用例各自钉一半: +// 1) 换一个 summarizer 实例(= 重启)后计数仍然接得上,阈值照旧触发; +// 2) 会话停了很久之后计数失效,不会被几周前攒下的 streak 误伤(他特别点名的那个 +// 反例:护栏自己变成新的误伤路径)。 +test("#493 补充:失败计数落库——换一个 summarizer 实例(模拟重启)仍然接得上", async () => { + const store = createStore(":memory:"); + const outputs = ["不是 JSON", "还不是 JSON", "依然不是 JSON"]; + let attempt = 0; + const failStream = () => (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: outputs[attempt++] ?? "[]" }; + yield { type: "finish", kind: "ok" }; + })(); + const config = { summarizeParseFailureMaxRetries: 3, distillRateLimitIntervalMs: 0 }; + const sessionOf = () => ({ + id: "s493-persisted-streak", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }] + }); + + // 第一个实例:失败两次就「重启」(dispose + 丢掉闭包),阈值 3 还没到。 + const first = setup(config, { store, stream: failStream }); + const handlerA = eventHandler(first.events); + const sessionA = sessionOf(); + await handlerA(sessionA, { seq: 2, type: "turn/end" }); + await handlerA(sessionA, { seq: 2, type: "turn/end" }); + first.summarizer.dispose(); + assert.equal(first.service.getDistillCursor("s493-persisted-streak"), undefined, "两次失败还没到阈值,游标不推进"); + assert.equal(first.store.getDistillParseFailureStreak("s493-persisted-streak"), 2, + "计数必须已经在库里——进程内 Map 在这里会是 0(重启就丢)"); + + // 第二个实例:同一个库、同一个会话。第 3 次失败必须凑满阈值并放弃窗口。 + const second = setup(config, { store, stream: failStream }); + const handlerB = eventHandler(second.events); + const sessionB = sessionOf(); + await handlerB(sessionB, { seq: 2, type: "turn/end" }); + + assert.equal(second.service.getDistillCursor("s493-persisted-streak")?.last_seq, 2, + "重启前攒下的 2 次 + 这次的 1 次 = 阈值,窗口必须被放弃(进程内 Map 到这儿是 1,不会放弃)"); + const abandoned = second.service.listLlmAudits().filter((r) => r.status === "error") + .find((r) => r.metadata?.skipped === "parse-failure-streak"); + assert.equal(abandoned?.metadata?.streak, 3, "审计里的 streak 是**累计**值,不是本次进程内从 1 起算"); + assert.equal(second.store.getDistillParseFailureStreak("s493-persisted-streak"), 0, + "放弃后清零:窗口已消费,下一窗从全新状态起算"); + second.summarizer.dispose(); + store.close(); +}); + +test("#493 补充:停了很久的会话,旧 streak 失效,不会被几周前的失败误伤", async () => { + const store = createStore(":memory:"); + let attempts = 0; + const { events, calls, service, summarizer } = setup( + { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 }, + { + store, + stream() { + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: attempts++ === 0 ? "不是 JSON" : "[]" }; + yield { type: "finish", kind: "ok" }; + })(); + } + } + ); + const handler = eventHandler(events); + const session = { + id: "s493-stale-streak", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("隔了很久才回来的窗口", 1), { seq: 2, type: "turn/end" }] + }; + + // 先在库里种一条「很久以前失败过一次」的记录(阈值 2,再有一次就会熔断)。 + service.bumpDistillParseFailureStreak("s493-stale-streak"); + const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, "s493-stale-streak"); + assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 1, "不判失效时它还在"); + assert.equal(store.getDistillParseFailureStreak("s493-stale-streak", { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0, + "读侧带失效窗口时必须当 0:30 天前的失败不算「连续失败」"); + + // 于是这次的第一次失败应当只算 streak=1,不熔断;成功一次即清零。 + await handler(session, { seq: 2, type: "turn/end" }); + const firstFail = service.listLlmAudits().find((r) => r.status === "error"); + assert.equal(firstFail?.metadata?.streak, 1, "旧记录失效之后,这次失败是「新的第一次」"); + assert.equal(firstFail?.metadata?.skipped, undefined, "不许因为几周前的失败就直接熔断当前窗口"); + assert.equal(service.getDistillCursor("s493-stale-streak"), undefined, "游标没被跳过,窗口仍可重试"); + + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 2); + assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 0, "成功即清零(DELETE,不是写 0)"); + summarizer.dispose(); + store.close(); +});