From 842a0aeff1b286d744de6ec4cc6e7f9cdea97117 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 12 Oct 2026 06:24:58 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(399):=20=E8=92=B8=E9=A6=8F=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=8E=9F=E5=A7=8B=E8=AE=B0=E5=BD=95=E7=95=99=E5=AD=98?= =?UTF-8?q?=20+=20=E8=AE=B0=E5=BF=86=E7=BA=A7=E6=BA=AF=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 蒸馏把会话压成记忆后原文随即消失,于是「这句原文没进任何记忆」永远 不可测。新增两张表把蒸馏从「替换」降级为「叠加索引」(arXiv 2609.36059 §3 Eq.1:与原文并存的旁路索引不丢信息): - distill_records(session_id, seq, role, text, window_id, distilled_at) 主键 (session_id, seq) 承担幂等——max-runs 门耗尽额度或宿主重启后会 重跑同一 seq 窗口,重跑不产生重复记录。落盘文本不受 distillMaxChars 与 trim() 三刀约束:留存的意义就是整段原文。 - memory_provenance(memory_id, session_id, seq_start, seq_end, created_at) 窗口级归因用 seq 区间而非逐事件外键。到期裁剪只删 distill_records, 链接保留指向空区间(显示「来源已过期」);删记忆只清链接,记录留住。 隐私:渲染与落盘共用 publicTranscriptParts 单一谓词,只认用户提示、 助手公开 text 块、子会话交付;reasoning 私有推理、插件注入的机器内容、 工具入参与结果、code 输出一律不落盘(工具/code 仍进 LLM 转录)。 开关 opt-in 默认关:distillRecords.enabled / retentionDays(默认 90), 进 settings 白名单 + NESTED_FLAG_PATHS + LIGHT_MODE_OFF;启动期裁剪挂 llmAudit purge 旁;reclaim dry-run 报告加第三项 distill_records。 回归锁 8 条:幂等重跑、隐私过滤、未截断 vs 渲染截断、裁剪不毁资产、 enabled=false 零写入、白名单校验、lightMode 预设、boot purge、reclaim。 --- README.md | 2 + SECURITY.md | 16 ++ dsh-mneme/CHANGELOG.md | 2 + dsh-mneme/README.md | 2 + dsh-mneme/bin/cli.mjs | 6 +- dsh-mneme/docs/CONFIGURATION.md | 13 +- dsh-mneme/docs/DISTILL.md | 160 ++++++++++++++++++ dsh-mneme/docs/STORAGE.md | 8 +- dsh-mneme/lib/api.js | 6 +- dsh-mneme/lib/config.js | 22 ++- dsh-mneme/lib/index.js | 14 ++ dsh-mneme/lib/maintenance.js | 28 ++- dsh-mneme/lib/service.js | 7 + dsh-mneme/lib/settings.js | 12 +- dsh-mneme/lib/store.js | 129 ++++++++++++++ dsh-mneme/lib/summarize.js | 225 ++++++++++++++++++++----- dsh-mneme/src/api.js | 6 +- dsh-mneme/src/config.js | 22 ++- dsh-mneme/src/index.js | 14 ++ dsh-mneme/src/maintenance.js | 28 ++- dsh-mneme/src/service.js | 7 + dsh-mneme/src/settings.js | 12 +- dsh-mneme/src/store.js | 129 ++++++++++++++ dsh-mneme/src/summarize.js | 225 ++++++++++++++++++++----- dsh-mneme/test/api.test.js | 16 +- dsh-mneme/test/llm-audit.test.js | 37 ++++ dsh-mneme/test/maintenance.test.js | 41 ++++- dsh-mneme/test/pre-inject-gate.test.js | 10 ++ dsh-mneme/test/settings.test.js | 8 + dsh-mneme/test/summarize.test.js | 137 +++++++++++++++ 30 files changed, 1233 insertions(+), 111 deletions(-) create mode 100644 dsh-mneme/docs/DISTILL.md diff --git a/README.md b/README.md index bbb86a28..0722f432 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 | stdio MCP server——Claude Code / Cursor 等任意 MCP 客户端接入记忆六件套 | [dsh-mneme/README.md · MCP Server](dsh-mneme/README.md#mcp-server任意-mcp-客户端接入) | | 独立服务 daemon(不开 DSH 常驻数据面) | [dsh-mneme/docs/DAEMON.md](dsh-mneme/docs/DAEMON.md) | | 配置说明(全键参考) | [dsh-mneme/docs/CONFIGURATION.md](dsh-mneme/docs/CONFIGURATION.md) | +| 蒸馏原文留存与记忆级溯源(#399) | [dsh-mneme/docs/DISTILL.md](dsh-mneme/docs/DISTILL.md) | | 实体结构化设计 | [dsh-mneme/docs/ENTITIES.md](dsh-mneme/docs/ENTITIES.md) | | 语义架构 | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) | | 本地模型部署指南 | [dsh-mneme/docs/LOCAL_MODEL.md](dsh-mneme/docs/LOCAL_MODEL.md) | @@ -387,6 +388,7 @@ Community projects that wire mneme into more platforms — not part of this repo | stdio MCP server — plug the six memory tools into any MCP client (Claude Code / Cursor / …) | [dsh-mneme/README.md · MCP Server](dsh-mneme/README.md#mcp-server任意-mcp-客户端接入)(中文) | | Standalone daemon (serve memories without DSH) | [dsh-mneme/docs/DAEMON.md](dsh-mneme/docs/DAEMON.md)(中文) | | Configuration reference (all keys) | [dsh-mneme/docs/CONFIGURATION.md](dsh-mneme/docs/CONFIGURATION.md)(中文) | +| Distill record retention & memory-level provenance (#399) | [dsh-mneme/docs/DISTILL.md](dsh-mneme/docs/DISTILL.md)(中文) | | Entity structure design | [dsh-mneme/docs/ENTITIES.md](dsh-mneme/docs/ENTITIES.md) | | Semantic architecture | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) | | Local model guide | [dsh-mneme/docs/LOCAL_MODEL.md](dsh-mneme/docs/LOCAL_MODEL.md) | diff --git a/SECURITY.md b/SECURITY.md index f2f40fee..ddd189e2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -287,6 +287,7 @@ All audit data is stored **locally** in the plugin database and never leaves the | `failure_memories` audit logs | Local database | Every failed write is recorded; used for forensic analysis | | `recall_run` search logs | Local database | Every search is recorded with timestamp and query metadata | | AutoDream receipts | Local database + Markdown mirror | Cryptographically verifiable; persist until user deletes them | +| `distill_records` raw window text (#399) | Local database | **Opt-in, off by default.** Only when `distillRecords.enabled: true`: the raw prompt/answer text of distilled windows is kept verbatim (no truncation), capped by `retentionDays` (default 90). Never includes private reasoning, plugin-injected content, tool arguments or tool output. Retention trims the text only — provenance links stay (they point at an empty range, shown as "source expired") | | Dependency scan reports | OrbisAI Security dashboard | Not stored on device | Retention is user-controlled: audit records live only as long as the local database does. There is no remote logging and no telemetry. @@ -335,6 +336,13 @@ dsh-mneme: llmAudit: enabled: true retentionDays: 90 + + # Raw distilled-window text is off by default (data minimization). + # Turn it on only if you need to audit what a memory was distilled from: + # text is kept verbatim (untruncated), so enable a short retentionDays. + distillRecords: + enabled: false + retentionDays: 90 ``` --- @@ -645,6 +653,7 @@ This project is licensed under the **MIT License**. See [LICENSE](https://github | `failure_memories` 审计日志 | 本地数据库 | 每次写入失败均记录,供取证分析 | | `recall_run` 搜索日志 | 本地数据库 | 每次搜索均记录时间戳和查询元数据 | | AutoDream receipts | 本地数据库 + Markdown 镜像 | 密码学可验证;保留至用户删除 | +| `distill_records` 蒸馏窗口原文(#399) | 本地数据库 | **opt-in,默认关。** 仅当 `distillRecords.enabled: true` 时:被蒸馏窗口的用户提示与助手公开正文**逐字留存**(不截断),受 `retentionDays`(默认 90)约束。**绝不**包含私有推理链、插件注入的机器内容、工具入参与工具输出。到期裁剪只删原文,溯源链接保留(指向空区间,显示「来源已过期」) | | 依赖扫描报告 | OrbisAI Security 控制台 | 不存储于设备 | 保留期由用户控制:审计记录仅随本地数据库存续。无远程日志、无遥测。 @@ -693,6 +702,13 @@ dsh-mneme: llmAudit: enabled: true retentionDays: 90 + + # 蒸馏窗口原文默认关闭(数据最小化)。 + # 只有需要复核「一条记忆蒸馏自哪段对话」时才打开: + # 原文逐字留存(不截断),请同时设一个较短的 retentionDays + distillRecords: + enabled: false + retentionDays: 90 ``` --- diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 88fc7da8..efe8d436 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -9,6 +9,8 @@ ## 🆕 新增 +- **蒸馏原文留存 + 记忆级溯源(issue #399,opt-in 默认关)**:蒸馏是一条**有损**管道——它把窗口喂给 LLM 拿回几条记忆,然后消费掉游标,原文此后在库内没有任何痕迹。后果是三件事同时成立:记忆没写进去的细节事后**无法回查**(不是没保存,是没地方找);一条记忆**说不清自己出自哪段对话**;蒸馏质量出问题时**没有基线可比**。信息论依据(arXiv 2609.36059 §3 Eq.1)很直白:旁路索引不丢信息——`I(Y;M,H,X)=I(Y;M,H)`,留存原文不改变已蒸馏产物的互信息,但取回原文的能力被保住了。所以这不是「再存一份」的冗余,而是把单向替换变成可回指的**叠加索引**。形态是两张只增不改的新表(`CREATE TABLE IF NOT EXISTS`,无 ALTER、无回填、**不动 `memories`**):`distill_records`(蒸过的窗口原文,主键 `(session_id, seq)`,`INSERT OR IGNORE` 幂等——同一窗口重蒸不翻倍且保留首次落盘时间)与 `memory_provenance`(`(memory_id, session_id, seq_start, seq_end)` 唯一索引 + `ON CONFLICT DO NOTHING`,每条新记忆回指来源窗口)。写入点在 `summarize()` 里**顺手落**:那里已经持有会话事件、已经算出窗口边界,不新建常驻管道、不新开监听器,于是留存范围与蒸馏范围天然一致;落盘与记忆写入同事务(游标消费也在同一个事务里),不存在「记忆写了但原文没落」的半截状态。**最要紧的约束是隐私谓词只有一份**:渲染与落盘都从 `publicTranscriptParts(event)` 取数,因为两条路各写一遍过滤逻辑必然漂移,而漂移方向必然是「存下来的比 LLM 看过的更多」——模型没看到的私有内容反而进了持久库,最糟的方向。口径:真实用户提示与助手公开 `text` 块两条路都进;私有推理链、插件注入的机器内容(AGENTS.md / skill 正文 / 文件变更通知)两条路都不进;工具调用入参与结果、code 输出**只进渲染、不落盘**——这是刻意的路径差异而非漂移(`codingRetrospect` 靠工具输出沉淀踩坑根因,渲染路径必须带它;但它体积大、含路径与命令、不属于对话内容),差异方向仍满足「落盘的 ⊆ 渲染的」。留存文本**不受** `distillMaxChars`(默认 24000,超长保尾部)与工具裁剪 `trim(args,300)` / `trim(out,500)` 约束——截断过的「原文」没有回查价值。删除语义两分:到期裁剪**只删 `distill_records`**,溯源链接保留指向空区间(面板显示「来源已过期」而不是查无此记忆),**绝不级联删记忆**;删记忆是另一条路,`store.remove(id)` 在事务内显式清链接(不靠 `ON DELETE CASCADE`——`store.js` 从不启用 `PRAGMA foreign_keys`,翻它会顺带激活 `recall_events → recall_runs` 那条既有约束的行为,不是本次范围)。配置照 `llmAudit` 形状:`distillRecords: { enabled: false, retentionDays: 90 }`,默认关 + 进 `LIGHT_MODE_OFF`(`retentionDays` 不进——它是时长不是行为面);两个键都进 settings 白名单与 api.js `NESTED_FLAG_PATHS`(#254 的教训:漏了后者 effective 会静默缺键)。启动期裁剪与 `reclaim` 都读装配后的 cfg(面板改了保留期也认),且默认关时启动期不动手(一张空表不该被每次启动扫一遍);`dsh-mneme reclaim` 报告从两项变三项,入口语义一字未动。已知边界三条:**无历史回填**(只对此后发生的蒸馏生效,从记忆反推原文是编造)、**从未蒸馏过的窗口不留记录**(窗口太小已消费游标 / 插件当时关着 / 没触发过蒸馏 / LLM abort,是「留存范围 = 蒸馏范围」的推论而非丢数据)、**不覆盖工具结果与 code 输出**。回归锁六条 + 五条:幂等(删游标换实例重跑同窗口仍一行)、隐私过滤(四类注入 + reasoning + 工具输出绝不落盘,而渲染路径仍带工具输出)、未截断(超长提示记录里逐字全文、渲染里找不到头部标记)、裁剪不毁资产(原文 0 / 链接在 / 记忆数不变 / `provenanceFor` 仍可查)、默认关零写入(写入调用计数为 0,连收集都不做)、进 reclaim dry-run 报告(报真实行数与列文本大小且一字节不改,执行只删窗口外原文),另加启动期裁剪读装配 cfg、白名单与 PUT 往返(含范围外整数被拒)、轻量模式只关 `enabled` 不动 `retentionDays`。非目标:不嵌入、不进 `fuseRecall`、不进注入、不做取代判定、不做 value histories 查询、不动 8000/300/500 三刀——这两张表是旁路索引,不是第二条检索路(接进召回是另一个决定,成本与召回质量都要重新测)。设计文档见 [docs/DISTILL.md](docs/DISTILL.md)。 + - **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、请求体逐字只有 type/title/content/importance/tags、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。评审期补的两处:**成功路径不得重置 `busy`**——提示要停 900ms 才关窗,那段时间按钮若重新可点,第二次同 type+title 会落进同一行并由 `appendContent` 把正文再追加一遍,提示还会从「已新建」翻成「已并入」,一次误触就写坏一条记忆;因此整个弹层只允许错误路径重置 `busy`,按钮文案也随之改成 `busy && !msg`,免得写完了还写着「保存中…」。另把字段锁从「整包里包含这些词」改成在 `CreateDialog` 源码切片上匹配真实请求体写法(在整包上断言 `clientSource.includes("title")` 恒真,等于没锁)。两侧都做了变异检验:面板/后端逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「成功路径放回 setBusy(false)」「按钮文案只看 busy」「表单长回 scope」「不 trim title」「打错路由」这类只有肉眼才能发现的退化)。 - **面板存储回收入口(issue #386 第 4 条)**:库文件只增不减,回收一直只能靠命令行(`dsh-mneme-maintenance`)或独立服务的数据面,面板侧连「现在能收多少」都看不见。状态页新增「维护」分区与「存储回收」卡:挂载即 GET 一次预估,摆出文件体积、空闲页、本次会清掉多少条输入快照与多少条归档向量;改窗口后必须点「重新估算」,数字与输入框不一致时执行钮保持禁用——对不可逆动作来说,确认态里摆着另一个窗口的数字是最坏的一种错位。执行是**两步确认**:第一次点击只进确认态(换成仓库既有的实心红 `mneme-btndangerconfirm`),确认态里把窗口与 VACUUM 状态一并锁死显示,第二次点击才发出请求,请求体逐字 `{dryRun:false, confirm:true, olderThanDays:Number(days), vacuum}`。**VACUUM 默认关**:它会短暂独占数据库(别的进程此时写入会 `SQLITE_BUSY`),属可选动作,默认开等于每次回收都替用户独占一次库。**VACUUM 失败不得渲染成干净的成功**:两个 UPDATE 各自提交且不可逆,VACUUM 只负责把文件缩回去,半成功态单独出文案说明「清理已完成、不可逆、重跑幂等」,并且无论成败都露出回执号(缺失时明写「未记录」)——不可逆动作没有回执,出事时无法对账。面板侧不使用 `window.confirm`(宿主 webview 里不可靠),确认门是渲染出来的分支而不是阻塞弹窗。后端新增路由 `GET/POST /api/dsh-mneme/maintenance/reclaim`,底下就是 `src/maintenance.js` 那一份与 CLI、standalone 共用的实现,**未改动数据面**(`src/api-standalone.js`、`src/maintenance.js` 零改动):GET 恒为只读预估;POST 默认 dry-run,要真执行必须带 `dryRun:false` + `confirm:true`,否则回 400 `confirm-required` 并**附上那份 dry-run 报告**(确认的依据就是报告里的数字,且报告必须描述「将要执行的那个动作」——带了 `vacuum` 就得看到会 VACUUM 的那份,否则用户照着「不 VACUUM」的估算按下去)。两个方法都要鉴权:这是不可逆内容丢弃的入口,不能有例外。窗口解析在两侧**刻意不同口径**:GET 是 query 字符串域,只收十进制整数字面量(`/^\d+$/`),**不用 `Number()`**——它把空串、空数组、纯空格都化成 0,而 0 在这里的含义是「清掉全部快照」,最坏解释绝不能从一个畸形输入静默拿到;POST 收 JSON 数字域,字符串 `"3"`、`-1`、`3.5`、`true`、`{}`、`[]` 一律 400(`NaN` 不在坏输入列表里:JSON 表达不了它,`stringify` 会把它变成 `null`,而 `null` 与缺省同义、落默认窗口)。边界值 0 两侧都合法。`invalid-json` 与 `invalid-body` 分开报(前者查调用方序列化坏了,后者查把数组当 body 发了),不用会吞成 `{}` 的 `parseBody`。报告**不加包装层**,字段就在顶层——面板与 standalone 两个客户端必须看到同一个字段路径。接线:`createApi` 增第 9 个参数 `maintenance`,`src/index.js` 把实例构造前移到 webServer 块之前并同时传给面板与 standalone(构造晚了面板路由拿到 `null`,功能「看起来加了」但每次都回 503;少传则如实报 503 `maintenance-unavailable` 而不是把 `undefined.reclaim` 落成崩溃,既有 14 处不传第 9 参的调用照旧工作)。回归锁 14 条进 `test/api-routes-gaps.test.js`(鉴权、GET 只读且畸形窗口不落最坏解释、POST 默认 dry-run 且三个改库原语一个都没被调用、确认门回带同窗口报告、执行真清且行数不变只置列、非布尔 vacuum、invalid-json 与 invalid-body 分开、POST 数字域、缺依赖 503、抛错映射 500 且 logger 留住原始错误文本、非 GET/POST 落 404、报告无包装层、执行时 VACUUM 真的跑、确认报告复述调用方要的窗口),面板侧 8 条进 `test/client.test.js`(32 个 i18n 键中英成对且分区真的渲染、路由是 in-process 那条而非数据面、请求体逐字含确认门且全卡只有一处 POST、无 `window.confirm`、卡片真的挂在状态页维护分区下、成功态只在确认门另一侧且 stale 时禁用、VACUUM 失败与回执如实渲染、VACUUM 默认关且非 2xx 带出服务端 error 串且成功后复位确认态)。**独立验证**:第三方复核方独立重做了实现者报的全部 19 条后端与 12 条面板变异(全部 RED),并**额外找出 5 条契约硬要求没被任何锁钉住的覆盖洞**(最严重的是「VACUUM 默认关」——危险默认没有任何测试守住),已逐条补锁并同样做变异检验转红;另有一处诚实记录:去掉 GET 内层 catch 后**只断言状态码抓不住**(外层 catch 也兜 500),必须靠注入 logger 断言原始错误文本留住才算真锁住。**评审期(PR #410)另修三处**:① `olderThanDays` 补**上界** `MAX_RECLAIM_WINDOW_DAYS = 3650`(与 `llmAudit.retentionDays`、`recallRetentionDays` 同值),GET 与 POST 两侧统一——整数校验挡不住量级,`1e20` 也是整数,而 `maintenance.reclaim` 会拿它算 `now() - days * 86400000` 再 `toISOString()`,超出 `Date` 有效范围就抛 `RangeError`;GET 侧还要额外挡 `Number.isFinite`,否则 400 位十进制字面量化成 `Infinity` 会被当成畸形输入静默落回默认窗口 7——调用方要一个巨大窗口,实际只清了 7 天外的东西,且报告上写着 7 看不出异样。② `confirm-required` 分支里那份 dry-run 报告挪进 `try/catch`:它在 `readBody` 的回调里计算,外层 try 那时已经退栈,抛出去就是一个没人接的 rejection——调用方拿到的是「一个字节都没回」,而不是执行路径给出的 500 `internal`。③ 面板「未记录」文案改成「未记录(审计未启用或回执写入失败)」:`writeReceipt` 返回 null 有两种成因(审计被关、或回执写库失败只 warn),旧文案把后者说成前者,而这行出现在不可逆操作的结果区,用户会照它去对账。面板同时补上本地窗口上界(超界直接提示窗口不合法,不发请求,免得用户把 400 看成网络故障)。补锁三条并逐条变异转红(GET/POST 两侧量级、confirm-required 抛错映射 500、文案必须同时点出两种成因)。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 73514a97..d467bc99 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -437,6 +437,7 @@ dsh web | `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 | | `writeAdmission` | `{enabled:false, enforce:false}` | 写入准入(issue #254,默认关):把「这条该不该进库」前移到 LLM 之前。第 1 级是零 LLM 的确定性判据——空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入,本批只定义接口)。`enabled` 跑判据并落审计行(`llm_audit_logs` 的 `metadata.deny`),`enforce` 才真的拒(`store.save` 之前返回,`memory_save` 返回 `action:"denied"` + `reason`);只开 `enabled` = 仅告警、写入不拦。去重键命中不进第 1 级(归 write-update 放行),G1/G2 阈值只计量。也走 feature_flags 白名单 | | `preInjectGate` | `{enabled:false, enforce:false}` | 注入前判定(issue #380,默认关):每帧注入候选出池后、进入 system prompt 前,一次池级 LLM 调用判出「会向本次请求注入意见/立场」的记忆(判定协议与 E12 实验同源;实测过滤把谄媚 26.9%→2.5%,且判定结果绝不进模型上下文——给模型看标记的线已被 E2/E3 关闭)。`enabled` 跑判定并落审计行(真实 token 用量 + 被标记记忆 id),`enforce` 才在缓存命中帧真滤除;冷缓存首帧降级为原样注入(宿主注入渲染是同步回调,判定走 prefetch+cache)。pin 池与检索线索行豁免;判定失败原样注入不阻塞;整池被标记时放弃本次滤除(fail-open);判定连续失败 3 次熔断一个冷却期;无默认路由时审计行 `model_id` 落 `unknown` 占位;`llmAudit` 关闭时本闸不运行;lightMode 强制关。也走 feature_flags 白名单 | +| `distillRecords` | `{enabled:false, retentionDays:90}` | 蒸馏原文留存 + 记忆级溯源(issue #399,默认关):把蒸馏从「替换」降级为「叠加索引」——蒸过的窗口原文旁路落 `distill_records`(一个事件一行,主键 `(session_id, seq)`,`INSERT OR IGNORE` 幂等),每条新记忆落 `memory_provenance` 回指来源窗口(`(memory_id, session_id, seq_start, seq_end)` 唯一)。**渲染与落盘共用同一隐私谓词**:私有推理链、插件注入的机器内容(AGENTS.md / skill 正文 / 文件变更通知)两条路都不进;工具调用入参与结果、code 输出**只进渲染、不落盘**(渲染是 `codingRetrospect` 的输入,落盘只留对话)。留存文本**不受** `distillMaxChars` / 工具裁剪约束(原文就是原文)。到期裁剪只删 `distill_records`,溯源链接保留指向空区间(面板显示「来源已过期」),**绝不级联删记忆**;删记忆时显式清链接。无历史回填、从未蒸馏的窗口不留记录。进 `dsh-mneme reclaim` 报告第三项;lightMode 强制关。也走 feature_flags 白名单 | | `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) | | `conflictFreezeMaxPending` | `100` | 冲突冻结队列最大挂起数(1-1000):超出后不再入队(防队列无限膨胀) | | `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) | @@ -639,6 +640,7 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动 > 设计文档位于仓库根 `docs/`,链接以 `../docs/` 相对路径指向(GitHub 上从本目录打开可正常跳转)。 - [配置说明(全键参考)](docs/CONFIGURATION.md) +- [蒸馏原文留存与记忆级溯源](docs/DISTILL.md) - [实体结构化记忆设计](docs/ENTITIES.md) - [语义增强架构](docs/SEMANTIC.md) - [本地模型部署指南](docs/LOCAL_MODEL.md) diff --git a/dsh-mneme/bin/cli.mjs b/dsh-mneme/bin/cli.mjs index f3ae0014..99512d19 100755 --- a/dsh-mneme/bin/cli.mjs +++ b/dsh-mneme/bin/cli.mjs @@ -579,10 +579,12 @@ function printReclaim(report, { applied }) { const size = report?.size ?? {}; console.log(`存储无损回收(${applied ? '已执行' : 'dry-run,未改动任何数据'})`); console.log(` 输入快照保留窗口: ${report?.olderThanDays ?? '?'} 天(cutoff ${report?.cutoff ?? '?'})`); - const rows = (o) => `dream_runs.input ${o.dream_run_inputs ?? 0} 行 / 归档行向量 ${o.archived_embeddings ?? 0} 行`; + // 第三项(#399):蒸馏原文的到期裁剪同样只删 distill_records,溯源链接刻意保留 + const rows = (o) => + `dream_runs.input ${o.dream_run_inputs ?? 0} 行 / 归档行向量 ${o.archived_embeddings ?? 0} 行 / 蒸馏原文 ${o.distill_records ?? 0} 行`; console.log(` ${applied ? '已清理' : '将清理'}: ${rows(applied ? cleared : planned)}`); console.log( - ` 列文本大小(上界,非回收量): ${formatBytes(columnBytes.dream_run_inputs)} / ${formatBytes(columnBytes.archived_embeddings)}` + ` 列文本大小(上界,非回收量): ${formatBytes(columnBytes.dream_run_inputs)} / ${formatBytes(columnBytes.archived_embeddings)} / ${formatBytes(columnBytes.distill_records)}` ); if (!applied) { console.log( diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index d8d79e78..3ef4697d 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -86,6 +86,17 @@ | `memoryQualityFilter.minContentLength` | `10` | 最短内容长度(字符) | — | | `memoryQualityFilter.exemptImportance` | `4` | importance ≥ 此值豁免静默自动归档(#135) | 设 1 = 等效关自动归档 | +## 蒸馏原文留存与溯源(#399) + +把蒸馏从「替换」降级为「叠加索引」:蒸过的窗口原文旁路落 `distill_records`,每条新记忆落 `memory_provenance` 回指来源窗口。**渲染与落盘共用同一隐私谓词**(`publicTranscriptParts`)——私有推理链与插件注入的机器内容两条路都不进;工具入参与结果只进渲染、不落盘。留存文本不受 `distillMaxChars` 与工具裁剪约束。详见 [蒸馏原文留存与记忆级溯源](DISTILL.md)。 + +| 键 | 默认 | 作用 | 开启后果 / 冲突 | +|---|---|---|---| +| `distillRecords.enabled` **light** | `false` | 蒸馏时顺带留存窗口原文 + 写记忆级溯源链接 | opt-in;关 = 一行不写、连收集都不做,行为与 #399 之前逐字节一致 | +| `distillRecords.retentionDays` | `90` | 原文保留天数(1–3650) | 启动期与 `dsh-mneme reclaim` 两处裁剪;**只删 `distill_records`**,溯源链接保留指向空区间(显示「来源已过期」),绝不级联删记忆 | + +两个键都走 feature_flags 白名单(面板可启停);`enabled` 进 lightMode 预设关闭清单,`retentionDays` 不进(它是时长不是行为面,用户显式设过就照旧)。`dsh-mneme reclaim` 的报告从两项变三项(`planned` / `column_bytes` / `cleared` 各多一个 `distill_records`),入口语义(默认 dry-run、`--apply` 才动手)不变。已知边界:无历史回填、从未蒸馏过的窗口不留记录、不覆盖工具结果与 code 输出。 + ## 注入 | 键 | 默认 | 作用 | 开启后果 / 冲突 | @@ -258,7 +269,7 @@ `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` · `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` · `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` · -`preInjectGate.enabled`(#380) +`preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399) 预设只是默认值而非强制:用户显式写进 feature_flags 的值在装配顺序上后展开、仍然生效 (「用户开关 > 轻量预设 > bundle 配置」)。 diff --git a/dsh-mneme/docs/DISTILL.md b/dsh-mneme/docs/DISTILL.md new file mode 100644 index 00000000..a08956c1 --- /dev/null +++ b/dsh-mneme/docs/DISTILL.md @@ -0,0 +1,160 @@ +# dsh-mneme 蒸馏原文留存与记忆级溯源(issue #399) + +- **日期**:2026-10-12 +- **状态**:✅ 已实现(opt-in,默认关) +- **范围**:把蒸馏从「替换」降级为「叠加索引」——窗口原文旁路留存 + 记忆回指来源窗口 +- **相关文档**:[存储与回收](STORAGE.md)(#275 第一批,同一条 reclaim 入口) · [配置参考](CONFIGURATION.md) · [压缩边缘抢救](CONTINUITY.md) + +## 1. 问题 + +蒸馏(`src/summarize.js`)是一条**有损**管道:它把一段会话窗口喂给 LLM,拿回几条结构化 +记忆,然后**消费掉游标**——窗口原文此后在库内没有任何痕迹。于是三件事同时成立: + +1. 记忆里没写进去的细节,事后**无法回查**(不是「没保存」,是「没地方找」); +2. 一条记忆**说不清自己出自哪段对话**,人工复核只能靠标题猜; +3. 蒸馏质量出问题时,**没有基线可比**(原文已经不在手上)。 + +信息论的依据很直白(arXiv 2609.36059 §3 Eq.1):旁路索引不丢信息—— +`I(Y;M,H,X) = I(Y;M,H)`,留存原文 `X` 不改变 `Y` 与已蒸馏产物 `(M,H)` 的互信息, +但**取回** `X` 的能力被保住了。所以这不是「再存一份」的冗余,而是把单向替换变成 +可回指的叠加索引。 + +## 2. 形态:两张表,只增不改 + +``` +distill_records(session_id TEXT, seq INTEGER, role TEXT, text TEXT, + window_id TEXT, distilled_at TEXT, PRIMARY KEY(session_id, seq)) +idx_distill_records_window(window_id) +idx_distill_records_distilled_at(distilled_at) + +memory_provenance(memory_id TEXT, session_id TEXT, seq_start INTEGER, seq_end INTEGER, + created_at TEXT) +UNIQUE idx_memory_provenance_link(memory_id, session_id, seq_start, seq_end) +``` + +- `CREATE TABLE IF NOT EXISTS`:**无 ALTER、无回填**,存量库启动即建表,旧库零迁移成本。 +- **不动 `memories`**:溯源是旁路,不往记忆表加列。删掉这两张表,插件行为逐字节回到 #399 之前。 +- `memory_id` 用 **TEXT**(issue 草稿里写的是 INTEGER):`memories.id` 在本仓一直是 TEXT + (`src/store.js` 的 `save()` 铸 UUID 风格字符串),写成 INTEGER 只会让 SQLite 按 + TEXT 亲和存进去、却在类型声明上误导后来人。 +- 主键 `(session_id, seq)` 决定了**一个事件一行**:`seq` 是会话事件序号,一次事件可能携带 + 多条公开文本(`agent-message` / `inbox` / `spliced` 可以一次投递多条子会话消息),它们 + 按事件合并成一行(`role` 取首条、`text` 以换行连接)。各写各的行会被 + `INSERT OR IGNORE` 静默丢掉后几条——那是**丢数据**,不是去重。 +- 幂等靠 `INSERT OR IGNORE`(记录)+ 唯一索引 `ON CONFLICT DO NOTHING`(链接): + 同一窗口重蒸一次不翻倍,且**保留首次落盘时间**(`distilled_at` 是「原文何时被留下」的 + 事实,重跑改写它才是撒谎)。 + +## 3. 写入点:蒸馏时顺手落,不新建常驻管道 + +`summarize()` 触发时已经持有会话事件、已经算出窗口边界(`previousSeq` / `triggerSeq`), +原文留存就在那里顺带落盘——**不新开监听器、不常驻订阅会话事件**。好处是留存范围与 +蒸馏范围**天然一致**:蒸过的窗口才留,没蒸过的窗口不留(见 §7 已知坑 2)。 + +落盘与记忆写入同事务(`service.transaction`):两者要么一起进,要么一起退。游标消费也在 +同一个事务里——「记忆写了但原文没落」这种半截状态不存在。 + +## 4. 隐私:渲染与落盘共用同一谓词 + +这是本次改动**最要紧**的约束。原文留存一旦与喂给 LLM 的转录各写一份过滤逻辑,两条路径 +就会漂移,而漂移的方向必然是「**存下来的比 LLM 看过的更多**」——最糟的方向:模型没看到的 +私有内容反而落进了持久库。 + +所以两边都从 `publicTranscriptParts(event)`(`src/summarize.js`)取数,同一个函数、同一份 +判定。口径: + +| 内容 | 进 LLM 转录 | 落 distill_records | +|---|---|---| +| 真实 `user/message`(`kind` 缺失 / `user` / `agent-message` / `subagent-settled`) | ✅ | ✅ | +| 助手公开 `text` 块 | ✅ | ✅ | +| 助手 `reasoning` 块(私有推理链) | ❌ | ❌ | +| 插件注入机器内容(`kind: "plugin:dsh-mneme"` / skill 正文 / AGENTS.md / 文件变更通知) | ❌ | ❌ | +| 工具调用入参、工具结果、code 输出 | ✅(截断) | ❌ | + +最后一行是**刻意的路径差异**,不是漂移:`codingRetrospect` 靠工具输出沉淀踩坑根因,所以 +渲染路径必须带它;但它体积大、含路径与命令、且不属于「对话内容」,落盘只留对话。 +差异方向仍满足「落盘的 ⊆ 渲染的」。 + +## 5. 留存不受喂模型的裁剪约束 + +喂 LLM 的转录受三道裁剪:`distillMaxChars`(默认 24000,超长保**尾部**)、工具入参 +`trim(args, 300)`、工具结果 `trim(out, 500)`。这三刀**不作用于落盘**——原文就是原文, +截断过的「原文」没有回查价值。 + +代价是库体积:一条超长提示会原样落库。所以留存默认**关**,并且有保留期(§6)。 +`test/summarize.test.js` 有一条锁专门钉「两条路径的差异」:超长用户提示在记录里逐字全文, +在喂模型的转录里找不到头部标记。 + +## 6. 生命周期与开关 + +```yaml +distillRecords: + enabled: false # 默认关(opt-in) + retentionDays: 90 # 保留期,1-3650 +``` + +| 层级 | 键 | 默认 | +|---|---|---| +| 父 | `autoSummarize` | 开(既有总闸) | +| 子 | `distillRecords.enabled` | **关**(引入新的持久化面,独立成键) | +| 子 | `distillRecords.retentionDays` | `90` | + +两个键都进 `settings.js` 白名单(面板可改);`enabled` 进 `config.js` 的 `LIGHT_MODE_OFF` +(轻量模式关掉留存这个成本/隐私面),`retentionDays` **不进**——它是时长而非行为面,用户 +显式设过就照旧。两个键都进 `api.js` 的 `NESTED_FLAG_PATHS`(漏了 effective 会静默缺键, +#254 踩过)。 + +**到期裁剪只删 `distill_records`**,`memory_provenance` 保留——链接从此指向空区间,面板 +显示「来源已过期」,而不是查无此记忆。**裁剪原文绝不级联删记忆**:原文是索引,记忆是资产。 +两处裁剪:启动期(`src/index.js`,只在 `enabled === true` 时动手——默认关时一张空表不该被 +每次启动扫一遍)+ `dsh-mneme reclaim`(默认 dry-run)。 + +删记忆是另一条路:`store.remove(id)` 在事务内**显式**清掉它的溯源链接。之所以不靠 +`ON DELETE CASCADE`,是因为 `src/store.js` 从不启用 `PRAGMA foreign_keys`(全库唯一的 +`REFERENCES` 是 `recall_events → recall_runs`,翻这个 pragma 会顺带激活那条既有约束的 +行为,不是本次改动的范围)。声明式约束保留在 schema 里作为意图记录,实际清理是显式的。 + +`reclaim` 报告因此从两项变三项: + +``` +planned: { dream_run_inputs, archived_embeddings, distill_records } +column_bytes: { ... } +cleared: { dream_run_inputs, archived_embeddings, distill_records } +``` + +`reclaim` 的入口语义(默认 dry-run、`--apply` 才动手、`--vacuum` 单独指定)**一字未动**。 + +## 7. 已知坑 + +1. **无历史回填**:本批只对**此后**发生的蒸馏生效。升级前已蒸过的窗口原文已经丢了, + 没有补回来的路(也不该假装能——从记忆反推原文是编造)。 +2. **从未蒸馏过的窗口不留记录**:留存挂在蒸馏上,所以四类窗口天然无记录——① 窗口太小 + (`window-too-small` 门,游标已被消费);② 插件当时是关的;③ 该窗口没触发过蒸馏 + (`autoSummarize` 关 / 限流挡住 / 会话结束得太早);④ 蒸过了但那一轮 LLM 调用 abort。 + 这些不是 bug,是「留存范围 = 蒸馏范围」的直接推论,但排查时容易误判成丢数据。 +3. **不覆盖工具结果与 code 输出**(§4 末行):想回查某次命令的真实输出,这两张表帮不上忙 + ——那是渲染路径的事,且落盘会带来体积与敏感信息两个问题。 + +## 8. 非目标(本批刻意不做) + +不嵌入、不进 `fuseRecall`、不进注入、不做「哪份更权威」的取代判定、不做 value histories +查询接口、不动 `distillMaxChars` / `trim(args,300)` / `trim(out,500)` 三刀。这两张表是 +**旁路索引**,不是第二条检索路——把它接进召回是另一个决定(成本与召回质量都要重新测), +不该跟着留存一起顺手打开。 + +## 9. 验收锚点 + +`test/summarize.test.js` 五条 + `test/maintenance.test.js` / `test/llm-audit.test.js` / +`test/settings.test.js` / `test/api.test.js` / `test/pre-inject-gate.test.js` 各一条: + +- **幂等**:删掉持久化游标 + 换新 summarizer 实例重跑同一窗口 → 记录仍一行、链接无重复; +- **隐私**:reasoning / AGENTS.md / skill 正文 / 文件变更通知绝不落盘,工具输出不落盘而 + 渲染路径仍带它; +- **未截断**:超长提示在记录里逐字全文,喂 LLM 的转录仍受 `distillMaxChars` 约束; +- **裁剪不毁资产**:到期裁剪后原文为 0、链接仍在、记忆数不变、`provenanceFor` 仍可查; +- **默认关零写入**:`enabled=false` 时连收集都不做(写入调用计数为 0),两表均 0 行, + 记忆写入照旧; +- **进 reclaim 报告**:dry-run 报出真实行数与列文本大小且一个字节不改,执行只删窗口外原文; +- **启动期裁剪**:读装配后的 cfg(面板改了保留期也认),且默认关时不动手; +- **白名单与往返**:两个键过 `settings` 校验(含范围外整数被拒),PUT 往返后 effective 读得回; +- **轻量模式**:预设关 `enabled`、保留用户显式设的 `retentionDays`、不污染用户对象。 diff --git a/dsh-mneme/docs/STORAGE.md b/dsh-mneme/docs/STORAGE.md index f9ee944e..459565b2 100644 --- a/dsh-mneme/docs/STORAGE.md +++ b/dsh-mneme/docs/STORAGE.md @@ -9,7 +9,9 @@ 这个入口把这两样回收掉。一条记忆都不删、一次价值判断都不做。 -它**不自动跑**:两项都是不可逆的内容丢弃,只在人看得见数字的时候按下(维护者 2026-09-23 拍板:不挂启动路径、不开 `auto_vacuum`)。 +> #399 起多回收第三样:`distill_records`(蒸馏原文留存,默认关)。它同样是「留着只占体积」——原文的回查价值随保留期衰减,但**溯源链接刻意不删**(链接是记忆的元数据,原文才是可弃的缓存)。见 [蒸馏原文留存](DISTILL.md)。 + +它**不自动跑**:几项都是不可逆的内容丢弃,只在人看得见数字的时候按下(维护者 2026-09-23 拍板:不挂启动路径、不开 `auto_vacuum`)。 ## 用法 @@ -32,6 +34,9 @@ dsh-mneme reclaim --json # 原始 JSON,给脚本用 | `memories.embedding`(`archived = 1`) | 置空 | 检索恒带 `archived = 0`,按定义不可达 | | `memories` 的行 | 一条不删 | 本入口止体积不止条数;条数与重复归 #254 与整理动作面 | | 活跃行的向量 | 不动 | 检索正在用 | +| `distill_records`(窗口外,#399) | 删行 | 蒸馏原文留存是 opt-in 旁路索引;到期即弃。**`memory_provenance` 链接保留**指向空区间(面板显示「来源已过期」),记忆与链接都不删——原文本就是「回查用」的,保留期到了就该走 | + +`distill_records` 只在 `distillRecords.enabled=true` 时才有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。 取消归档时(`memory_archive` 还原)服务端会重新排队嵌入,所以被回收的向量能拿回来。 @@ -63,6 +68,7 @@ VACUUM 是 O(库大小),且需要排他写锁:库越大越慢,期间的写 - 还原归档行后需要一次重嵌入才回到向量检索(排队的语义与写入路径一致:事务中或嵌入器未就绪会延后)。 - `llmAudit.enabled=false` 时回执不落库(那个开关连审计行的启动期清理一起关掉),但清理照做。 - 窗口内(默认 7 天)的输入快照不动:近期 run 仍可能在离线回放里被用到。 +- `distill_records` 的窗口用同一个 `--older-than`(默认 7 天),但它的**保留期另有一处**:`distillRecords.retentionDays`(默认 90)在插件启动时裁剪。两处不冲突(启动裁剪是常态、reclaim 是手动瘦身),但别指望只调其中一个就能同时改两边——想一次对齐就把 `--older-than` 显式传成与 `retentionDays` 相同的值。裁剪只删原文,溯源链接留着(详见 [蒸馏原文留存](DISTILL.md))。 - `run_type='organize'` 的行任何窗口都不清:它们的 `input` 存的是 apply 的重放载荷(`src/organize.js` 直接读它重建候选索引),不是可重建的快照。置空会让那次 apply 静默空转并把报告锁死。 ## 升格吸收的 evidence(#230 × #275) diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index 8d1f14e2..07b519c9 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -151,7 +151,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // Issue #380:注入前判定。漏登记的后果是 effective 静默缺键、面板盲—— // 上面的 round-trip 测试专门锁这张表与白名单成对。 "preInjectGate.enabled": ["preInjectGate", "enabled"], - "preInjectGate.enforce": ["preInjectGate", "enforce"] + "preInjectGate.enforce": ["preInjectGate", "enforce"], + // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 + + // retentionDays 整数),漏登记的后果同上——effective 静默缺键。 + "distillRecords.enabled": ["distillRecords", "enabled"], + "distillRecords.retentionDays": ["distillRecords", "retentionDays"] // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue // 的默认分支即可。 }; diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 07557330..b84f6ac5 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -677,6 +677,22 @@ export const Config = z.object({ retentionDays: z.natural().min(1).max(3650).default(90) }).default({}), + // --- distill records + provenance (#399) ------------------------------------ + // 蒸馏把窗口压成记忆后,原文默认不留——于是「这条记忆出自哪段对话」事后无法 + // 复核(arXiv 2609.36059 §3 Eq.1:旁路索引不丢信息,替换才丢)。开启后蒸馏时 + // 顺带把**公开**正文原样落盘(distill_records),并给写出的记忆挂上窗口链接 + // (memory_provenance)。默认关:这是一份对话原文副本,价值与暴露面都取决于 + // 用户是否愿意留档,不该由升级默认打开。 + // + // retentionDays 只裁 distill_records(启动即清,挂 llmAudit purge 旁);链接 + // 保留指向空区间,面板/CLI 显示「来源已过期」——删原文不等于篡改历史。落盘 + // 文本不受 distillMaxChars / trim(args,300) / trim(out,500) 约束(记录是凭证, + // 不是喂给 LLM 的预算);工具调用入参与结果、code 输出不落盘(推理亦然)。 + distillRecords: z.object({ + enabled: z.boolean().default(false), + retentionDays: z.natural().min(1).max(3650).default(90) + }).default({}), + // --- write admission (#254) ------------------------------------------------ // 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工 // 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档), @@ -868,7 +884,11 @@ const LIGHT_MODE_OFF = [ // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制: // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同 // injectGuidanceEnabled)。 - "continuityRescueEnabled" + "continuityRescueEnabled", + // #399:轻量档不开蒸馏原文留存——它是一份对话原文副本(磁盘 + 暴露面), + // 与轻量档「小模型 / 小上下文 / 少留痕」的取向相反。默认关,这里只是 + // 轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。 + "distillRecords.enabled" ]; /** diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index 9a7ef3b9..960af710 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -238,6 +238,20 @@ export const apply = (ctx, config) => { } } catch { /* non-fatal */ } + // #399 的启动期裁剪:与上面同款(读装配后的 cfg,理由同上——面板写的保留期必须 + // 与写入侧取到同一个值)。不同的一点:蒸馏原文留存默认关,而「默认关」在写入侧 + // 的含义就是零写入,所以这里也只在 enabled === true 时才动手——否则一张空表被 + // 每次启动扫一遍,白付一次全表 DELETE 的代价。只裁 distill_records;链接 + // (memory_provenance)保留,指向空区间即为「来源已过期」。 + try { + if (cfg.distillRecords?.enabled === true) { + const retentionMs = Number.isInteger(cfg.distillRecords?.retentionDays) + ? cfg.distillRecords.retentionDays + : 90; + store.clearDistillRecords(new Date(Date.now() - retentionMs * 86400000).toISOString()); + } + } catch { /* non-fatal */ } + // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream / // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。 const mirror = createMirror(memoryDir, langOf(cfg)); diff --git a/dsh-mneme/lib/maintenance.js b/dsh-mneme/lib/maintenance.js index c080af36..897876a9 100644 --- a/dsh-mneme/lib/maintenance.js +++ b/dsh-mneme/lib/maintenance.js @@ -6,7 +6,7 @@ // 东西)。所以模块里没有任何定时器,挂点只有两个:standalone 数据面的 // `POST /maintenance/reclaim` 与它上面的 `dsh-mneme reclaim` 子命令。 // -// 两项都零价值判断、零条数变化: +// 三项都零价值判断、零条数变化: // A `dream_runs.input` 置空。这一列的含义按 run_type 分叉:auto / sleep 的 run 存的 // 是当时的记忆库快照,可由记忆库重建;**organize 的 run 存的是 apply 的重放载荷**, // 置空会让那次 apply 静默空转并把报告锁死,所以 organize 行不在可清范围内(谓词 @@ -14,6 +14,11 @@ // 与 receipt 一律保留,行永不删(拍板 3:裁列同意、删行不同意)。 // B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是 // 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。 +// C #399 蒸馏原文记录按 distilled_at 到期删除:记录是蒸馏那次调用的凭证,越旧越 +// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项,所以 +// 它有自己的保留期配置键 distillRecords.retentionDays,不跟 A 的 7 天走)。链接 +// (memory_provenance)**不在可清范围内**:保留指向空区间即「来源已过期」,删链 +// 接等于假装这条记忆从来没有过出处——那是篡改历史,不是回收。 // // 报告口径(拍板 2):收益按 VACUUM 前后体积量,不按列字节估——实际释放来自溢出页与 // 索引页回收,列文本大小只是上界,所以两个数分开报,不混成一个「省了多少」。 @@ -33,7 +38,8 @@ export const DEFAULT_INPUT_RETENTION_DAYS = 7; * * @param {object} deps * @param {object} deps.store 存储层(dreamRunInputStats / clearDreamRunInputs / - * archivedEmbeddingStats / clearArchivedEmbeddings / storageStats / vacuum / saveLlmAudit) + * archivedEmbeddingStats / clearArchivedEmbeddings / distillRecordStats / + * clearDistillRecords / storageStats / vacuum / saveLlmAudit) * @param {object} [deps.config] 已解析配置(读 llmAudit.enabled:关掉 audit 时不落 receipt) * @param {object} [deps.logger] * @param {() => number} [deps.now] 时钟注入(测试用) @@ -86,15 +92,26 @@ export function createMaintenance({ store, config, logger, now = Date.now } = {} const before = store.storageStats(); const inputs = store.dreamRunInputStats(cutoff); const embeddings = store.archivedEmbeddingStats(); + // #399:蒸馏原文记录与上面两项同款——按 distilled_at 到期裁剪。链接 + // (memory_provenance)不在此列:它保留指向空区间,显示「来源已过期」。 + const records = store.distillRecordStats(cutoff); const report = { dryRun, olderThanDays: days, cutoff, // planned = 这次会动几行;cleared = 实际动了几行(dry-run 恒 0) - planned: { dream_run_inputs: inputs.runs, archived_embeddings: embeddings.rows }, + planned: { + dream_run_inputs: inputs.runs, + archived_embeddings: embeddings.rows, + distill_records: records.rows + }, // 列文本大小:上界,不是回收量(真实释放看下面的 size 差值) - column_bytes: { dream_run_inputs: inputs.bytes, archived_embeddings: embeddings.bytes }, - cleared: { dream_run_inputs: 0, archived_embeddings: 0 }, + column_bytes: { + dream_run_inputs: inputs.bytes, + archived_embeddings: embeddings.bytes, + distill_records: records.bytes + }, + cleared: { dream_run_inputs: 0, archived_embeddings: 0, distill_records: 0 }, size: { before_bytes: before.fileBytes, after_bytes: before.fileBytes, @@ -114,6 +131,7 @@ export function createMaintenance({ store, config, logger, now = Date.now } = {} } report.cleared.dream_run_inputs = store.clearDreamRunInputs(cutoff); report.cleared.archived_embeddings = store.clearArchivedEmbeddings(); + report.cleared.distill_records = store.clearDistillRecords(cutoff); if (report.vacuum.requested) { // VACUUM 失败不能把前面的清理一起吞掉:两个 UPDATE 已经各自提交了,此时抛出去 // 就是「数据已经不可逆地清了,但没有任何回执」。所以失败记进报告、回执照写, diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 17526a95..9902eb02 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -2498,6 +2498,13 @@ export function createService({ store, mirror, config, onWrite, logger, document transaction, getDistillCursor: (sessionId) => store.getDistillCursor(sessionId), setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq), + // #399 蒸馏原始记录与溯源:与上面两条游标一样是纯 passthrough——它们不是 + // 记忆写入,不该触发 syncMirror / notifyWrite / 实体抽取(那些钩子挂在 + // saveWithDedupe 上)。distill_records 的落盘由 summarize.js 在蒸馏窗口内 + // 一次性批写,读回只给面板/CLI 的溯源视图用。 + saveDistillRecords: (records) => store.saveDistillRecords(records), + saveMemoryProvenance: (link) => store.saveMemoryProvenance(link), + provenanceFor: (memoryId) => store.provenanceFor(memoryId), enqueue, setDreamHook(fn) { dreamHook = fn; }, setSleepHook(fn) { sleepHook = fn; }, diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index 69966715..7dfbaf15 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -135,7 +135,12 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个 // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由 // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。 - "sensitiveScanEnabled" + "sensitiveScanEnabled", + // Issue #399:蒸馏原文留存 + 记忆级溯源。enabled 决定蒸馏时是否把公开正文原样 + // 落盘(distill_records)并给记忆挂窗口链接(memory_provenance);默认关。 + // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。 + // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。 + "distillRecords.enabled" ]; // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。 const FEATURE_FLAG_INT_RANGES = { @@ -178,7 +183,10 @@ const FEATURE_FLAG_INT_RANGES = { graphSeedCap: [1, 30], graphCascadeDepth: [1, 3], // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。 - graphInjectBudget: [0, 5] + graphInjectBudget: [0, 5], + // Issue #399:蒸馏原文记录的保留天数(与 config.js 的 z.natural().min(1).max(3650) + // 对齐;同 llmAudit.retentionDays,是 int 而非开关)。 + "distillRecords.retentionDays": [1, 3650] }; // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关 // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。 diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 55b2fd7c..982fd457 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -219,6 +219,52 @@ CREATE TABLE IF NOT EXISTS distill_cursors ( updated_at TEXT NOT NULL ); +-- #399 蒸馏窗口的原始公开记录(凭证)。蒸馏此前只把会话压成记忆、原文随即消失, +-- 于是「这句原文没进任何记忆」永远不可测——留存一份原始副本,蒸馏就从「替换」 +-- 降级成「叠加索引」(arXiv 2609.36059 §3 Eq.1:与原文并存的旁路索引不丢信息)。 +-- 主键 (session_id, seq) 承担幂等:额度耗尽 / 宿主重启后会重跑同一 seq 窗口 +-- (summarize.js 的 max-runs 门刻意不消费游标),重跑必须不产生重复记录。 +-- 落盘文本不受 distillMaxChars / trim() 三刀约束——留存的意义就是整段原文。 +-- 刻意不加 sensitivity 列:机密级是**记忆级**判断,写记录那一刻它还不存在 +-- (一个窗口里什么都有),加了就是往 schema 里写一个做不到的承诺。 +CREATE TABLE IF NOT EXISTS distill_records ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, -- user | assistant | agent(lang.js 的三种公开形态) + text TEXT NOT NULL, -- 未截断的公开正文 + window_id TEXT, + distilled_at TEXT NOT NULL, + PRIMARY KEY (session_id, seq) +); +CREATE INDEX IF NOT EXISTS idx_distill_records_window ON distill_records(window_id); +-- 到期裁剪按 distilled_at 扫(与 llm_audit_logs.timestamp 同一口径)。 +CREATE INDEX IF NOT EXISTS idx_distill_records_distilled_at ON distill_records(distilled_at); + +-- #399 记忆级溯源:一条记忆由哪个蒸馏窗口的哪个 seq 区间派生。归因用**区间**而非 +-- 逐事件外键——蒸馏窗口本身就是连续 seq 段,窗口级归因第一步够用。 +-- 删除语义:到期裁剪只删 distill_records,本表保留并指向空区间(面板/CLI 显示 +-- 「来源已过期」),绝不级联删记忆;删记忆只清链接、记录保留——一个窗口的凭证 +-- 不该因删掉其中一条派生记忆而消失。 +-- memory_id 是 TEXT 而非 issue 草稿里的 INTEGER:memories.id 是 TEXT 主键, +-- 类型必须与父表一致(否则 join 恒空)。 +-- ON DELETE CASCADE 在本库**不生效**——store.js 从不打开 PRAGMA foreign_keys, +-- 全库唯一那条 REFERENCES 也一直是惰性的。与其为一个新表翻转全局 pragma +-- (会同时改变既有 recall_events 的写入行为),不如在 remove() 里显式清链接: +-- 语义相同、影响面只在本表。见 remove() 的注释。 +CREATE TABLE IF NOT EXISTS memory_provenance ( + memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + seq_start INTEGER NOT NULL, + seq_end INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_memory_provenance_memory ON memory_provenance(memory_id); +CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(session_id); +-- issue 草稿没有唯一约束,但重跑同一窗口会重复插同一链接(幂等测试锁 1 会挂)。 +-- 同一 (记忆, 会话, 区间) 只有一条事实,用唯一索引 + ON CONFLICT DO NOTHING 表达。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link + ON memory_provenance(memory_id, session_id, seq_start, seq_end); + -- 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 @@ -1235,6 +1281,12 @@ export function createStore(path) { function remove(id) { runAtomically(() => { + // #399:memory_provenance 的 ON DELETE CASCADE 在本库是惰性的(createStore + // 从不打开 PRAGMA foreign_keys),所以链接在这里显式清。不用翻转全局 pragma + // 换这一条级联:那会同时激活 recall_events 那条既有的 REFERENCES,改变一个 + // 与 #399 无关的写入路径。记录本身(distill_records)不删——删掉一条派生记忆 + // 不该让整个窗口的原文凭证消失。 + db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id); db.prepare("DELETE FROM memories WHERE id = ?").run(id); // Mirror sync must reflect the deletion; bump desired generation so a // crash between the delete and syncMirror leaves a recoverable debt. @@ -2113,6 +2165,77 @@ export function createStore(path) { return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes; } + // --- 蒸馏原始记录与记忆级溯源(#399)------------------------------------- + + /** + * 落盘一个蒸馏窗口的公开记录(#399)。 + * + * `INSERT OR IGNORE` 而非 OR REPLACE:主键 (session_id, seq) 的冲突只可能来自 + * **同一窗口的重跑**(max-runs 门耗尽额度、宿主重启都会重跑同一 seq 段,见 + * summarize.js 里「max-runs 刻意不消费游标」的注释),而重跑必须幂等——同一 + * seq 的原文没有第二次「更真」的版本,保留首次落盘时间才诚实。 + * @param {Array<{seq:number, role:string, text:string, windowId?:string, distilledAt:string}>} records + * @returns {number} 实际插入行数(被忽略的冲突不计) + */ + function saveDistillRecords(records) { + if (!Array.isArray(records) || records.length === 0) return 0; + const stmt = db.prepare( + `INSERT OR IGNORE INTO distill_records (session_id, seq, role, text, window_id, distilled_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + let inserted = 0; + for (const r of records) { + inserted += stmt.run(r.sessionId, r.seq, r.role, r.text, r.windowId ?? null, r.distilledAt).changes; + } + return inserted; + } + + /** + * 到期记录统计(#275 回收报告口径,dry-run 用)。`bytes` 是列文本大小上界。 + * @returns {{rows: number, bytes: number}} + */ + function distillRecordStats(before) { + const row = db.prepare( + `SELECT count(*) AS c, COALESCE(SUM(LENGTH(text)), 0) AS b + FROM distill_records WHERE distilled_at < ?` + ).get(before); + return { rows: row.c, bytes: row.b }; + } + + /** + * 删除到期原始记录。返回实际删除行数。 + * + * 只删 distill_records——memory_provenance 的链接**刻意保留**并从此指向空区间, + * 面板/CLI 显示「来源已过期」。级联删链接会把「这条记忆有来源、只是来源到期」 + * 降级成「这条记忆无来源」,把可观测性削成不可观测(#399 删除语义)。 + */ + function clearDistillRecords(before) { + return db.prepare("DELETE FROM distill_records WHERE distilled_at < ?").run(before).changes; + } + + /** + * 记一条记忆级溯源链接(#399):某条记忆派生自某会话的 seq 区间。 + * + * `ON CONFLICT DO NOTHING` 配合 idx_memory_provenance_link 唯一索引吸收同窗口 + * 重跑的重复链接(否则幂等测试锁会挂)。 + * @returns {number} 实际插入行数 + */ + function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) { + return db.prepare( + `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING` + ).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes; + } + + /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面)。 */ + function provenanceFor(memoryId) { + return db.prepare( + `SELECT memory_id, session_id, seq_start, seq_end, created_at + FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start` + ).all(memoryId); + } + // --- 存储生命周期:无损回收(#275 第一批)-------------------------------- // // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。 @@ -3009,6 +3132,12 @@ export function createStore(path) { countLlmAudits, getLlmAuditStats, deleteOldLlmAudits, + // 蒸馏原始记录与记忆级溯源(#399):窗口凭证的写入/统计/裁剪,链接的读写。 + saveDistillRecords, + distillRecordStats, + clearDistillRecords, + saveMemoryProvenance, + provenanceFor, // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。 dreamRunInputStats, clearDreamRunInputs, diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 4efe2855..3101c93b 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -225,6 +225,83 @@ function eventSeq(event) { const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]); +// 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例 +// 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。 +function textOf(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : ""))) + .filter((s) => s) + .join("\n"); +} + +/** + * Issue #399:公开会话正文的**单一隐私谓词**。 + * + * 渲染(喂给 LLM 的转录)与落盘(distill_records 原始记录)必须走同一份判定, + * 各写一遍必然漂移——而漂移方向是「存下来的比 LLM 看过的更多」,也就是把从未 + * 交给模型的内容留在盘上,是最糟的那个方向。所以这里只回答一个问题:这个事件 + * 里有没有**可公开的会话正文**,是什么角色、什么原文;裁剪与格式化交给调用方。 + * + * 返回 `[]` 表示不含可公开正文,包含三种情形: + * - 机器注入内容(AGENTS.md、skill 正文、文件变更通知):source.kind 不是 + * user / agent-message / subagent-settled,整个事件被挡在门外; + * - 私有推理(assistant 的 reasoning 块):textOf 只认 `type:"text"` 块, + * 思考链在两条路径上都到不了; + * - 工具调用与结果、code 输出:它们是**渲染专属**内容(codingRetrospect 需要 + * 模型看见工具报错才能沉淀踩坑根因),但 #399 明确不落盘(已知边界 3), + * 因此由 collectMessages 的渲染分支单独处理,不进这个函数。 + * + * @returns {Array<{role: "user"|"assistant"|"agent", text: string, key?: string}>} + * text 是**未截断**原文;key 仅子会话消息用于跨事件去重。 + */ +function publicTranscriptParts(event) { + const data = event?.data ?? {}; + const kind = data?.source?.kind; + const parts = []; + const pushAgentMessage = (message) => { + const messageKind = message?.source?.kind; + if (!SUBAGENT_MESSAGE_KINDS.has(messageKind)) return; + const text = textOf(message?.content); + if (!text.trim()) return; + parts.push({ + role: "agent", + text, + // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced + // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。 + key: typeof message?.id === "string" && message.id + ? `id:${message.id}` + : `content:${messageKind}:${text}` + }); + }; + switch (event?.type) { + case "user/message": { + if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break; + const text = textOf(data?.content); + if (!text.trim()) break; + if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data); + else parts.push({ role: "user", text }); + break; + } + case "agent/inbox/spliced": { + const inserted = Array.isArray(data?.inserted) ? data.inserted : []; + for (const message of inserted) pushAgentMessage(message); + break; + } + case "assistant/message": { + const blocks = Array.isArray(data?.message?.content) ? data.message.content : []; + const text = textOf(blocks); + // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。 + if (text.trim()) parts.push({ role: "assistant", text }); + break; + } + default: + break; + } + return parts; +} + // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。 // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容 // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。 @@ -253,16 +330,6 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th const events = snapshotSessionEvents(session, afterSeq, throughSeq); const lines = []; const seenSubagentMessages = new Set(); - // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例 - // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。 - const textOf = (content) => { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : ""))) - .filter((s) => s) - .join("\n"); - }; const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s); const trimTranscript = (s, n) => { if (typeof s !== "string" || s.length <= n) return s; @@ -283,29 +350,22 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th }; for (const event of events) { const data = event?.data ?? {}; - const kind = data?.source?.kind; - switch (event.type) { - case "user/message": { - if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break; - const text = textOf(data?.content); - if (!text.trim()) break; - if (SUBAGENT_MESSAGE_KINDS.has(kind)) appendSubagentMessage(data); - else lines.push(STR.transcriptUser[language](text)); - break; - } - case "agent/inbox/spliced": { - const inserted = Array.isArray(data?.inserted) ? data.inserted : []; - for (const message of inserted) appendSubagentMessage(message); - break; - } - case "assistant/message": { - const msg = data?.message; - const blocks = Array.isArray(msg?.content) ? msg.content : []; - const text = textOf(blocks); - if (text.trim()) lines.push(STR.transcriptAssistant[language](text)); - // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。 - break; + // #399:用户提示 / 助手公开正文 / 子会话交付三种可公开内容由共享谓词产出, + // 渲染与落盘因此不可能对「什么算公开」产生分歧(见 publicTranscriptParts)。 + for (const part of publicTranscriptParts(event)) { + if (part.role === "agent") { + if (part.key && seenSubagentMessages.has(part.key)) continue; + if (part.key) seenSubagentMessages.add(part.key); + lines.push(STR.transcriptAgent[language](part.text)); + } else if (part.role === "user") { + lines.push(STR.transcriptUser[language](part.text)); + } else { + lines.push(STR.transcriptAssistant[language](part.text)); } + } + // 渲染专属内容:工具调用/结果与 code 输出进 LLM 转录(codingRetrospect 靠 + // 它们沉淀踩坑根因),但不进 distill_records——#399 已知边界 3 明说不覆盖。 + switch (event.type) { case "tool/call": { const args = typeof data.arguments === "string" ? data.arguments @@ -351,6 +411,56 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th }; } +/** + * Issue #399:把当前蒸馏窗口里**可公开的会话正文**收集成待落盘记录。 + * + * 与 collectMessages 共用 publicTranscriptParts,但**不截断**:留存的意义就是 + * 整段原文(超长用户提示在记录里必须全文,而喂给 LLM 的转录仍受 distillMaxChars + * 约束)——两条路径在裁剪上的差异是设计的一部分,测试锁 3 显式覆盖。 + * + * 只收有 seq 的事件:distill_records 的主键是 (session_id, seq),seq 也是溯源 + * 区间与幂等的唯一依据;没有 seq 就无法表达「这条记录属于哪个窗口」。 + */ +function collectTranscriptRecords(session, afterSeq, throughSeq, distilledAt) { + const events = snapshotSessionEvents(session, afterSeq, throughSeq); + const records = []; + const seenKeys = new Set(); + // 窗口的 seq 区间取窗口内**全部**事件的 min/max(不只是公开正文那些):这是 + // LLM 实际看到的那个窗口,溯源链接要回答的是「这条记忆出自哪个窗口」。 + let seqStart; + let seqEnd; + for (const event of events) { + const seq = eventSeq(event); + if (seq !== undefined) { + if (seqStart === undefined || seq < seqStart) seqStart = seq; + if (seqEnd === undefined || seq > seqEnd) seqEnd = seq; + } + if (seq === undefined) continue; + const parts = []; + for (const part of publicTranscriptParts(event)) { + if (part.key) { + if (seenKeys.has(part.key)) continue; + seenKeys.add(part.key); + } + parts.push(part); + } + if (!parts.length) continue; + // 主键是 (session_id, seq):一个事件一行。agent/inbox/spliced 一次可投递多条 + // 子会话消息(同 seq、同 role),各自成行会被 INSERT OR IGNORE 静默丢掉后几条; + // 按事件合并成一行才是这套主键的真实语义(角色同事件内不会混:user/message 与 + // assistant/message 各只产出一条公开 part)。 + records.push({ + sessionId: session.id, + seq, + role: parts[0].role, + text: parts.map((part) => part.text).join("\n"), + windowId: `session:${session.id}:${seq}`, + distilledAt + }); + } + return { records, seqStart, seqEnd }; +} + // ── 智能调速器(429 保护)───────────────────────────────────────────── // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的 // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔 @@ -792,7 +902,45 @@ export function createSummarizer(ctx, service, config, deps = {}) { } writes.push({ entry, source, dup }); } - if (writes.length) { + // #399:蒸馏窗口的原始公开记录 + 记忆级溯源链接,与记忆写入同事务落盘。 + // 凭证与它的派生记忆必须同生共死:记忆写了而记录没写,溯源就断在「不知道 + // 出自哪段原文」;记录写了而记忆没写,盘上留一份没有索引的原文。默认关 + // (enabled !== true)时连收集都不做,一次 SQL 都不发(测试锁 5)。 + const distilledAt = new Date(now()).toISOString(); + const transcript = config?.distillRecords?.enabled === true + && typeof service.saveDistillRecords === "function" + ? collectTranscriptRecords(session, previousSeq, triggerSeq, distilledAt) + : null; + const writeWindow = () => { + const savedIds = []; + for (const { entry, source, dup } of writes) { + const outcome = service.saveWithDedupe({ + ...entry, + source, + ...(dup ? { _mergeInto: dup.memory.id } : {}) + }); + // created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是 + // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。 + if (outcome?.memory?.id) savedIds.push(outcome.memory.id); + } + if (transcript && transcript.records.length) { + service.saveDistillRecords(transcript.records); + if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined + && typeof service.saveMemoryProvenance === "function") { + for (const memoryId of savedIds) { + service.saveMemoryProvenance({ + memoryId, + sessionId: session.id, + seqStart: transcript.seqStart, + seqEnd: transcript.seqEnd, + createdAt: distilledAt + }); + } + } + } + persistCursor(session.id, nextSeq); + }; + if (writes.length || (transcript && transcript.records.length)) { // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。 // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复 // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留 @@ -800,16 +948,7 @@ export function createSummarizer(ctx, service, config, deps = {}) { if (typeof service.transaction !== "function") { throw new Error("dsh-mneme: atomic summarization writes require service.transaction"); } - service.transaction(() => { - for (const { entry, source, dup } of writes) { - service.saveWithDedupe({ - ...entry, - source, - ...(dup ? { _mergeInto: dup.memory.id } : {}) - }); - } - persistCursor(session.id, nextSeq); - }); + service.transaction(writeWindow); } else { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index 8d1f14e2..07b519c9 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -151,7 +151,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // Issue #380:注入前判定。漏登记的后果是 effective 静默缺键、面板盲—— // 上面的 round-trip 测试专门锁这张表与白名单成对。 "preInjectGate.enabled": ["preInjectGate", "enabled"], - "preInjectGate.enforce": ["preInjectGate", "enforce"] + "preInjectGate.enforce": ["preInjectGate", "enforce"], + // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 + + // retentionDays 整数),漏登记的后果同上——effective 静默缺键。 + "distillRecords.enabled": ["distillRecords", "enabled"], + "distillRecords.retentionDays": ["distillRecords", "retentionDays"] // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue // 的默认分支即可。 }; diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 07557330..b84f6ac5 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -677,6 +677,22 @@ export const Config = z.object({ retentionDays: z.natural().min(1).max(3650).default(90) }).default({}), + // --- distill records + provenance (#399) ------------------------------------ + // 蒸馏把窗口压成记忆后,原文默认不留——于是「这条记忆出自哪段对话」事后无法 + // 复核(arXiv 2609.36059 §3 Eq.1:旁路索引不丢信息,替换才丢)。开启后蒸馏时 + // 顺带把**公开**正文原样落盘(distill_records),并给写出的记忆挂上窗口链接 + // (memory_provenance)。默认关:这是一份对话原文副本,价值与暴露面都取决于 + // 用户是否愿意留档,不该由升级默认打开。 + // + // retentionDays 只裁 distill_records(启动即清,挂 llmAudit purge 旁);链接 + // 保留指向空区间,面板/CLI 显示「来源已过期」——删原文不等于篡改历史。落盘 + // 文本不受 distillMaxChars / trim(args,300) / trim(out,500) 约束(记录是凭证, + // 不是喂给 LLM 的预算);工具调用入参与结果、code 输出不落盘(推理亦然)。 + distillRecords: z.object({ + enabled: z.boolean().default(false), + retentionDays: z.natural().min(1).max(3650).default(90) + }).default({}), + // --- write admission (#254) ------------------------------------------------ // 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工 // 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档), @@ -868,7 +884,11 @@ const LIGHT_MODE_OFF = [ // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制: // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同 // injectGuidanceEnabled)。 - "continuityRescueEnabled" + "continuityRescueEnabled", + // #399:轻量档不开蒸馏原文留存——它是一份对话原文副本(磁盘 + 暴露面), + // 与轻量档「小模型 / 小上下文 / 少留痕」的取向相反。默认关,这里只是 + // 轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。 + "distillRecords.enabled" ]; /** diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index 9a7ef3b9..960af710 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -238,6 +238,20 @@ export const apply = (ctx, config) => { } } catch { /* non-fatal */ } + // #399 的启动期裁剪:与上面同款(读装配后的 cfg,理由同上——面板写的保留期必须 + // 与写入侧取到同一个值)。不同的一点:蒸馏原文留存默认关,而「默认关」在写入侧 + // 的含义就是零写入,所以这里也只在 enabled === true 时才动手——否则一张空表被 + // 每次启动扫一遍,白付一次全表 DELETE 的代价。只裁 distill_records;链接 + // (memory_provenance)保留,指向空区间即为「来源已过期」。 + try { + if (cfg.distillRecords?.enabled === true) { + const retentionMs = Number.isInteger(cfg.distillRecords?.retentionDays) + ? cfg.distillRecords.retentionDays + : 90; + store.clearDistillRecords(new Date(Date.now() - retentionMs * 86400000).toISOString()); + } + } catch { /* non-fatal */ } + // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream / // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。 const mirror = createMirror(memoryDir, langOf(cfg)); diff --git a/dsh-mneme/src/maintenance.js b/dsh-mneme/src/maintenance.js index c080af36..897876a9 100644 --- a/dsh-mneme/src/maintenance.js +++ b/dsh-mneme/src/maintenance.js @@ -6,7 +6,7 @@ // 东西)。所以模块里没有任何定时器,挂点只有两个:standalone 数据面的 // `POST /maintenance/reclaim` 与它上面的 `dsh-mneme reclaim` 子命令。 // -// 两项都零价值判断、零条数变化: +// 三项都零价值判断、零条数变化: // A `dream_runs.input` 置空。这一列的含义按 run_type 分叉:auto / sleep 的 run 存的 // 是当时的记忆库快照,可由记忆库重建;**organize 的 run 存的是 apply 的重放载荷**, // 置空会让那次 apply 静默空转并把报告锁死,所以 organize 行不在可清范围内(谓词 @@ -14,6 +14,11 @@ // 与 receipt 一律保留,行永不删(拍板 3:裁列同意、删行不同意)。 // B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是 // 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。 +// C #399 蒸馏原文记录按 distilled_at 到期删除:记录是蒸馏那次调用的凭证,越旧越 +// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项,所以 +// 它有自己的保留期配置键 distillRecords.retentionDays,不跟 A 的 7 天走)。链接 +// (memory_provenance)**不在可清范围内**:保留指向空区间即「来源已过期」,删链 +// 接等于假装这条记忆从来没有过出处——那是篡改历史,不是回收。 // // 报告口径(拍板 2):收益按 VACUUM 前后体积量,不按列字节估——实际释放来自溢出页与 // 索引页回收,列文本大小只是上界,所以两个数分开报,不混成一个「省了多少」。 @@ -33,7 +38,8 @@ export const DEFAULT_INPUT_RETENTION_DAYS = 7; * * @param {object} deps * @param {object} deps.store 存储层(dreamRunInputStats / clearDreamRunInputs / - * archivedEmbeddingStats / clearArchivedEmbeddings / storageStats / vacuum / saveLlmAudit) + * archivedEmbeddingStats / clearArchivedEmbeddings / distillRecordStats / + * clearDistillRecords / storageStats / vacuum / saveLlmAudit) * @param {object} [deps.config] 已解析配置(读 llmAudit.enabled:关掉 audit 时不落 receipt) * @param {object} [deps.logger] * @param {() => number} [deps.now] 时钟注入(测试用) @@ -86,15 +92,26 @@ export function createMaintenance({ store, config, logger, now = Date.now } = {} const before = store.storageStats(); const inputs = store.dreamRunInputStats(cutoff); const embeddings = store.archivedEmbeddingStats(); + // #399:蒸馏原文记录与上面两项同款——按 distilled_at 到期裁剪。链接 + // (memory_provenance)不在此列:它保留指向空区间,显示「来源已过期」。 + const records = store.distillRecordStats(cutoff); const report = { dryRun, olderThanDays: days, cutoff, // planned = 这次会动几行;cleared = 实际动了几行(dry-run 恒 0) - planned: { dream_run_inputs: inputs.runs, archived_embeddings: embeddings.rows }, + planned: { + dream_run_inputs: inputs.runs, + archived_embeddings: embeddings.rows, + distill_records: records.rows + }, // 列文本大小:上界,不是回收量(真实释放看下面的 size 差值) - column_bytes: { dream_run_inputs: inputs.bytes, archived_embeddings: embeddings.bytes }, - cleared: { dream_run_inputs: 0, archived_embeddings: 0 }, + column_bytes: { + dream_run_inputs: inputs.bytes, + archived_embeddings: embeddings.bytes, + distill_records: records.bytes + }, + cleared: { dream_run_inputs: 0, archived_embeddings: 0, distill_records: 0 }, size: { before_bytes: before.fileBytes, after_bytes: before.fileBytes, @@ -114,6 +131,7 @@ export function createMaintenance({ store, config, logger, now = Date.now } = {} } report.cleared.dream_run_inputs = store.clearDreamRunInputs(cutoff); report.cleared.archived_embeddings = store.clearArchivedEmbeddings(); + report.cleared.distill_records = store.clearDistillRecords(cutoff); if (report.vacuum.requested) { // VACUUM 失败不能把前面的清理一起吞掉:两个 UPDATE 已经各自提交了,此时抛出去 // 就是「数据已经不可逆地清了,但没有任何回执」。所以失败记进报告、回执照写, diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 17526a95..9902eb02 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -2498,6 +2498,13 @@ export function createService({ store, mirror, config, onWrite, logger, document transaction, getDistillCursor: (sessionId) => store.getDistillCursor(sessionId), setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq), + // #399 蒸馏原始记录与溯源:与上面两条游标一样是纯 passthrough——它们不是 + // 记忆写入,不该触发 syncMirror / notifyWrite / 实体抽取(那些钩子挂在 + // saveWithDedupe 上)。distill_records 的落盘由 summarize.js 在蒸馏窗口内 + // 一次性批写,读回只给面板/CLI 的溯源视图用。 + saveDistillRecords: (records) => store.saveDistillRecords(records), + saveMemoryProvenance: (link) => store.saveMemoryProvenance(link), + provenanceFor: (memoryId) => store.provenanceFor(memoryId), enqueue, setDreamHook(fn) { dreamHook = fn; }, setSleepHook(fn) { sleepHook = fn; }, diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index 69966715..7dfbaf15 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -135,7 +135,12 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个 // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由 // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。 - "sensitiveScanEnabled" + "sensitiveScanEnabled", + // Issue #399:蒸馏原文留存 + 记忆级溯源。enabled 决定蒸馏时是否把公开正文原样 + // 落盘(distill_records)并给记忆挂窗口链接(memory_provenance);默认关。 + // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。 + // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。 + "distillRecords.enabled" ]; // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。 const FEATURE_FLAG_INT_RANGES = { @@ -178,7 +183,10 @@ const FEATURE_FLAG_INT_RANGES = { graphSeedCap: [1, 30], graphCascadeDepth: [1, 3], // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。 - graphInjectBudget: [0, 5] + graphInjectBudget: [0, 5], + // Issue #399:蒸馏原文记录的保留天数(与 config.js 的 z.natural().min(1).max(3650) + // 对齐;同 llmAudit.retentionDays,是 int 而非开关)。 + "distillRecords.retentionDays": [1, 3650] }; // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关 // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。 diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 55b2fd7c..982fd457 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -219,6 +219,52 @@ CREATE TABLE IF NOT EXISTS distill_cursors ( updated_at TEXT NOT NULL ); +-- #399 蒸馏窗口的原始公开记录(凭证)。蒸馏此前只把会话压成记忆、原文随即消失, +-- 于是「这句原文没进任何记忆」永远不可测——留存一份原始副本,蒸馏就从「替换」 +-- 降级成「叠加索引」(arXiv 2609.36059 §3 Eq.1:与原文并存的旁路索引不丢信息)。 +-- 主键 (session_id, seq) 承担幂等:额度耗尽 / 宿主重启后会重跑同一 seq 窗口 +-- (summarize.js 的 max-runs 门刻意不消费游标),重跑必须不产生重复记录。 +-- 落盘文本不受 distillMaxChars / trim() 三刀约束——留存的意义就是整段原文。 +-- 刻意不加 sensitivity 列:机密级是**记忆级**判断,写记录那一刻它还不存在 +-- (一个窗口里什么都有),加了就是往 schema 里写一个做不到的承诺。 +CREATE TABLE IF NOT EXISTS distill_records ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, -- user | assistant | agent(lang.js 的三种公开形态) + text TEXT NOT NULL, -- 未截断的公开正文 + window_id TEXT, + distilled_at TEXT NOT NULL, + PRIMARY KEY (session_id, seq) +); +CREATE INDEX IF NOT EXISTS idx_distill_records_window ON distill_records(window_id); +-- 到期裁剪按 distilled_at 扫(与 llm_audit_logs.timestamp 同一口径)。 +CREATE INDEX IF NOT EXISTS idx_distill_records_distilled_at ON distill_records(distilled_at); + +-- #399 记忆级溯源:一条记忆由哪个蒸馏窗口的哪个 seq 区间派生。归因用**区间**而非 +-- 逐事件外键——蒸馏窗口本身就是连续 seq 段,窗口级归因第一步够用。 +-- 删除语义:到期裁剪只删 distill_records,本表保留并指向空区间(面板/CLI 显示 +-- 「来源已过期」),绝不级联删记忆;删记忆只清链接、记录保留——一个窗口的凭证 +-- 不该因删掉其中一条派生记忆而消失。 +-- memory_id 是 TEXT 而非 issue 草稿里的 INTEGER:memories.id 是 TEXT 主键, +-- 类型必须与父表一致(否则 join 恒空)。 +-- ON DELETE CASCADE 在本库**不生效**——store.js 从不打开 PRAGMA foreign_keys, +-- 全库唯一那条 REFERENCES 也一直是惰性的。与其为一个新表翻转全局 pragma +-- (会同时改变既有 recall_events 的写入行为),不如在 remove() 里显式清链接: +-- 语义相同、影响面只在本表。见 remove() 的注释。 +CREATE TABLE IF NOT EXISTS memory_provenance ( + memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + seq_start INTEGER NOT NULL, + seq_end INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_memory_provenance_memory ON memory_provenance(memory_id); +CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(session_id); +-- issue 草稿没有唯一约束,但重跑同一窗口会重复插同一链接(幂等测试锁 1 会挂)。 +-- 同一 (记忆, 会话, 区间) 只有一条事实,用唯一索引 + ON CONFLICT DO NOTHING 表达。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link + ON memory_provenance(memory_id, session_id, seq_start, seq_end); + -- 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 @@ -1235,6 +1281,12 @@ export function createStore(path) { function remove(id) { runAtomically(() => { + // #399:memory_provenance 的 ON DELETE CASCADE 在本库是惰性的(createStore + // 从不打开 PRAGMA foreign_keys),所以链接在这里显式清。不用翻转全局 pragma + // 换这一条级联:那会同时激活 recall_events 那条既有的 REFERENCES,改变一个 + // 与 #399 无关的写入路径。记录本身(distill_records)不删——删掉一条派生记忆 + // 不该让整个窗口的原文凭证消失。 + db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id); db.prepare("DELETE FROM memories WHERE id = ?").run(id); // Mirror sync must reflect the deletion; bump desired generation so a // crash between the delete and syncMirror leaves a recoverable debt. @@ -2113,6 +2165,77 @@ export function createStore(path) { return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes; } + // --- 蒸馏原始记录与记忆级溯源(#399)------------------------------------- + + /** + * 落盘一个蒸馏窗口的公开记录(#399)。 + * + * `INSERT OR IGNORE` 而非 OR REPLACE:主键 (session_id, seq) 的冲突只可能来自 + * **同一窗口的重跑**(max-runs 门耗尽额度、宿主重启都会重跑同一 seq 段,见 + * summarize.js 里「max-runs 刻意不消费游标」的注释),而重跑必须幂等——同一 + * seq 的原文没有第二次「更真」的版本,保留首次落盘时间才诚实。 + * @param {Array<{seq:number, role:string, text:string, windowId?:string, distilledAt:string}>} records + * @returns {number} 实际插入行数(被忽略的冲突不计) + */ + function saveDistillRecords(records) { + if (!Array.isArray(records) || records.length === 0) return 0; + const stmt = db.prepare( + `INSERT OR IGNORE INTO distill_records (session_id, seq, role, text, window_id, distilled_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + let inserted = 0; + for (const r of records) { + inserted += stmt.run(r.sessionId, r.seq, r.role, r.text, r.windowId ?? null, r.distilledAt).changes; + } + return inserted; + } + + /** + * 到期记录统计(#275 回收报告口径,dry-run 用)。`bytes` 是列文本大小上界。 + * @returns {{rows: number, bytes: number}} + */ + function distillRecordStats(before) { + const row = db.prepare( + `SELECT count(*) AS c, COALESCE(SUM(LENGTH(text)), 0) AS b + FROM distill_records WHERE distilled_at < ?` + ).get(before); + return { rows: row.c, bytes: row.b }; + } + + /** + * 删除到期原始记录。返回实际删除行数。 + * + * 只删 distill_records——memory_provenance 的链接**刻意保留**并从此指向空区间, + * 面板/CLI 显示「来源已过期」。级联删链接会把「这条记忆有来源、只是来源到期」 + * 降级成「这条记忆无来源」,把可观测性削成不可观测(#399 删除语义)。 + */ + function clearDistillRecords(before) { + return db.prepare("DELETE FROM distill_records WHERE distilled_at < ?").run(before).changes; + } + + /** + * 记一条记忆级溯源链接(#399):某条记忆派生自某会话的 seq 区间。 + * + * `ON CONFLICT DO NOTHING` 配合 idx_memory_provenance_link 唯一索引吸收同窗口 + * 重跑的重复链接(否则幂等测试锁会挂)。 + * @returns {number} 实际插入行数 + */ + function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) { + return db.prepare( + `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING` + ).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes; + } + + /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面)。 */ + function provenanceFor(memoryId) { + return db.prepare( + `SELECT memory_id, session_id, seq_start, seq_end, created_at + FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start` + ).all(memoryId); + } + // --- 存储生命周期:无损回收(#275 第一批)-------------------------------- // // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。 @@ -3009,6 +3132,12 @@ export function createStore(path) { countLlmAudits, getLlmAuditStats, deleteOldLlmAudits, + // 蒸馏原始记录与记忆级溯源(#399):窗口凭证的写入/统计/裁剪,链接的读写。 + saveDistillRecords, + distillRecordStats, + clearDistillRecords, + saveMemoryProvenance, + provenanceFor, // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。 dreamRunInputStats, clearDreamRunInputs, diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 4efe2855..3101c93b 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -225,6 +225,83 @@ function eventSeq(event) { const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]); +// 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例 +// 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。 +function textOf(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : ""))) + .filter((s) => s) + .join("\n"); +} + +/** + * Issue #399:公开会话正文的**单一隐私谓词**。 + * + * 渲染(喂给 LLM 的转录)与落盘(distill_records 原始记录)必须走同一份判定, + * 各写一遍必然漂移——而漂移方向是「存下来的比 LLM 看过的更多」,也就是把从未 + * 交给模型的内容留在盘上,是最糟的那个方向。所以这里只回答一个问题:这个事件 + * 里有没有**可公开的会话正文**,是什么角色、什么原文;裁剪与格式化交给调用方。 + * + * 返回 `[]` 表示不含可公开正文,包含三种情形: + * - 机器注入内容(AGENTS.md、skill 正文、文件变更通知):source.kind 不是 + * user / agent-message / subagent-settled,整个事件被挡在门外; + * - 私有推理(assistant 的 reasoning 块):textOf 只认 `type:"text"` 块, + * 思考链在两条路径上都到不了; + * - 工具调用与结果、code 输出:它们是**渲染专属**内容(codingRetrospect 需要 + * 模型看见工具报错才能沉淀踩坑根因),但 #399 明确不落盘(已知边界 3), + * 因此由 collectMessages 的渲染分支单独处理,不进这个函数。 + * + * @returns {Array<{role: "user"|"assistant"|"agent", text: string, key?: string}>} + * text 是**未截断**原文;key 仅子会话消息用于跨事件去重。 + */ +function publicTranscriptParts(event) { + const data = event?.data ?? {}; + const kind = data?.source?.kind; + const parts = []; + const pushAgentMessage = (message) => { + const messageKind = message?.source?.kind; + if (!SUBAGENT_MESSAGE_KINDS.has(messageKind)) return; + const text = textOf(message?.content); + if (!text.trim()) return; + parts.push({ + role: "agent", + text, + // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced + // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。 + key: typeof message?.id === "string" && message.id + ? `id:${message.id}` + : `content:${messageKind}:${text}` + }); + }; + switch (event?.type) { + case "user/message": { + if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break; + const text = textOf(data?.content); + if (!text.trim()) break; + if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data); + else parts.push({ role: "user", text }); + break; + } + case "agent/inbox/spliced": { + const inserted = Array.isArray(data?.inserted) ? data.inserted : []; + for (const message of inserted) pushAgentMessage(message); + break; + } + case "assistant/message": { + const blocks = Array.isArray(data?.message?.content) ? data.message.content : []; + const text = textOf(blocks); + // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。 + if (text.trim()) parts.push({ role: "assistant", text }); + break; + } + default: + break; + } + return parts; +} + // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。 // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容 // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。 @@ -253,16 +330,6 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th const events = snapshotSessionEvents(session, afterSeq, throughSeq); const lines = []; const seenSubagentMessages = new Set(); - // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例 - // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。 - const textOf = (content) => { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : ""))) - .filter((s) => s) - .join("\n"); - }; const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s); const trimTranscript = (s, n) => { if (typeof s !== "string" || s.length <= n) return s; @@ -283,29 +350,22 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th }; for (const event of events) { const data = event?.data ?? {}; - const kind = data?.source?.kind; - switch (event.type) { - case "user/message": { - if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break; - const text = textOf(data?.content); - if (!text.trim()) break; - if (SUBAGENT_MESSAGE_KINDS.has(kind)) appendSubagentMessage(data); - else lines.push(STR.transcriptUser[language](text)); - break; - } - case "agent/inbox/spliced": { - const inserted = Array.isArray(data?.inserted) ? data.inserted : []; - for (const message of inserted) appendSubagentMessage(message); - break; - } - case "assistant/message": { - const msg = data?.message; - const blocks = Array.isArray(msg?.content) ? msg.content : []; - const text = textOf(blocks); - if (text.trim()) lines.push(STR.transcriptAssistant[language](text)); - // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。 - break; + // #399:用户提示 / 助手公开正文 / 子会话交付三种可公开内容由共享谓词产出, + // 渲染与落盘因此不可能对「什么算公开」产生分歧(见 publicTranscriptParts)。 + for (const part of publicTranscriptParts(event)) { + if (part.role === "agent") { + if (part.key && seenSubagentMessages.has(part.key)) continue; + if (part.key) seenSubagentMessages.add(part.key); + lines.push(STR.transcriptAgent[language](part.text)); + } else if (part.role === "user") { + lines.push(STR.transcriptUser[language](part.text)); + } else { + lines.push(STR.transcriptAssistant[language](part.text)); } + } + // 渲染专属内容:工具调用/结果与 code 输出进 LLM 转录(codingRetrospect 靠 + // 它们沉淀踩坑根因),但不进 distill_records——#399 已知边界 3 明说不覆盖。 + switch (event.type) { case "tool/call": { const args = typeof data.arguments === "string" ? data.arguments @@ -351,6 +411,56 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th }; } +/** + * Issue #399:把当前蒸馏窗口里**可公开的会话正文**收集成待落盘记录。 + * + * 与 collectMessages 共用 publicTranscriptParts,但**不截断**:留存的意义就是 + * 整段原文(超长用户提示在记录里必须全文,而喂给 LLM 的转录仍受 distillMaxChars + * 约束)——两条路径在裁剪上的差异是设计的一部分,测试锁 3 显式覆盖。 + * + * 只收有 seq 的事件:distill_records 的主键是 (session_id, seq),seq 也是溯源 + * 区间与幂等的唯一依据;没有 seq 就无法表达「这条记录属于哪个窗口」。 + */ +function collectTranscriptRecords(session, afterSeq, throughSeq, distilledAt) { + const events = snapshotSessionEvents(session, afterSeq, throughSeq); + const records = []; + const seenKeys = new Set(); + // 窗口的 seq 区间取窗口内**全部**事件的 min/max(不只是公开正文那些):这是 + // LLM 实际看到的那个窗口,溯源链接要回答的是「这条记忆出自哪个窗口」。 + let seqStart; + let seqEnd; + for (const event of events) { + const seq = eventSeq(event); + if (seq !== undefined) { + if (seqStart === undefined || seq < seqStart) seqStart = seq; + if (seqEnd === undefined || seq > seqEnd) seqEnd = seq; + } + if (seq === undefined) continue; + const parts = []; + for (const part of publicTranscriptParts(event)) { + if (part.key) { + if (seenKeys.has(part.key)) continue; + seenKeys.add(part.key); + } + parts.push(part); + } + if (!parts.length) continue; + // 主键是 (session_id, seq):一个事件一行。agent/inbox/spliced 一次可投递多条 + // 子会话消息(同 seq、同 role),各自成行会被 INSERT OR IGNORE 静默丢掉后几条; + // 按事件合并成一行才是这套主键的真实语义(角色同事件内不会混:user/message 与 + // assistant/message 各只产出一条公开 part)。 + records.push({ + sessionId: session.id, + seq, + role: parts[0].role, + text: parts.map((part) => part.text).join("\n"), + windowId: `session:${session.id}:${seq}`, + distilledAt + }); + } + return { records, seqStart, seqEnd }; +} + // ── 智能调速器(429 保护)───────────────────────────────────────────── // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的 // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔 @@ -792,7 +902,45 @@ export function createSummarizer(ctx, service, config, deps = {}) { } writes.push({ entry, source, dup }); } - if (writes.length) { + // #399:蒸馏窗口的原始公开记录 + 记忆级溯源链接,与记忆写入同事务落盘。 + // 凭证与它的派生记忆必须同生共死:记忆写了而记录没写,溯源就断在「不知道 + // 出自哪段原文」;记录写了而记忆没写,盘上留一份没有索引的原文。默认关 + // (enabled !== true)时连收集都不做,一次 SQL 都不发(测试锁 5)。 + const distilledAt = new Date(now()).toISOString(); + const transcript = config?.distillRecords?.enabled === true + && typeof service.saveDistillRecords === "function" + ? collectTranscriptRecords(session, previousSeq, triggerSeq, distilledAt) + : null; + const writeWindow = () => { + const savedIds = []; + for (const { entry, source, dup } of writes) { + const outcome = service.saveWithDedupe({ + ...entry, + source, + ...(dup ? { _mergeInto: dup.memory.id } : {}) + }); + // created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是 + // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。 + if (outcome?.memory?.id) savedIds.push(outcome.memory.id); + } + if (transcript && transcript.records.length) { + service.saveDistillRecords(transcript.records); + if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined + && typeof service.saveMemoryProvenance === "function") { + for (const memoryId of savedIds) { + service.saveMemoryProvenance({ + memoryId, + sessionId: session.id, + seqStart: transcript.seqStart, + seqEnd: transcript.seqEnd, + createdAt: distilledAt + }); + } + } + } + persistCursor(session.id, nextSeq); + }; + if (writes.length || (transcript && transcript.records.length)) { // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。 // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复 // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留 @@ -800,16 +948,7 @@ export function createSummarizer(ctx, service, config, deps = {}) { if (typeof service.transaction !== "function") { throw new Error("dsh-mneme: atomic summarization writes require service.transaction"); } - service.transaction(() => { - for (const { entry, source, dup } of writes) { - service.saveWithDedupe({ - ...entry, - source, - ...(dup ? { _mergeInto: dup.memory.id } : {}) - }); - } - persistCursor(session.id, nextSeq); - }); + service.transaction(writeWindow); } else { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index bd8fa3ce..228d9d43 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -678,7 +678,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支)、 // issue #380 新增 preInjectGate.enabled/preInjectGate.enforce(注入前判定两级开关)、 // #135 成本审计派生新增 dreamMaxMergePerRun(单轮 merge 决策上限)) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1); + // issue #399 新增 distillRecords.enabled/distillRecords.retentionDays(蒸馏原文留存 + + // 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS)) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1 + 2); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMergeGuard, true); @@ -708,6 +710,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d assert.equal(data.effective["writeAdmission.enforce"], false); // #164 A2:密钥/PII 判据自己的闸,同样默认关(#332 的行为逐字节保留)。 assert.equal(data.effective.sensitiveScanEnabled, false); + // #399:蒸馏原文留存默认关(这是一份对话原文副本,不该由升级默认打开), + // 保留期默认 90 天(与 llmAudit.retentionDays 同款)。 + assert.equal(data.effective["distillRecords.enabled"], false); + assert.equal(data.effective["distillRecords.retentionDays"], 90); // 新增字符串 / URL / 枚举键 assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5"); assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434"); @@ -765,6 +771,10 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys" // #380:同款点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。 "preInjectGate.enabled": true, "preInjectGate.enforce": true, + // #399:布尔与整数范围两类点号键都往返一次(retentionDays 是 int,同样必须进 + // NESTED_FLAG_PATHS——否则 configFlagValue 落到顶层同名键上,effective 静默缺键)。 + "distillRecords.enabled": true, + "distillRecords.retentionDays": 30, // #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返; // 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。 sensitiveScanEnabled: true, @@ -784,6 +794,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys" "writeAdmission.enforce": true, "preInjectGate.enabled": true, "preInjectGate.enforce": true, + "distillRecords.enabled": true, + "distillRecords.retentionDays": 30, sensitiveScanEnabled: true, embedProvider: "local", ollamaBaseUrl: "http://127.0.0.1:11434", @@ -798,6 +810,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys" // #380:往返回来的覆盖值压过 bundle 配置默认 assert.equal(data.effective["preInjectGate.enabled"], true); assert.equal(data.effective["preInjectGate.enforce"], true); + assert.equal(data.effective["distillRecords.enabled"], true); + assert.equal(data.effective["distillRecords.retentionDays"], 30, "整数点号键也要读得回来"); assert.equal(data.effective.sensitiveScanEnabled, true); assert.equal(data.effective.embedProvider, "local"); assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434"); diff --git a/dsh-mneme/test/llm-audit.test.js b/dsh-mneme/test/llm-audit.test.js index 120439ce..912b717e 100644 --- a/dsh-mneme/test/llm-audit.test.js +++ b/dsh-mneme/test/llm-audit.test.js @@ -657,3 +657,40 @@ test("boot retention purge reads the assembled config, not rawCfg", async () => after.close(); assert.equal(left, 0, "面板开着的审计必须按 cfg 清理,否则审计行只增不减"); }); + +// #399 的启动期裁剪同理:留存开关与保留期都要取装配后的 cfg。这里额外锁一条 +// 「默认关就不动手」——留存是 opt-in,默认关时一张空表不该被每次启动扫一遍。 +test("boot purge trims expired distill records only when retention is on (issue #399)", async () => { + const dir = mkdtempSync(join(tmpdir(), "mneme-distill-retention-")); + const dbPath = join(dir, "memory.db"); + + const seed = createStore(dbPath); + createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true, "distillRecords.retentionDays": 1 }); + seed.saveDistillRecords([ + { + sessionId: "s-boot", + seq: 1, + role: "user", + text: "过期原文", + windowId: "s-boot:1", + distilledAt: new Date(Date.now() - 2 * 86400000).toISOString() + } + ]); + assert.equal(seed.db.prepare("SELECT count(*) AS c FROM distill_records").get().c, 1, "先放一行过期原文"); + seed.close(); + + const ctx = new Context(); + ctx.provide("tools", { register() { return () => {}; } }); + ctx.provide("commands", { register() { return () => {}; } }); + ctx.provide("systemPrompt", { context() { return () => {}; } }); + ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } }); + ctx.provide("llm", { async *stream() { yield { type: "finish", reason: { kind: "stop" } }; } }); + ctx.provide("webServer", { register() { return () => {}; } }); + // bundle 说关、面板说开(保留期 1 天,那行已过期 2 天):只有读装配后的 cfg 才会清掉 + await ctx.plugin(mneme, { memoryDir: dir, distillRecords: { enabled: false, retentionDays: 1 } }); + + const after = new DatabaseSync(dbPath); + const left = after.prepare("SELECT count(*) AS c FROM distill_records").get().c; + after.close(); + assert.equal(left, 0, "面板开着的留存必须按 cfg 裁剪,否则原文只增不减"); +}); diff --git a/dsh-mneme/test/maintenance.test.js b/dsh-mneme/test/maintenance.test.js index 575dfe56..5dd90d30 100644 --- a/dsh-mneme/test/maintenance.test.js +++ b/dsh-mneme/test/maintenance.test.js @@ -102,8 +102,8 @@ test("dry-run:报出将清理的行数与列文本大小,一个字节都不 assert.equal(report.dryRun, true); assert.equal(report.olderThanDays, 7, "默认保留窗口 7 天"); - assert.deepEqual(report.planned, { dream_run_inputs: 1, archived_embeddings: 1 }); - assert.deepEqual(report.cleared, { dream_run_inputs: 0, archived_embeddings: 0 }); + assert.deepEqual(report.planned, { dream_run_inputs: 1, archived_embeddings: 1, distill_records: 0 }); + assert.deepEqual(report.cleared, { dream_run_inputs: 0, archived_embeddings: 0, distill_records: 0 }); assert.ok(report.column_bytes.dream_run_inputs > 600, "列文本大小要给真实数字(它是上界)"); assert.equal(typeof report.size.before_bytes, "number"); assert.equal(report.vacuum.ran, false); @@ -116,6 +116,41 @@ test("dry-run:报出将清理的行数与列文本大小,一个字节都不 } }); +test("dry-run 与执行:蒸馏原文进报告,裁剪只删原文、溯源链接留住(#399)", () => { + const { store, close } = fileStore(); + try { + seed(store); + const old = new Date(NOW - 30 * DAY).toISOString(); + const fresh = new Date(NOW - 1 * DAY).toISOString(); + store.saveDistillRecords([ + { sessionId: "s-old", seq: 1, role: "user", text: "z".repeat(600), windowId: "s-old:1", distilledAt: old }, + { sessionId: "s-fresh", seq: 1, role: "user", text: "新窗口的原文", windowId: "s-fresh:1", distilledAt: fresh } + ]); + const memoryId = store.all()[0].id; + store.saveMemoryProvenance({ memoryId, sessionId: "s-old", seqStart: 1, seqEnd: 1, createdAt: old }); + + // dry-run:第三项要报出真实行数与列文本大小(上界),且一个字节都不改 + const dry = maintenanceFor(store).reclaim({ dryRun: true }); + assert.equal(dry.planned.distill_records, 1, "只有窗口外那行算数"); + assert.ok(dry.column_bytes.distill_records >= 600, "蒸馏原文的列文本大小要给真实数字"); + assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_records").get().c, 2, "dry-run 不删原文"); + + const applied = maintenanceFor(store).reclaim({ dryRun: false }); + assert.equal(applied.cleared.distill_records, 1); + const rows = store.db.prepare("SELECT session_id FROM distill_records").all(); + assert.deepEqual(rows.map((r) => r.session_id), ["s-fresh"], "窗口内原文留住"); + // 裁剪只删原文:链接与记忆都留着,面板/CLI 显示「来源已过期」而不是查无此记忆。 + assert.equal( + store.db.prepare("SELECT count(*) AS c FROM memory_provenance").get().c, + 1, + "溯源链接刻意保留,指向已空区间" + ); + assert.equal(rawMemoryCount(store), 2, "裁剪原文不删记忆"); + } finally { + close(); + } +}); + test("执行:只清窗口外的输入快照,run 的骨架与决策原文留住", () => { const { store, close } = fileStore(); try { @@ -191,6 +226,8 @@ test("VACUUM 失败不回吞:清理照做、报告与回执都带上失败原 clearDreamRunInputs: (b) => store.clearDreamRunInputs(b), archivedEmbeddingStats: () => store.archivedEmbeddingStats(), clearArchivedEmbeddings: () => store.clearArchivedEmbeddings(), + distillRecordStats: (b) => store.distillRecordStats(b), + clearDistillRecords: (b) => store.clearDistillRecords(b), storageStats: () => store.storageStats(), saveLlmAudit: (e) => store.saveLlmAudit(e), vacuum: () => { throw new Error("SQLITE_BUSY: database is locked"); } diff --git a/dsh-mneme/test/pre-inject-gate.test.js b/dsh-mneme/test/pre-inject-gate.test.js index 271cd850..736d789f 100644 --- a/dsh-mneme/test/pre-inject-gate.test.js +++ b/dsh-mneme/test/pre-inject-gate.test.js @@ -365,6 +365,16 @@ test("lightMode:预设翻转 preInjectGate.enabled 且不污染用户配置对 assert.equal(flat.heatEnabled, false, "平铺键照旧直赋(既有行为回归)"); }); +test("lightMode:预设关掉 distillRecords.enabled,但保留用户显式设的 retentionDays(#399)", () => { + // 轻量模式关的是「留存原文」这个成本/隐私面;retentionDays 是保留窗口的时长, + // 不在 LIGHT_MODE_OFF 里——用户显式设过就照旧,别把配置顺手抹成默认值。 + const user = { lightMode: true, distillRecords: { enabled: true, retentionDays: 30 } }; + const preset = applyLightModePreset(user); + assert.equal(preset.distillRecords.enabled, false, "轻量模式必须关掉原始记录留存"); + assert.equal(preset.distillRecords.retentionDays, 30, "保留窗口时长不受轻量模式影响"); + assert.equal(user.distillRecords.enabled, true, "不改到用户原对象"); +}); + test("判定超时:abort 底层流(真宿主守约)→ degraded 审计 + 不缓存 + 下一帧重试(CodeRabbit on #382)", async () => { const llm = fakeLlm([{ hang: true }, { json: { ids: [], reason: "recovered" } }]); const s = setup({ preInjectGate: { enabled: true } }, llm, { judgeTimeoutMs: 50 }); diff --git a/dsh-mneme/test/settings.test.js b/dsh-mneme/test/settings.test.js index b0733e14..c6809df7 100644 --- a/dsh-mneme/test/settings.test.js +++ b/dsh-mneme/test/settings.test.js @@ -182,6 +182,8 @@ test("feature flags accept the new boolean, string, url and enum keys", () => { settings.setFeatureFlags({ "memoryQualityFilter.enabled": false, "llmAudit.enabled": false, + "distillRecords.enabled": false, + "distillRecords.retentionDays": 30, bm25SearchEnabled: false, conflictFreezeEnabled: true, trustEpistemicWeighting: true, @@ -196,6 +198,9 @@ test("feature flags accept the new boolean, string, url and enum keys", () => { const flags = settings.getFeatureFlags(); assert.equal(flags["memoryQualityFilter.enabled"], false, "nested keys stored flat"); assert.equal(flags["llmAudit.enabled"], false); + // #399:布尔与整数范围两类键都要能过白名单(retentionDays 是 natural 1..3650) + assert.equal(flags["distillRecords.enabled"], false); + assert.equal(flags["distillRecords.retentionDays"], 30); assert.equal(flags.bm25SearchEnabled, false); assert.equal(flags.conflictFreezeEnabled, true); assert.equal(flags.trustEpistemicWeighting, true); @@ -215,6 +220,9 @@ test("nested feature flag keys are validated as booleans and rejected otherwise" const { store, settings } = setup(); assert.throws(() => settings.setFeatureFlags({ "memoryQualityFilter.enabled": "yes" }), /memoryQualityFilter\.enabled/); assert.throws(() => settings.setFeatureFlags({ "llmAudit.enabled": 1 }), /llmAudit\.enabled/); + assert.throws(() => settings.setFeatureFlags({ "distillRecords.enabled": 1 }), /distillRecords\.enabled/); + // 范围外的整数也要被挡(retentionDays 上限 3650) + assert.throws(() => settings.setFeatureFlags({ "distillRecords.retentionDays": 0 }), /distillRecords\.retentionDays/); assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing"); store.close(); }); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index f9aa3baa..d3f25c0b 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -1500,3 +1500,140 @@ test("parseSummaryJson salvage does not turn string-embedded objects into memori assert.equal(parsed[0].title, "good"); assert.match(parsed[0].content, /fake/); }); + +// ── Issue #399:蒸馏窗口原始记录 + 记忆级溯源 ───────────────────────────── +// +// 这五条锁的是「把蒸馏从替换降级为叠加索引」的四个承重点:重跑幂等、隐私谓词 +// 在落盘路径同样成立、留存不受喂给模型的裁剪约束、裁剪不毁资产、默认关零写入。 +// 锁形状而非锁实现:断言直接查表,不去读 summarize.js 的中间变量。 + +/** Issue #399 的开启档 + 关掉真实节流(否则同会话第二轮会被间隔门挡下)。 */ +const RECORDS_ON = { distillRecords: { enabled: true }, distillRateLimitIntervalMs: 0 }; + +function countRows(store, sql) { + return store.db.prepare(sql).get().c; +} + +function recordsSession(id, events) { + return { + id, + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events + }; +} + +function eventHandler(events) { + return events.find((e) => e.name === "session/event").fn; +} + +test("issue#399: re-distilling the same window never duplicates records", async () => { + const first = setup(RECORDS_ON); + const session = recordsSession("s399-idem", [userMessage("同一窗口的提问", 1), { seq: 2, type: "turn/end" }]); + await eventHandler(first.events)(session, { seq: 2, type: "turn/end" }); + assert.equal(countRows(first.store, "SELECT count(*) AS c FROM distill_records"), 1); + + // 模拟宿主重启后重跑同一 seq 段(代码注释里点名的路径:max-runs 耗尽额度、游标 + // 写入随事务回滚后丢失):删掉持久化游标 + 换一个全新的 summarizer 实例,于是 + // lastDistilledSeq 这份内存游标也一并归零,同一窗口会被完整重蒸一遍。 + first.store.db.prepare("DELETE FROM distill_cursors").run(); + const second = setup(RECORDS_ON, { store: first.store }); + await eventHandler(second.events)(session, { seq: 2, type: "turn/end" }); + + assert.equal( + countRows(first.store, "SELECT count(*) AS c FROM distill_records"), + 1, + "同一 (session_id, seq) 只留一行:重跑必须幂等,保留首次落盘时间才诚实" + ); + assert.equal( + countRows( + first.store, + `SELECT count(*) AS c FROM ( + SELECT memory_id, session_id, seq_start, seq_end FROM memory_provenance + GROUP BY memory_id, session_id, seq_start, seq_end HAVING count(*) > 1)` + ), + 0, + "同一窗口重跑不重复挂链接(唯一索引 + ON CONFLICT DO NOTHING)" + ); +}); + +test("issue#399: records keep only public transcript; reasoning and machine-injected content never land on disk", async () => { + const { events, store, calls } = setup(RECORDS_ON); + const session = recordsSession("s399-priv", [ + userMessage("真实提问:这个报错怎么修", 1), + // 机器注入的三类:AGENTS.md / skill 正文 / 文件变更通知。source.kind 不是 + // user / agent-message / subagent-settled,必须整个事件被挡在门外。 + { seq: 2, type: "user/message", data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 注入正文不该落盘" }] } }, + { seq: 3, type: "user/message", data: { source: { kind: "skill" }, content: [{ type: "text", text: "SKILL_BODY 不该落盘" }] } }, + { seq: 4, type: "user/message", data: { source: { kind: "file-change" }, content: [{ type: "text", text: "FILE_CHANGE 通知不该落盘" }] } }, + { + seq: 5, + type: "assistant/message", + data: { message: { content: [ + { type: "reasoning", text: "PRIVATE_REASONING 不该落盘" }, + { type: "text", text: "公开回答:检查第 3 行。" } + ] } } + }, + toolResultEvent("TOOL_OUTPUT 只进渲染不落盘", false, 6), + { seq: 7, type: "turn/end" } + ]); + await eventHandler(events)(session, { seq: 7, type: "turn/end" }); + + const texts = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all().map((r) => r.text).join("\n"); + assert.ok(texts.includes("真实提问"), "用户提示落盘"); + assert.ok(texts.includes("公开回答"), "助手公开正文落盘"); + assert.ok(!texts.includes("AGENTS.md"), "插件注入的 AGENTS.md 绝不落盘"); + assert.ok(!texts.includes("SKILL_BODY"), "skill 正文绝不落盘"); + assert.ok(!texts.includes("FILE_CHANGE"), "文件变更通知绝不落盘"); + assert.ok(!texts.includes("PRIVATE_REASONING"), "私有推理链绝不落盘"); + assert.ok(!texts.includes("TOOL_OUTPUT"), "工具结果不落盘(#399 已知边界 3)"); + // 渲染路径照旧带工具输出(codingRetrospect 靠它沉淀踩坑根因)——两条路径在 + // 「工具输出」上的差异是设计的一部分,不是漂移:漂移的方向必须是「落盘的更少」。 + assert.ok(JSON.stringify(calls[0].messages).includes("TOOL_OUTPUT"), "工具输出仍进 LLM 转录"); +}); + +test("issue#399: records keep the untruncated prompt while the LLM transcript stays capped", async () => { + // 头部标记 + 超长正文:trimTranscript 保尾部,所以「渲染里找不到头部标记」正是 + // 被 distillMaxChars 截断的证据;记录里必须连头部标记都在。 + const long = `HEAD_MARKER ${"x".repeat(1200)} TAIL_MARKER`; + const { events, store, calls } = setup({ ...RECORDS_ON, distillMaxChars: 200 }); + const session = recordsSession("s399-long", [userMessage(long, 1), { seq: 2, type: "turn/end" }]); + await eventHandler(events)(session, { seq: 2, type: "turn/end" }); + + const rows = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all(); + assert.equal(rows.length, 1); + assert.equal(rows[0].text, long, "留存的是整段原文,不截断、不加省略号"); + assert.ok(!JSON.stringify(calls[0].messages).includes("HEAD_MARKER"), "喂 LLM 的转录仍受 distillMaxChars 约束"); +}); + +test("issue#399: expiring records keeps the memories and leaves provenance dangling-but-present", async () => { + const { events, store } = setup(RECORDS_ON); + const session = recordsSession("s399-expire", [userMessage("会被裁剪的原文", 1), { seq: 2, type: "turn/end" }]); + await eventHandler(events)(session, { seq: 2, type: "turn/end" }); + assert.equal(store.count(), 2); + assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2); + + // 到期裁剪:把 cutoff 推到未来,等价于「这条记录已经过期」。 + assert.equal(store.clearDistillRecords(new Date(Date.now() + 86400000).toISOString()), 1); + assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0); + assert.equal(store.count(), 2, "裁剪原文绝不级联删记忆"); + assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2, "链接保留,从此指向空区间"); + const memoryId = store.all()[0].id; + assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期"); +}); + +test("issue#399: enabled=false (the default) writes nothing at all", async () => { + const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 }); + let saverCalls = 0; + const original = service.saveDistillRecords; + service.saveDistillRecords = (...args) => { + saverCalls++; + return original(...args); + }; + const session = recordsSession("s399-off", [userMessage("默认关不该留原文", 1), { seq: 2, type: "turn/end" }]); + await eventHandler(events)(session, { seq: 2, type: "turn/end" }); + + assert.equal(saverCalls, 0, "默认关:连收集都不做,一次写入调用都不发"); + assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0); + assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 0); + assert.equal(store.count(), 2, "记忆写入照旧"); +}); From 93e4b50032077969bd1325695d8e9e1a55429ccf Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 12 Oct 2026 09:43:08 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(399):=20reclaim=20=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E9=94=81=E9=9A=8F=20distill=5Frecords=20?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E5=9B=9E=E6=94=B6=E9=9D=A2=E8=80=8C=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit origin/main(#410/#411 回收链)的 api-routes-gaps.test.js:811 把 planned 的 键清单冻结为 [archived_embeddings, dream_run_inputs];#399 的蒸馏原文到期清理 (src/maintenance.js:103-114)有意把 distill_records 纳入 reclaim 报告——它是 本入口唯一的「真删内容」项,按 distilled_at 到期,有自己的保留期键 distillRecords.retentionDays。两边意图各自成立,按后者演进:扩清单并注明这份 锁防的是「路由层包一层 { report }」,不是冻结回收范围本身。 npm test 1659(1658 pass / 0 fail / 1 skip 原有);双徽章同步 1659。 --- dsh-mneme/test/api-routes-gaps.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dsh-mneme/test/api-routes-gaps.test.js b/dsh-mneme/test/api-routes-gaps.test.js index 68c36d24..774bfa55 100644 --- a/dsh-mneme/test/api-routes-gaps.test.js +++ b/dsh-mneme/test/api-routes-gaps.test.js @@ -818,7 +818,11 @@ test("reclaim report has no wrapper layer: fields sit at the top level", async ( assert.ok(out.body.size.before_bytes > 0); assert.equal(typeof out.body.size.freelist_pages, "number"); assert.equal(typeof out.body.duration_ms, "number"); - assert.deepEqual(Object.keys(out.body.planned).sort(), ["archived_embeddings", "dream_run_inputs"]); + // #399:蒸馏原文记录按 distilled_at 到期纳入回收范围后,planned 的键随回收面 + // 演进——这份锁防的是路由层悄悄包一层 { report }(键路径变深,面板与 standalone + // 会对不上字段),不是冻结回收范围本身;新增回收项时同步扩这份清单,并配 + // maintenance.test.js 的同款断言。 + assert.deepEqual(Object.keys(out.body.planned).sort(), ["archived_embeddings", "distill_records", "dream_run_inputs"]); assert.deepEqual(Object.keys(out.body.vacuum).sort(), ["duration_ms", "error", "ran", "requested"]); assert.ok("receipt" in out.body); assert.ok("cutoff" in out.body); From cd3856804d156b3d638ec66bbc4e7f25ba45bcf6 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 12 Oct 2026 11:13:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(399):=20=E5=8C=BA=E5=88=86=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=9C=9F=E4=B8=8E=20reclaim=20=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E4=BF=9D=E7=95=99=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retentionDays 只管启动期裁剪;reclaim 不读这个键,与另两个表共用 --older-than(默认 7 天)。同步修正 CONFIGURATION.md、DISTILL.md 与 maintenance.js 注释(原注释「不跟 7 天走」与实现矛盾)。 --- dsh-mneme/docs/CONFIGURATION.md | 2 +- dsh-mneme/docs/DISTILL.md | 6 ++++-- dsh-mneme/lib/maintenance.js | 6 ++++-- dsh-mneme/src/maintenance.js | 6 ++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 3ef4697d..b1f992c4 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -93,7 +93,7 @@ | 键 | 默认 | 作用 | 开启后果 / 冲突 | |---|---|---|---| | `distillRecords.enabled` **light** | `false` | 蒸馏时顺带留存窗口原文 + 写记忆级溯源链接 | opt-in;关 = 一行不写、连收集都不做,行为与 #399 之前逐字节一致 | -| `distillRecords.retentionDays` | `90` | 原文保留天数(1–3650) | 启动期与 `dsh-mneme reclaim` 两处裁剪;**只删 `distill_records`**,溯源链接保留指向空区间(显示「来源已过期」),绝不级联删记忆 | +| `distillRecords.retentionDays` | `90` | 原文保留天数(1–3650) | **只管启动期裁剪**(`src/index.js`,`enabled` 时动手)。`dsh-mneme reclaim` 不读这个键:它对三个表用同一条 `--older-than` 截止(默认 7 天),想对齐就显式传成与 `retentionDays` 相同的值(见 STORAGE.md)。两处都**只删 `distill_records`**,溯源链接保留指向空区间(显示「来源已过期」),绝不级联删记忆 | 两个键都走 feature_flags 白名单(面板可启停);`enabled` 进 lightMode 预设关闭清单,`retentionDays` 不进(它是时长不是行为面,用户显式设过就照旧)。`dsh-mneme reclaim` 的报告从两项变三项(`planned` / `column_bytes` / `cleared` 各多一个 `distill_records`),入口语义(默认 dry-run、`--apply` 才动手)不变。已知边界:无历史回填、从未蒸馏过的窗口不留记录、不覆盖工具结果与 code 输出。 diff --git a/dsh-mneme/docs/DISTILL.md b/dsh-mneme/docs/DISTILL.md index a08956c1..ac1f5e0c 100644 --- a/dsh-mneme/docs/DISTILL.md +++ b/dsh-mneme/docs/DISTILL.md @@ -106,8 +106,10 @@ distillRecords: **到期裁剪只删 `distill_records`**,`memory_provenance` 保留——链接从此指向空区间,面板 显示「来源已过期」,而不是查无此记忆。**裁剪原文绝不级联删记忆**:原文是索引,记忆是资产。 -两处裁剪:启动期(`src/index.js`,只在 `enabled === true` 时动手——默认关时一张空表不该被 -每次启动扫一遍)+ `dsh-mneme reclaim`(默认 dry-run)。 +两处裁剪,**窗口各自独立**:启动期(`src/index.js`,只在 `enabled === true` 时动手——默认关时 +一张空表不该被每次启动扫一遍)按 `retentionDays` 裁;`dsh-mneme reclaim`(默认 dry-run) +**不读这个键**,跟同一条 `--older-than`(默认 7 天)走——两个窗口互不联动,想一次对齐就把 +`--older-than` 显式传成与 `retentionDays` 相同的值。 删记忆是另一条路:`store.remove(id)` 在事务内**显式**清掉它的溯源链接。之所以不靠 `ON DELETE CASCADE`,是因为 `src/store.js` 从不启用 `PRAGMA foreign_keys`(全库唯一的 diff --git a/dsh-mneme/lib/maintenance.js b/dsh-mneme/lib/maintenance.js index 897876a9..c194f8f0 100644 --- a/dsh-mneme/lib/maintenance.js +++ b/dsh-mneme/lib/maintenance.js @@ -15,8 +15,10 @@ // B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是 // 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。 // C #399 蒸馏原文记录按 distilled_at 到期删除:记录是蒸馏那次调用的凭证,越旧越 -// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项,所以 -// 它有自己的保留期配置键 distillRecords.retentionDays,不跟 A 的 7 天走)。链接 +// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项)。它的 +// 常态保留期是独立配置键 distillRecords.retentionDays(启动期裁剪走那个键);本入口 +// **不读那个键**,与 A/B 共用同一条 --older-than 截止(想对齐就显式传同样的天数, +// 见 STORAGE.md「两处不冲突」)。链接 // (memory_provenance)**不在可清范围内**:保留指向空区间即「来源已过期」,删链 // 接等于假装这条记忆从来没有过出处——那是篡改历史,不是回收。 // diff --git a/dsh-mneme/src/maintenance.js b/dsh-mneme/src/maintenance.js index 897876a9..c194f8f0 100644 --- a/dsh-mneme/src/maintenance.js +++ b/dsh-mneme/src/maintenance.js @@ -15,8 +15,10 @@ // B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是 // 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。 // C #399 蒸馏原文记录按 distilled_at 到期删除:记录是蒸馏那次调用的凭证,越旧越 -// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项,所以 -// 它有自己的保留期配置键 distillRecords.retentionDays,不跟 A 的 7 天走)。链接 +// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项)。它的 +// 常态保留期是独立配置键 distillRecords.retentionDays(启动期裁剪走那个键);本入口 +// **不读那个键**,与 A/B 共用同一条 --older-than 截止(想对齐就显式传同样的天数, +// 见 STORAGE.md「两处不冲突」)。链接 // (memory_provenance)**不在可清范围内**:保留指向空区间即「来源已过期」,删链 // 接等于假装这条记忆从来没有过出处——那是篡改历史,不是回收。 // From e4009f69a37effd4af3e0b62b909b30dab813001 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 12 Oct 2026 18:41:38 +0800 Subject: [PATCH 4/4] chore: sync test badge to 1695 after rebase on integration stack --- README.md | 6 +++--- dsh-mneme/README.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0722f432..810410a1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -208,7 +208,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 1687 个测试 +npm test # 1695 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -416,7 +416,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 1687 tests +npm test # 1695 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index d467bc99..b8a5a6ef 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-1687%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1695%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) @@ -617,7 +617,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1687 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1695 个 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 运行时清单 ``` @@ -626,7 +626,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1687 个测试 +npm test # 运行 1695 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ```