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

@@ -270,7 +270,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 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 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-2638%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-2654%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -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;