diff --git a/README.md b/README.md
index 0da39e21..fd74a305 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -270,7 +270,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790
```bash
cd dsh-mneme && npm install
-npm test # 2577 个测试
+npm test # 2623 个测试
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 # 2577 tests
+npm test # 2623 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 3bba67f5..c4fa262c 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -4,6 +4,16 @@
## 🆕 新增
+- **溯源读出口补上反方向:`GET /memory/provenance?sessionId=`——「这个会话蒸出了哪几个窗口」第一次可直接查,此前只能「从一条记忆往回找」(#524,从 #421 拆出方向 2)**:#421 当初拆成两个方向,方向 1(记忆 → 窗口 → `distill_records` 原文)由 #509 交付,方向 2(会话 → 它产出的蒸馏窗口)一直没做。底下其实早就齐了——`memory_provenance` 自 #399 就在写、`idx_memory_provenance_session` 自那时就建着——缺的只是入参方向:`parseProvenanceQuery` 只认 `?id=`,`session_id` 只在响应里躺着。**复用同一条路径而不是新开一条**:多一条路径就多四处要登记的地方(`SECURITY.md` 两张表、包 README 路由表、`test/security-doc.test.js` 的清单锁),而两个方向读的是同一批数据、只是入口不同;`SECURITY.md` 只登记路径不登记 query 形状,所以方向 2 在那两张表里一行都不用加。两个主键**互斥**:都缺 `400 missing-id`、都给 `400 ambiguous-id`——**刻意不做「都给则择一」**,择一意味着调用方可以发一个自己也不知道会被读哪个主键的请求,而两个方向的答复形状不同,那种静默选择只会在出事之后才被发现。
+
+ **`limit` 不是可选的礼貌参数,是这条方向的前置条件**:按会话读是这张脸里天然最容易放大的方向——一个会话几十个窗口、每个窗口都是整段逐字原文,不限量就是「按会话导出全量转录」,与 `/export`(一次拿全库)同量级只是换了形状,而它同样受门控。默认 20、上限 100,两个方向共用同一份解析:**只收十进制整数字面量**,`""` / `" "` / `0x10` / `1e3` / `"+1"` / `"12abc"` / 超 `Number.MAX_SAFE_INTEGER` 一律 400 `invalid-limit`——走 `Number()` 的话 `?limit=` 会静默变成 0 个窗口,一个畸形输入拿到最坏解释(`reclaim-http.js` 的 `parseReclaimQuery` 为同一理由立过这条规矩);越界也给 400 而不截到 100,因为「要 1000 个、实际给 100 还不吭声」比直接拒更糟。撞上限时答复带 `truncated: true`:**少了它,被截断的答复与「本来就只有这么多」逐字节相同**,调用方会把半份转录当成全部,那正是这张脸最容易骗人的地方。实现上 session 侧向 store 要 `limit + 1` 行、多出来那条只当探针不出口;memory 侧 `provenanceFor` 本来就全量返回,按长度判即可。
+
+ **方向 2 不能逐链接返回**:`memory_provenance` 的粒度是「一条记忆 → 一个窗口」,而一次蒸馏窗口通常产出**多条**记忆(`saveMemoryProvenance` 在 `src/summarize.js` 的写窗口循环里按条调用),照原样列会让「这个会话蒸了几次」这个唯一有意义的计数被重复行淹没。新增 `store.distillWindowsFor(sessionId, limit)` 走 `GROUP BY session_id, seq_start, seq_end`,带 `COUNT(*) AS link_count`——`link_count` 说明该窗口派生出几条记忆,是按窗口看的第二维;**刻意不回 `memory_id` 列表**,方向 1 已经是「记忆 → 窗口」,把 id 也塞进这一侧会让两个方向不再正交(同一份映射有两条读法就得有两处维护),真要逐条查仍走方向 1。`ORDER BY seq_start` 而不是 `created_at`:seq 是事件序号、就是会话内的先后,按它排才是「这个会话按时间蒸了哪几段」,按 `created_at` 会把同一批重跑的行打散。**`window_id` 也不能当分组键**——它是逐条记录生成的 `session::`(一行一个 seq),拿它分组会退化成「列原文」。
+
+ **503 改成按方向判**:`REQUIRED_METHODS` 分 `memory: [provenanceFor, distillRecordsFor]` / `session: [distillWindowsFor, distillRecordsFor]`,只缺 session 那侧方法时方向 1 仍然 200——把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署。409 与 503 的分工不动:前者是「用户关了 `distillRecords.enabled`」,后者是「装配没接线」。`provenanceReply` 的入参从 `{service, id, config, warn}` 改成 `{service, selector, config, warn}`,selector 由 `parseProvenanceSelector` 产出(两个壳都换成它,静态锁的 regex 同步改成 `selector: parsed.selector`)。**不动 `lib/client.js`**:面板侧本来就没有溯源读取 UI,这条方向是给 MCP / 代理 / CLI 用的。
+
+ 回归测试 +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)。
+
- **会话级无痕闸门:指定会话与子代理「不进不出」,其余会话照常(#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 f2013471..398616f8 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -5,7 +5,7 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -535,7 +535,7 @@ 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=` | 记忆级溯源读出口(#399 写、#421 读;宿主内面板路由 `/api/dsh-mneme/memory/provenance` 同一口径):一条溯源链接一个窗口 `{session_id, seq_start, seq_end, created_at, records[]}`,`records` 是那段蒸馏窗口的**逐条会话原文**(按 `seq` 升序、不越出区间)。没有链接的记忆返回**空 `windows`**——「不是蒸馏来的」是有效答案,不是错误。状态码阶梯:`401` 无 token → `400` 缺 `id` → `409 {"error":"distill-records-disabled"}`(`distillRecords.enabled` 关着;该开关是**启动快照**,面板改完要到下次启动生效,面板会显示「待重启」)→ `503 {"error":"provenance-unavailable"}`(装配没接线)→ `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 是一条溯源链接一个窗口;方向 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 +719,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/ # 2577 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 2623 个 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 +728,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 2577 个测试
+npm test # 运行 2623 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/docs/DAEMON.md b/dsh-mneme/docs/DAEMON.md
index 24dc0d98..98867e1d 100644
--- a/dsh-mneme/docs/DAEMON.md
+++ b/dsh-mneme/docs/DAEMON.md
@@ -14,9 +14,11 @@ daemon 是**数据面**,不是第二个宿主:
## 2. 对外接口
-路由面 = `src/api-standalone.js` 全表(health/status/profile/rules/memories 读写/`/memory/provenance`(记忆级溯源读出口,#421)/search/context/share/receive(含 `/receive/undo`)/maintenance/bootstrap),鉴权同源(Bearer + timingSafeEqual,`GET /health` 免鉴权)。daemon 自身零新路由;#370 的 `/context` 落在 api-standalone 工厂里,宿主外部访问与 daemon 自动同享(参数、scope 语义与 recall_runs 注入分账见包 README 路由表)。daemon 侧唯一自有选项是 `strictPort`(见 §4)。
+路由面 = `src/api-standalone.js` 全表(health/status/profile/rules/memories 读写/`/memory/provenance`(记忆级 / 会话级溯源读出口,#421+#524)/search/context/share/receive(含 `/receive/undo`)/maintenance/bootstrap),鉴权同源(Bearer + timingSafeEqual,`GET /health` 免鉴权)。daemon 自身零新路由;#370 的 `/context` 落在 api-standalone 工厂里,宿主外部访问与 daemon 自动同享(参数、scope 语义与 recall_runs 注入分账见包 README 路由表)。daemon 侧唯一自有选项是 `strictPort`(见 §4)。
-`/memory/provenance` 的开关门(#509 评审整改):这个面返回蒸馏窗口的**逐条会话原文**,所以它吃 `distillRecords.enabled`——与写侧 `src/summarize.js:988` 同一枚开关、同一个判据表达式(读侧唯一一份在 `src/provenance-http.js` 的 `isDistillRecordsEnabled`)。关着时回 `409 {"error":"distill-records-disabled"}`,不是 404(本端点刻意不就「这条记忆是否存在」作断言:未知 id 是 200 + 空 windows),也不是 503(那是「装配没接线」,`src/index.js:589-591` 立的可区分性原则)。状态码阶梯:`401` 无 token → `400` 缺 id → `409` 功能关了 → `503` 装配没接线 → `200`。daemon 读的是**面板持久化的开关**(`serve.js` 把 `feature_flags` 里的 `distillRecords.enabled` 展开进 cfg,与宿主 `index.js:217-233` 同款),与宿主共用同一个 memory.db、同一份 kv——所以用户在面板里开着,daemon 这一面就读得到。
+`/memory/provenance` 的开关门(#509 评审整改):这个面返回蒸馏窗口的**逐条会话原文**,所以它吃 `distillRecords.enabled`——与写侧 `src/summarize.js:988` 同一枚开关、同一个判据表达式(读侧唯一一份在 `src/provenance-http.js` 的 `isDistillRecordsEnabled`)。关着时回 `409 {"error":"distill-records-disabled"}`,不是 404(本端点刻意不就「这条记忆是否存在」作断言:未知 id 是 200 + 空 windows),也不是 503(那是「装配没接线」,`src/index.js:589-591` 立的可区分性原则)。状态码阶梯:`401` 无 token → `400` 缺参(`missing-id` / `ambiguous-id` / `invalid-limit`) → `409` 功能关了 → `503` 装配没接线(按方向判) → `200`。
+
+两个读方向共用同一条路径(#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 这一面就读得到。
残留不对称要知道:daemon 结构上看不到宿主的 **bundle config**(第一期不吃宿主 config schema),所以「只写在部署侧 bundle 配置里打开、从没在面板点过」的用户,宿主认、daemon 不认——daemon 会对每个请求回 409。这不是故障,是 daemon 装配面的已知边界;要让 daemon 也认,去面板里把开关显式打开一次(面板的「待重启」提示同样适用:开关是启动快照,改完要重启才生效)。
diff --git a/dsh-mneme/docs/DISTILL.md b/dsh-mneme/docs/DISTILL.md
index 98792b21..1ac503ac 100644
--- a/dsh-mneme/docs/DISTILL.md
+++ b/dsh-mneme/docs/DISTILL.md
@@ -166,25 +166,47 @@ cleared: { dream_run_inputs, archived_embeddings, distill_records }
- **白名单与往返**:两个键过 `settings` 校验(含范围外整数被拒),PUT 往返后 effective 读得回;
- **轻量模式**:预设关 `enabled`、保留用户显式设的 `retentionDays`、不污染用户对象。
-## 10. 读出口:`/memory/provenance` 与 `distillRecords.enabled` 门(#421 写、#509 整改)
+## 10. 读出口:`/memory/provenance` 与 `distillRecords.enabled` 门(#421 写、#509 整改、#524 补方向 2)
两张表自 #399 起只有写入路径:库里有答案、`service.provenanceFor` 能查,外部拿不到——
「这条记忆是从哪几句蒸出来的」只能开 sqlite 手写 JOIN。#421 补上读出口,两个入口共用
一份实现:
- 宿主内面板路由 `GET /api/dsh-mneme/memory/provenance`(`policy: token`);
-- Bearer 数据面 `GET /memory/provenance?id=`(daemon 与宿主外部访问同享)。
+- Bearer 数据面 `GET /memory/provenance`(daemon 与宿主外部访问同享)。
+
+**两个读方向共用同一条路径**(#524):`?id=` 是「这条记忆派生自哪些窗口」
+(方向 1,`store.provenanceFor`),`?sessionId=` 是「这个会话蒸出了哪些窗口」
+(方向 2,`store.distillWindowsFor`)。两个主键**互斥**:都缺 `400 missing-id`、都给
+`400 ambiguous-id`。**刻意不做「都给则择一」**——择一意味着调用方可以发一个自己也不
+知道会被读哪个主键的请求,而两个方向的答复形状不同(`memoryId` / `sessionId` 各认
+领自己那一侧),那种静默选择只会在出事之后才被发现。
+
+为什么不新开一条路径:多一条路径就多四处要登记的地方(`SECURITY.md` 两张表、包
+README 路由表、`test/security-doc.test.js` 的清单锁),而两个方向读的是**同一批数
+据**,只是入口不同。`SECURITY.md` 只登记路径、不登记 query 形状,所以方向 2 不需要
+在那两张表里再加一行。
口径只写在 `src/provenance-http.js` 一处,两个壳都调它——#411 已经为 reclaim / share 各
踩过一次「两张皮整段复制必然漂移」,`test/provenance-http.test.js` 的静态锁把「错误码字面
量与开关判据只许出现在本模块、两个壳都真的把 `config` 转交进来、读侧判据与写侧
`src/summarize.js:988` 逐字同源」写成断言。
-**形状**:一个链接一个窗口 `{session_id, seq_start, seq_end, created_at, records[]}`,
+**形状**:一个窗口 `{session_id, seq_start, seq_end, created_at, link_count, records[]}`,
`records` 是区间内的逐条会话原文(按 `seq` 升序、不越界)。没有链接的记忆返回**空
`windows`**——「不是蒸馏来的」是有效答案,不是错误;未知 id 同样回空 `windows`(读端点
不替调用方判断记忆是否存在)。到期裁剪后链接保留、指向空区间,面板据此显示「原文已过期」。
+`link_count` 只有方向 2 会真的不等于 1(方向 1 的每一行本来就是「一条记忆 → 一个窗
+口」,用 `?? 1` 兜住,免得答复凭空多一个 null 键)。方向 2 的存储读法是
+`GROUP BY session_id, seq_start, seq_end`——`memory_provenance` 的粒度是「一条记忆 →
+一个窗口」,一次蒸馏窗口通常产出**多条**记忆,逐链接返回会让「这个会话蒸了几次」这
+个唯一有意义的计数被重复行淹没。`link_count` 是按窗口看的第二维;**刻意不回
+`memory_id` 列表**——方向 1 已经是「记忆 → 窗口」,把 id 也塞进这一侧会让两个方向不
+再正交(同一份映射有两条读法就得有两处维护),真要逐条查仍走方向 1。
+`ORDER BY seq_start` 而不是 `created_at`:seq 是事件序号、就是会话内的先后,按它排才
+是「这个会话按时间蒸了哪几段」,按 `created_at` 会把同一批重跑的行打散。
+
**开关门(#509 评审整改)**:读出口吃 `distillRecords.enabled`——与写侧**同一枚开关、同一
个判据表达式**(`config?.distillRecords?.enabled === true`,读侧唯一一份在
`isDistillRecordsEnabled`)。此前全树只有 `src/index.js:270`(启动期裁剪)与
@@ -194,10 +216,26 @@ cleared: { dream_run_inputs, archived_embeddings, distill_records }
| 状态码 | 含义 | 为什么不是别的 |
|---|---|---|
| `401` | 没带 token | 两个壳各自的门,在共用模块之前 |
-| `400 {"error":"missing-id"}` | 缺 `id` | 语法畸形,与服务端状态无关,所以排在功能开关之前(与 reclaim 的 `invalid-json` 同档) |
+| `400 {"error":"missing-id"}` | 两个主键一个都没给 | 语法畸形,与服务端状态无关,所以排在功能开关之前(与 reclaim 的 `invalid-json` 同档) |
+| `400 {"error":"ambiguous-id"}` | 两个主键都给了 | 不静默择一,理由见上;同为语法类,同样排在开关之前 |
+| `400 {"error":"invalid-limit"}` | `limit` 畸形或越界(只收十进制整数字面量,1–100) | **不用 `Number()`**:它把 `""` / `" "` / `0x10` / `1e3` 都化成数,于是 `?limit=` 会静默变成 0 个窗口——一个畸形输入拿到最坏解释。越界也给 400 而不是截到 100:调用方要 1000 个窗口,实际给 100 却不吭声比直接拒更糟 |
| `409 {"error":"distill-records-disabled"}` | 用户关了 `distillRecords.enabled` | 与 `src/api.js:525` 的 `profileExtractEnabled` 同型:**用户关了 = 409,装配坏了 = 503**,两个答案必须可区分(`src/index.js:589-591`) |
-| `503 {"error":"provenance-unavailable"}` | service 上没这两个方法(headless / 旧装配) | 「没接线」不是「这条记忆没有来源」,两种空必须分开读数 |
-| `200` | `{memoryId, windows}` | 空 `windows` = 不是蒸馏来的 |
+| `503 {"error":"provenance-unavailable"}` | service 上缺**该方向**要用的方法(headless / 旧装配) | 「没接线」不是「这条记忆没有来源」,两种空必须分开读数。**按方向判**:只缺 session 那侧的方法时方向 1 仍然 200——把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署 |
+| `200` | `{memoryId\|sessionId, windows, truncated}` | 空 `windows` = 不是蒸馏来的;`truncated` 见下 |
+
+**limit 与 truncated(#524 的第 2 条前置)**:按会话读是这张脸里天然最容易放大
+的方向——一个会话几十个窗口,每个窗口都是整段逐字原文,不限量就是「按会话导出全量转
+录」,与 `/export`(一次拿全库)同量级,只是换了形状,而它同样受门控。所以 `limit`
+不是可选的礼貌参数:默认 20、上限 100,两个方向共用同一个读数。20 这个数的取法是本
+机实测一个蒸馏会话的窗口数在个位数,20 已覆盖正常排查;真要更多就显式往上要,**不
+许**靠缺省值默默给全量。100 的上界比列表类接口(`MAX_PAGE_LIMIT` 1000 / `MAX_SEARCH_TOP_K`
+200)小一档,因为每个窗口带的是一整段原文而不是一行元数据。
+
+撞上 limit 时答复带 `truncated: true`。**少了它,被截断的答复与「本来就只有这么多」
+逐字节相同**,调用方会把半份转录当成全部——那正是这张脸最容易骗人的地方。实现上
+session 侧向 store 要 `limit + 1` 行、多出来那条只当探针不出口;memory 侧
+`provenanceFor` 本来就全量返回,按长度判即可。store 侧只照 limit 取,**不替调用方判
+断「还有没有」**——那是协议层的事,判据只许有一份。
**为什么不是 404**:本端点被明确设计成永远不就「这条记忆是否存在」作断言(缺 id 是 400、
未知 id 是 200 + 空 windows),而 404 在本仓已有稳定含义——未知路由与资源不存在
diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js
index 97dbdd0c..9e50ffb2 100644
--- a/dsh-mneme/lib/api-standalone.js
+++ b/dsh-mneme/lib/api-standalone.js
@@ -21,7 +21,7 @@ import { bootstrapFromDirectory } from "./bootstrap.js";
import { PACKAGE_VERSION } from "./version-check.js";
import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
-import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
+import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
const DEFAULT_PORT = 8790;
const DEFAULT_HOST = "127.0.0.1";
@@ -487,6 +487,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
// 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
// 空 windows——「不是蒸馏来的」是有效答案,不是错误。
//
+ // 两个读方向共一条路径(#524):?id= 走方向 1(记忆 → 窗口),
+ // ?sessionId= 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
+ // 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
+ //
// 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
// open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
//
@@ -497,12 +501,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
// 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
// provenance-http.js 文件头。
if (req.method === "GET" && pathname === "/memory/provenance") {
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
+ const parsed = parseProvenanceSelector({
+ memoryId: url.searchParams.get("id"),
+ sessionId: url.searchParams.get("sessionId"),
+ limit: url.searchParams.get("limit")
+ });
if (!parsed.ok) {
sendJson(res, parsed.status, { error: parsed.error });
return;
}
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => logger?.warn?.(m) });
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
sendJson(res, status, body);
return;
}
diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js
index 6334e876..d3a75f1f 100644
--- a/dsh-mneme/lib/api.js
+++ b/dsh-mneme/lib/api.js
@@ -14,7 +14,7 @@ import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallba
import { summarizeSkipped } from "./dream/decisions.js";
import { getInjectionSnapshot } from "./inject.js";
import { renderUserSettingsBlock } from "./user-settings.js";
-import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
+import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
@@ -1314,6 +1314,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
// 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来
// 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。
//
+ // 两个读方向共一条路径(#524):?id= 是「这条记忆从哪几句蒸出来的」,
+ // ?sessionId= 是「这个会话蒸出了哪几段」。两个主键互斥——都缺 400、都给
+ // 400(择一意味着调用方发一个自己也不知道会被读哪个主键的请求,而两边结果形状
+ // 不同)。共用路径是有意的:多一条路径就多一处要登记的地方(SECURITY.md 两张
+ // 表、README 路由表、security-doc 的清单锁),而两者读的是同一批数据。
+ //
// 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆
// (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是
// 有效答案,不是错误,所以不 404。
@@ -1341,12 +1347,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
handler(req, res) {
try {
const url = new URL(req.url, "http://localhost");
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
+ const parsed = parseProvenanceSelector({
+ memoryId: url.searchParams.get("id"),
+ sessionId: url.searchParams.get("sessionId"),
+ limit: url.searchParams.get("limit")
+ });
if (!parsed.ok) {
sendJson(res, parsed.status, { error: parsed.error });
return;
}
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => ctx.logger?.warn?.(m) });
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => ctx.logger?.warn?.(m) });
sendJson(res, status, body);
} catch {
sendJson(res, 500, { error: "internal" });
diff --git a/dsh-mneme/lib/provenance-http.js b/dsh-mneme/lib/provenance-http.js
index 0ee3faab..2fa1b54f 100644
--- a/dsh-mneme/lib/provenance-http.js
+++ b/dsh-mneme/lib/provenance-http.js
@@ -2,41 +2,51 @@
//
// 为什么要单独一个模块:这张脸有两个入口——宿主内的面板路由(src/api.js 的
// /api/dsh-mneme/memory/provenance)与 Bearer 数据面(src/api-standalone.js 的
-// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillRecordsFor),
-// 差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,于是「改一边忘一边」
-// 在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,本模块照那次的结论办。
+// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillWindowsFor
+// + distillRecordsFor),差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,
+// 于是「改一边忘一边」在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,
+// 本模块照那次的结论办。
//
-// 口径(错误码、两种「空」的区分、窗口形状)现在只写在这里,两个入口都调它。
+// 口径(错误码、两种「空」的区分、窗口形状、上限)现在只写在这里,两个入口都调它。
// 真要再写第二份,test/provenance-http.test.js 的静态锁会直接转红。
//
-// 这里只做协议层的事:解析 id、判开关、组装窗口、区分「用户关了」「没接线」与
+// 这里只做协议层的事:解析入参、判开关、组装窗口、区分「用户关了」「没接线」与
// 「没有来源」。一行数据都不改——读取在 src/store.js,透传在 src/service.js。
//
+// 两个读方向(#421 拆成两条,方向 1 由 #509 交付、方向 2 由 #524 补上):
+// memory: ?id= 一条记忆 → 派生它的那些蒸馏窗口
+// session: ?sessionId= 一个会话 → 它产出的那些蒸馏窗口
+// 两个主键互斥(都缺 400、都给 400)。共用同一条路径是有意的:多一条路径就多一处
+// 要登记的地方(SECURITY.md 两张表、README 路由表、security-doc 的清单锁),而这两
+// 个方向读的是**同一批数据**,只是入口不同。
+//
// 状态码阶梯(自前而后,先匹配的先回):
// 401 未鉴权 ——两个壳各自的门(api.js 的 register 包装 / standalone 的
// isAuthorized),在本模块之前,不在这里重复。
-// 400 缺 id ——语法畸形,与服务端状态无关,所以排在功能开关之前;与
+// 400 缺参/多参 ——语法畸形,与服务端状态无关,所以排在功能开关之前;与
// reclaim 的 invalid-json 同档(reclaim-http.js:30-33 立的是
-// 「503 要赢过**字段校验**的 400」,缺 id 不是字段校验而是请求
+// 「503 要赢过**字段校验**的 400」,缺参不是字段校验而是请求
// 本身不成立,所以不适用那条)。判断在壳里
-// (parseProvenanceQuery),走到本模块时 id 一定非空。
-// ⚠ 将来要加**语义类** 400(如 limit 越界、id 格式不合法),
-// 必须挪到接线检查之后——那条 reclaim 口径正是为它立的。
+// (parseProvenanceSelector),走到本模块时选择器一定成型。
+// limit 越界属**语义类** 400,按上面那条口径仍排在接线检查之前
+// ——它是入参本身的形状问题,不是「服务端现在能不能读」。
// 409 功能关了 ——distillRecords.enabled !== true。回的是「端点的属性」,
// 与 src/api.js:525 的 profileExtractEnabled 同构:用户关了
// 某功能 = 409,装配坏了 = 503,两者必须是可区分的答案
// (src/index.js:589-591 的设计原话)。
-// 503 装配没接线 ——service 上没有 provenanceFor / distillRecordsFor。
+// 503 装配没接线 ——service 上缺该方向要用的方法(按选择器分方向判,见
+// REQUIRED_METHODS:只缺 session 那侧的方法时,memory 查询
+// 仍然该能用)。
// 读(200 / 500)
-// 刻意不用 404:本端点永远不就「这条记忆是否存在」作断言(缺 id 是 400、未知 id
-// 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在
+// 刻意不用 404:本端点永远不就「这条记忆/这个会话是否存在」作断言(缺参是 400、
+// 未知 id 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在
// (src/api-standalone.js:506/515、src/api.js:1367/1445)。把「功能关了」塞进
// 404,调用方会在状态码这个唯一被代理 / 监控 / 重试逻辑读取的通道上,把它读成
// 「这条记忆不存在」。
/**
- * 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有 provenanceFor 或
- * distillRecordsFor,那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数,
+ * 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有本方向要用的方法,
+ * 那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数,
* 否则调用方看到一个空 windows 会以为拿到了答案。
*/
export const PROVENANCE_UNAVAILABLE = "provenance-unavailable";
@@ -50,6 +60,24 @@ export const PROVENANCE_UNAVAILABLE = "provenance-unavailable";
*/
export const DISTILL_RECORDS_DISABLED = "distill-records-disabled";
+/**
+ * 一次答复最多带几个窗口。缺省 20。
+ *
+ * 为什么**必须**有上限(#524 的第 2 条前置):按会话读是这张脸里天然最容易放大
+ * 的方向——一个会话几十个窗口,每个窗口都是整段逐字原文,不限量就是「按会话导出
+ * 全量转录」,与 /export(一次拿全库)同量级、只是换了形状,而它同样受门控。
+ * 20 这个数的取法:本机实测一个蒸馏会话的窗口数在个位数,20 已覆盖正常排查;
+ * 真要更多就带 limit 往上要,**不许**靠缺省值默默给全量。
+ */
+export const PROVENANCE_DEFAULT_WINDOWS = 20;
+
+/**
+ * limit 的硬上界。取值与 api.js 的 MAX_PAGE_LIMIT(1000)/ MAX_SEARCH_TOP_K(200)
+ * 同一考虑:参数直接进 SQL LIMIT,上界是防御。这里比列表类接口更小,因为每个窗口
+ * 带的是一整段原文而不是一行元数据——100 个窗口已经是一份不小的转录。
+ */
+export const PROVENANCE_MAX_WINDOWS = 100;
+
/**
* 读侧唯一的开关判据,**与写侧 src/summarize.js:988 逐字同源**。
*
@@ -77,48 +105,114 @@ export function provenanceWarnMessage(error) {
}
/**
- * 解析 ?id。空 / 缺失 → 400;其余原样交给 service(id 不存在时返回空 windows,
- * 那是有效答案,不是错误——读端点不替调用方判断记忆是否存在)。
+ * 按选择器分方向列出「要读必须先有哪个方法」。503 只在**该方向**缺方法时回:
+ * 只补了 session 侧的装配不该让 memory 查询一起 503(那是把一个方向的问题
+ * 报成整张脸坏了)。
+ */
+const REQUIRED_METHODS = {
+ memory: ["provenanceFor", "distillRecordsFor"],
+ session: ["distillWindowsFor", "distillRecordsFor"]
+};
+
+function trimmed(raw) {
+ return typeof raw === "string" ? raw.trim() : "";
+}
+
+/**
+ * 解析 ?limit。**只收十进制整数字面量**,缺省落默认值。
+ *
+ * 不用 Number():它把 "" / " " / 十六进制 / 指数写法都化成数,于是 `?limit=`
+ * 会静默变成 0 个窗口——一个畸形输入拿到最坏解释(reclaim-http.js 的
+ * parseReclaimQuery 为同一理由立过这条规矩)。越界也算畸形:调用方要 1000 个
+ * 窗口,实际给 100 而不说一声,比直接拒更糟。
*
- * @returns {{ok: true, id: string} | {ok: false, status: 400, error: string}}
+ * @returns {{ok: true, limit: number} | {ok: false, status: 400, error: string}}
*/
-export function parseProvenanceQuery(raw) {
- const id = typeof raw === "string" ? raw.trim() : "";
- if (!id) return { ok: false, status: 400, error: "missing-id" };
- return { ok: true, id };
+export function parseProvenanceLimit(raw) {
+ if (raw === null || raw === undefined) return { ok: true, limit: PROVENANCE_DEFAULT_WINDOWS };
+ const text = typeof raw === "string" ? raw.trim() : "";
+ if (!/^\d+$/.test(text)) return { ok: false, status: 400, error: "invalid-limit" };
+ const limit = Number(text);
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > PROVENANCE_MAX_WINDOWS) {
+ return { ok: false, status: 400, error: "invalid-limit" };
+ }
+ return { ok: true, limit };
}
/**
- * 组装答复:一个链接一个窗口(区间 + 区间内原文行,按 seq 升序)。
+ * 解析两个互斥主键。都缺 → 400 missing-id;都给 → 400 ambiguous-id。
+ *
+ * 「都给时择一」是刻意不做的:择一意味着调用方可以发一个自己也不知道会被读哪个
+ * 主键的请求,而两边的结果形状不同——那种静默选择只会在出事之后才被发现。
+ * 400 是两个都错,比猜一个更好。
+ *
+ * @param {{memoryId?: string|null, sessionId?: string|null, limit?: string|null}} params
+ * @returns {{ok: true, selector: {mode: "memory"|"session", memoryId?: string,
+ * sessionId?: string, limit: number}} | {ok: false, status: 400, error: string}}
+ */
+export function parseProvenanceSelector({ memoryId, sessionId, limit } = {}) {
+ const mem = trimmed(memoryId);
+ const ses = trimmed(sessionId);
+ // 参数形状排在功能开关之前:请求本身不成立时报「功能关了」是误导。
+ if (mem && ses) return { ok: false, status: 400, error: "ambiguous-id" };
+ if (!mem && !ses) return { ok: false, status: 400, error: "missing-id" };
+ const parsedLimit = parseProvenanceLimit(limit);
+ if (!parsedLimit.ok) return parsedLimit;
+ return {
+ ok: true,
+ selector: mem
+ ? { mode: "memory", memoryId: mem, limit: parsedLimit.limit }
+ : { mode: "session", sessionId: ses, limit: parsedLimit.limit }
+ };
+}
+
+/**
+ * 组装答复:一个窗口一项(区间 + 区间内原文行,按 seq 升序)。
*
* 顺序敏感(阶梯全貌见文件头):409 功能关了 → 503 装配没接线 → 读。
* 503 要赢过空结果——装配没接线时报「这条记忆没有来源」是误导,与 reclaimReply 的
* maintenance-unavailable 同构;而 409 要赢过 503——「用户关了」比「装配坏了」更
* 具体,把前者报成后者会让人去查一个没坏的部署。
*
- * @param {{service: object, id: string, config?: object|null, warn?: Function}} args
+ * `truncated` 是两种模式共有的读数(#524):撞上 limit 时把「还有更多」说出来。
+ * 少了它,被截断的答复与「本来就只有这么多」逐字节相同,调用方会把半份转录
+ * 当成全部——那正是这张脸最容易骗人的地方。
+ *
+ * @param {{service: object, selector: object, config?: object|null, warn?: Function}} args
* config 缺省即关(fail-closed),见 isDistillRecordsEnabled。
* @returns {{status: number, body: object}}
*/
-export function provenanceReply({ service, id, config = null, warn = null }) {
+export function provenanceReply({ service, selector, config = null, warn = null }) {
if (!isDistillRecordsEnabled(config)) {
return { status: 409, body: { error: DISTILL_RECORDS_DISABLED } };
}
- if (typeof service?.provenanceFor !== "function" || typeof service?.distillRecordsFor !== "function") {
+ const mode = selector?.mode === "session" ? "session" : "memory";
+ if (REQUIRED_METHODS[mode].some((name) => typeof service?.[name] !== "function")) {
return { status: 503, body: { error: PROVENANCE_UNAVAILABLE } };
}
+ const limit = selector?.limit > 0 ? selector.limit : PROVENANCE_DEFAULT_WINDOWS;
try {
- const links = service.provenanceFor(id);
- const windows = links.map((link) => ({
+ // session 侧多取一行判截断(store 侧只照 limit 取,不替调用方判断「还有没有」;
+ // memory 侧 provenanceFor 本来就全量返回,按长度判即可)。
+ const rows = mode === "session"
+ ? 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,
- // 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的
- // 语义),面板据此显示「原文已过期」而非「没有来源」。
+ 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)
}));
- return { status: 200, body: { memoryId: id, windows } };
+ const body = mode === "session"
+ ? { sessionId: selector.sessionId, windows, truncated }
+ : { memoryId: selector.memoryId, windows, truncated };
+ return { status: 200, body };
} catch (error) {
warn?.(provenanceWarnMessage(error));
return { status: 500, body: { error: "internal" } };
diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js
index e204bdad..9aa887a3 100644
--- a/dsh-mneme/lib/service.js
+++ b/dsh-mneme/lib/service.js
@@ -2648,6 +2648,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
// #421 读出口:按 (session, seq 区间) 取蒸馏原文。与 provenanceFor 成对——
// 一个给区间、一个给区间里的原文,合起来才是「这条记忆从哪几句蒸出来的」。
distillRecordsFor: (sessionId, seqStart, seqEnd) => store.distillRecordsFor(sessionId, seqStart, seqEnd),
+ // #524 方向 2:按会话列出它的蒸馏窗口。与 provenanceFor 同一张表、另一条索引
+ // (idx_memory_provenance_session),所以也是纯 passthrough——读的是 #399 早就
+ // 落好的事实,不产生任何写入。
+ distillWindowsFor: (sessionId, limit) => store.distillWindowsFor(sessionId, limit),
enqueue,
setDreamHook(fn) { dreamHook = fn; },
setSleepHook(fn) { sleepHook = fn; },
diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js
index 41d19927..608573ba 100644
--- a/dsh-mneme/lib/store.js
+++ b/dsh-mneme/lib/store.js
@@ -2779,6 +2779,40 @@ export function createStore(path) {
).all(sessionId, seqStart, seqEnd);
}
+ /**
+ * 一个会话产出的蒸馏窗口(#524 方向 2 的存储侧)。
+ *
+ * #421 把溯源读出口拆成两个方向:方向 1(记忆 → 窗口)由 #509 落地,走
+ * provenanceFor;这是方向 2(会话 → 窗口)。两个方向读的是同一张表,只是索引不
+ * 同(idx_memory_provenance_session 就是为这一条建的,早已存在——所以这不是
+ * schema 变更)。
+ *
+ * **必须带 limit**:一个会话可能有几十个窗口,每个窗口接着就是整段逐字原文,
+ * 不限量等于「按会话导出全量转录」(#524 第 2 条前置)。上限的口径在协议层
+ * (provenance-http.js 的 PROVENANCE_MAX_WINDOWS),这一层只照参数取。
+ *
+ * 一行 = 一个窗口(同一 (session, seq_start, seq_end) 的多条链接 GROUP BY 成一行)
+ * ——一条蒸馏窗口通常产出多条记忆,逐链接返回会让「这个会话蒸了几次」这个唯一
+ * 有意义的计数被重复行淹没。link_count 说明该窗口派生出几条记忆,是按窗口看的
+ * 第二维;**刻意不回 memory_id 列表**:方向 1 已经是「记忆 → 窗口」,把 id 也塞进
+ * 这一侧会让两个方向不再正交(同一份映射有两条读法就得有两处维护),而真要逐条
+ * 查仍是按方向 1 走。
+ *
+ * ORDER BY seq_start:seq 是事件序号,就是会话内的先后;窗口按它排才是「这个会话
+ * 按时间蒸了哪几段」,按 created_at 排会把同一批重跑的行打散。
+ */
+ function distillWindowsFor(sessionId, limit) {
+ return db.prepare(
+ `SELECT session_id, seq_start, seq_end, MIN(created_at) AS created_at,
+ COUNT(*) AS link_count
+ FROM memory_provenance
+ WHERE session_id = ?
+ GROUP BY session_id, seq_start, seq_end
+ ORDER BY seq_start, seq_end
+ LIMIT ?`
+ ).all(sessionId, limit);
+ }
+
// --- 存储生命周期:无损回收(#275 第一批)--------------------------------
//
// 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
@@ -3868,6 +3902,7 @@ export function createStore(path) {
saveMemoryProvenance,
provenanceFor,
distillRecordsFor,
+ distillWindowsFor,
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
dreamRunInputStats,
clearDreamRunInputs,
diff --git a/dsh-mneme/src/api-standalone.js b/dsh-mneme/src/api-standalone.js
index 97dbdd0c..9e50ffb2 100644
--- a/dsh-mneme/src/api-standalone.js
+++ b/dsh-mneme/src/api-standalone.js
@@ -21,7 +21,7 @@ import { bootstrapFromDirectory } from "./bootstrap.js";
import { PACKAGE_VERSION } from "./version-check.js";
import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
-import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
+import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
const DEFAULT_PORT = 8790;
const DEFAULT_HOST = "127.0.0.1";
@@ -487,6 +487,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
// 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
// 空 windows——「不是蒸馏来的」是有效答案,不是错误。
//
+ // 两个读方向共一条路径(#524):?id= 走方向 1(记忆 → 窗口),
+ // ?sessionId= 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
+ // 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
+ //
// 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
// open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
//
@@ -497,12 +501,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
// 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
// provenance-http.js 文件头。
if (req.method === "GET" && pathname === "/memory/provenance") {
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
+ const parsed = parseProvenanceSelector({
+ memoryId: url.searchParams.get("id"),
+ sessionId: url.searchParams.get("sessionId"),
+ limit: url.searchParams.get("limit")
+ });
if (!parsed.ok) {
sendJson(res, parsed.status, { error: parsed.error });
return;
}
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => logger?.warn?.(m) });
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
sendJson(res, status, body);
return;
}
diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js
index 6334e876..d3a75f1f 100644
--- a/dsh-mneme/src/api.js
+++ b/dsh-mneme/src/api.js
@@ -14,7 +14,7 @@ import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallba
import { summarizeSkipped } from "./dream/decisions.js";
import { getInjectionSnapshot } from "./inject.js";
import { renderUserSettingsBlock } from "./user-settings.js";
-import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
+import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
@@ -1314,6 +1314,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
// 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来
// 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。
//
+ // 两个读方向共一条路径(#524):?id= 是「这条记忆从哪几句蒸出来的」,
+ // ?sessionId= 是「这个会话蒸出了哪几段」。两个主键互斥——都缺 400、都给
+ // 400(择一意味着调用方发一个自己也不知道会被读哪个主键的请求,而两边结果形状
+ // 不同)。共用路径是有意的:多一条路径就多一处要登记的地方(SECURITY.md 两张
+ // 表、README 路由表、security-doc 的清单锁),而两者读的是同一批数据。
+ //
// 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆
// (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是
// 有效答案,不是错误,所以不 404。
@@ -1341,12 +1347,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
handler(req, res) {
try {
const url = new URL(req.url, "http://localhost");
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
+ const parsed = parseProvenanceSelector({
+ memoryId: url.searchParams.get("id"),
+ sessionId: url.searchParams.get("sessionId"),
+ limit: url.searchParams.get("limit")
+ });
if (!parsed.ok) {
sendJson(res, parsed.status, { error: parsed.error });
return;
}
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => ctx.logger?.warn?.(m) });
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => ctx.logger?.warn?.(m) });
sendJson(res, status, body);
} catch {
sendJson(res, 500, { error: "internal" });
diff --git a/dsh-mneme/src/provenance-http.js b/dsh-mneme/src/provenance-http.js
index 0ee3faab..2fa1b54f 100644
--- a/dsh-mneme/src/provenance-http.js
+++ b/dsh-mneme/src/provenance-http.js
@@ -2,41 +2,51 @@
//
// 为什么要单独一个模块:这张脸有两个入口——宿主内的面板路由(src/api.js 的
// /api/dsh-mneme/memory/provenance)与 Bearer 数据面(src/api-standalone.js 的
-// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillRecordsFor),
-// 差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,于是「改一边忘一边」
-// 在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,本模块照那次的结论办。
+// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillWindowsFor
+// + distillRecordsFor),差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,
+// 于是「改一边忘一边」在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,
+// 本模块照那次的结论办。
//
-// 口径(错误码、两种「空」的区分、窗口形状)现在只写在这里,两个入口都调它。
+// 口径(错误码、两种「空」的区分、窗口形状、上限)现在只写在这里,两个入口都调它。
// 真要再写第二份,test/provenance-http.test.js 的静态锁会直接转红。
//
-// 这里只做协议层的事:解析 id、判开关、组装窗口、区分「用户关了」「没接线」与
+// 这里只做协议层的事:解析入参、判开关、组装窗口、区分「用户关了」「没接线」与
// 「没有来源」。一行数据都不改——读取在 src/store.js,透传在 src/service.js。
//
+// 两个读方向(#421 拆成两条,方向 1 由 #509 交付、方向 2 由 #524 补上):
+// memory: ?id= 一条记忆 → 派生它的那些蒸馏窗口
+// session: ?sessionId= 一个会话 → 它产出的那些蒸馏窗口
+// 两个主键互斥(都缺 400、都给 400)。共用同一条路径是有意的:多一条路径就多一处
+// 要登记的地方(SECURITY.md 两张表、README 路由表、security-doc 的清单锁),而这两
+// 个方向读的是**同一批数据**,只是入口不同。
+//
// 状态码阶梯(自前而后,先匹配的先回):
// 401 未鉴权 ——两个壳各自的门(api.js 的 register 包装 / standalone 的
// isAuthorized),在本模块之前,不在这里重复。
-// 400 缺 id ——语法畸形,与服务端状态无关,所以排在功能开关之前;与
+// 400 缺参/多参 ——语法畸形,与服务端状态无关,所以排在功能开关之前;与
// reclaim 的 invalid-json 同档(reclaim-http.js:30-33 立的是
-// 「503 要赢过**字段校验**的 400」,缺 id 不是字段校验而是请求
+// 「503 要赢过**字段校验**的 400」,缺参不是字段校验而是请求
// 本身不成立,所以不适用那条)。判断在壳里
-// (parseProvenanceQuery),走到本模块时 id 一定非空。
-// ⚠ 将来要加**语义类** 400(如 limit 越界、id 格式不合法),
-// 必须挪到接线检查之后——那条 reclaim 口径正是为它立的。
+// (parseProvenanceSelector),走到本模块时选择器一定成型。
+// limit 越界属**语义类** 400,按上面那条口径仍排在接线检查之前
+// ——它是入参本身的形状问题,不是「服务端现在能不能读」。
// 409 功能关了 ——distillRecords.enabled !== true。回的是「端点的属性」,
// 与 src/api.js:525 的 profileExtractEnabled 同构:用户关了
// 某功能 = 409,装配坏了 = 503,两者必须是可区分的答案
// (src/index.js:589-591 的设计原话)。
-// 503 装配没接线 ——service 上没有 provenanceFor / distillRecordsFor。
+// 503 装配没接线 ——service 上缺该方向要用的方法(按选择器分方向判,见
+// REQUIRED_METHODS:只缺 session 那侧的方法时,memory 查询
+// 仍然该能用)。
// 读(200 / 500)
-// 刻意不用 404:本端点永远不就「这条记忆是否存在」作断言(缺 id 是 400、未知 id
-// 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在
+// 刻意不用 404:本端点永远不就「这条记忆/这个会话是否存在」作断言(缺参是 400、
+// 未知 id 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在
// (src/api-standalone.js:506/515、src/api.js:1367/1445)。把「功能关了」塞进
// 404,调用方会在状态码这个唯一被代理 / 监控 / 重试逻辑读取的通道上,把它读成
// 「这条记忆不存在」。
/**
- * 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有 provenanceFor 或
- * distillRecordsFor,那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数,
+ * 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有本方向要用的方法,
+ * 那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数,
* 否则调用方看到一个空 windows 会以为拿到了答案。
*/
export const PROVENANCE_UNAVAILABLE = "provenance-unavailable";
@@ -50,6 +60,24 @@ export const PROVENANCE_UNAVAILABLE = "provenance-unavailable";
*/
export const DISTILL_RECORDS_DISABLED = "distill-records-disabled";
+/**
+ * 一次答复最多带几个窗口。缺省 20。
+ *
+ * 为什么**必须**有上限(#524 的第 2 条前置):按会话读是这张脸里天然最容易放大
+ * 的方向——一个会话几十个窗口,每个窗口都是整段逐字原文,不限量就是「按会话导出
+ * 全量转录」,与 /export(一次拿全库)同量级、只是换了形状,而它同样受门控。
+ * 20 这个数的取法:本机实测一个蒸馏会话的窗口数在个位数,20 已覆盖正常排查;
+ * 真要更多就带 limit 往上要,**不许**靠缺省值默默给全量。
+ */
+export const PROVENANCE_DEFAULT_WINDOWS = 20;
+
+/**
+ * limit 的硬上界。取值与 api.js 的 MAX_PAGE_LIMIT(1000)/ MAX_SEARCH_TOP_K(200)
+ * 同一考虑:参数直接进 SQL LIMIT,上界是防御。这里比列表类接口更小,因为每个窗口
+ * 带的是一整段原文而不是一行元数据——100 个窗口已经是一份不小的转录。
+ */
+export const PROVENANCE_MAX_WINDOWS = 100;
+
/**
* 读侧唯一的开关判据,**与写侧 src/summarize.js:988 逐字同源**。
*
@@ -77,48 +105,114 @@ export function provenanceWarnMessage(error) {
}
/**
- * 解析 ?id。空 / 缺失 → 400;其余原样交给 service(id 不存在时返回空 windows,
- * 那是有效答案,不是错误——读端点不替调用方判断记忆是否存在)。
+ * 按选择器分方向列出「要读必须先有哪个方法」。503 只在**该方向**缺方法时回:
+ * 只补了 session 侧的装配不该让 memory 查询一起 503(那是把一个方向的问题
+ * 报成整张脸坏了)。
+ */
+const REQUIRED_METHODS = {
+ memory: ["provenanceFor", "distillRecordsFor"],
+ session: ["distillWindowsFor", "distillRecordsFor"]
+};
+
+function trimmed(raw) {
+ return typeof raw === "string" ? raw.trim() : "";
+}
+
+/**
+ * 解析 ?limit。**只收十进制整数字面量**,缺省落默认值。
+ *
+ * 不用 Number():它把 "" / " " / 十六进制 / 指数写法都化成数,于是 `?limit=`
+ * 会静默变成 0 个窗口——一个畸形输入拿到最坏解释(reclaim-http.js 的
+ * parseReclaimQuery 为同一理由立过这条规矩)。越界也算畸形:调用方要 1000 个
+ * 窗口,实际给 100 而不说一声,比直接拒更糟。
*
- * @returns {{ok: true, id: string} | {ok: false, status: 400, error: string}}
+ * @returns {{ok: true, limit: number} | {ok: false, status: 400, error: string}}
*/
-export function parseProvenanceQuery(raw) {
- const id = typeof raw === "string" ? raw.trim() : "";
- if (!id) return { ok: false, status: 400, error: "missing-id" };
- return { ok: true, id };
+export function parseProvenanceLimit(raw) {
+ if (raw === null || raw === undefined) return { ok: true, limit: PROVENANCE_DEFAULT_WINDOWS };
+ const text = typeof raw === "string" ? raw.trim() : "";
+ if (!/^\d+$/.test(text)) return { ok: false, status: 400, error: "invalid-limit" };
+ const limit = Number(text);
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > PROVENANCE_MAX_WINDOWS) {
+ return { ok: false, status: 400, error: "invalid-limit" };
+ }
+ return { ok: true, limit };
}
/**
- * 组装答复:一个链接一个窗口(区间 + 区间内原文行,按 seq 升序)。
+ * 解析两个互斥主键。都缺 → 400 missing-id;都给 → 400 ambiguous-id。
+ *
+ * 「都给时择一」是刻意不做的:择一意味着调用方可以发一个自己也不知道会被读哪个
+ * 主键的请求,而两边的结果形状不同——那种静默选择只会在出事之后才被发现。
+ * 400 是两个都错,比猜一个更好。
+ *
+ * @param {{memoryId?: string|null, sessionId?: string|null, limit?: string|null}} params
+ * @returns {{ok: true, selector: {mode: "memory"|"session", memoryId?: string,
+ * sessionId?: string, limit: number}} | {ok: false, status: 400, error: string}}
+ */
+export function parseProvenanceSelector({ memoryId, sessionId, limit } = {}) {
+ const mem = trimmed(memoryId);
+ const ses = trimmed(sessionId);
+ // 参数形状排在功能开关之前:请求本身不成立时报「功能关了」是误导。
+ if (mem && ses) return { ok: false, status: 400, error: "ambiguous-id" };
+ if (!mem && !ses) return { ok: false, status: 400, error: "missing-id" };
+ const parsedLimit = parseProvenanceLimit(limit);
+ if (!parsedLimit.ok) return parsedLimit;
+ return {
+ ok: true,
+ selector: mem
+ ? { mode: "memory", memoryId: mem, limit: parsedLimit.limit }
+ : { mode: "session", sessionId: ses, limit: parsedLimit.limit }
+ };
+}
+
+/**
+ * 组装答复:一个窗口一项(区间 + 区间内原文行,按 seq 升序)。
*
* 顺序敏感(阶梯全貌见文件头):409 功能关了 → 503 装配没接线 → 读。
* 503 要赢过空结果——装配没接线时报「这条记忆没有来源」是误导,与 reclaimReply 的
* maintenance-unavailable 同构;而 409 要赢过 503——「用户关了」比「装配坏了」更
* 具体,把前者报成后者会让人去查一个没坏的部署。
*
- * @param {{service: object, id: string, config?: object|null, warn?: Function}} args
+ * `truncated` 是两种模式共有的读数(#524):撞上 limit 时把「还有更多」说出来。
+ * 少了它,被截断的答复与「本来就只有这么多」逐字节相同,调用方会把半份转录
+ * 当成全部——那正是这张脸最容易骗人的地方。
+ *
+ * @param {{service: object, selector: object, config?: object|null, warn?: Function}} args
* config 缺省即关(fail-closed),见 isDistillRecordsEnabled。
* @returns {{status: number, body: object}}
*/
-export function provenanceReply({ service, id, config = null, warn = null }) {
+export function provenanceReply({ service, selector, config = null, warn = null }) {
if (!isDistillRecordsEnabled(config)) {
return { status: 409, body: { error: DISTILL_RECORDS_DISABLED } };
}
- if (typeof service?.provenanceFor !== "function" || typeof service?.distillRecordsFor !== "function") {
+ const mode = selector?.mode === "session" ? "session" : "memory";
+ if (REQUIRED_METHODS[mode].some((name) => typeof service?.[name] !== "function")) {
return { status: 503, body: { error: PROVENANCE_UNAVAILABLE } };
}
+ const limit = selector?.limit > 0 ? selector.limit : PROVENANCE_DEFAULT_WINDOWS;
try {
- const links = service.provenanceFor(id);
- const windows = links.map((link) => ({
+ // session 侧多取一行判截断(store 侧只照 limit 取,不替调用方判断「还有没有」;
+ // memory 侧 provenanceFor 本来就全量返回,按长度判即可)。
+ const rows = mode === "session"
+ ? 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,
- // 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的
- // 语义),面板据此显示「原文已过期」而非「没有来源」。
+ 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)
}));
- return { status: 200, body: { memoryId: id, windows } };
+ const body = mode === "session"
+ ? { sessionId: selector.sessionId, windows, truncated }
+ : { memoryId: selector.memoryId, windows, truncated };
+ return { status: 200, body };
} catch (error) {
warn?.(provenanceWarnMessage(error));
return { status: 500, body: { error: "internal" } };
diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js
index e204bdad..9aa887a3 100644
--- a/dsh-mneme/src/service.js
+++ b/dsh-mneme/src/service.js
@@ -2648,6 +2648,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
// #421 读出口:按 (session, seq 区间) 取蒸馏原文。与 provenanceFor 成对——
// 一个给区间、一个给区间里的原文,合起来才是「这条记忆从哪几句蒸出来的」。
distillRecordsFor: (sessionId, seqStart, seqEnd) => store.distillRecordsFor(sessionId, seqStart, seqEnd),
+ // #524 方向 2:按会话列出它的蒸馏窗口。与 provenanceFor 同一张表、另一条索引
+ // (idx_memory_provenance_session),所以也是纯 passthrough——读的是 #399 早就
+ // 落好的事实,不产生任何写入。
+ distillWindowsFor: (sessionId, limit) => store.distillWindowsFor(sessionId, limit),
enqueue,
setDreamHook(fn) { dreamHook = fn; },
setSleepHook(fn) { sleepHook = fn; },
diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js
index 41d19927..608573ba 100644
--- a/dsh-mneme/src/store.js
+++ b/dsh-mneme/src/store.js
@@ -2779,6 +2779,40 @@ export function createStore(path) {
).all(sessionId, seqStart, seqEnd);
}
+ /**
+ * 一个会话产出的蒸馏窗口(#524 方向 2 的存储侧)。
+ *
+ * #421 把溯源读出口拆成两个方向:方向 1(记忆 → 窗口)由 #509 落地,走
+ * provenanceFor;这是方向 2(会话 → 窗口)。两个方向读的是同一张表,只是索引不
+ * 同(idx_memory_provenance_session 就是为这一条建的,早已存在——所以这不是
+ * schema 变更)。
+ *
+ * **必须带 limit**:一个会话可能有几十个窗口,每个窗口接着就是整段逐字原文,
+ * 不限量等于「按会话导出全量转录」(#524 第 2 条前置)。上限的口径在协议层
+ * (provenance-http.js 的 PROVENANCE_MAX_WINDOWS),这一层只照参数取。
+ *
+ * 一行 = 一个窗口(同一 (session, seq_start, seq_end) 的多条链接 GROUP BY 成一行)
+ * ——一条蒸馏窗口通常产出多条记忆,逐链接返回会让「这个会话蒸了几次」这个唯一
+ * 有意义的计数被重复行淹没。link_count 说明该窗口派生出几条记忆,是按窗口看的
+ * 第二维;**刻意不回 memory_id 列表**:方向 1 已经是「记忆 → 窗口」,把 id 也塞进
+ * 这一侧会让两个方向不再正交(同一份映射有两条读法就得有两处维护),而真要逐条
+ * 查仍是按方向 1 走。
+ *
+ * ORDER BY seq_start:seq 是事件序号,就是会话内的先后;窗口按它排才是「这个会话
+ * 按时间蒸了哪几段」,按 created_at 排会把同一批重跑的行打散。
+ */
+ function distillWindowsFor(sessionId, limit) {
+ return db.prepare(
+ `SELECT session_id, seq_start, seq_end, MIN(created_at) AS created_at,
+ COUNT(*) AS link_count
+ FROM memory_provenance
+ WHERE session_id = ?
+ GROUP BY session_id, seq_start, seq_end
+ ORDER BY seq_start, seq_end
+ LIMIT ?`
+ ).all(sessionId, limit);
+ }
+
// --- 存储生命周期:无损回收(#275 第一批)--------------------------------
//
// 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
@@ -3868,6 +3902,7 @@ export function createStore(path) {
saveMemoryProvenance,
provenanceFor,
distillRecordsFor,
+ distillWindowsFor,
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
dreamRunInputStats,
clearDreamRunInputs,
diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js
index 8d2d3d39..97c9f525 100644
--- a/dsh-mneme/test/api.test.js
+++ b/dsh-mneme/test/api.test.js
@@ -2486,3 +2486,70 @@ test("GET /api/dsh-mneme/memory/provenance:lightMode 预设把关掉 → 409
assert.equal(res.statusCode, 409);
assert.equal(JSON.parse(res.body).error, "distill-records-disabled");
});
+
+// #524 方向 2:按会话列窗口(store 侧 GROUP BY 成一行一窗口)。这条用例盯三件事:
+// 同一窗口派生的多条记忆不许变成多行、区间按 seq_start 排、答复用 sessionId 认领。
+test("GET /api/dsh-mneme/memory/provenance?sessionId= 列出该会话的蒸馏窗口(#524)", async () => {
+ const { routes, service, store } = setup(null, "secret-token", RECORDS_ON);
+ service.saveWithDedupe({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 });
+ service.saveWithDedupe({ type: "decision", title: "备选", content: "先用 JSON 文件顶着", importance: 3 });
+ const [m1, m2] = service.all();
+ // 两个窗口:后一个 seq 更小,用来验证答复按 seq_start 排而不是按写入序。
+ store.saveMemoryProvenance({ memoryId: m1.id, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
+ store.saveMemoryProvenance({ memoryId: m2.id, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
+ store.saveMemoryProvenance({ memoryId: m1.id, sessionId: "s1", seqStart: 1, seqEnd: 3, createdAt: "t1" });
+ // 另一个会话的窗口不该出现。
+ store.saveMemoryProvenance({ memoryId: m1.id, sessionId: "s2", seqStart: 50, seqEnd: 51, createdAt: "t3" });
+
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
+ const res = new FakeRes();
+ const r = req("/api/dsh-mneme/memory/provenance?sessionId=s1");
+ r.headers = { authorization: "Bearer secret-token" };
+ await route.handler(r, res);
+ assert.equal(res.statusCode, 200);
+ const data = JSON.parse(res.body);
+ assert.equal(data.sessionId, "s1", "方向 2 的答复用 sessionId 认领,不冒充 memoryId");
+ assert.equal(data.memoryId, undefined, "两个方向的认领键不许同时出现");
+ assert.equal(data.truncated, false);
+ assert.deepEqual(data.windows.map((w) => [w.seq_start, w.seq_end]), [[1, 3], [10, 12]],
+ "同一 (seq_start, seq_end) 的两条记忆必须并成一行,且按 seq_start 升序");
+ assert.deepEqual(data.windows.map((w) => w.link_count), [1, 2],
+ "link_count 说明该窗口派生出几条记忆——这是一行一窗口之后唯一还剩的「多」的信息");
+});
+
+test("GET /api/dsh-mneme/memory/provenance:两个主键都给 → 400,不静默择一(#524)", async () => {
+ const { routes } = setup(null, "secret-token", RECORDS_ON);
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
+ const res = new FakeRes();
+ const r = req("/api/dsh-mneme/memory/provenance?id=m1&sessionId=s1");
+ r.headers = { authorization: "Bearer secret-token" };
+ await route.handler(r, res);
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "ambiguous-id");
+});
+
+test("GET /api/dsh-mneme/memory/provenance?sessionId=:limit 越界 → 400;撞上限时 truncated=true(#524)", async () => {
+ const { routes, service, store } = setup(null, "secret-token", RECORDS_ON);
+ service.saveWithDedupe({ type: "decision", title: "核心决策", content: "x", importance: 5 });
+ const memory = service.all()[0];
+ for (const n of [1, 2, 3]) {
+ store.saveMemoryProvenance({ memoryId: memory.id, sessionId: "s1", seqStart: n, seqEnd: n, createdAt: "t" });
+ }
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
+
+ const bad = new FakeRes();
+ const badReq = req("/api/dsh-mneme/memory/provenance?sessionId=s1&limit=0");
+ badReq.headers = { authorization: "Bearer secret-token" };
+ await route.handler(badReq, bad);
+ assert.equal(bad.statusCode, 400);
+ assert.equal(JSON.parse(bad.body).error, "invalid-limit");
+
+ const ok = new FakeRes();
+ const okReq = req("/api/dsh-mneme/memory/provenance?sessionId=s1&limit=2");
+ okReq.headers = { authorization: "Bearer secret-token" };
+ await route.handler(okReq, ok);
+ assert.equal(ok.statusCode, 200);
+ const data = JSON.parse(ok.body);
+ assert.equal(data.windows.length, 2);
+ assert.equal(data.truncated, true, "还有第三个窗口没回——必须说出来,否则半份转录会被当成全部");
+});
diff --git a/dsh-mneme/test/provenance-http.test.js b/dsh-mneme/test/provenance-http.test.js
index 5f8f61dc..a75b5047 100644
--- a/dsh-mneme/test/provenance-http.test.js
+++ b/dsh-mneme/test/provenance-http.test.js
@@ -16,12 +16,19 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
DISTILL_RECORDS_DISABLED,
+ PROVENANCE_DEFAULT_WINDOWS,
+ PROVENANCE_MAX_WINDOWS,
PROVENANCE_UNAVAILABLE,
isDistillRecordsEnabled,
- parseProvenanceQuery,
+ parseProvenanceLimit,
+ parseProvenanceSelector,
provenanceReply
} from "../src/provenance-http.js";
+// 方向 1 的选择器:绝大多数用例只关心「读出去的那一侧」,方向本身用这个常量固定。
+const MEM = { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS };
+const SES = { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS };
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
// 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须
@@ -31,19 +38,73 @@ const RECORDS_ON = { distillRecords: { enabled: true } };
function fakeService(over = {}) {
return {
provenanceFor: (id) => over.links ?? [],
+ // #524 方向 2 的存储读法:按会话列窗口(store 侧 GROUP BY 成一行一窗口)。
+ distillWindowsFor: (sessionId, limit) => over.windows ?? [],
distillRecordsFor: (sessionId, from, to) => over.records ?? [],
...over.service
};
}
-test("parseProvenanceQuery:空/缺失 id 报 400,非空原样通过", () => {
- for (const raw of [null, undefined, "", " "]) {
- const r = parseProvenanceQuery(raw);
- assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
+// --- 入参:两个互斥主键 + limit(#524)---------------------------------------
+// 方向 2 复用同一条路径,于是「哪个主键说了算」必须是个**显式**判断:都缺是请求
+// 不成立(400 missing-id),都给也要 400——择一意味着调用方发一个自己也不知道会
+// 读哪个主键的请求,而两个方向的结果形状不同(memoryId / sessionId 各有自己的键)。
+
+test("parseProvenanceSelector:两个主键都缺 → 400 missing-id", () => {
+ for (const params of [
+ {}, { memoryId: null, sessionId: null }, { memoryId: "", sessionId: "" },
+ { memoryId: " ", sessionId: "\t" }
+ ]) {
+ const r = parseProvenanceSelector(params);
+ assert.equal(r.ok, false, `${JSON.stringify(params)} 不该通过`);
assert.equal(r.status, 400);
assert.equal(r.error, "missing-id");
}
- assert.deepEqual(parseProvenanceQuery(" m1 "), { ok: true, id: "m1" }, "id 两端空白要裁掉");
+});
+
+test("parseProvenanceSelector:两个主键都给 → 400 ambiguous-id(不静默择一)", () => {
+ const r = parseProvenanceSelector({ memoryId: "m1", sessionId: "s1" });
+ assert.equal(r.ok, false);
+ assert.equal(r.status, 400);
+ assert.equal(r.error, "ambiguous-id");
+});
+
+test("parseProvenanceSelector:两端空白裁掉,方向按哪个主键非空确定,limit 缺省 20", () => {
+ assert.deepEqual(parseProvenanceSelector({ memoryId: " m1 " }), {
+ ok: true,
+ selector: { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS }
+ });
+ assert.deepEqual(parseProvenanceSelector({ sessionId: " s1 ", memoryId: " " }), {
+ ok: true,
+ selector: { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS }
+ }, "另一侧是空白串时按缺省处理,不算「都给」");
+});
+
+test("parseProvenanceLimit:只收十进制整数字面量,畸形与越界都算 400", () => {
+ assert.equal(parseProvenanceLimit(null).limit, PROVENANCE_DEFAULT_WINDOWS, "不带就是缺省");
+ assert.equal(parseProvenanceLimit("1").limit, 1);
+ assert.equal(parseProvenanceLimit(String(PROVENANCE_MAX_WINDOWS)).limit, PROVENANCE_MAX_WINDOWS);
+ // 两端空白是**裁掉**而不是判畸形——与 id 的处理保持一致(" 12 " 就是 12)。
+ // 但裁完必须是纯十进制字面量。
+ assert.equal(parseProvenanceLimit(" 12 ").limit, 12, "两端空白裁掉,与 id 一致");
+ // 下面这些如果走 Number(),"" / " " 会化成 0 个窗口,十六进制与指数写法也会被
+ // 静默接受——一个畸形输入拿到最坏解释。所以这里逐个钉住。
+ for (const raw of [
+ "", " ", "0", "-1", "1.5", "1e3", "0x10", "abc", "+1", "12abc",
+ String(PROVENANCE_MAX_WINDOWS + 1), "99999999999999999999"
+ ]) {
+ const r = parseProvenanceLimit(raw);
+ assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
+ assert.equal(r.status, 400);
+ assert.equal(r.error, "invalid-limit");
+ }
+});
+
+test("parseProvenanceSelector:limit 畸形时连着主键一起 400(入参形状先于功能开关)", () => {
+ const r = parseProvenanceSelector({ sessionId: "s1", limit: "abc" });
+ assert.equal(r.ok, false);
+ assert.equal(r.status, 400);
+ assert.equal(r.error, "invalid-limit");
});
test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => {
@@ -55,7 +116,7 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
return [{ seq: 12 }, { seq: 10 }, { seq: 11 }];
}
};
- const r = provenanceReply({ service, id: "m1", config: RECORDS_ON });
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
assert.equal(r.status, 200);
assert.equal(r.body.memoryId, "m1");
assert.equal(r.body.windows.length, 1);
@@ -69,14 +130,14 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
});
test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
- const r = provenanceReply({ service: fakeService(), id: "m1", config: RECORDS_ON });
+ const r = provenanceReply({ service: fakeService(), selector: MEM, config: RECORDS_ON });
assert.equal(r.status, 200);
- assert.deepEqual(r.body, { memoryId: "m1", windows: [] });
+ assert.deepEqual(r.body, { memoryId: "m1", windows: [], truncated: false });
});
test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
// headless / 旧装配:service 上没有这两个方法。
- const r = provenanceReply({ service: {}, id: "m1", config: RECORDS_ON });
+ const r = provenanceReply({ service: {}, selector: MEM, config: RECORDS_ON });
assert.equal(r.status, 503);
assert.equal(r.body.error, PROVENANCE_UNAVAILABLE);
});
@@ -87,7 +148,7 @@ test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用
provenanceFor: () => { throw new Error("db locked"); },
distillRecordsFor: () => []
};
- const r = provenanceReply({ service, id: "m1", config: RECORDS_ON, warn: (m) => warnings.push(m) });
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON, warn: (m) => warnings.push(m) });
assert.equal(r.status, 500);
assert.equal(r.body.error, "internal");
assert.equal(warnings.length, 1, "失败要留痕");
@@ -114,7 +175,7 @@ test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail
test("provenanceReply:开关没开 → 409 distill-records-disabled,一个键都不多带", () => {
// config 整个缺失(旧调用方直连、daemon 最小 cfg)也走这一格:默认方向必须是关。
for (const cfg of [null, undefined, {}, { distillRecords: { enabled: false } }]) {
- const r = provenanceReply({ service: fakeService(), id: "m1", config: cfg });
+ const r = provenanceReply({ service: fakeService(), selector: MEM, config: cfg });
assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`);
assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED },
"body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」");
@@ -123,7 +184,7 @@ test("provenanceReply:开关没开 → 409 distill-records-disabled,一个
test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => {
// 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。
- const r = provenanceReply({ service: {}, id: "m1", config: { distillRecords: { enabled: false } } });
+ const r = provenanceReply({ service: {}, selector: MEM, config: { distillRecords: { enabled: false } } });
assert.equal(r.status, 409);
assert.equal(r.body.error, DISTILL_RECORDS_DISABLED);
});
@@ -135,20 +196,100 @@ test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)"
provenanceFor: () => { touched++; return []; },
distillRecordsFor: () => { touched++; return []; }
} });
- provenanceReply({ service, id: "m1", config: null });
+ provenanceReply({ service, selector: MEM, config: null });
assert.equal(touched, 0);
});
test("provenanceReply:开关开着才读得到——200 与 409 是同一把门的两个方向", () => {
const service = fakeService({ links: [{ session_id: "s1", seq_start: 1, seq_end: 1, created_at: "t" }], records: [{ seq: 1 }] });
- const on = provenanceReply({ service, id: "m1", config: RECORDS_ON });
+ const on = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
assert.equal(on.status, 200);
assert.equal(on.body.windows.length, 1);
- const off = provenanceReply({ service, id: "m1", config: { distillRecords: { enabled: false } } });
+ const off = provenanceReply({ service, selector: MEM, config: { distillRecords: { enabled: false } } });
assert.equal(off.status, 409);
assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED });
});
+// --- 方向 2:会话 → 窗口(#524)--------------------------------------------
+
+test("provenanceReply(session 方向):一行一窗口,原文按区间取,答复带 sessionId", () => {
+ const asked = [];
+ const service = {
+ distillWindowsFor: (sid, limit) => {
+ asked.push([sid, limit]);
+ return [
+ { session_id: "s1", seq_start: 5, seq_end: 9, created_at: "t2", link_count: 2 },
+ { session_id: "s1", seq_start: 1, seq_end: 4, created_at: "t1", link_count: 1 }
+ ];
+ },
+ distillRecordsFor: (sid, from, to) => [{ seq: from }, { seq: to }]
+ };
+ const r = provenanceReply({ service, selector: SES, config: RECORDS_ON });
+ assert.equal(r.status, 200);
+ assert.equal(r.body.sessionId, "s1", "session 方向的答复用 sessionId 认领,不冒充 memoryId");
+ assert.equal(r.body.truncated, false);
+ assert.deepEqual(r.body.windows.map((w) => [w.seq_start, w.seq_end]), [[5, 9], [1, 4]],
+ "次序由 store 的 ORDER BY seq_start 保证,答复层不改序");
+ assert.deepEqual(r.body.windows[0].records.map((x) => x.seq), [5, 9], "每个窗口装自己区间的原文");
+ assert.equal(r.body.windows[0].link_count, 2, "同一窗口派生了 2 条记忆——这个计数是「按窗口看」的第二维");
+ // 多取一行判截断:store 侧只照 limit 取,不替调用方判断「还有没有」。
+ assert.deepEqual(asked, [["s1", PROVENANCE_DEFAULT_WINDOWS + 1]]);
+});
+
+test("provenanceReply:撞上 limit 时 truncated=true,且多出来的那条不许漏出去", () => {
+ // 少了这个读数,被截断的答复与「本来就只有这么多」逐字节相同——调用方会把半份
+ // 转录当成全部,这正是这张脸最容易骗人的地方。
+ const service = {
+ distillWindowsFor: () => [1, 2, 3].map((n) => ({
+ session_id: "s1", seq_start: n, seq_end: n, created_at: "t", link_count: 1
+ })),
+ distillRecordsFor: () => []
+ };
+ const r = provenanceReply({ service, selector: { ...SES, limit: 2 }, config: RECORDS_ON });
+ assert.equal(r.status, 200);
+ assert.equal(r.body.truncated, true);
+ assert.equal(r.body.windows.length, 2, "limit=2 就只许回 2 个窗口,探针那一条不能混进答复");
+});
+
+test("provenanceReply:memory 方向也带 truncated(两个方向共一个读数)", () => {
+ const service = {
+ provenanceFor: () => [1, 2].map((n) => ({
+ session_id: "s1", seq_start: n, seq_end: n, created_at: "t"
+ })),
+ distillRecordsFor: () => []
+ };
+ const r = provenanceReply({ service, selector: { ...MEM, limit: 1 }, config: RECORDS_ON });
+ assert.equal(r.status, 200);
+ assert.equal(r.body.truncated, true);
+ assert.equal(r.body.windows.length, 1);
+ assert.equal(r.body.memoryId, "m1");
+});
+
+test("provenanceReply:503 按方向判——只缺 session 那侧的方法时,memory 查询照旧 200", () => {
+ // 把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署。
+ const onlyMemory = {
+ provenanceFor: () => [],
+ distillRecordsFor: () => []
+ };
+ const ses = provenanceReply({ service: onlyMemory, selector: SES, config: RECORDS_ON });
+ assert.equal(ses.status, 503);
+ assert.equal(ses.body.error, PROVENANCE_UNAVAILABLE);
+ const mem = provenanceReply({ service: onlyMemory, selector: MEM, config: RECORDS_ON });
+ assert.equal(mem.status, 200, "缺的只是方向 2 的方法,方向 1 不该跟着坏");
+});
+
+test("provenanceReply:开关关着时 session 方向也 409,且一次 SQL 都不发", () => {
+ let touched = 0;
+ const service = {
+ distillWindowsFor: () => { touched++; return []; },
+ distillRecordsFor: () => { touched++; return []; }
+ };
+ const r = provenanceReply({ service, selector: SES, config: { distillRecords: { enabled: false } } });
+ assert.equal(r.status, 409);
+ assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED }, "方向不同,门是同一把");
+ assert.equal(touched, 0);
+});
+
test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => {
// #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。
for (const name of ["api.js", "api-standalone.js"]) {
@@ -162,7 +303,7 @@ test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,
assert.ok(!src.includes("distillRecords?.enabled === true"),
`src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`);
// 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。
- assert.match(src, /provenanceReply\(\{ service, id: parsed\.id, config,/,
+ assert.match(src, /provenanceReply\(\{ service, selector: parsed\.selector, config,/,
`src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`);
}
});
diff --git a/dsh-mneme/test/standalone-api.test.js b/dsh-mneme/test/standalone-api.test.js
index 17f78376..b33d6ea3 100644
--- a/dsh-mneme/test/standalone-api.test.js
+++ b/dsh-mneme/test/standalone-api.test.js
@@ -1268,3 +1268,53 @@ test("GET /memory/provenance: 409 beats 503 — feature off is more specific tha
close();
}
});
+
+// --- #524 方向 2:?sessionId= 列该会话的蒸馏窗口 -----------------------
+// 与宿主内 API 是同一份口径(共用 provenance-http.js),这里只钉两张皮的那条差
+// 异之外的部分真的走通了:一行一窗口、按 seq_start 排、truncated 说出来。
+test("GET /memory/provenance?sessionId= lists a session's distill windows", async () => {
+ const { base, auth, store, close } = await setup({ config: RECORDS_ON });
+ try {
+ const a = await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite", importance: 5 });
+ const b = await saveMemory(base, auth, { type: "decision", title: "备选", content: "JSON 文件", importance: 3 });
+ const idA = a.memory?.id ?? a.id;
+ const idB = b.memory?.id ?? b.id;
+ // 同一窗口派生两条记忆:答复里必须并成一行(link_count=2),不是两行。
+ store.saveMemoryProvenance({ memoryId: idA, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
+ store.saveMemoryProvenance({ memoryId: idB, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
+ store.saveMemoryProvenance({ memoryId: idA, sessionId: "s1", seqStart: 1, seqEnd: 3, createdAt: "t1" });
+ store.saveMemoryProvenance({ memoryId: idA, sessionId: "s2", seqStart: 50, seqEnd: 51, createdAt: "t3" });
+
+ const res = await fetch(`${base}/memory/provenance?sessionId=s1`, { headers: auth });
+ assert.equal(res.status, 200);
+ const body = await res.json();
+ assert.equal(body.sessionId, "s1");
+ assert.equal(body.memoryId, undefined, "两个方向的认领键不许同时出现");
+ assert.equal(body.truncated, false);
+ assert.deepEqual(body.windows.map((w) => [w.seq_start, w.seq_end]), [[1, 3], [10, 12]],
+ "同一 (seq_start, seq_end) 并成一行,按 seq_start 升序;别的会话的窗口不出现");
+ assert.deepEqual(body.windows.map((w) => w.link_count), [1, 2]);
+ } finally {
+ close();
+ }
+});
+
+test("GET /memory/provenance: both selectors at once is 400, and limit is bounded", async () => {
+ const { base, auth, close } = await setup({ config: RECORDS_ON });
+ try {
+ const both = await fetch(`${base}/memory/provenance?id=m1&sessionId=s1`, { headers: auth });
+ assert.equal(both.status, 400, "两个主键都给不许静默择一");
+ assert.equal((await both.json()).error, "ambiguous-id");
+
+ for (const bad of ["0", "-1", "1.5", "abc", "1e3", "101"]) {
+ const res = await fetch(`${base}/memory/provenance?sessionId=s1&limit=${bad}`, { headers: auth });
+ assert.equal(res.status, 400, `limit=${bad} 必须 400`);
+ assert.equal((await res.json()).error, "invalid-limit");
+ }
+
+ const missing = await fetch(`${base}/memory/provenance`, { headers: auth });
+ assert.equal(missing.status, 400, "两个主键都缺仍是 400 missing-id");
+ } finally {
+ close();
+ }
+});