diff --git a/README.md b/README.md
index 143a06d8..8380913d 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -270,7 +270,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790
```bash
cd dsh-mneme && npm install
-npm test # 2638 个测试
+npm test # 2654 个测试
npm run stress # 三轴线压测
npm run sync # src → lib 同步
```
@@ -550,7 +550,7 @@ Community projects that wire mneme into more platforms — not part of this repo
```bash
cd dsh-mneme && npm install
-npm test # 2638 tests
+npm test # 2654 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
diff --git a/SECURITY.md b/SECURITY.md
index 3dd9be28..51ddf4e0 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -299,6 +299,7 @@ All plugin-side audit data is stored **locally** in the plugin database and neve
| `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") |
+| `distill_runs` run ledger + `memory_mints` (#512) | Local database | Attribution metadata only — **no conversation text**. `distill_runs` records one row per distillation *round* (status, window range, memory count, provider/model, and `audit_id` joining the spend row); `memory_mints` records which run minted a given memory. Both live as long as the library does; unlike the raw text above they are not covered by `distillRecords.enabled` (they contain no transcript). Rows written before this change are left **NULL / absent on purpose**: guessing an attribution is worse than not having one |
| Dependency scan reports | GitHub Security tab (code scanning / Dependabot) | 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.
@@ -539,7 +540,7 @@ Token 是**写**面上的锁,不是把整个 API 围起来的墙。
- **真正的边界是回环 + 宿主监听器。** 宿主侧路由(`/api/dsh-mneme/*`)挂在 DSH 端口上,该端口绑 `127.0.0.1`,并由宿主挡掉 DNS rebinding 与跨站浏览器请求。这道围栏**不建立身份**:任何本地进程、以及任何跑在宿主同源页面里的脚本,都在这道围栏之内。
- **鉴权是 opt-in 且 fail-open。** `apiToken` 默认为空;为空期间 `isAuthorized()` 对每个请求都返回 true——读和写都放行。配了 token 之后,它守的是写路由与敏感读,不守普通读。
- **开放读(即使配了 token 也不需要)**:`GET /`、`/info`、`/version-check`、`/list`、`/recall-stats`、`/search`、`/semantic`、`/semantic/llm-audit`、`/semantic/llm-audit/stats`、`/semantic/graph/*`、`/entities`、`/memories/entities`、`/dream-status`、`/inject-status`、`/inject-preview`、`/pipeline`、`/candidates`、`/llm-providers`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge`、`/receive`、`/receive/undo` 的 `GET`/`HEAD` 分支。把它们当作「本地任何东西都能读」。`/receive`、`/receive/undo` 明明是写路由也出现在这一串里,这不是笔误:`token-write` 下 `needsToken()` 对 `GET`/`HEAD` 免 token,所以这两条路径上真正「开放」的只有一个 `405`,压根没有可读分支。这一节照 policy 声明抄、不照意图写,`dsh-mneme/test/security-doc.test.js` 会拿真实装配出来的路由双向比对这两行。
-- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`、`/profile/extract`(只收 POST:花一次宿主 LLM 产一段画像**草稿**,其它方法在产生任何副作用之前就 405)、`/receive`、`/receive/undo`(这两条写的是**本机自己的库**,所以归 `token-write`),以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加六条读:`/export`、`/external-api`、`/conflicts`、`/health`、`/share`(分享包是「用 POST 递出来的全库读取」,GET 一律 405,没有开放的读分支)、`/memory/provenance`(它回的是蒸馏窗口的**逐字会话原文**,`distillRecords.enabled` 打开才有;否则把它和上面那个开放的列表路由串起来,任何本地进程都能先枚举 id、再按 id 把那些原文捞回去——可达结果与 `/export`(一次拿全库)同量级,只是换了形状)。
+- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`、`/profile/extract`(只收 POST:花一次宿主 LLM 产一段画像**草稿**,其它方法在产生任何副作用之前就 405)、`/receive`、`/receive/undo`(这两条写的是**本机自己的库**,所以归 `token-write`),以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加六条读:`/export`、`/external-api`、`/conflicts`、`/health`、`/share`(分享包是「用 POST 递出来的全库读取」,GET 一律 405,没有开放的读分支)、`/memory/provenance`(它回的是蒸馏窗口的**逐字会话原文**,并自 #512 起按窗口带出 `distill_run_id` / `run_type` / `trigger_source` / `run_status` 的运行归因;`distillRecords.enabled` 打开才有;否则把它和上面那个开放的列表路由串起来,任何本地进程都能先枚举 id、再按 id 把那些原文捞回去——可达结果与 `/export`(一次拿全库)同量级,只是换了形状)。
- **独立数据面**(`127.0.0.1:8790`)是反过来的形状:除 `GET /health` 外每条路由都要自己的 Bearer token。因此把 `externalApiHost` 指向非回环地址,就等于把它变成一个「明文、只靠 token」的 HTTP 面——这是运营者的显式决定。
- **推论**:如果你把插件暴露到回环之外(反代、绑非回环地址),请设 `apiToken`,**并**核对上面那串开放读是否也能被看到——它们不受 token 保护。
- **这张表怎么保持为真**:每条路由在注册时自己声明 `policy`(`open` / `token` / `token-write`),`createRouteRegistrar` 在路由没声明或复用同一路径时直接抛错。新路由忘了归类会**启动失败**,而不是静默开门。上面两张清单还会被 `dsh-mneme/test/security-doc.test.js` 拿真实装配出来的路由做双向比对——加了路由却不更新这一节,CI 会红,而不是悄悄漂走。这张矩阵来自代码,不来自谁的记忆。
@@ -683,6 +684,7 @@ Token 是**写**面上的锁,不是把整个 API 围起来的墙。
| `failure_memories` 审计日志 | 本地数据库 | 每次写入失败均记录,供取证分析 |
| `recall_run` 搜索日志 | 本地数据库 | 每次搜索均记录时间戳和查询元数据 |
| AutoDream receipts | 本地数据库 + Markdown 镜像 | 密码学可验证;保留至用户删除 |
+| `distill_runs` 运行台账 + `memory_mints` 铸造归因(#512) | 本地数据库 | 只有归因元数据,**不含任何会话正文**。`distill_runs` 一次蒸馏落一行(状态、窗口区间、记忆条数、provider/model,以及连到开销行的 `audit_id`);`memory_mints` 记「这条记忆出生自哪一轮」。两者随库存续,**不受** `distillRecords.enabled` 约束(里面没有转录文本)。改造之前写下的行**刻意留 NULL / 不补**:猜一个归因比没有归因更坏 |
| `distill_records` 蒸馏窗口原文(#399) | 本地数据库 | **opt-in,默认关。** 仅当 `distillRecords.enabled: true` 时:被蒸馏窗口的用户提示与助手公开正文**逐字留存**(不截断),受 `retentionDays`(默认 90)约束。**绝不**包含私有推理链、插件注入的机器内容、工具入参与工具输出。到期裁剪只删原文,溯源链接保留(指向空区间,显示「来源已过期」) |
| 依赖扫描报告 | GitHub Security 标签页(code scanning / Dependabot) | 不存储于设备 |
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 381424f0..6ef98ffb 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -14,6 +14,8 @@
回归测试 +11(`test/provenance-http.test.js` 21 条:两个主键都缺 / 都给 / 裁空白、`limit` 十二种畸形与越界、session 方向一行一窗口 + `link_count` + 顺序、两个方向各自的 `truncated`、503 按方向判、session 方向开关关也 409 且零 SQL)与 +3(`test/api.test.js`:`GROUP BY` 并行 + `link_count=2` + 跨会话不串、两主键都给 400、`limit=0` 400 与 `limit=2` truncated)与 +2(`test/standalone-api.test.js`:`?sessionId=` 列出窗口、两主键都给 400 且 `limit` 六种畸形全 400)。
+- **蒸馏归因只到「会话区间」、到不了「哪一次蒸馏」:同一段原文被重蒸过几次、哪一轮失败了,库里一个字都没存(#512,提案与实测由 @modusensus 提供)**:#421 的溯源读出口(#509)能让「这条记忆蒸自哪几句原文」被查到,但它回答的一直是 `哪一段`,不是 `哪一轮`。`memory_provenance` 的粒度是 (memory, 会话, seq 区间),区间是**输入**的坐标,不是**运行**的身份;`distill_run_id` / `run_type` / `trigger_source` 三个键全仓零命中——不是出口少透一个字段,是库里根本没存。对照 `autoDream` 每轮一行 `dream_runs`、可审计可回放,`autoSummarize` 只在 `llm_audit_logs` 留一条计费痕迹且不与归因链接。于是「这个窗口为什么一直没有记忆」只能靠逐帧解压宿主日志去对,而审计行里的 token 数说明不了哪一步塌了。**归因拆成两层,因为它们回答的不是同一个问题**:① **窗口级** `memory_provenance.distill_run_id` → 新表 `distill_runs`(一次蒸馏落一行:`status` / `error` / `session_id` / `seq_start` / `seq_end` / `memory_count` / `provider` / `model` / `audit_id`),只由按窗口写入的 `autoSummarize` 路径填;② **行级** 新表 `memory_mints(memory_id, run_id)`,记「这条记忆出生自哪一轮巩固」,由 `dream` / `sleep` 的 create 动作填。**行级为什么另开一张表**:dream / sleep 铸造的 pattern 没有会话区间——session 与 seq 都不存在,把合成区间塞进 `memory_provenance` 就是编造坐标,而一个错的坐标比没有坐标更坏(它看起来完全合法,会让「能追到」变成谎话)。**行级为什么只填 create**:merge / conflict / update / supersede 动的是既有行,`receipt_chain` 早已按 (run_id, record_id) 记过;再造一份就是第二套账,两边迟早对不上。create 是那边唯一的洞——`buildOutcome` 刻意不给 create 派 disposition,`receipt_chain` 也就不产行。**为什么不复用 `llm_audit_logs.id` 当 run id**:`llmAudit.retentionDays` 到期裁剪会删掉 audit 行(`src/index.js` 启动即调 `deleteOldLlmAudits`),归因会跟着悬空;而 `memory_provenance` 刻意活得比原文更久(#399 的「来源已过期」正是这条设计的产物)。所以 run 是**归因主体**、audit 行只是它的**开销读数**,两边靠 `distill_runs.audit_id` 连,**不是靠时间戳猜**——先写 audit 拿 id、再落 run,顺序不能反。**为什么不复用 `dream_runs`**:那张表每一列都是巩固语义(`snapshot_hash` / `input_count` / `decisions` / `outcome` / `receipt`),「每行都能回放一次巩固」是它对消费方的契约;硬塞 `run_type='autoSummarize'` 会让这条契约失效,而一个字段两种含义是这套 schema 里最容易腐烂的设计。dream / sleep 的轮次仍只记在 `dream_runs`、**不复制**进 `distill_runs`——两张表各说一件事,比一张表说两件事好。**历史行不猜**:迁移只加列 / 加表,存量 `memory_provenance.distill_run_id` 一律 NULL;反推一个「看起来能追」的合成 id 是最坏的一种坏(读数正常、实际是编的),口径与 #534 的 `raw_output`「历史失败轮反推不出原文」同源。面板遇到 NULL 显示「无运行归因(历史行)」,不是空白——空白会被读成「这里本来就没有东西」。**失败轮也记账**:`status` 取 `ok | degraded | noop | failed | aborted`,模型侧流失败与解析失败都留一行 `failed`,只有 dispose / 会话关闭才是 `aborted`(这一轮被主动打断)。这两条出口原先根本不设状态,于是失败轮一行 run 都不落——「跑过但失败了」与「压根没跑」在台账上长得一模一样,正是本条要消灭的那种静默。**不新增开关**:这是记账,记账默认就该在(与 #521 的「只加观测、不动判定」同一条原则),多一个键就要多一份白名单 + schema + 旗标计数锁。**面板**:详情抽屉新增「蒸发归因」区块,同时渲染两层;拉不到时是「没有归因」而不是空白,历史行单独标「无运行归因」。**读出口**:`GET /api/dsh-mneme/memory/provenance?memoryId=` 的窗口按上表带上 `distill_run_id / run_type / trigger_source / run_status`(读不到整组 null),答复级另带 `mints[]`。**回归测试 +14**(`test/store.test.js` 5 条:老库补列补表且存量链接留 NULL、重开已升级的库幂等且不动已落盘的归因、同 id replay 覆写而不重复、`audit_id` 事后补注且补不存在的 run 返回 0、同一 (记忆, run) 只记一次且删记忆时显式清掉归因;`test/summarize.test.js` 4 条:一个窗口一行 run 且每条链接都指向它、run 与开销行靠 `audit_id` 而不是时间戳相连、`llmAudit` 关掉时 run 照落、失败轮仍留一行说它失败了;`test/mint-source.test.js` 4 条:create 记得住是哪一轮铸的它、不给 run id 时不编造记录、改动既有行不进 `memory_mints`、sleep 铸造的 pattern 指回真正那一轮;`test/client.test.js` 1 条:抽屉同时渲染两层归因)。变异自证:面板那条最初只断言 `includes("provenance.mints")`,被空态判断里的 `provenance.mints.length` 满足、改坏了仍绿——**断到 `.map(` 为止才咬得住**;另外 `test/mint-source.test.js` 的 sleep 用例首跑就抓到 `phasePatterns` 调 `applyDecisions` 时**漏传 `runId`**(正文注释写着「runId 让账本行能追到哪一轮」,而 sleep 这条真实铸造路径一直没给),漏了它铸造出的 pattern 只能追到 sleep 这条管道、追不到哪一轮——正是本条要补的口子。
+
- **会话级无痕闸门:指定会话与子代理「不进不出」,其余会话照常(#539,实现由 @wuchichiumike 在 #540 贡献)**:AgentTeams / subagent 的一次性会话也会拿到 Mneme 注入并写进同一个库,而此前能关的只有全局——关插件或关 `autoSummarize` 会连带打死别的会话,#471 的 scopeState 又是 agent/workspace 级政策、且本来就不阻断保存。这一版加的是**逐会话**判定:命中会话不注入记忆 / 用户设置 / 工具指引,不接受其 `memory_*` 与 `set_scope_state` 调用(拒绝发生在任何副作用之前),不对它蒸馏,压缩抢救 / M.O. 记账 / 自动提议三条写路径同样早退。
三个键全部 **opt-in**:`sessionGateEnabled`(默认 `false`,总闸)、`sessionGateSubagents`(默认 `true`,总闸开启时自动命中子代理)、`sessionGateSessions`(默认 `[]`,手动 id 名单,两者 OR)。`sessionGateEnabled` / `sessionGateSubagents` 进 `settings.js` 白名单并可走 `/features`(重启生效),`sessionGateSessions` 是数组、只能写在 bundle 配置。
diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md
index e6821cd5..ef51fa9d 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -5,7 +5,7 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -535,7 +535,8 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz"
| `GET` | `/profile` | 用户画像,返回 `{profile:"..."}`;未设置时为空字符串 |
| `GET` | `/rules` | 行为规则列表,返回 `{rules:[...]}`;未设置时为空数组 |
| `GET` | `/memories?limit&offset&type&minImportance&source&order=chrono&include_archived&occurred_from&occurred_to` | 分页列出记忆(`include_archived=true` 含归档;occurred 时间窗按 `occurred_at` 回退 `created_at`) |
-| `GET` | `/memory/provenance?id=` 或 `?sessionId=`(可选 `&limit=`,默认 20、上限 100) | 溯源读出口,两个方向共用一条路径(#421 拆出两条:方向 1 记忆→窗口由 #509 交付,方向 2 会话→窗口由 #524 补上;宿主内面板路由 `/api/dsh-mneme/memory/provenance` 同一口径)。**两个主键互斥**:都缺 `400 {"error":"missing-id"}`、都给 `400 {"error":"ambiguous-id"}`(刻意不静默择一——两个方向的结果形状不同,猜一个只会在出事之后才发现)。方向 1 是一条溯源链接一个窗口;方向 2 把同一个 `(session_id, seq_start, seq_end)` 合并成一项(`GROUP BY`),带 `link_count`=这个窗口派生出几条记忆,按 `seq_start` 排——逐链接返回会让「这个会话蒸了几次」被重复行淹没。两个方向都带 `truncated`(撞上 limit 时说「还有更多」;少了它,被截断的答复与「本来就这么少」逐字节相同)。`records` 是那段蒸馏窗口的**逐条会话原文**(按 `seq` 升序、不越出区间)。没有链接返回**空 `windows`**——「不是蒸馏来的」是有效答案,不是错误。状态码阶梯:`401` 无 token → `400` 缺参/多参/`invalid-limit` → `409 {"error":"distill-records-disabled"}`(`distillRecords.enabled` 关着;该开关是**启动快照**,面板改完要到下次启动生效,面板会显示「待重启」)→ `503 {"error":"provenance-unavailable"}`(该方向要用的方法没接线,**按方向判**——只缺 session 侧方法时方向 1 仍然 200)→ `200`。`409` 与 `503` 必须分开读数:前者是「用户关了」,后者是「装配坏了」。**关开关 ≠ 删掉已留存原文**——只是不再经 HTTP 提供,盘上的行要 `dsh-mneme reclaim`(或重新开启后的启动裁剪)才走,详见 [docs/DISTILL.md](docs/DISTILL.md) |
+| `GET` | `/memory/provenance?id=` 或 `?sessionId=`(可选 `&limit=`,默认 20、上限 100) | 溯源读出口,两个方向共用一条路径(#421 拆出两条:方向 1 记忆→窗口由 #509 交付,方向 2 会话→窗口由 #524 补上;宿主内面板路由 `/api/dsh-mneme/memory/provenance` 同一口径)。**两个主键互斥**:都缺 `400 {"error":"missing-id"}`、都给 `400 {"error":"ambiguous-id"}`(刻意不静默择一——两个方向的结果形状不同,猜一个只会在出事之后才发现)。方向 1 是一条溯源链接一个窗口 `{session_id, seq_start, seq_end, created_at, distill_run_id, run_type, trigger_source, run_status, records[]}`(#512 补运行归因:run 是归因主体,窗口只是它的一批输入;读不到整轮归因时整组 `null`,**不从区间或时间戳反推**),答复级另带 `mints[]`(行级归因:这条记忆出生自哪一轮 dream/sleep,只填 create);方向 2 把同一个 `(session_id, seq_start, seq_end)` 合并成一项(`GROUP BY`),带 `link_count`=这个窗口派生出几条记忆,按 `seq_start` 排——逐链接返回会让「这个会话蒸了几次」被重复行淹没。两个方向都带 `truncated`(撞上 limit 时说「还有更多」;少了它,被截断的答复与「本来就这么少」逐字节相同)。`records` 是那段蒸馏窗口的**逐条会话原文**(按 `seq` 升序、不越出区间)。没有链接返回**空 `windows`**——「不是蒸馏来的」是有效答案,不是错误。状态码阶梯:`401` 无 token → `400` 缺参/多参/`invalid-limit` → `409 {"error":"distill-records-disabled"}`(`distillRecords.enabled` 关着;该开关是**启动快照**,面板改完要到下次启动生效,面板会显示「待重启」)→ `503 {"error":"provenance-unavailable"}`(该方向要用的方法没接线,**按方向判**——只缺 session 侧方法时方向 1 仍然 200)→ `200`。`409` 与 `503` 必须分开读数:前者是「用户关了」,后者是「装配坏了」。**关开关 ≠ 删掉已留存原文**——只是不再经 HTTP 提供,盘上的行要 `dsh-mneme reclaim`(或重新开启后的启动裁剪)才走,详见 [docs/DISTILL.md](docs/DISTILL.md) |
+
| `GET` | `/memories/:id` | 单条记忆 |
| `POST` | `/memories` | 新增记忆 `{type,title,content,importance?,tags?,source?,sensitivity?,occurred_at?,agent_scope?,workspace_scope?}`;响应含 `action: created\|merged` |
| `PUT` | `/memories/:id` | 局部更新 `{title?,content?,type?,importance?,tags?,reason?,agent_scope?,workspace_scope?}`;content 改写按 human_override 入档 |
@@ -719,7 +720,7 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
-test/ # 2638 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 2654 个 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 运行时清单
```
@@ -728,7 +729,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 2638 个测试
+npm test # 运行 2654 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md
index 6db605e6..25e4ee24 100644
--- a/dsh-mneme/docs/CONFIGURATION.md
+++ b/dsh-mneme/docs/CONFIGURATION.md
@@ -140,6 +140,8 @@
`enabled` 这一格**同时管读写两侧**(#509 评审整改后):写侧是蒸馏时是否落盘(`src/summarize.js:988`),读侧是溯源读出口是否把原文读出去(判据在 `src/provenance-http.js`,与写侧逐字同源,只此一份)。关着时读出口回 `409 {"error":"distill-records-disabled"}`——但**盘上的原文不会因此消失**:保留期裁剪与写入同在 `src/index.js:270` 一道门里,关掉开关意味着「不再写、启动期也不再裁」,已留的行会一直躺在库里,直到 `dsh-mneme reclaim`(它不读这个键,手动可删)或重新开启后的启动裁剪。所以「关掉就干净了」是误读,诚实的表述是「关掉 = 不再经 HTTP 提供」。
+**运行归因(#512)不加开关**:溯源窗口另外带 `distill_run_id / run_type / trigger_source / run_status`,答复级另带 `mints[]`(这条记忆出生自哪一轮 dream / sleep)。它记的是归因元数据、不含任何会话正文,所以不依附 `distillRecords.enabled`——上面那枚开关关着时端点照旧 409,但 `distill_runs` 与 `memory_mints` 两张表仍在记账。改造之前写下的行刻意留 NULL、不补:反推一个「看起来能追」的合成 id 比没有归因更坏。
+
## 注入
| 键 | 默认 | 作用 | 开启后果 / 冲突 |
diff --git a/dsh-mneme/docs/DAEMON.md b/dsh-mneme/docs/DAEMON.md
index 98867e1d..e360957d 100644
--- a/dsh-mneme/docs/DAEMON.md
+++ b/dsh-mneme/docs/DAEMON.md
@@ -20,6 +20,8 @@ daemon 是**数据面**,不是第二个宿主:
两个读方向共用同一条路径(#524 补上方向 2):`?id=` 是「这条记忆派生自哪些窗口」,`?sessionId=` 是「这个会话蒸出了哪些窗口」;两个主键**互斥**,都缺与都给都回 400(择一等于让调用方发一个自己也不知道会读哪个主键的请求,而两边的结果形状不同)。方向 2 必须带 `limit`(默认 20、上限 100)——按会话读是这张脸里天然最容易放大的方向,一个会话几十个窗口、每个窗口都是整段逐字原文,不限量就是「按会话导出全量转录」,与 `/export` 同量级只是换了形状;撞上限时答复带 `truncated: true`,少了这个读数,被截断的答复与「本来就这么少」逐字节相同。方向 2 的窗口按 `(session_id, seq_start, seq_end)` 合并成一行 `GROUP BY`,带 `link_count`——一条蒸馏窗口通常产出多条记忆,逐链接返回会让「这个会话蒸了几次」被重复行淹没。daemon 读的是**面板持久化的开关**(`serve.js` 把 `feature_flags` 里的 `distillRecords.enabled` 展开进 cfg,与宿主 `index.js:217-233` 同款),与宿主共用同一个 memory.db、同一份 kv——所以用户在面板里开着,daemon 这一面就读得到。
+`/memory/provenance` 的响应形状(#512):窗口在 `session_id/seq_start/seq_end/created_at/records` 之外另带 `distill_run_id / run_type / trigger_source / run_status`(读不到整轮归因时整组 `null`,不从区间或时间戳反推),答复级再带 `mints[]`(行级归因:这条记忆出生自哪一轮 dream/sleep)。归因元数据不受 `distillRecords.enabled` 约束——它不含任何会话正文,所以开关关着时这个端点照旧 409,但库里那两张表仍在记账。
+
残留不对称要知道:daemon 结构上看不到宿主的 **bundle config**(第一期不吃宿主 config schema),所以「只写在部署侧 bundle 配置里打开、从没在面板点过」的用户,宿主认、daemon 不认——daemon 会对每个请求回 409。这不是故障,是 daemon 装配面的已知边界;要让 daemon 也认,去面板里把开关显式打开一次(面板的「待重启」提示同样适用:开关是启动快照,改完要重启才生效)。
`/context` 的档位信号(#383):响应多一个 `tier`(`full` 语义档 / `degraded` 规则档 / `unknown` 语义路未启用或判不出来,fail-closed),**具体降级原因只在 stderr**——`dsh-mneme: /context degraded to the rule tier ()`,不进响应体。原因是给运维的读数,六值互不吞并(成例「修法不同就分开」):`embed-unavailable` 无可用接口(embedder 对象在、缺 `embedSingle`/`embed`,修适配器)/ `embed-empty` 空向量 / `embed-timeout` 挂起 / `embed-error` 抛错 / `embed-dimension` 维度不符(有向量但没有一条与查询同维,修法是重建索引)/ `no-embedder` 压根没装配 embedder(`--embed off` / lightMode / 宿主未装配,**预期态不是故障**,修法是把嵌装扮上或开起来)。`tier` 是给调用方的读数,两套消费者分开;六值的唯一真源是 `src/api-standalone.js` 的 `CONTEXT_DEGRADE_REASONS`,契约测试从它派生。`unknown` 之下(维度探针没跑成 / 有行却一条向量都读不出来)另有一行 `dsh-mneme: /context tier left unknown: `,**不是**那六值的一员(`tier` 不是 degraded,没有降级原因可记),运维程序不要按原因枚举解析它——没有它,调用方对 unknown 的策略是「不告警」时,一次真实的判档故障会整个消失。`no-embedder` 不并进 `embed-unavailable` 是故意的:那两者共用「语义路没通」的结果,但一个是适配器坏了(异常)、一个是语义路没开(设计内常态),并成一个值会让按 `embed-unavailable` 告警的面板对每个轻量模式安装报警;用词则与宿主面 `/api/dsh-mneme/semantic` 的 `reason` 对齐(那边是另一套枚举、另一个消费者,别顺手统一)。这里最值得盯的是 `embed-dimension`:嵌入接口一切正常,但库里是旧维度向量,`searchVector` 直接跳过、不抛错,召回静默退回关键词档——修法是重建索引,不是查端点。判档有成本:`q` 非空且装了 embedder 时一次 `/context` 会真起一次查询嵌入(一次往返 + `embedTimeoutMs` 窗口),维度探针本身是**按匹配短路的存在性判据**(有没有一条活跃向量与查询同维,不是 `LIMIT 1` 读一行——混合维度库里那会抽错维误报 degraded、首条损坏又会漏报 full):一次无 `LIMIT` 的全 id 扫描 + 短路解析,同维度健康库只解析 1 个向量,只有要报 degraded 时才走完全表;不复用 `/health`(它只回 `{ok:true}`)也不做全表扫。
diff --git a/dsh-mneme/docs/DISTILL.md b/dsh-mneme/docs/DISTILL.md
index 1ac503ac..7153dbb5 100644
--- a/dsh-mneme/docs/DISTILL.md
+++ b/dsh-mneme/docs/DISTILL.md
@@ -192,7 +192,7 @@ README 路由表、`test/security-doc.test.js` 的清单锁),而两个方向
量与开关判据只许出现在本模块、两个壳都真的把 `config` 转交进来、读侧判据与写侧
`src/summarize.js:988` 逐字同源」写成断言。
-**形状**:一个窗口 `{session_id, seq_start, seq_end, created_at, link_count, records[]}`,
+**形状**:窗口在 `{session_id, seq_start, seq_end, created_at, records[]}` 之外按方向分叉——方向 1(`?id=`)每行是一个溯源链接,另带 `distill_run_id / run_type / trigger_source / run_status`(后四个字段见 §11),答复级再带 `mints[]`;方向 2(`?sessionId=`)把同一窗口的多条链接合并成一行,另带 `link_count`。**run 归因只挂在方向 1 上**:方向 2 的一行是一个窗口、通常派生多条记忆,没有单一归因主体,要逐条查仍走方向 1。
`records` 是区间内的逐条会话原文(按 `seq` 升序、不越界)。没有链接的记忆返回**空
`windows`**——「不是蒸馏来的」是有效答案,不是错误;未知 id 同样回空 `windows`(读端点
不替调用方判断记忆是否存在)。到期裁剪后链接保留、指向空区间,面板据此显示「原文已过期」。
@@ -260,3 +260,49 @@ memory.db 的进程仍可读;唯一出路是 `dsh-mneme reclaim`(`src/mainte
键,手动可删)或重新开启后的启动裁剪。把「关 = 删掉」写进任何文档都是谎话。后续可做的
是让裁剪与写开关解耦(`retentionDays` 有值就裁,无论 `enabled`),那是让开关语义完全诚实
的唯一修法,超出本轮范围。
+
+## 11. 运行归因:从「会话区间」到「哪一次蒸馏」(#512)
+
+§10 的窗口只回答「这条记忆蒸自哪一段原文」。同一段区间被重蒸馏过几次、哪一轮失败了、
+失败在哪一步——这些在 §10 的形状里读不出来,因为 `memory_provenance` 的粒度是
+(memory, 会话, 区间),而区间是**输入**的坐标,不是**运行**的身份。
+
+拆成两层,因为它们回答的不是同一个问题:
+
+| 层 | 表 | 回答 | 谁能填 |
+|---|---|---|---|
+| 窗口级 | `memory_provenance.distill_run_id` → `distill_runs` | 这条记忆是**哪一轮蒸馏**从这段原文里提出来的 | 只有按窗口写入的 autoSummarize 路径 |
+| 行级 | `memory_mints(memory_id, run_id)` | 这条记忆**出生自哪一轮巩固** | dream / sleep 的 create 动作 |
+
+**为什么行级要另开一张表**:dream / sleep 铸造的 pattern 没有会话区间——session 与 seq
+都不存在。把合成区间塞进 `memory_provenance` 就是编造坐标,而一个错的坐标比没有坐标更坏
+(它看起来完全合法,会让「能追到」变成谎话)。所以那一层只记 run id,不记区间。
+
+**为什么行级只填 create**:merge / conflict / update / supersede 动的是**既有行**,
+`receipt_chain` 已按 (run_id, record_id) 记过一遍。再造一份就是第二套账,两边迟早对不上。
+create 是那边唯一的洞:`buildOutcome` 刻意不给 create 派 disposition,`receipt_chain`
+也就不产行。
+
+**为什么不复用 `llm_audit_logs.id` 当 run id**:`llmAudit.retentionDays` 到期裁剪会删掉
+audit 行,归因会跟着悬空;而 `memory_provenance` 刻意活得比原文更久(§10 的「来源已过期」
+就是这条设计的产物)。所以 run 是**归因主体**,audit 行只是它的**开销读数**,两边靠
+`distill_runs.audit_id` 连——不是靠时间戳猜。
+
+**为什么不复用 `dream_runs`**:那张表每一列都是巩固语义(`snapshot_hash` / `input_count` /
+`decisions` / `outcome` / `receipt`),「每行都能回放一次巩固」是它对消费方的契约。硬塞
+`run_type='autoSummarize'` 会让这条契约失效,而一个字段两种含义是这套 schema 里最容易腐烂
+的那种设计。dream / sleep 的轮次仍只记在 `dream_runs`,不复制进 `distill_runs`——两张表
+各说一件事,比一张表说两件事好。
+
+**历史行不猜**:迁移只加列/加表,存量 `memory_provenance.distill_run_id` 一律 NULL。反推
+一个「看起来能追」的合成 id 是最坏的一种坏:读数正常,实际是编的。面板遇到 NULL 显示
+「无运行归因(历史行)」,不是空白。
+
+**失败轮也记账**:`distill_runs.status` 取 `ok | degraded | noop | failed | aborted`。
+模型侧流失败与解析失败都会留一行 `failed`——否则「这个窗口为什么一直没有记忆」在台账上
+查不到原因。只有 dispose / 会话关闭才是 `aborted`(这一轮被主动打断),两者必须分得开。
+
+**读出口**:`GET /api/dsh-mneme/memory/provenance?id=` 的窗口按上表带上
+`distill_run_id / run_type / trigger_source / run_status`(读不到整组 null),答复级另带
+`mints[]`(`{run_id, run_type, trigger_source, created_at}`)。面板详情抽屉有一个
+「蒸发归因」区块渲染这两层。
diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js
index 1d18dd57..c0cddef4 100644
--- a/dsh-mneme/lib/client.js
+++ b/dsh-mneme/lib/client.js
@@ -892,6 +892,12 @@ window.__ModuleLoader__.load({
"memory.explorer.detail.quality": "质量分",
"memory.explorer.detail.entities": "关联实体",
"memory.explorer.detail.entitiesEmpty": "开启实体抽取后,相关人物/项目会出现在这里",
+ "memory.explorer.detail.provenance": "蒸发归因",
+ "memory.explorer.detail.provenanceEmpty": "这条记忆没有归因:早于改造的历史行不补、也不猜",
+ "memory.explorer.detail.provenanceMint": "铸造自",
+ "memory.explorer.detail.provenanceWindow": "蒸馏窗口",
+ "memory.explorer.detail.provenanceNoRun": "无运行归因(历史行)",
+ "memory.explorer.detail.provenanceRecords": "{n} 条原文",
"memory.explorer.detail.closeAria": "关闭详情",
"memory.explorer.detail.editTitle": "标题",
"memory.explorer.detail.editContent": "内容",
@@ -1655,6 +1661,12 @@ window.__ModuleLoader__.load({
"memory.explorer.detail.quality": "Quality",
"memory.explorer.detail.entities": "Related entities",
"memory.explorer.detail.entitiesEmpty": "Turn on entity extraction and related people / projects appear here",
+ "memory.explorer.detail.provenance": "Distillation provenance",
+ "memory.explorer.detail.provenanceEmpty": "No attribution for this memory: rows older than the change are left blank, never guessed",
+ "memory.explorer.detail.provenanceMint": "Minted by",
+ "memory.explorer.detail.provenanceWindow": "Distill window",
+ "memory.explorer.detail.provenanceNoRun": "No run attribution (legacy row)",
+ "memory.explorer.detail.provenanceRecords": "{n} records",
"memory.explorer.detail.closeAria": "Close details",
"memory.explorer.detail.editTitle": "Title",
"memory.explorer.detail.editContent": "Content",
@@ -2777,7 +2789,10 @@ window.__ModuleLoader__.load({
// ③ 关联记忆 memories recalled through the entity: prefix search
// ④ 关系图谱 the same ego graph the old graph-only tab drew
function EntityPanel({ t, focusEntity, onJumpMemory }) {
- const [entities, setEntities] = useState(null); // null = still loading
+ const [entities, setEntities] = useState(null);
+ // #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。
+ // null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。
+ const [provenance, setProvenance] = useState(null); // null = still loading
const [railQuery, setRailQuery] = useState("");
const [entityName, setEntityName] = useState(focusEntity || "");
const [depth, setDepth] = useState(1);
@@ -6501,6 +6516,21 @@ window.__ModuleLoader__.load({
return () => { cancelled = true; };
}, [memory.id]);
+ // #512:归因单独取一次。读失败/未开启时保持 null——「没读到」不等于
+ // 「没有归因」,界面上不能把这两件事显示成同一句话。
+ useEffect(() => {
+ let cancelled = false;
+ setProvenance(null);
+ apiFetch("/api/dsh-mneme/memory/provenance?id=" + encodeURIComponent(memory.id))
+ .then((res) => (res.ok ? res.json() : null))
+ .then((d) => {
+ if (cancelled) return;
+ if (d && Array.isArray(d.windows) && Array.isArray(d.mints)) setProvenance(d);
+ })
+ .catch(() => { if (!cancelled) setProvenance(null); });
+ return () => { cancelled = true; };
+ }, [memory.id]);
+
const postUpdate = (patch, opts) => {
setBusy(true);
setSaveError(false);
@@ -6681,6 +6711,25 @@ window.__ModuleLoader__.load({
h("span", { className: "mneme-xdot", style: { color: typeColor(e.type) }, "aria-hidden": "true" }),
e.name
)))),
+ provenance !== null && h(react.Fragment, null,
+ h("div", { className: "mneme-xcolhead", style: { padding: "16px 0 0" } }, t("memory.explorer.detail.provenance")),
+ (provenance.windows.length === 0 && provenance.mints.length === 0)
+ ? h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.detail.provenanceEmpty"))
+ : h("div", { className: "mneme-dmeta" },
+ provenance.mints.map((m, i) => h(react.Fragment, { key: "mint-" + m.run_id + "-" + i },
+ h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.provenanceMint")),
+ h("span", { className: "mneme-dmetaval", title: m.run_id },
+ [m.run_type, m.trigger_source, formatDateShort(m.created_at)].filter(Boolean).join(" · ")))),
+ provenance.windows.map((w, i) => h(react.Fragment, { key: "win-" + w.session_id + "-" + w.seq_start + "-" + i },
+ h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.provenanceWindow")),
+ h("span", { className: "mneme-dmetaval", title: w.session_id },
+ [w.session_id + " · seq " + w.seq_start + "–" + w.seq_end,
+ w.distill_run_id
+ ? [w.run_type, w.run_status].filter(Boolean).join(" / ")
+ : t("memory.explorer.detail.provenanceNoRun"),
+ t("memory.explorer.detail.provenanceRecords").replace("{n}", String(w.records ? w.records.length : 0))
+ ].join(" · ")))))
+ ),
editing && h("div", { className: "mneme-xcolhead", style: { padding: "16px 0 4px" } }, t("memory.explorer.detail.editContent")),
editing
? h("textarea", { className: "mneme-dcontent-input", value: content, onChange: (e) => setContent(e.target.value) })
diff --git a/dsh-mneme/lib/dream/decisions.js b/dsh-mneme/lib/dream/decisions.js
index 7abe0745..ae0aeb93 100644
--- a/dsh-mneme/lib/dream/decisions.js
+++ b/dsh-mneme/lib/dream/decisions.js
@@ -525,7 +525,7 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
committed.push({ action: "keep", ids: d.ids });
continue;
}
- const outcome = applyOne(d, service, snapshot, config);
+ const outcome = applyOne(d, service, snapshot, config, logger);
if (outcome === "skipped") continue;
applied += outcome.applied;
committed.push(outcome.committed);
@@ -542,14 +542,14 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
return { applied, conflicts, failures, committed };
}
-function applyOne(d, service, snapshot, config = {}) {
+function applyOne(d, service, snapshot, config = {}, logger = null) {
switch (d.action) {
case "archive": return applyArchive(d, service, snapshot);
case "merge": return applyMerge(d, service, snapshot, config);
case "conflict": return applyConflict(d, service, snapshot, config);
case "supersede": return applySupersede(d, service, snapshot, config);
case "differentiate": return applyDifferentiate(d, service, snapshot, config);
- case "create": return applyCreate(d, service, config);
+ case "create": return applyCreate(d, service, config, logger);
default: return applyUpdate(d, service, snapshot, config);
}
}
@@ -581,7 +581,7 @@ function pickBestKeeper(ids, preferred, service) {
* Evidence ids ride in the content so a pattern stays traceable to its source
* memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
*/
-function applyCreate(d, service, config = {}) {
+function applyCreate(d, service, config = {}, logger = null) {
const language = langOf(config);
const title = String(d.title ?? "").trim();
const content = String(d.content ?? "").trim();
@@ -606,6 +606,22 @@ function applyCreate(d, service, config = {}) {
});
const memory = created?.memory;
if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
+ // #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
+ // 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
+ // 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
+ try {
+ if (typeof service?.saveMemoryMint === "function" && config?.runId) {
+ service.saveMemoryMint({
+ memoryId: memory.id,
+ runId: config.runId,
+ runType: config.mintSource === "sleep" ? "sleep" : "dream",
+ triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
+ createdAt: new Date().toISOString()
+ });
+ }
+ } catch (mintError) {
+ logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
+ }
return { applied: 1, committed: { action: "create", id: memory.id, type } };
}
diff --git a/dsh-mneme/lib/dream/sleep.js b/dsh-mneme/lib/dream/sleep.js
index 0b82fc40..b0078981 100644
--- a/dsh-mneme/lib/dream/sleep.js
+++ b/dsh-mneme/lib/dream/sleep.js
@@ -881,8 +881,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
// 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
// applyDecisions 的 @param config。
+ // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
+ // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
const { applied, failures, conflicts } = applyDecisions(
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
);
return {
status: applied > 0 ? "ok" : "noop",
diff --git a/dsh-mneme/lib/provenance-http.js b/dsh-mneme/lib/provenance-http.js
index 2fa1b54f..eb5b25a5 100644
--- a/dsh-mneme/lib/provenance-http.js
+++ b/dsh-mneme/lib/provenance-http.js
@@ -198,20 +198,51 @@ export function provenanceReply({ service, selector, config = null, warn = null
? service.distillWindowsFor(selector.sessionId, limit + 1)
: service.provenanceFor(selector.memoryId);
const truncated = rows.length > limit;
- const windows = rows.slice(0, limit).map((link) => ({
- session_id: link.session_id,
- seq_start: link.seq_start,
- seq_end: link.seq_end,
- created_at: link.created_at,
- link_count: link.link_count ?? 1,
- // link_count 只有方向 2(session)会带:一个窗口派生出几条记忆。方向 1 的每
- // 一行本来就是「一条记忆 → 一个窗口」,这个计数恒为 1,带出去只会是噪音(用
- // ?? 兜住,免得方向 1 的答复凭空多一个 null 键)。
- records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end)
- }));
+ // #512:run 是归因主体,窗口只是它的一批输入。能读到 run 就带上 run_type /
+ // trigger_source / status,读不到(旧库历史链接、或 store 没这一步)就整组
+ // 缺省——**不拿区间或时间戳去猜**:「看起来能追、实际是编的」比没有更坏。
+ const canReadRuns = typeof service?.getDistillRun === "function";
+ const windows = rows.slice(0, limit).map((link) => {
+ const base = {
+ session_id: link.session_id,
+ seq_start: link.seq_start,
+ seq_end: link.seq_end,
+ created_at: link.created_at,
+ // 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的
+ // 语义),面板据此显示「原文已过期」而非「没有来源」。
+ records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end)
+ };
+ if (mode === "session") {
+ // link_count 只有方向 2(session)会带:一个窗口派生出几条记忆。方向 1 的每
+ // 一行本来就是「一条记忆 → 一个窗口」,这个计数恒为 1,带出去只会是噪音(用
+ // ?? 兜住,免得方向 1 的答复凭空多一个 null 键)。
+ return { ...base, link_count: link.link_count ?? 1 };
+ }
+ // 方向 2 的一行是一个窗口、可能派生多条记忆,没有单一归因主体,所以运行归因
+ // 只挂在方向 1 上;要逐条查某条记忆出生自哪一轮,仍走方向 1。
+ const run = canReadRuns && link.distill_run_id ? service.getDistillRun(link.distill_run_id) : undefined;
+ return {
+ ...base,
+ // #512:distill_run_id 为 null 就是历史行为——改造之前写下的链接没有第二轮
+ // 真相可查,面板据此显示「这一条没有运行归因」而不是给一个像样的空壳。
+ distill_run_id: link.distill_run_id ?? null,
+ run_type: run?.run_type ?? null,
+ trigger_source: run?.trigger_source ?? null,
+ run_status: run?.status ?? null
+ };
+ });
+ // 行级铸造归因(#512):这条记忆出生自哪一轮。与窗口级是两层,各自独立缺省。
+ const mints = mode === "memory" && typeof service?.mintsFor === "function"
+ ? service.mintsFor(selector.memoryId).map((mint) => ({
+ run_id: mint.run_id,
+ run_type: mint.run_type,
+ trigger_source: mint.trigger_source,
+ created_at: mint.created_at
+ }))
+ : [];
const body = mode === "session"
? { sessionId: selector.sessionId, windows, truncated }
- : { memoryId: selector.memoryId, windows, truncated };
+ : { memoryId: selector.memoryId, windows, truncated, mints };
return { status: 200, body };
} catch (error) {
warn?.(provenanceWarnMessage(error));
diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js
index 9aa887a3..6bf35bca 100644
--- a/dsh-mneme/lib/service.js
+++ b/dsh-mneme/lib/service.js
@@ -2652,6 +2652,14 @@ export function createService({ store, mirror, config, onWrite, logger, document
// (idx_memory_provenance_session),所以也是纯 passthrough——读的是 #399 早就
// 落好的事实,不产生任何写入。
distillWindowsFor: (sessionId, limit) => store.distillWindowsFor(sessionId, limit),
+ // #512 蒸馏运行与铸造归因:同样是 bookkeeping passthrough(与 saveDreamRun
+ // 那一组同语义)——run 是审计主体,notifyWrite 会绕回它自己的调度器。
+ saveDistillRun: (run) => store.saveDistillRun(run),
+ attachDistillRunAudit: (id, auditId) => store.attachDistillRunAudit(id, auditId),
+ getDistillRun: (id) => store.getDistillRun(id),
+ listDistillRuns: (opts) => store.listDistillRuns(opts),
+ saveMemoryMint: (mint) => store.saveMemoryMint(mint),
+ mintsFor: (memoryId) => store.mintsFor(memoryId),
enqueue,
setDreamHook(fn) { dreamHook = fn; },
setSleepHook(fn) { sleepHook = fn; },
diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js
index 059ef4df..10437e6f 100644
--- a/dsh-mneme/lib/store.js
+++ b/dsh-mneme/lib/store.js
@@ -351,6 +351,61 @@ CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(se
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link
ON memory_provenance(memory_id, session_id, seq_start, seq_end);
+-- #512 蒸馏运行(run):一次 autoSummarize 调用就是一轮。此前这条链路只在
+-- llm_audit_logs 里留一条计费痕迹,记忆侧的链接却只记到「会话 + seq 区间」——
+-- 「这条记忆是哪一次蒸馏带进来的」答不上,同一个区间重跑两次更分不出前后。
+-- 与 llm_audit_logs 的关系是**归因主体与它的开销读数**:run 是被归因的那一轮,
+-- audit 行是那一轮的开销;两边靠 distill_runs.audit_id 直接连,不靠时间戳猜。
+-- 为什么不复用 llm_audit_logs 的行 id 当 run id:audit 表按 llmAudit.retentionDays
+-- 到期裁剪(index.js 启动即调 deleteOldLlmAudits),归因会悬空;而 memory_provenance
+-- 与蒸馏窗口刻意活得更久(见 clearDistillRecords 注释)。归因不能挂在会被删掉的表上。
+-- 为什么不复用 dream_runs:那张表的每一列(snapshot_hash / input_count / decisions /
+-- outcome / receipt)都是巩固的语义,硬塞一个 run_type='autoSummarize' 会让
+-- 「dream_runs 的每一行都能回放一次巩固」这条契约失效。两张表各自的形状才是真的。
+-- audit_id 可空:llmAudit 关掉时那一轮没有 audit 行,run 仍然成立(归因不该依赖计费开关)。
+CREATE TABLE IF NOT EXISTS distill_runs (
+ id TEXT PRIMARY KEY,
+ created_at TEXT NOT NULL,
+ status TEXT NOT NULL, -- ok | degraded | noop | failed | aborted
+ error TEXT,
+ -- 只放**有窗口**的蒸馏(autoSummarize,以及将来手动的 manual)。dream / sleep 的
+ -- 轮次不进这张表:它们是 dream_runs 的行,把同一轮复制成两行会让「哪张表是事实源」
+ -- 这个问题永远没有答案;那些记忆的归因走 memory_mints(见下)。
+ run_type TEXT NOT NULL, -- autoSummarize | manual
+ trigger_source TEXT NOT NULL, -- autoSummarize | manual
+ session_id TEXT,
+ seq_start INTEGER,
+ seq_end INTEGER,
+ memory_count INTEGER NOT NULL DEFAULT 0,
+ provider TEXT,
+ model TEXT,
+ audit_id INTEGER -- llm_audit_logs.id(NULL = 那一轮没有 audit 行)
+);
+CREATE INDEX IF NOT EXISTS idx_distill_runs_created ON distill_runs(created_at);
+
+-- #512 记忆级「出生自哪一轮」。为什么它不是 memory_provenance 的一列:那张表的
+-- 粒度是 (记忆, 会话, seq 区间),能表达的只有**窗口派生**的归因;而 dream / sleep
+-- 铸造的记忆根本没有窗口(session 与 seq 都不存在),硬塞一个合成区间就是
+-- 「看起来能追、实际是编的」。所以归因分两层,各自只记它真知道的事:
+-- · memory_provenance(+ distill_run_id)= 窗口级,「这段原文是哪一轮蒸出来的」;
+-- · 本表 = 行级,「这条记忆出生自哪一轮」,覆盖没有窗口的铸造路径。
+-- 只填 **create** 一种动作:merge/conflict/update/supersede 对既有行的改动,receipt_chain
+-- 已经按 (run_id, record_id) 记过了——再造一份就是第二套账。create 是那边唯一的洞
+-- (buildOutcome 刻意不给 create 派 disposition,receipt_chain 也就不产行)。
+-- run_type / trigger_source 就地冗余:run_id 在 dream_runs 与 distill_runs 两张表里
+-- 各有自己的主键域,读侧不该为了显示一个类型去 union 两张审计表。
+-- ON DELETE CASCADE:记忆行没了,归因没有指代对象;remove() 仍显式清(与
+-- memory_provenance 同一口径,不把清理挂在驱动默认上)。
+CREATE TABLE IF NOT EXISTS memory_mints (
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
+ run_id TEXT NOT NULL,
+ run_type TEXT NOT NULL, -- dream | sleep
+ trigger_source TEXT NOT NULL, -- autoDream | sleep
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (memory_id, run_id)
+);
+CREATE INDEX IF NOT EXISTS idx_memory_mints_run ON memory_mints(run_id);
+
-- 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
@@ -651,6 +706,26 @@ function toDreamRun(row) {
};
}
+function toDistillRun(row) {
+ if (!row) return undefined;
+ return {
+ id: row.id,
+ created_at: row.created_at,
+ status: row.status,
+ error: row.error ?? undefined,
+ run_type: row.run_type,
+ trigger_source: row.trigger_source,
+ session_id: row.session_id ?? undefined,
+ seq_start: row.seq_start ?? undefined,
+ seq_end: row.seq_end ?? undefined,
+ memory_count: row.memory_count ?? 0,
+ provider: row.provider ?? undefined,
+ model: row.model ?? undefined,
+ // NULL = 那一轮没有 audit 行(llmAudit 关着,或还没写回)。
+ audit_id: row.audit_id ?? undefined
+ };
+}
+
function toReceipt(row) {
if (!row) return undefined;
return {
@@ -1050,6 +1125,14 @@ export function createStore(path) {
addColumn("llm_audit_logs", "session_key", "ALTER TABLE llm_audit_logs ADD COLUMN session_key TEXT");
db.exec("CREATE INDEX IF NOT EXISTS idx_llm_audit_session ON llm_audit_logs(session_key);");
+ // #512:把每条溯源链接指回产出它的那一轮蒸馏(distill_runs.id)。
+ // 存量链接一律留 NULL——那是**本次改造之前的历史**,按 (session, 区间, created_at)
+ // 反推一个合成 id 会得到「读数正常、实际是猜的」归因,比不填更坏(与 #534
+ // raw_output「历史失败轮反推不出原文」同一口径:补不出的就承认补不出)。
+ // 索引建在加列之后:老库打开时列还不存在(SCHEMA 的 CREATE TABLE 只对新建库生效)。
+ addColumn("memory_provenance", "distill_run_id", "ALTER TABLE memory_provenance ADD COLUMN distill_run_id TEXT");
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_provenance_run ON memory_provenance(distill_run_id);");
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
@@ -1480,6 +1563,8 @@ export function createStore(path) {
// 只有这一条挡得住。记录本身(distill_records)不删——删掉一条派生记忆
// 不该让整个窗口的原文凭证消失。
db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id);
+ // #512:铸造记录同理——它也只是指着记忆行的索引,行没了索引就没有指代对象。
+ db.prepare("DELETE FROM memory_mints 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.
@@ -1498,7 +1583,8 @@ export function createStore(path) {
* 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
* 什么都没删就别记一笔镜像债(remove() 每次必 bump,那是它的既有行为,不在这里扩大)。
*
- * side table 清理口径 = remove() 的口径(#399):显式同事务清 memory_provenance。
+ * side table 清理口径 = remove() 的口径(#399 / #512):显式同事务清 memory_provenance
+ * 与 memory_mints。
* 这条是双保险,不是「FK 没开所以必须手写」:memory_provenance 挂着 ON DELETE CASCADE,
* 而 node:sqlite 默认就把 foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),今天删行
* 确实另有级联兜底;显式清理的存在是为了不依赖这份没人钉过的驱动默认,同时跟 remove()
@@ -1540,6 +1626,7 @@ export function createStore(path) {
const chunk = ids.slice(start, start + DELETE_ID_CHUNK);
const marks = chunk.map(() => "?").join(", ");
db.prepare(`DELETE FROM memory_provenance WHERE memory_id IN (${marks})`).run(...chunk);
+ db.prepare(`DELETE FROM memory_mints WHERE memory_id IN (${marks})`).run(...chunk);
deleted += db.prepare(`DELETE FROM memories WHERE id IN (${marks})`).run(...chunk).changes;
}
incrementGeneration();
@@ -2756,22 +2843,101 @@ export function createStore(path) {
* 重跑的重复链接(否则幂等测试锁会挂)。
* @returns {number} 实际插入行数
*/
- function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) {
+ function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId }) {
+ // #512:distill_run_id 是「这条链接由哪一轮蒸馏写下」的可选归因。缺省落 NULL,
+ // 且**不回填**——存量链接没有第二轮真相可查,反推一个合成 id 比留空更坏。
return db.prepare(
- `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at)
- VALUES (?, ?, ?, ?, ?)
+ `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at, distill_run_id)
+ VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`
- ).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes;
+ ).run(memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId ?? null).changes;
}
- /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面)。 */
+ /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面;#512 起带 distill_run_id)。 */
function provenanceFor(memoryId) {
return db.prepare(
- `SELECT memory_id, session_id, seq_start, seq_end, created_at
+ `SELECT memory_id, session_id, seq_start, seq_end, created_at, distill_run_id
FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start`
).all(memoryId);
}
+ /**
+ * 落盘一轮蒸馏运行(#512)。写入幂等(同 id replay 覆写、绝不重复),与
+ * saveDreamRun 同一口径,让同一逻辑轮可以重放核对。
+ *
+ * audit_id 是入参而不是在这里顺手写 audit:audit 行在 summarize.js 的 finally 里
+ * 落盘,而且**可能压根不存在**(llmAudit 关着)。run 先落、audit 后补再回填这一列,
+ * 比把两件事塞进一个事务诚实——归因不该依赖计费开关。
+ */
+ function saveDistillRun(run) {
+ const id = run.id ?? randomUUID();
+ const now = nowIso();
+ db.prepare(
+ `INSERT INTO distill_runs (id, created_at, status, error, run_type, trigger_source,
+ session_id, seq_start, seq_end, memory_count, provider, model, audit_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ created_at=excluded.created_at, status=excluded.status, error=excluded.error,
+ run_type=excluded.run_type, trigger_source=excluded.trigger_source,
+ session_id=excluded.session_id, seq_start=excluded.seq_start, seq_end=excluded.seq_end,
+ memory_count=excluded.memory_count, provider=excluded.provider, model=excluded.model,
+ audit_id=excluded.audit_id`
+ ).run(
+ id,
+ run.created_at ?? now,
+ run.status,
+ run.error ?? null,
+ run.run_type,
+ run.trigger_source,
+ run.session_id ?? null,
+ Number.isInteger(run.seq_start) ? run.seq_start : null,
+ Number.isInteger(run.seq_end) ? run.seq_end : null,
+ Number.isInteger(run.memory_count) ? run.memory_count : 0,
+ run.provider ?? null,
+ run.model ?? null,
+ Number.isInteger(run.audit_id) ? run.audit_id : null
+ );
+ return getDistillRun(id);
+ }
+
+ /** #512:事后补注某一轮的开销读数 id(audit 行在 run 之后才落盘)。 */
+ function attachDistillRunAudit(id, auditId) {
+ if (!id || !Number.isInteger(auditId)) return 0;
+ return db.prepare("UPDATE distill_runs SET audit_id = ? WHERE id = ?").run(auditId, id).changes;
+ }
+
+ function getDistillRun(id) {
+ return toDistillRun(db.prepare("SELECT * FROM distill_runs WHERE id = ?").get(id));
+ }
+
+ function listDistillRuns({ limit = 50, offset = 0 } = {}) {
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
+ return db.prepare(
+ "SELECT * FROM distill_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
+ ).all(lim, off).map(toDistillRun);
+ }
+
+ /**
+ * 记一条「这条记忆出生自哪一轮」(#512)。只记 create:既有行的改动由
+ * receipt_chain 按 (run_id, record_id) 记,两套账不能有第二份。
+ * INSERT OR IGNORE 让重放幂等——同一 run 重放不该重复记一次铸造。
+ */
+ function saveMemoryMint({ memoryId, runId, runType, triggerSource, createdAt }) {
+ if (!memoryId || !runId || !runType || !triggerSource) return 0;
+ return db.prepare(
+ `INSERT OR IGNORE INTO memory_mints (memory_id, run_id, run_type, trigger_source, created_at)
+ VALUES (?, ?, ?, ?, ?)`
+ ).run(memoryId, runId, runType, triggerSource, createdAt ?? nowIso()).changes;
+ }
+
+ /** 一条记忆的铸造记录(#512 读侧;通常 0 或 1 条,跨轮重放才会多)。 */
+ function mintsFor(memoryId) {
+ return db.prepare(
+ `SELECT memory_id, run_id, run_type, trigger_source, created_at
+ FROM memory_mints WHERE memory_id = ? ORDER BY created_at, run_id`
+ ).all(memoryId);
+ }
+
/**
* 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。
*
@@ -3912,6 +4078,14 @@ export function createStore(path) {
provenanceFor,
distillRecordsFor,
distillWindowsFor,
+ // #512 蒸馏运行与铸造归因:run 是归因主体,audit 行只是它的开销读数。
+ // 写入幂等(replay 覆写),读侧只读——两处都不触发 write hooks(bookkeeping)。
+ saveDistillRun,
+ attachDistillRunAudit,
+ getDistillRun,
+ listDistillRuns,
+ saveMemoryMint,
+ mintsFor,
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
dreamRunInputStats,
clearDreamRunInputs,
diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js
index 43212f86..fa7ddad9 100644
--- a/dsh-mneme/lib/summarize.js
+++ b/dsh-mneme/lib/summarize.js
@@ -6,6 +6,9 @@ import { STR, langOf } from "./lang.js";
import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
// v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
import { createScopeResolver } from "./scope.js";
+// #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
+// 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
+import { randomUUID } from "node:crypto";
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -735,6 +738,14 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// ever blocking the summarization itself.
let audit = null;
let abortedRun = false;
+ // #512:run 的三个事实必须活在 try 之外——finally 要落 run 行,而 finally 看不见
+ // try 块内声明的 let/const(会 ReferenceError,且那一下会把 inFlight 的清理一起跳过)。
+ let distillRunId = null;
+ let distillRunOpenedAt = null;
+ let distilledMemoryCount = 0;
+ let distilledWindow = null;
+ let distillRunStatus = null;
+ let distillRunError = null;
try {
if (!route) return;
const persistedCursor = typeof service.getDistillCursor === "function"
@@ -801,6 +812,19 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
lastRunAt.set(session.id, Date.now());
runsUsed.set(session.id, (prevRuns ?? 0) + 1);
+ // #512:一轮蒸馏 = 一个 run。落点选在这里而不是更早——上面所有 return(间隔门、
+ // 高峰门、run 预算、空窗口、窗口过小)都不是一轮真蒸馏,各有自己的审计口径;
+ // 从这里往下就是「调用确实发生过」,与 lastRunAt 那两行的边界完全重合。
+ // run 的落盘在 finally 里独立 try/catch:归因是审计主体,写不成只该 warn,
+ // 不该把已经落盘的记忆连坐。所以本函数在 store 没有该能力时整体降级为无归因。
+ // 归因用的三件事都从**真正发生的事**里取,不从推断里取:区间取实际写进
+ // memory_provenance 的那个窗口(transcript 关了就没有),条数取实际写成功的
+ // 记忆数(含 merged——被并入的既有行确实由这段原文改动)。
+ if (typeof service?.saveDistillRun === "function") {
+ distillRunId = randomUUID();
+ distillRunOpenedAt = new Date(now()).toISOString();
+ }
+
if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
audit = {
route,
@@ -942,6 +966,13 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
if (aborted) {
abortedRun = true;
+ // #512:aborted 不等于「没发生」——LLM 调用真的发出去了,窗口也因此没被消费。
+ // 只有 dispose / 会话关闭才是「这一轮被主动打断」;模型侧流失败是一次失败的轮次,
+ // 两者在 run 台账上必须分得开,否则「这个窗口为什么一直没记忆」查不到原因。
+ distillRunStatus = controller.signal.aborted ? "aborted" : "failed";
+ distillRunError = controller.signal.aborted
+ ? "disposed"
+ : (streamFailure || "llm stream aborted");
return;
}
const parsedResult = parseSummaryJsonResult(text || assembledText);
@@ -968,11 +999,15 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
commitCursor(session.id, nextSeq);
parseFailureStreak.delete(session.id);
}
+ // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
+ // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
+ const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
+ // #512:解析失败也是失败轮,run 要和 audit 记同一句原因(两个口径不一致
+ // 等于让人去猜哪一边是对的)。
+ distillRunStatus = "failed";
+ distillRunError = failureAudit.errorMessage;
if (audit) {
audit.status = "error";
- // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
- // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
- const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
audit.errorMessage = failureAudit.errorMessage;
audit.metadata = {
distillChars,
@@ -1063,8 +1098,14 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
}
+ // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
+ // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
+ distilledMemoryCount = savedIds.length;
if (transcript && transcript.records.length) {
service.saveDistillRecords(transcript.records);
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
+ distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
+ }
if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
&& typeof service.saveMemoryProvenance === "function") {
for (const memoryId of savedIds) {
@@ -1073,7 +1114,11 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
sessionId: session.id,
seqStart: transcript.seqStart,
seqEnd: transcript.seqEnd,
- createdAt: distilledAt
+ createdAt: distilledAt,
+ // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
+ // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
+ // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
+ distillRunId: distillRunId ?? undefined
});
}
}
@@ -1096,6 +1141,12 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
// capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
// 一条都不留——下一级预判规则的阈值就无从定标。
+ // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
+ // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
+ // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
+ distillRunStatus = distilledMemoryCount > 0
+ ? (parsedResult.salvaged ? "degraded" : "ok")
+ : "noop";
if (audit) {
audit.metadata = {
distillChars,
@@ -1109,6 +1160,8 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// 都会在此之前退出,从而保留窗口供下一次重试。
if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
} catch (error) {
+ distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
+ distillRunError = String(error?.message ?? error);
if (audit) {
audit.status = "error";
audit.errorMessage = String(error?.message ?? error);
@@ -1125,9 +1178,14 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
if (prevRuns === undefined) runsUsed.delete(session.id);
else runsUsed.set(session.id, prevRuns);
}
+ // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
+ // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
+ // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
+ // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
+ let auditRow = null;
if (audit) {
try {
- service.saveLlmAudit({
+ auditRow = service.saveLlmAudit({
timestamp: audit.timestamp,
trigger_source: "autoSummarize",
operation_type: "summarize_compress",
@@ -1148,6 +1206,31 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
}
}
+ // #512:一轮真蒸馏对应一行 distill_runs,落点必须在这里(finally)而不是
+ // 事务里:run 是审计主体,事务只负责「记忆 + 原文 + 链接」同生共死;把 run
+ // 塞进事务会让一个审计写失败回滚掉已经算出来的记忆。
+ // status 为 null 说明在开轮之前就退出了(间隔门/高峰门/短窗口),那不是一轮。
+ if (distillRunId && distillRunStatus) {
+ try {
+ service.saveDistillRun({
+ id: distillRunId,
+ created_at: distillRunOpenedAt,
+ status: distillRunStatus,
+ error: distillRunError ?? undefined,
+ run_type: "autoSummarize",
+ trigger_source: "autoSummarize",
+ session_id: session.id,
+ seq_start: distilledWindow?.seqStart,
+ seq_end: distilledWindow?.seqEnd,
+ memory_count: distilledMemoryCount,
+ provider: audit?.route?.provider ?? route?.provider,
+ model: audit?.route?.model ?? route?.model,
+ audit_id: auditRow?.id
+ });
+ } catch (runError) {
+ ctx.logger?.warn?.(`dsh-mneme: distill run write failed: ${String(runError)}`);
+ }
+ }
inFlight.delete(session.id);
}
}
diff --git a/dsh-mneme/src/dream/decisions.js b/dsh-mneme/src/dream/decisions.js
index 7abe0745..ae0aeb93 100644
--- a/dsh-mneme/src/dream/decisions.js
+++ b/dsh-mneme/src/dream/decisions.js
@@ -525,7 +525,7 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
committed.push({ action: "keep", ids: d.ids });
continue;
}
- const outcome = applyOne(d, service, snapshot, config);
+ const outcome = applyOne(d, service, snapshot, config, logger);
if (outcome === "skipped") continue;
applied += outcome.applied;
committed.push(outcome.committed);
@@ -542,14 +542,14 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
return { applied, conflicts, failures, committed };
}
-function applyOne(d, service, snapshot, config = {}) {
+function applyOne(d, service, snapshot, config = {}, logger = null) {
switch (d.action) {
case "archive": return applyArchive(d, service, snapshot);
case "merge": return applyMerge(d, service, snapshot, config);
case "conflict": return applyConflict(d, service, snapshot, config);
case "supersede": return applySupersede(d, service, snapshot, config);
case "differentiate": return applyDifferentiate(d, service, snapshot, config);
- case "create": return applyCreate(d, service, config);
+ case "create": return applyCreate(d, service, config, logger);
default: return applyUpdate(d, service, snapshot, config);
}
}
@@ -581,7 +581,7 @@ function pickBestKeeper(ids, preferred, service) {
* Evidence ids ride in the content so a pattern stays traceable to its source
* memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
*/
-function applyCreate(d, service, config = {}) {
+function applyCreate(d, service, config = {}, logger = null) {
const language = langOf(config);
const title = String(d.title ?? "").trim();
const content = String(d.content ?? "").trim();
@@ -606,6 +606,22 @@ function applyCreate(d, service, config = {}) {
});
const memory = created?.memory;
if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
+ // #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
+ // 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
+ // 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
+ try {
+ if (typeof service?.saveMemoryMint === "function" && config?.runId) {
+ service.saveMemoryMint({
+ memoryId: memory.id,
+ runId: config.runId,
+ runType: config.mintSource === "sleep" ? "sleep" : "dream",
+ triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
+ createdAt: new Date().toISOString()
+ });
+ }
+ } catch (mintError) {
+ logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
+ }
return { applied: 1, committed: { action: "create", id: memory.id, type } };
}
diff --git a/dsh-mneme/src/dream/sleep.js b/dsh-mneme/src/dream/sleep.js
index 0b82fc40..b0078981 100644
--- a/dsh-mneme/src/dream/sleep.js
+++ b/dsh-mneme/src/dream/sleep.js
@@ -881,8 +881,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
// 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
// applyDecisions 的 @param config。
+ // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
+ // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
const { applied, failures, conflicts } = applyDecisions(
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
);
return {
status: applied > 0 ? "ok" : "noop",
diff --git a/dsh-mneme/src/provenance-http.js b/dsh-mneme/src/provenance-http.js
index 2fa1b54f..eb5b25a5 100644
--- a/dsh-mneme/src/provenance-http.js
+++ b/dsh-mneme/src/provenance-http.js
@@ -198,20 +198,51 @@ export function provenanceReply({ service, selector, config = null, warn = null
? service.distillWindowsFor(selector.sessionId, limit + 1)
: service.provenanceFor(selector.memoryId);
const truncated = rows.length > limit;
- const windows = rows.slice(0, limit).map((link) => ({
- session_id: link.session_id,
- seq_start: link.seq_start,
- seq_end: link.seq_end,
- created_at: link.created_at,
- link_count: link.link_count ?? 1,
- // link_count 只有方向 2(session)会带:一个窗口派生出几条记忆。方向 1 的每
- // 一行本来就是「一条记忆 → 一个窗口」,这个计数恒为 1,带出去只会是噪音(用
- // ?? 兜住,免得方向 1 的答复凭空多一个 null 键)。
- records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end)
- }));
+ // #512:run 是归因主体,窗口只是它的一批输入。能读到 run 就带上 run_type /
+ // trigger_source / status,读不到(旧库历史链接、或 store 没这一步)就整组
+ // 缺省——**不拿区间或时间戳去猜**:「看起来能追、实际是编的」比没有更坏。
+ const canReadRuns = typeof service?.getDistillRun === "function";
+ const windows = rows.slice(0, limit).map((link) => {
+ const base = {
+ session_id: link.session_id,
+ seq_start: link.seq_start,
+ seq_end: link.seq_end,
+ created_at: link.created_at,
+ // 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的
+ // 语义),面板据此显示「原文已过期」而非「没有来源」。
+ records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end)
+ };
+ if (mode === "session") {
+ // link_count 只有方向 2(session)会带:一个窗口派生出几条记忆。方向 1 的每
+ // 一行本来就是「一条记忆 → 一个窗口」,这个计数恒为 1,带出去只会是噪音(用
+ // ?? 兜住,免得方向 1 的答复凭空多一个 null 键)。
+ return { ...base, link_count: link.link_count ?? 1 };
+ }
+ // 方向 2 的一行是一个窗口、可能派生多条记忆,没有单一归因主体,所以运行归因
+ // 只挂在方向 1 上;要逐条查某条记忆出生自哪一轮,仍走方向 1。
+ const run = canReadRuns && link.distill_run_id ? service.getDistillRun(link.distill_run_id) : undefined;
+ return {
+ ...base,
+ // #512:distill_run_id 为 null 就是历史行为——改造之前写下的链接没有第二轮
+ // 真相可查,面板据此显示「这一条没有运行归因」而不是给一个像样的空壳。
+ distill_run_id: link.distill_run_id ?? null,
+ run_type: run?.run_type ?? null,
+ trigger_source: run?.trigger_source ?? null,
+ run_status: run?.status ?? null
+ };
+ });
+ // 行级铸造归因(#512):这条记忆出生自哪一轮。与窗口级是两层,各自独立缺省。
+ const mints = mode === "memory" && typeof service?.mintsFor === "function"
+ ? service.mintsFor(selector.memoryId).map((mint) => ({
+ run_id: mint.run_id,
+ run_type: mint.run_type,
+ trigger_source: mint.trigger_source,
+ created_at: mint.created_at
+ }))
+ : [];
const body = mode === "session"
? { sessionId: selector.sessionId, windows, truncated }
- : { memoryId: selector.memoryId, windows, truncated };
+ : { memoryId: selector.memoryId, windows, truncated, mints };
return { status: 200, body };
} catch (error) {
warn?.(provenanceWarnMessage(error));
diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js
index 9aa887a3..6bf35bca 100644
--- a/dsh-mneme/src/service.js
+++ b/dsh-mneme/src/service.js
@@ -2652,6 +2652,14 @@ export function createService({ store, mirror, config, onWrite, logger, document
// (idx_memory_provenance_session),所以也是纯 passthrough——读的是 #399 早就
// 落好的事实,不产生任何写入。
distillWindowsFor: (sessionId, limit) => store.distillWindowsFor(sessionId, limit),
+ // #512 蒸馏运行与铸造归因:同样是 bookkeeping passthrough(与 saveDreamRun
+ // 那一组同语义)——run 是审计主体,notifyWrite 会绕回它自己的调度器。
+ saveDistillRun: (run) => store.saveDistillRun(run),
+ attachDistillRunAudit: (id, auditId) => store.attachDistillRunAudit(id, auditId),
+ getDistillRun: (id) => store.getDistillRun(id),
+ listDistillRuns: (opts) => store.listDistillRuns(opts),
+ saveMemoryMint: (mint) => store.saveMemoryMint(mint),
+ mintsFor: (memoryId) => store.mintsFor(memoryId),
enqueue,
setDreamHook(fn) { dreamHook = fn; },
setSleepHook(fn) { sleepHook = fn; },
diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js
index 059ef4df..10437e6f 100644
--- a/dsh-mneme/src/store.js
+++ b/dsh-mneme/src/store.js
@@ -351,6 +351,61 @@ CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(se
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link
ON memory_provenance(memory_id, session_id, seq_start, seq_end);
+-- #512 蒸馏运行(run):一次 autoSummarize 调用就是一轮。此前这条链路只在
+-- llm_audit_logs 里留一条计费痕迹,记忆侧的链接却只记到「会话 + seq 区间」——
+-- 「这条记忆是哪一次蒸馏带进来的」答不上,同一个区间重跑两次更分不出前后。
+-- 与 llm_audit_logs 的关系是**归因主体与它的开销读数**:run 是被归因的那一轮,
+-- audit 行是那一轮的开销;两边靠 distill_runs.audit_id 直接连,不靠时间戳猜。
+-- 为什么不复用 llm_audit_logs 的行 id 当 run id:audit 表按 llmAudit.retentionDays
+-- 到期裁剪(index.js 启动即调 deleteOldLlmAudits),归因会悬空;而 memory_provenance
+-- 与蒸馏窗口刻意活得更久(见 clearDistillRecords 注释)。归因不能挂在会被删掉的表上。
+-- 为什么不复用 dream_runs:那张表的每一列(snapshot_hash / input_count / decisions /
+-- outcome / receipt)都是巩固的语义,硬塞一个 run_type='autoSummarize' 会让
+-- 「dream_runs 的每一行都能回放一次巩固」这条契约失效。两张表各自的形状才是真的。
+-- audit_id 可空:llmAudit 关掉时那一轮没有 audit 行,run 仍然成立(归因不该依赖计费开关)。
+CREATE TABLE IF NOT EXISTS distill_runs (
+ id TEXT PRIMARY KEY,
+ created_at TEXT NOT NULL,
+ status TEXT NOT NULL, -- ok | degraded | noop | failed | aborted
+ error TEXT,
+ -- 只放**有窗口**的蒸馏(autoSummarize,以及将来手动的 manual)。dream / sleep 的
+ -- 轮次不进这张表:它们是 dream_runs 的行,把同一轮复制成两行会让「哪张表是事实源」
+ -- 这个问题永远没有答案;那些记忆的归因走 memory_mints(见下)。
+ run_type TEXT NOT NULL, -- autoSummarize | manual
+ trigger_source TEXT NOT NULL, -- autoSummarize | manual
+ session_id TEXT,
+ seq_start INTEGER,
+ seq_end INTEGER,
+ memory_count INTEGER NOT NULL DEFAULT 0,
+ provider TEXT,
+ model TEXT,
+ audit_id INTEGER -- llm_audit_logs.id(NULL = 那一轮没有 audit 行)
+);
+CREATE INDEX IF NOT EXISTS idx_distill_runs_created ON distill_runs(created_at);
+
+-- #512 记忆级「出生自哪一轮」。为什么它不是 memory_provenance 的一列:那张表的
+-- 粒度是 (记忆, 会话, seq 区间),能表达的只有**窗口派生**的归因;而 dream / sleep
+-- 铸造的记忆根本没有窗口(session 与 seq 都不存在),硬塞一个合成区间就是
+-- 「看起来能追、实际是编的」。所以归因分两层,各自只记它真知道的事:
+-- · memory_provenance(+ distill_run_id)= 窗口级,「这段原文是哪一轮蒸出来的」;
+-- · 本表 = 行级,「这条记忆出生自哪一轮」,覆盖没有窗口的铸造路径。
+-- 只填 **create** 一种动作:merge/conflict/update/supersede 对既有行的改动,receipt_chain
+-- 已经按 (run_id, record_id) 记过了——再造一份就是第二套账。create 是那边唯一的洞
+-- (buildOutcome 刻意不给 create 派 disposition,receipt_chain 也就不产行)。
+-- run_type / trigger_source 就地冗余:run_id 在 dream_runs 与 distill_runs 两张表里
+-- 各有自己的主键域,读侧不该为了显示一个类型去 union 两张审计表。
+-- ON DELETE CASCADE:记忆行没了,归因没有指代对象;remove() 仍显式清(与
+-- memory_provenance 同一口径,不把清理挂在驱动默认上)。
+CREATE TABLE IF NOT EXISTS memory_mints (
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
+ run_id TEXT NOT NULL,
+ run_type TEXT NOT NULL, -- dream | sleep
+ trigger_source TEXT NOT NULL, -- autoDream | sleep
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (memory_id, run_id)
+);
+CREATE INDEX IF NOT EXISTS idx_memory_mints_run ON memory_mints(run_id);
+
-- 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
@@ -651,6 +706,26 @@ function toDreamRun(row) {
};
}
+function toDistillRun(row) {
+ if (!row) return undefined;
+ return {
+ id: row.id,
+ created_at: row.created_at,
+ status: row.status,
+ error: row.error ?? undefined,
+ run_type: row.run_type,
+ trigger_source: row.trigger_source,
+ session_id: row.session_id ?? undefined,
+ seq_start: row.seq_start ?? undefined,
+ seq_end: row.seq_end ?? undefined,
+ memory_count: row.memory_count ?? 0,
+ provider: row.provider ?? undefined,
+ model: row.model ?? undefined,
+ // NULL = 那一轮没有 audit 行(llmAudit 关着,或还没写回)。
+ audit_id: row.audit_id ?? undefined
+ };
+}
+
function toReceipt(row) {
if (!row) return undefined;
return {
@@ -1050,6 +1125,14 @@ export function createStore(path) {
addColumn("llm_audit_logs", "session_key", "ALTER TABLE llm_audit_logs ADD COLUMN session_key TEXT");
db.exec("CREATE INDEX IF NOT EXISTS idx_llm_audit_session ON llm_audit_logs(session_key);");
+ // #512:把每条溯源链接指回产出它的那一轮蒸馏(distill_runs.id)。
+ // 存量链接一律留 NULL——那是**本次改造之前的历史**,按 (session, 区间, created_at)
+ // 反推一个合成 id 会得到「读数正常、实际是猜的」归因,比不填更坏(与 #534
+ // raw_output「历史失败轮反推不出原文」同一口径:补不出的就承认补不出)。
+ // 索引建在加列之后:老库打开时列还不存在(SCHEMA 的 CREATE TABLE 只对新建库生效)。
+ addColumn("memory_provenance", "distill_run_id", "ALTER TABLE memory_provenance ADD COLUMN distill_run_id TEXT");
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_provenance_run ON memory_provenance(distill_run_id);");
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
@@ -1480,6 +1563,8 @@ export function createStore(path) {
// 只有这一条挡得住。记录本身(distill_records)不删——删掉一条派生记忆
// 不该让整个窗口的原文凭证消失。
db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id);
+ // #512:铸造记录同理——它也只是指着记忆行的索引,行没了索引就没有指代对象。
+ db.prepare("DELETE FROM memory_mints 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.
@@ -1498,7 +1583,8 @@ export function createStore(path) {
* 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
* 什么都没删就别记一笔镜像债(remove() 每次必 bump,那是它的既有行为,不在这里扩大)。
*
- * side table 清理口径 = remove() 的口径(#399):显式同事务清 memory_provenance。
+ * side table 清理口径 = remove() 的口径(#399 / #512):显式同事务清 memory_provenance
+ * 与 memory_mints。
* 这条是双保险,不是「FK 没开所以必须手写」:memory_provenance 挂着 ON DELETE CASCADE,
* 而 node:sqlite 默认就把 foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),今天删行
* 确实另有级联兜底;显式清理的存在是为了不依赖这份没人钉过的驱动默认,同时跟 remove()
@@ -1540,6 +1626,7 @@ export function createStore(path) {
const chunk = ids.slice(start, start + DELETE_ID_CHUNK);
const marks = chunk.map(() => "?").join(", ");
db.prepare(`DELETE FROM memory_provenance WHERE memory_id IN (${marks})`).run(...chunk);
+ db.prepare(`DELETE FROM memory_mints WHERE memory_id IN (${marks})`).run(...chunk);
deleted += db.prepare(`DELETE FROM memories WHERE id IN (${marks})`).run(...chunk).changes;
}
incrementGeneration();
@@ -2756,22 +2843,101 @@ export function createStore(path) {
* 重跑的重复链接(否则幂等测试锁会挂)。
* @returns {number} 实际插入行数
*/
- function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) {
+ function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId }) {
+ // #512:distill_run_id 是「这条链接由哪一轮蒸馏写下」的可选归因。缺省落 NULL,
+ // 且**不回填**——存量链接没有第二轮真相可查,反推一个合成 id 比留空更坏。
return db.prepare(
- `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at)
- VALUES (?, ?, ?, ?, ?)
+ `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at, distill_run_id)
+ VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`
- ).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes;
+ ).run(memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId ?? null).changes;
}
- /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面)。 */
+ /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面;#512 起带 distill_run_id)。 */
function provenanceFor(memoryId) {
return db.prepare(
- `SELECT memory_id, session_id, seq_start, seq_end, created_at
+ `SELECT memory_id, session_id, seq_start, seq_end, created_at, distill_run_id
FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start`
).all(memoryId);
}
+ /**
+ * 落盘一轮蒸馏运行(#512)。写入幂等(同 id replay 覆写、绝不重复),与
+ * saveDreamRun 同一口径,让同一逻辑轮可以重放核对。
+ *
+ * audit_id 是入参而不是在这里顺手写 audit:audit 行在 summarize.js 的 finally 里
+ * 落盘,而且**可能压根不存在**(llmAudit 关着)。run 先落、audit 后补再回填这一列,
+ * 比把两件事塞进一个事务诚实——归因不该依赖计费开关。
+ */
+ function saveDistillRun(run) {
+ const id = run.id ?? randomUUID();
+ const now = nowIso();
+ db.prepare(
+ `INSERT INTO distill_runs (id, created_at, status, error, run_type, trigger_source,
+ session_id, seq_start, seq_end, memory_count, provider, model, audit_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ created_at=excluded.created_at, status=excluded.status, error=excluded.error,
+ run_type=excluded.run_type, trigger_source=excluded.trigger_source,
+ session_id=excluded.session_id, seq_start=excluded.seq_start, seq_end=excluded.seq_end,
+ memory_count=excluded.memory_count, provider=excluded.provider, model=excluded.model,
+ audit_id=excluded.audit_id`
+ ).run(
+ id,
+ run.created_at ?? now,
+ run.status,
+ run.error ?? null,
+ run.run_type,
+ run.trigger_source,
+ run.session_id ?? null,
+ Number.isInteger(run.seq_start) ? run.seq_start : null,
+ Number.isInteger(run.seq_end) ? run.seq_end : null,
+ Number.isInteger(run.memory_count) ? run.memory_count : 0,
+ run.provider ?? null,
+ run.model ?? null,
+ Number.isInteger(run.audit_id) ? run.audit_id : null
+ );
+ return getDistillRun(id);
+ }
+
+ /** #512:事后补注某一轮的开销读数 id(audit 行在 run 之后才落盘)。 */
+ function attachDistillRunAudit(id, auditId) {
+ if (!id || !Number.isInteger(auditId)) return 0;
+ return db.prepare("UPDATE distill_runs SET audit_id = ? WHERE id = ?").run(auditId, id).changes;
+ }
+
+ function getDistillRun(id) {
+ return toDistillRun(db.prepare("SELECT * FROM distill_runs WHERE id = ?").get(id));
+ }
+
+ function listDistillRuns({ limit = 50, offset = 0 } = {}) {
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
+ return db.prepare(
+ "SELECT * FROM distill_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
+ ).all(lim, off).map(toDistillRun);
+ }
+
+ /**
+ * 记一条「这条记忆出生自哪一轮」(#512)。只记 create:既有行的改动由
+ * receipt_chain 按 (run_id, record_id) 记,两套账不能有第二份。
+ * INSERT OR IGNORE 让重放幂等——同一 run 重放不该重复记一次铸造。
+ */
+ function saveMemoryMint({ memoryId, runId, runType, triggerSource, createdAt }) {
+ if (!memoryId || !runId || !runType || !triggerSource) return 0;
+ return db.prepare(
+ `INSERT OR IGNORE INTO memory_mints (memory_id, run_id, run_type, trigger_source, created_at)
+ VALUES (?, ?, ?, ?, ?)`
+ ).run(memoryId, runId, runType, triggerSource, createdAt ?? nowIso()).changes;
+ }
+
+ /** 一条记忆的铸造记录(#512 读侧;通常 0 或 1 条,跨轮重放才会多)。 */
+ function mintsFor(memoryId) {
+ return db.prepare(
+ `SELECT memory_id, run_id, run_type, trigger_source, created_at
+ FROM memory_mints WHERE memory_id = ? ORDER BY created_at, run_id`
+ ).all(memoryId);
+ }
+
/**
* 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。
*
@@ -3912,6 +4078,14 @@ export function createStore(path) {
provenanceFor,
distillRecordsFor,
distillWindowsFor,
+ // #512 蒸馏运行与铸造归因:run 是归因主体,audit 行只是它的开销读数。
+ // 写入幂等(replay 覆写),读侧只读——两处都不触发 write hooks(bookkeeping)。
+ saveDistillRun,
+ attachDistillRunAudit,
+ getDistillRun,
+ listDistillRuns,
+ saveMemoryMint,
+ mintsFor,
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
dreamRunInputStats,
clearDreamRunInputs,
diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js
index 43212f86..fa7ddad9 100644
--- a/dsh-mneme/src/summarize.js
+++ b/dsh-mneme/src/summarize.js
@@ -6,6 +6,9 @@ import { STR, langOf } from "./lang.js";
import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
// v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
import { createScopeResolver } from "./scope.js";
+// #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
+// 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
+import { randomUUID } from "node:crypto";
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -735,6 +738,14 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// ever blocking the summarization itself.
let audit = null;
let abortedRun = false;
+ // #512:run 的三个事实必须活在 try 之外——finally 要落 run 行,而 finally 看不见
+ // try 块内声明的 let/const(会 ReferenceError,且那一下会把 inFlight 的清理一起跳过)。
+ let distillRunId = null;
+ let distillRunOpenedAt = null;
+ let distilledMemoryCount = 0;
+ let distilledWindow = null;
+ let distillRunStatus = null;
+ let distillRunError = null;
try {
if (!route) return;
const persistedCursor = typeof service.getDistillCursor === "function"
@@ -801,6 +812,19 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
lastRunAt.set(session.id, Date.now());
runsUsed.set(session.id, (prevRuns ?? 0) + 1);
+ // #512:一轮蒸馏 = 一个 run。落点选在这里而不是更早——上面所有 return(间隔门、
+ // 高峰门、run 预算、空窗口、窗口过小)都不是一轮真蒸馏,各有自己的审计口径;
+ // 从这里往下就是「调用确实发生过」,与 lastRunAt 那两行的边界完全重合。
+ // run 的落盘在 finally 里独立 try/catch:归因是审计主体,写不成只该 warn,
+ // 不该把已经落盘的记忆连坐。所以本函数在 store 没有该能力时整体降级为无归因。
+ // 归因用的三件事都从**真正发生的事**里取,不从推断里取:区间取实际写进
+ // memory_provenance 的那个窗口(transcript 关了就没有),条数取实际写成功的
+ // 记忆数(含 merged——被并入的既有行确实由这段原文改动)。
+ if (typeof service?.saveDistillRun === "function") {
+ distillRunId = randomUUID();
+ distillRunOpenedAt = new Date(now()).toISOString();
+ }
+
if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
audit = {
route,
@@ -942,6 +966,13 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
if (aborted) {
abortedRun = true;
+ // #512:aborted 不等于「没发生」——LLM 调用真的发出去了,窗口也因此没被消费。
+ // 只有 dispose / 会话关闭才是「这一轮被主动打断」;模型侧流失败是一次失败的轮次,
+ // 两者在 run 台账上必须分得开,否则「这个窗口为什么一直没记忆」查不到原因。
+ distillRunStatus = controller.signal.aborted ? "aborted" : "failed";
+ distillRunError = controller.signal.aborted
+ ? "disposed"
+ : (streamFailure || "llm stream aborted");
return;
}
const parsedResult = parseSummaryJsonResult(text || assembledText);
@@ -968,11 +999,15 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
commitCursor(session.id, nextSeq);
parseFailureStreak.delete(session.id);
}
+ // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
+ // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
+ const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
+ // #512:解析失败也是失败轮,run 要和 audit 记同一句原因(两个口径不一致
+ // 等于让人去猜哪一边是对的)。
+ distillRunStatus = "failed";
+ distillRunError = failureAudit.errorMessage;
if (audit) {
audit.status = "error";
- // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
- // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
- const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
audit.errorMessage = failureAudit.errorMessage;
audit.metadata = {
distillChars,
@@ -1063,8 +1098,14 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
}
+ // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
+ // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
+ distilledMemoryCount = savedIds.length;
if (transcript && transcript.records.length) {
service.saveDistillRecords(transcript.records);
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
+ distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
+ }
if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
&& typeof service.saveMemoryProvenance === "function") {
for (const memoryId of savedIds) {
@@ -1073,7 +1114,11 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
sessionId: session.id,
seqStart: transcript.seqStart,
seqEnd: transcript.seqEnd,
- createdAt: distilledAt
+ createdAt: distilledAt,
+ // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
+ // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
+ // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
+ distillRunId: distillRunId ?? undefined
});
}
}
@@ -1096,6 +1141,12 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
// capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
// 一条都不留——下一级预判规则的阈值就无从定标。
+ // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
+ // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
+ // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
+ distillRunStatus = distilledMemoryCount > 0
+ ? (parsedResult.salvaged ? "degraded" : "ok")
+ : "noop";
if (audit) {
audit.metadata = {
distillChars,
@@ -1109,6 +1160,8 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
// 都会在此之前退出,从而保留窗口供下一次重试。
if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
} catch (error) {
+ distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
+ distillRunError = String(error?.message ?? error);
if (audit) {
audit.status = "error";
audit.errorMessage = String(error?.message ?? error);
@@ -1125,9 +1178,14 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
if (prevRuns === undefined) runsUsed.delete(session.id);
else runsUsed.set(session.id, prevRuns);
}
+ // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
+ // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
+ // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
+ // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
+ let auditRow = null;
if (audit) {
try {
- service.saveLlmAudit({
+ auditRow = service.saveLlmAudit({
timestamp: audit.timestamp,
trigger_source: "autoSummarize",
operation_type: "summarize_compress",
@@ -1148,6 +1206,31 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
}
}
+ // #512:一轮真蒸馏对应一行 distill_runs,落点必须在这里(finally)而不是
+ // 事务里:run 是审计主体,事务只负责「记忆 + 原文 + 链接」同生共死;把 run
+ // 塞进事务会让一个审计写失败回滚掉已经算出来的记忆。
+ // status 为 null 说明在开轮之前就退出了(间隔门/高峰门/短窗口),那不是一轮。
+ if (distillRunId && distillRunStatus) {
+ try {
+ service.saveDistillRun({
+ id: distillRunId,
+ created_at: distillRunOpenedAt,
+ status: distillRunStatus,
+ error: distillRunError ?? undefined,
+ run_type: "autoSummarize",
+ trigger_source: "autoSummarize",
+ session_id: session.id,
+ seq_start: distilledWindow?.seqStart,
+ seq_end: distilledWindow?.seqEnd,
+ memory_count: distilledMemoryCount,
+ provider: audit?.route?.provider ?? route?.provider,
+ model: audit?.route?.model ?? route?.model,
+ audit_id: auditRow?.id
+ });
+ } catch (runError) {
+ ctx.logger?.warn?.(`dsh-mneme: distill run write failed: ${String(runError)}`);
+ }
+ }
inFlight.delete(session.id);
}
}
diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js
index 9b8dc605..b4b15aa4 100644
--- a/dsh-mneme/test/client.test.js
+++ b/dsh-mneme/test/client.test.js
@@ -2575,3 +2575,39 @@ test("mobile: the narrow-container breakpoints reflow the two-column skeletons i
".mneme-graphsvg 不得改回 touch-action:pan-y —— 纵向手势会被浏览器拿去滚页面," +
"而图谱的拖拽只有鼠标事件实现,触屏拖拽行为无真机无法验证(见上方注释)");
});
+
+// Issue #512:面板详情抽屉要能说清「这条记忆是从哪来的」。
+//
+// 归因分两层:窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。这两层
+// 任何一层缺失都必须显示成「没有归因」,而不是让用户看到空白以为自己眼花了。
+test("memory drawer surfaces distillation provenance, both layers (issue #512)", () => {
+ for (const key of [
+ "memory.explorer.detail.provenance",
+ "memory.explorer.detail.provenanceEmpty",
+ "memory.explorer.detail.provenanceMint",
+ "memory.explorer.detail.provenanceWindow",
+ "memory.explorer.detail.provenanceNoRun",
+ "memory.explorer.detail.provenanceRecords"
+ ]) {
+ // 只数字典定义处("key": 形态)——键在渲染里还会被 t("…") 再引一次,
+ // 数全量出现次数会把两种用法混为一谈。
+ const defined = clientSource.split(`"${key}":`).length - 1;
+ assert.equal(defined, 2, `i18n key ${key} must be defined exactly once in zh and once in en (got ${defined})`);
+ }
+
+ // 必须真的去读那一个端点,且是读不是算——归因的判据只有后端一处。
+ assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id="),
+ "the drawer must fetch the provenance endpoint");
+ // 两层都要渲染:只渲染窗口会让铸造出来的 pattern 永远显示「没有来源」,
+ // 而它们恰恰是归因最该说清的一类(没有会话区间可看)。
+ // 断到 .map( 为止:只写 "provenance.mints" 会被空态判断里的 .length 满足,
+ // 于是「渲染被整段删掉」这种回归照样能过(本用例的变异自证就是从这里翻车的)。
+ assert.ok(clientSource.includes("provenance.mints.map("), "mint-level attribution (which run minted the row) must render");
+ assert.ok(clientSource.includes("provenance.windows.map("), "window-level attribution (which transcript) must render");
+ assert.ok(clientSource.includes("\"memory.explorer.detail.provenanceNoRun\""),
+ "a legacy link without a run id must say so explicitly, not render an empty cell");
+
+ // 读失败要落回「没读到」而不是伪造一份空归因:两者在界面上是不同的话。
+ assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id=") && clientSource.includes("setProvenance(null)"),
+ "a failed read must clear the block instead of pretending there is no attribution");
+});
diff --git a/dsh-mneme/test/mint-source.test.js b/dsh-mneme/test/mint-source.test.js
index 7e60d02f..d8d9b6f6 100644
--- a/dsh-mneme/test/mint-source.test.js
+++ b/dsh-mneme/test/mint-source.test.js
@@ -200,3 +200,86 @@ test("mint source: rows are countable by source after minting", () => {
assert.equal(bySource.get("manual"), undefined, "nothing minted here fell back to the default");
store.close();
});
+
+// ── Issue #512:铸造出来的记忆要能追到「哪一轮」 ──
+//
+// source 只说「哪条管道」,不说「哪一次运行」。同一管道跑过几百轮之后,
+// 「这条 pattern 是哪一轮抽出来的」在数据面上仍然不可读——这正是 #512 要补的。
+// 归因落在 memory_mints:dream/sleep 铸造的记忆没有会话区间(session/seq 都不存在),
+// 硬塞合成区间就是编的,所以窗口级归因(memory_provenance)与行级归因(本表)分层。
+
+test("issue#512: a created row remembers which run minted it", () => {
+ const { store, service } = setup();
+ applyDecisions(
+ [{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3 }],
+ service,
+ null,
+ null,
+ { mintSource: "dream", runId: "run-abc" }
+ );
+ const row = service.all().find((m) => m.type === "pattern");
+ assert.ok(row, "pattern minted");
+ const mints = store.mintsFor(row.id);
+ assert.equal(mints.length, 1, "one created row = one mint record");
+ assert.equal(mints[0].run_id, "run-abc");
+ assert.equal(mints[0].run_type, "dream");
+ assert.equal(mints[0].trigger_source, "autoDream");
+ store.close();
+});
+
+// 没有 run id 时什么都不写。旧装配(或直接调 applyDecisions 的调用方)没有 run 概念,
+// 这时候编一个 id 出来比留空更坏:它会让「能追到」变成一句谎话。
+test("issue#512: without a run id no mint record is fabricated", () => {
+ const { store, service } = setup();
+ applyDecisions(
+ [{ action: "create", type: "pattern", title: "无轮模式", content: "规律", importance: 3 }],
+ service,
+ null,
+ null,
+ { mintSource: "dream" }
+ );
+ const row = service.all().find((m) => m.type === "pattern");
+ assert.ok(row, "pattern still minted — 记账缺失不该拦铸造");
+ assert.deepEqual(store.mintsFor(row.id), []);
+ store.close();
+});
+
+// 只记 create。merge/conflict/update/supersede 动的是既有行,receipt_chain 已按
+// (run_id, record_id) 记过一遍;再造一份就是第二套账,两边迟早对不上。
+test("issue#512: only creation is recorded — edits to existing rows stay in receipt_chain", () => {
+ const { store, service } = setup();
+ const existing = service.saveWithDedupe({ type: "project", title: "既有行", content: "旧内容", importance: 3 }).memory;
+ applyDecisions(
+ [{ action: "update", ids: [existing.id], content: "新内容", importance: 4 }],
+ service,
+ null,
+ null,
+ { mintSource: "dream", runId: "run-upd" }
+ );
+ assert.deepEqual(store.mintsFor(existing.id), [], "update 不是铸造,不进 memory_mints");
+ store.close();
+});
+
+// 端到端:sleep 真的跑一轮,铸造出来的行要指回那一轮的 runId(不是随便一个 id)。
+test("issue#512: sleep mints point back at the run that produced them", async () => {
+ const { store, service } = setup();
+ mint(service);
+ mint(service);
+ const ctx = sleepCtx(
+ JSON.stringify([{ action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3 }])
+ );
+ const config = { sleepModeEnabled: true, sleepPatternMinMemories: 10, sleepMaxPatternPerRun: 3 };
+ const result = await runSleep(ctx, service, config, ctx.logger, null, null);
+ assert.equal(result.phases.patterns.status, "ok", "pattern phase minted");
+
+ const row = service.all().find((m) => m.type === "pattern");
+ assert.ok(row, "pattern minted");
+ const mints = store.mintsFor(row.id);
+ assert.equal(mints.length, 1);
+ assert.equal(mints[0].run_id, result.runId, "归因要指回这一轮的 runId,不是另编一个");
+ assert.equal(mints[0].run_type, "sleep");
+ assert.equal(mints[0].trigger_source, "sleep");
+ // 那一轮 run 台账本身也在:两边能对上,归因才成立。
+ assert.equal(store.getDreamRun(result.runId).run_type, "sleep");
+ store.close();
+});
diff --git a/dsh-mneme/test/provenance-http.test.js b/dsh-mneme/test/provenance-http.test.js
index a75b5047..ed4a1436 100644
--- a/dsh-mneme/test/provenance-http.test.js
+++ b/dsh-mneme/test/provenance-http.test.js
@@ -129,10 +129,52 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store");
});
+test("provenanceReply #512:窗口带得动归属的那一轮 run,读不到就整组缺省而不是猜", () => {
+ const runs = new Map([["r1", { run_type: "autoSummarize", trigger_source: "autoSummarize", status: "ok" }]]);
+ const service = {
+ provenanceFor: () => [
+ { session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r1" },
+ // 改造之前写下的链接:distill_run_id 是 NULL。
+ { session_id: "s1", seq_start: 20, seq_end: 22, created_at: "2026-09-01T00:00:00Z", distill_run_id: null }
+ ],
+ distillRecordsFor: () => [],
+ getDistillRun: (id) => runs.get(id),
+ mintsFor: () => [{ run_id: "d1", run_type: "dream", trigger_source: "autoDream", created_at: "2026-11-01T00:00:00Z" }]
+ };
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
+ assert.equal(r.status, 200);
+ const [fresh, legacy] = r.body.windows;
+ assert.equal(fresh.distill_run_id, "r1");
+ assert.equal(fresh.run_type, "autoSummarize");
+ assert.equal(fresh.trigger_source, "autoSummarize");
+ assert.equal(fresh.run_status, "ok");
+ // 历史链接:id 为 null,三个派生字段也必须是 null——不许拿区间或时间戳反推一轮。
+ assert.equal(legacy.distill_run_id, null);
+ assert.equal(legacy.run_type, null, "没有 run 就不给类型,否则等于替用户编了一次归因");
+ assert.equal(legacy.trigger_source, null);
+ assert.equal(legacy.run_status, null);
+ assert.equal(r.body.mints.length, 1);
+ assert.equal(r.body.mints[0].run_type, "dream");
+});
+
+test("provenanceReply #512:service 没有 run 读取能力时,窗口字段照旧齐全但归因为空", () => {
+ // headless / 旧装配:provenanceFor 与 distillRecordsFor 在,getDistillRun / mintsFor 不在。
+ const service = {
+ provenanceFor: () => [{ session_id: "s1", seq_start: 1, seq_end: 2, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r9" }],
+ distillRecordsFor: () => []
+ };
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
+ assert.equal(r.status, 200, "读 run 的能力缺一档不算装配坏——窗口本身仍可读");
+ assert.equal(r.body.windows[0].distill_run_id, "r9");
+ assert.equal(r.body.windows[0].run_type, null);
+ assert.deepEqual(r.body.mints, []);
+});
+
test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
const r = provenanceReply({ service: fakeService(), selector: MEM, config: RECORDS_ON });
assert.equal(r.status, 200);
- assert.deepEqual(r.body, { memoryId: "m1", windows: [], truncated: false });
+ // #524 的 truncated 与 #512 的 mints 都是答复级读数,各自独立缺省。
+ assert.deepEqual(r.body, { memoryId: "m1", windows: [], truncated: false, mints: [] });
});
test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
diff --git a/dsh-mneme/test/store.test.js b/dsh-mneme/test/store.test.js
index 494b7a95..07cfa2b1 100644
--- a/dsh-mneme/test/store.test.js
+++ b/dsh-mneme/test/store.test.js
@@ -554,3 +554,138 @@ test("Issue #495: listDreamRuns excludes a run type by pushdown, and only accept
assert.equal(store.listDreamRuns({ excludeRunType: "nope" }).length, 3);
store.close();
});
+
+// ============================ #512 蒸馏 run 归因
+
+test("#512 迁移:老库补上 distill_run_id 列与两张新表,存量链接留 NULL 不猜", () => {
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run-"));
+ const dbPath = join(dir, "legacy.db");
+ try {
+ // 存量库:#399 时代的形状——memory_provenance 没有 distill_run_id,也没有
+ // distill_runs 与 memory_mints。这一行代表「改造之前就落盘的链接」,它没有 run 可指。
+ const legacy = new DatabaseSync(dbPath);
+ legacy.exec("CREATE TABLE memories (" +
+ "id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, " +
+ "tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3, " +
+ "forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, " +
+ "source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);");
+ legacy.exec("CREATE TABLE memory_provenance (" +
+ "memory_id TEXT NOT NULL, session_id TEXT NOT NULL, " +
+ "seq_start INTEGER NOT NULL, seq_end INTEGER NOT NULL, created_at TEXT NOT NULL);");
+ legacy.exec("INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at) " +
+ "VALUES ('old1', 'history', '老记忆', '内容', '[]', 3, 0, 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');");
+ legacy.exec("INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) " +
+ "VALUES ('old1', 's-old', 1, 5, '2026-01-02T00:00:00.000Z');");
+ legacy.close();
+
+ const store = createStore(dbPath);
+ const cols = store.db.prepare("PRAGMA table_info(memory_provenance)").all().map((c) => c.name);
+ assert.ok(cols.includes("distill_run_id"), "存量库重开即补上 distill_run_id");
+ for (const table of ["distill_runs", "memory_mints"]) {
+ const row = store.db.prepare(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?"
+ ).get(table);
+ assert.ok(row, "存量库重开即建 " + table);
+ }
+
+ const links = store.provenanceFor("old1");
+ assert.equal(links.length, 1);
+ assert.equal(
+ links[0].distill_run_id,
+ null,
+ "历史链接保持 NULL:反推一个合成 run id 比不填更坏(读数正常、实际是猜的)"
+ );
+ assert.equal(store.mintsFor("old1").length, 0, "历史记忆不补铸造记录——那时根本没有 run 概念");
+ store.close();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("#512 迁移幂等:重开已升级的库不重复 ALTER、不动已落盘的 run 归因", () => {
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run2-"));
+ const dbPath = join(dir, "upgraded.db");
+ try {
+ const first = createStore(dbPath);
+ const m = first.save({ type: "history", title: "t", content: "c" });
+ first.saveDistillRun({
+ id: "run-a",
+ status: "ok",
+ run_type: "autoSummarize",
+ trigger_source: "autoSummarize",
+ session_id: "s1",
+ seq_start: 1,
+ seq_end: 2,
+ memory_count: 1
+ });
+ first.saveMemoryProvenance({
+ memoryId: m.id, sessionId: "s1", seqStart: 1, seqEnd: 2,
+ createdAt: "2026-11-01T00:00:00.000Z", distillRunId: "run-a"
+ });
+ first.close();
+
+ const second = createStore(dbPath);
+ const cols = second.db.prepare("PRAGMA table_info(memory_provenance)")
+ .all().filter((c) => c.name === "distill_run_id");
+ assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
+ assert.equal(second.provenanceFor(m.id)[0].distill_run_id, "run-a", "重开不动已落盘的归因");
+ assert.equal(second.getDistillRun("run-a").status, "ok");
+ second.close();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("#512 distill_runs:同 id replay 覆写而不重复", () => {
+ const store = openMemory();
+ store.saveDistillRun({
+ id: "r1", status: "failed", error: "boom",
+ run_type: "autoSummarize", trigger_source: "autoSummarize",
+ session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 0,
+ created_at: "2026-11-01T00:00:00.000Z"
+ });
+ // 同一轮重放(等价于宿主重启后重试同一逻辑轮):必须覆写,不能变成两行。
+ store.saveDistillRun({
+ id: "r1", status: "ok",
+ run_type: "autoSummarize", trigger_source: "autoSummarize",
+ session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 2,
+ created_at: "2026-11-01T00:00:00.000Z"
+ });
+ const runs = store.listDistillRuns();
+ assert.equal(runs.length, 1, "一轮只有一行");
+ assert.equal(runs[0].status, "ok", "replay 覆写成最新的事实");
+ assert.equal(runs[0].error, undefined, "覆写清掉上一次的错误");
+ assert.equal(runs[0].memory_count, 2);
+ assert.equal(runs[0].audit_id, undefined, "没有 audit 行时是 undefined,不是 0");
+ store.close();
+});
+
+test("#512 distill_runs:audit_id 事后补注,补不存在的 run 静默返回 0", () => {
+ const store = openMemory();
+ store.saveDistillRun({ id: "r2", status: "ok", run_type: "autoSummarize", trigger_source: "autoSummarize" });
+ assert.equal(store.attachDistillRunAudit("r2", 42), 1);
+ assert.equal(store.getDistillRun("r2").audit_id, 42);
+ assert.equal(store.attachDistillRunAudit("missing", 42), 0, "行不存在时不动任何东西");
+ assert.equal(store.attachDistillRunAudit("r2", null), 0, "非整数 audit id 一律不写");
+ store.close();
+});
+
+test("#512 memory_mints:同一 (记忆, run) 只记一次,删记忆时显式清掉归因", () => {
+ const store = openMemory();
+ const m = store.save({ type: "pattern", title: "抽出来的规律", content: "c", source: "dream" });
+ const mint = { memoryId: m.id, runId: "run-d", runType: "dream", triggerSource: "autoDream", createdAt: "2026-11-01T00:00:00.000Z" };
+ assert.equal(store.saveMemoryMint(mint), 1);
+ assert.equal(store.saveMemoryMint(mint), 0, "INSERT OR IGNORE:重放不重复记一次铸造");
+ const rows = store.mintsFor(m.id);
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].run_type, "dream");
+ assert.equal(rows[0].trigger_source, "autoDream");
+
+ store.remove(m.id);
+ assert.equal(
+ store.db.prepare("SELECT count(*) AS c FROM memory_mints WHERE memory_id = ?").get(m.id).c,
+ 0,
+ "记忆没了,归因不许悬空(CASCADE 之外仍显式清,与 memory_provenance 同一口径)"
+ );
+ store.close();
+});
diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js
index d1e66174..1f457936 100644
--- a/dsh-mneme/test/summarize.test.js
+++ b/dsh-mneme/test/summarize.test.js
@@ -1637,6 +1637,82 @@ test("issue#399: expiring records keeps the memories and leaves provenance dangl
assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
});
+// ── Issue #512:蒸馏归因补 run 概念 ────────────────────────────────────────
+//
+// 这四条锁的是「这条记忆是哪一次蒸馏带进来的」这条问句现在回答得出来:
+// ① 一轮真蒸馏落一行 distill_runs,链接上带同一个 distill_run_id;
+// ② run 与它的开销读数(llm_audit_logs)靠 audit_id 直连,不靠时间戳猜;
+// ③ llmAudit 关掉时 run 仍然成立(归因不该依赖计费开关),只是没有 audit_id;
+// ④ 失败轮也留一行——「那一轮跑了但没成」本身就是归因要回答的事。
+// 反面(历史链接保持 NULL、不回填)在 test/store.test.js 的迁移用例里锁。
+
+test("issue#512: a distilled window records one run, and every link points at it", async () => {
+ const { events, store } = setup(RECORDS_ON);
+ const session = recordsSession("s512-ok", [userMessage("这轮要能追到 run", 1), { seq: 2, type: "turn/end" }]);
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
+
+ const runs = store.listDistillRuns();
+ assert.equal(runs.length, 1, "一轮真蒸馏就是一行 run");
+ const run = runs[0];
+ assert.equal(run.status, "ok");
+ assert.equal(run.run_type, "autoSummarize");
+ assert.equal(run.trigger_source, "autoSummarize");
+ assert.equal(run.session_id, "s512-ok");
+ assert.deepEqual([run.seq_start, run.seq_end], [1, 2], "run 记的是这一步真正蒸馏掉的窗口");
+ assert.equal(run.memory_count, 2, "记忆条数是实际写成功的条数");
+
+ const links = store.db.prepare("SELECT memory_id, distill_run_id FROM memory_provenance").all();
+ assert.equal(links.length, 2);
+ for (const link of links) {
+ assert.equal(link.distill_run_id, run.id, "每条记忆的链接都指向这一轮,不是 NULL、也不是别的轮");
+ }
+});
+
+test("issue#512: the run and its spend row are joined by audit_id, not guessed from timestamps", async () => {
+ const { events, store } = setup(RECORDS_ON);
+ const session = recordsSession("s512-audit", [userMessage("归因要连到开销行", 1), { seq: 2, type: "turn/end" }]);
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
+
+ const run = store.listDistillRuns()[0];
+ const audits = store.listLlmAudits({ source: "autoSummarize" });
+ assert.equal(audits.length, 1);
+ assert.equal(run.audit_id, audits[0].id, "run 直接指着它自己那一行,不靠时间窗匹配");
+ // 反向也成立:从 run 拿到的 id 读回来就是那一条。
+ const join = store.getDistillRun(run.id);
+ assert.equal(join.audit_id, audits[0].id);
+});
+
+test("issue#512: with llmAudit off the run still lands — attribution must not depend on the spend switch", async () => {
+ const { events, store } = setup({ ...RECORDS_ON, llmAudit: { enabled: false } });
+ const session = recordsSession("s512-noaudit", [userMessage("关了计费也要能归因", 1), { seq: 2, type: "turn/end" }]);
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
+
+ assert.equal(store.listLlmAudits().length, 0, "开关关着,一行 audit 都不写");
+ const runs = store.listDistillRuns();
+ assert.equal(runs.length, 1, "但 run 照落");
+ assert.equal(runs[0].status, "ok");
+ assert.equal(runs[0].audit_id, undefined, "没有开销行时这一列是空的——不填假 id");
+});
+
+test("issue#512: a failed round still leaves a run row saying it failed", async () => {
+ // 流失败:窗口保留待重试,但「跑过一轮且失败」这件事必须留痕,否则归因只记得住成的。
+ const { events, store } = setup(RECORDS_ON, {
+ stream: () => (async function* () {
+ yield { type: "block-start", block: { type: "text" } };
+ yield { type: "finish", kind: "error", error: { message: "upstream exploded" } };
+ })()
+ });
+ const session = recordsSession("s512-fail", [userMessage("这轮会失败", 1), { seq: 2, type: "turn/end" }]);
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
+
+ const runs = store.listDistillRuns();
+ assert.equal(runs.length, 1, "失败轮也是一轮");
+ assert.equal(runs[0].status, "failed");
+ assert.ok(runs[0].error, "失败要有原因,空错误等于没说");
+ assert.equal(runs[0].memory_count, 0);
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0, "失败轮不落原文");
+});
+
test("issue#399: enabled=false (the default) writes nothing at all", async () => {
const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
let saverCalls = 0;