diff --git a/README.md b/README.md index 0da39e21..fd74a305 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 # 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 @@ [![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-2577%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-2623%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,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(); + } +});