diff --git a/SECURITY.md b/SECURITY.md index b6ef93e0..bfed0a77 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,7 +51,7 @@ The plugin can optionally start its own HTTP server (`externalApiEnabled`, defau - **Loopback by default**: the server binds `127.0.0.1:8790` (`externalApiHost` / `externalApiPort`, or persisted panel settings). Rebinding to a non-loopback address exposes the store to your network and is an explicit operator decision. - **Token authentication**: every route except `GET /health` requires `Authorization: Bearer `; the token is auto-generated on first use, persisted in the plugin settings store and verified with `timingSafeEqual`. To rotate it, clear the persisted value (settings kv key `external_api`) and let the next boot mint a new one — there is no rotate endpoint yet. -- **Surface**: memories read/write (`GET/POST /memories`, `GET/PUT/DELETE /memories/:id`, `GET /search`, `GET /context`, `GET /status`), plus profile/rules **reads** (`GET /profile`, `GET /rules`), `POST /bootstrap` and `POST /maintenance/reclaim` (the host-side twin `/api/dsh-mneme/maintenance/reclaim` also answers `GET` for dry-run previews). Writes go through the same dedupe + quality-filter path as in-conversation saves. The full route list lives in [docs/DAEMON.md](dsh-mneme/docs/DAEMON.md). +- **Surface**: memories read/write (`GET/POST /memories`, `GET/PUT/DELETE /memories/:id`, `GET /search`, `GET /context`, `GET /status`), profile/rules **reads** (`GET /profile`, `GET /rules`), `GET /memory/provenance`, `POST /bootstrap` and `POST /maintenance/reclaim` (the host-side twin `/api/dsh-mneme/maintenance/reclaim` also answers `GET` for dry-run previews). Writes go through the same dedupe + quality-filter path as in-conversation saves. The full route list lives in [docs/DAEMON.md](dsh-mneme/docs/DAEMON.md). - **CLI**: `bin/cli.mjs` is a thin client over this API and stores its credentials in `~/.dsh-mneme/cli.json` - treat that file like any credential file. --- @@ -154,7 +154,7 @@ The token is a lock on the **write** surface, not a wall around the whole API. - **The real boundary is loopback + the DSH host listener.** The host-side routes (`/api/dsh-mneme/*`) are mounted on the DSH port, which binds `127.0.0.1` and is fenced by the host against DNS rebinding and cross-site browser requests. That fence does **not** establish identity: any local process, and any page running in the host's own origin, is inside the perimeter. - **Authentication is opt-in and fail-open.** `apiToken` defaults to empty; while it is empty, `isAuthorized()` returns true for every request — reads *and* writes. Setting a token gates the write routes and the sensitive reads; it does not gate plain reads. - **Open reads (no token even when one is configured)**: `GET /`, `/info`, `/version-check`, `/list`, `/recall-stats`, `/search`, `/semantic`, `/semantic/llm-audit`, `/semantic/llm-audit/stats`, `/semantic/graph/*`, `/entities`, `/memories/entities`, `/dream-status`, `/inject-status`, `/inject-preview`, `/pipeline`, `/candidates`, `/llm-providers`, plus the `GET`/`HEAD` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge`, and of the two share-receive routes `/receive` and `/receive/undo`. Treat them as readable by anything local. `/receive` and `/receive/undo` belong on this list even though they write: under the `token-write` policy, `needsToken()` exempts `GET`/`HEAD` from the token, so the only thing genuinely open on those two paths is a `405` — no read branch exists. This section mirrors the policy declarations rather than anyone's intent, which is why a write route shows up under "open reads"; `dsh-mneme/test/security-doc.test.js` compares both lists against the routes actually mounted, in both directions. -- **Gated routes (when a token is configured)**: every write — `/create`, `/update`, `/delete`, `/import`, `/vector-config`, `/vector-reindex`, `/runtime/provision`, `/maintenance/reclaim`, `/test-model`, `/conflicts/resolve`, `/profile/extract` (POST-only: one host-LLM call that returns a profile **draft**; other methods get 405 before any side effect), `/receive`, `/receive/undo` (the two share-receive routes write into *this* machine's library, which is why they are `token-write`), and the non-`GET` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge` — plus five reads: `/export`, `/external-api`, `/conflicts`, `/health`, `/share` (the share pack is a whole-library read delivered over POST, and it answers 405 to GET — no open read path exists). +- **Gated routes (when a token is configured)**: every write — `/create`, `/update`, `/delete`, `/import`, `/vector-config`, `/vector-reindex`, `/runtime/provision`, `/maintenance/reclaim`, `/test-model`, `/conflicts/resolve`, `/profile/extract` (POST-only: one host-LLM call that returns a profile **draft**; other methods get 405 before any side effect), `/receive`, `/receive/undo` (the two share-receive routes write into *this* machine's library, which is why they are `token-write`), and the non-`GET` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge` — plus six reads: `/export`, `/external-api`, `/conflicts`, `/health`, `/share`, `/memory/provenance`. `/share` is gated because the share pack is a whole-library read delivered over POST, and it answers 405 to GET — no open read path exists. `/memory/provenance` is gated because it returns the **verbatim raw text** of distilled conversation windows (opt-in via `distillRecords.enabled`); chained with the open list route it would otherwise let any local process enumerate ids and then pull that text back, which reaches the same result as `/export` in a different shape. - **The standalone data plane** (`127.0.0.1:8790`) is the opposite shape: every route except `GET /health` requires its own Bearer token. Pointing `externalApiHost` at a non-loopback address therefore turns it into a plaintext, token-only HTTP surface — an explicit operator decision. - **Consequence**: if you expose the plugin beyond loopback (reverse proxy, non-loopback bind), set `apiToken` *and* check that the plain reads above are acceptable to expose — they are not covered by it. - **How this list stays true**: every route declares its own `policy` (`open` / `token` / `token-write`) at registration, and `createRouteRegistrar` throws when a route has none or reuses a path. A new route that forgets its classification **fails to boot** instead of silently opening. The two lists above are compared against the routes actually mounted by `dsh-mneme/test/security-doc.test.js` in both directions, so adding a route without updating this section fails CI rather than quietly drifting — the matrix is derived from code, not from anyone's memory. @@ -432,7 +432,7 @@ This project is licensed under the **MIT License**. See [LICENSE](https://github - **默认只绑回环**:服务绑 `127.0.0.1:8790`(`externalApiHost` / `externalApiPort`,或面板里持久化的设置)。改绑非回环地址等于把记忆库暴露给你的网络,这是运营者的显式决定。 - **Token 鉴权**:除 `GET /health` 外每条路由都要 `Authorization: Bearer `;token 首次使用时自动生成、持久化在插件设置库、用 `timingSafeEqual` 校验。要轮换就清掉持久化的值(设置 kv 键 `external_api`),下次启动会重新铸造——目前没有 rotate 端点。 -- **路由面**:记忆读写(`GET/POST /memories`、`GET/PUT/DELETE /memories/:id`、`GET /search`、`GET /context`、`GET /status`),profile/rules 的**读**(`GET /profile`、`GET /rules`),以及 `POST /bootstrap` 与 `POST /maintenance/reclaim`(宿主侧孪生路由 `/api/dsh-mneme/maintenance/reclaim` 另外还答 `GET`,用于 dry-run 预览)。写入走与对话内保存相同的去重 + 质量过滤路径。完整路由表见 [docs/DAEMON.md](dsh-mneme/docs/DAEMON.md)。 +- **路由面**:记忆读写(`GET/POST /memories`、`GET/PUT/DELETE /memories/:id`、`GET /search`、`GET /context`、`GET /status`),profile/rules 的**读**(`GET /profile`、`GET /rules`),`GET /memory/provenance`,以及 `POST /bootstrap` 与 `POST /maintenance/reclaim`(宿主侧孪生路由 `/api/dsh-mneme/maintenance/reclaim` 另外还答 `GET`,用于 dry-run 预览)。写入走与对话内保存相同的去重 + 质量过滤路径。完整路由表见 [docs/DAEMON.md](dsh-mneme/docs/DAEMON.md)。 - **CLI**:`bin/cli.mjs` 是这个 API 上的薄客户端,凭据存在 `~/.dsh-mneme/cli.json`——请把该文件当凭据文件对待。 --- @@ -535,7 +535,7 @@ Token 是**写**面上的锁,不是把整个 API 围起来的墙。 - **真正的边界是回环 + 宿主监听器。** 宿主侧路由(`/api/dsh-mneme/*`)挂在 DSH 端口上,该端口绑 `127.0.0.1`,并由宿主挡掉 DNS rebinding 与跨站浏览器请求。这道围栏**不建立身份**:任何本地进程、以及任何跑在宿主同源页面里的脚本,都在这道围栏之内。 - **鉴权是 opt-in 且 fail-open。** `apiToken` 默认为空;为空期间 `isAuthorized()` 对每个请求都返回 true——读和写都放行。配了 token 之后,它守的是写路由与敏感读,不守普通读。 - **开放读(即使配了 token 也不需要)**:`GET /`、`/info`、`/version-check`、`/list`、`/recall-stats`、`/search`、`/semantic`、`/semantic/llm-audit`、`/semantic/llm-audit/stats`、`/semantic/graph/*`、`/entities`、`/memories/entities`、`/dream-status`、`/inject-status`、`/inject-preview`、`/pipeline`、`/candidates`、`/llm-providers`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge`、`/receive`、`/receive/undo` 的 `GET`/`HEAD` 分支。把它们当作「本地任何东西都能读」。`/receive`、`/receive/undo` 明明是写路由也出现在这一串里,这不是笔误:`token-write` 下 `needsToken()` 对 `GET`/`HEAD` 免 token,所以这两条路径上真正「开放」的只有一个 `405`,压根没有可读分支。这一节照 policy 声明抄、不照意图写,`dsh-mneme/test/security-doc.test.js` 会拿真实装配出来的路由双向比对这两行。 -- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`、`/profile/extract`(只收 POST:花一次宿主 LLM 产一段画像**草稿**,其它方法在产生任何副作用之前就 405)、`/receive`、`/receive/undo`(这两条写的是**本机自己的库**,所以归 `token-write`),以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加五条读:`/export`、`/external-api`、`/conflicts`、`/health`、`/share`(分享包是「用 POST 递出来的全库读取」,GET 一律 405,没有开放的读分支)。 +- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`、`/profile/extract`(只收 POST:花一次宿主 LLM 产一段画像**草稿**,其它方法在产生任何副作用之前就 405)、`/receive`、`/receive/undo`(这两条写的是**本机自己的库**,所以归 `token-write`),以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加六条读:`/export`、`/external-api`、`/conflicts`、`/health`、`/share`(分享包是「用 POST 递出来的全库读取」,GET 一律 405,没有开放的读分支)、`/memory/provenance`(它回的是蒸馏窗口的**逐字会话原文**,`distillRecords.enabled` 打开才有;否则把它和上面那个开放的列表路由串起来,任何本地进程都能先枚举 id、再按 id 把那些原文捞回去——可达结果与 `/export`(一次拿全库)同量级,只是换了形状)。 - **独立数据面**(`127.0.0.1:8790`)是反过来的形状:除 `GET /health` 外每条路由都要自己的 Bearer token。因此把 `externalApiHost` 指向非回环地址,就等于把它变成一个「明文、只靠 token」的 HTTP 面——这是运营者的显式决定。 - **推论**:如果你把插件暴露到回环之外(反代、绑非回环地址),请设 `apiToken`,**并**核对上面那串开放读是否也能被看到——它们不受 token 保护。 - **这张表怎么保持为真**:每条路由在注册时自己声明 `policy`(`open` / `token` / `token-write`),`createRouteRegistrar` 在路由没声明或复用同一路径时直接抛错。新路由忘了归类会**启动失败**,而不是静默开门。上面两张清单还会被 `dsh-mneme/test/security-doc.test.js` 拿真实装配出来的路由做双向比对——加了路由却不更新这一节,CI 会红,而不是悄悄漂走。这张矩阵来自代码,不来自谁的记忆。 @@ -760,5 +760,5 @@ dsh-mneme: --- -*Last updated: 2026-10-18* +*Last updated: 2026-10-23* *Policy version: 3.0* diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 164b4b11..fac099cc 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## 🆕 新增 + +- **记忆溯源有了读出口:`GET /api/dsh-mneme/memory/provenance?memoryId=`——「这条记忆是哪次蒸馏带进来的、原文里到底有没有那句话」第一次可直接查(#421;实现由 @z2Ace0107 在 #509 贡献)**:`distill_records` 此前只有写入路径,写进去就再没有读它的地方,所以「这条记忆来自哪个窗口」只能靠逐帧解压宿主会话日志去对,而 #399 的原始判断是「只显示条数、不返回原文,等于没解决」。面板与 Bearer 数据面共用同一份实现(`src/provenance-http.js`),按 #411 那条纪律只许剩鉴权与路径不同:解析、答复、错误码→状态各只有一份。状态码阶梯是 `401 → 400 缺 id → 409 distill-records-disabled → 503 provenance-unavailable → 200`,其中 **409 与 503 必须分开**——`src/index.js:589-591` 早就写明 503 只该出现在旧宿主/装配缺失,不该被读成「用户没开」;而这条端点**永远不出 404**:未知 id 给 200 + 空 windows,读端点不替调用方判断记忆是否存在(把「功能关了」写成 404,等于让代理与重试逻辑在唯一它读的通道上读成「这条记忆不存在」)。依附既有 `distillRecords.enabled`、不新增开关,判据 `isDistillRecordsEnabled` 只认严格的 `true`(其余一律算关),与写侧 `src/summarize.js:988` 逐字同源,并有一条静态锁钉住这件事。**语义是「关 = 不再经 HTTP 提供」,不是「关 = 删掉」**:保留期裁剪与写入在同一道门里,先开后关的库里历史原文仍在盘上(`dsh-mneme reclaim` 仍能清),这条不对称写进了 `docs/DISTILL.md` §10 与 `docs/STORAGE.md`,真正的修法另账。`dsh-mneme-serve` 那边把该键从面板持久化配置展开进 cfg——否则 daemon 会对**每个请求**谎报「功能关了」,包括用户明明开着、库里也有活跃原文的那些;只靠 bundle 打开、从没在面板点过的用户仍然宿主认、daemon 不认,这一格写进 `docs/DAEMON.md`。开关是**启动快照**:面板关掉要到下次重启才 409、写侧同样到重启才停,该键已在设置白名单与 `NESTED_FLAG_PATHS` 登记,界面带「待重启」。用例覆盖:400 缺 id 赢 409、409 赢 503、409 只带一个键、关着时已知/未知 id 同答(不当存在性预言机)、关着时一次 SQL 都不发、开关开着才读得到;既有多条直调用例原先三处装配全都拿不到 `enabled: true`——那是假绿灯,现已换成显式夹具。 + ## 🐛 修复 - **dream 的 merge 被 `dreamMergeGuard` 永久拦死:提示词还在催合并,46 轮零合并、每轮照付满载 token(#521)**:两个护栏从来没对齐过——`archive` 早就有逃生门(`reason` 命中 `ARCHIVE_RATIONALE_RE` 的「重复/过时」词表就放行长保留类型),`merge` 没有;而 `src/lang.js` 的巩固提示词【任务】第 1 条明确要求「识别主题相近的条目 → merge」,【硬性规则】里的长保留类型禁区**只对 archive 说过**。报告者的实测:46 轮巩固、`skipped` 决定 299 条里 280 条(93.6%)被这条护栏拦下且 `action` 全是 merge,整轮 `applied` 合计 375 条、**merge 恒为 0**;模型提的永远是同一簇 preference,全被拦 → 什么也没变 → `updated_at` 不动 → 下一轮同样条目还在 200 条窗口里 → 再提一次。单轮 `input_count` 46 轮全部 200(窗口饱和),同一条记忆 46 轮里被提出去合并 40 次。**模型没错,护栏也没错,两者从未对上。** 两条一起改:① 提示词(zh/en × consolidation + conflictFull 四处)补上 merge 的类型禁区,与 archive 那句同形——「长保留类型仅当确属重复或过时才可 merge,且 reason 必须写明;只是主题相近请输出 keep」;② `src/dream/decisions.js` 的 merge 护栏加逃生门,条件与 archive 护栏同一条词表且**要求所有 id 同类型**(跨类型合并本就另受 `allowCrossTypeMerge` 约束,逃生门不跟着放宽)。reason 只写「主题相近」不会命中词表——这是故意的:相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。**默认值不变**(`dreamMergeGuard` 仍默认 `true`),报告者也明确不请求关掉它——它默认开有理由(E8 实测 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉)。两条链路共用同一 `validateDecisions`(`src/dream.js:1244` 主链路 + `src/dream/sleep.js:393` sleep 链路),改一处两处都受益。回归测试 +3:真重复放行、只有理由不同类不放行、同类型但只写「主题相近」仍拦。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index e790a60c..70ed8d92 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -506,7 +506,7 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz" | `conflictFreezeMaxPending` | `100` | 冲突冻结队列最大挂起数(1-1000):超出后不再入队(防队列无限膨胀) | | `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) | -> 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)——**未配 `apiToken` 时读和写都开放**,真正的围栏是回环 + 宿主监听器的 Host/Origin 校验(它防 DNS rebinding 与跨站浏览器请求,但不建立身份)。如需防护(如局域网暴露),在配置中设置 `apiToken`:此时**写操作**(新增/更新/删除/导入/收包(`/receive`、`/receive/undo`——写的是**本机自己的库**,归 `token-write`,所以免 token 的 GET 在那两条路径上只会拿到 405)/画像/规则/命令/开关/开关计费类)与**敏感读**(`/export`、`/external-api`、`/vector-config`、`/vector-reindex`、`/maintenance/*`、`/runtime/provision`、`/share`)需携带 `Authorization: Bearer `(前端设置面板填同一个 token);只读的 `list` / `search` / `semantic` / `inject-preview` 等**仍然开放**,不要把它们当成被 token 保护的面。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。 +> 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)——**未配 `apiToken` 时读和写都开放**,真正的围栏是回环 + 宿主监听器的 Host/Origin 校验(它防 DNS rebinding 与跨站浏览器请求,但不建立身份)。如需防护(如局域网暴露),在配置中设置 `apiToken`:此时**写操作**(新增/更新/删除/导入/收包(`/receive`、`/receive/undo`——写的是**本机自己的库**,归 `token-write`,所以免 token 的 GET 在那两条路径上只会拿到 405)/画像/规则/命令/开关/开关计费类)与**敏感读**(`/export`、`/external-api`、`/vector-config`、`/vector-reindex`、`/maintenance/*`、`/runtime/provision`、`/share`、`/memory/provenance`(蒸馏窗口的逐条会话原文,另受 `distillRecords.enabled` 门控))需携带 `Authorization: Bearer `(前端设置面板填同一个 token);只读的 `list` / `search` / `semantic` / `inject-preview` 等**仍然开放**,不要把它们当成被 token 保护的面。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。 > > 安全模型全表(哪些路由免鉴权、为什么)见 [SECURITY.md](../SECURITY.md#5-security-model-what-actually-protects-what)。 @@ -532,6 +532,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` | `/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 入档 | diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 14e20865..e05e72c9 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -117,11 +117,13 @@ | 键 | 默认 | 作用 | 开启后果 / 冲突 | |---|---|---|---| -| `distillRecords.enabled` **light** | `false` | 蒸馏时顺带留存窗口原文 + 写记忆级溯源链接 | opt-in;关 = 一行不写、连收集都不做,行为与 #399 之前逐字节一致 | +| `distillRecords.enabled` **light** | `false` | 蒸馏时顺带留存窗口原文 + 写记忆级溯源链接;同时是溯源读出口(`GET /memory/provenance` 与宿主内 `/api/dsh-mneme/memory/provenance`)的总闸 | opt-in;关 = 一行不写、连收集都不做,行为与 #399 之前逐字节一致;读出口此时回 `409 {"error":"distill-records-disabled"}`(与「装配没接线」的 503 分开读数)。**关开关 ≠ 删掉已留存的原文**:只不再经 HTTP 提供,盘上的行要 `dsh-mneme reclaim` 或重新开启后的启动裁剪才走。开关是**启动快照**(与 `panel_mode` 同节奏),面板改完要到下次启动生效,面板会显示「待重启」 | | `distillRecords.retentionDays` | `90` | 原文保留天数(1–3650) | **只管启动期裁剪**(`src/index.js`,`enabled` 时动手)。`dsh-mneme reclaim` 不读这个键:它对三个表用同一条 `--older-than` 截止(默认 7 天),想对齐就显式传成与 `retentionDays` 相同的值(见 STORAGE.md)。两处都**只删 `distill_records`**,溯源链接保留指向空区间(显示「来源已过期」),绝不级联删记忆 | 两个键都走 feature_flags 白名单(面板可启停);`enabled` 进 lightMode 预设关闭清单,`retentionDays` 不进(它是时长不是行为面,用户显式设过就照旧)。`dsh-mneme reclaim` 的报告从两项变三项(`planned` / `column_bytes` / `cleared` 各多一个 `distill_records`),入口语义(默认 dry-run、`--apply` 才动手)不变。已知边界:无历史回填、从未蒸馏过的窗口不留记录、不覆盖工具结果与 code 输出。 +`enabled` 这一格**同时管读写两侧**(#509 评审整改后):写侧是蒸馏时是否落盘(`src/summarize.js:988`),读侧是溯源读出口是否把原文读出去(判据在 `src/provenance-http.js`,与写侧逐字同源,只此一份)。关着时读出口回 `409 {"error":"distill-records-disabled"}`——但**盘上的原文不会因此消失**:保留期裁剪与写入同在 `src/index.js:270` 一道门里,关掉开关意味着「不再写、启动期也不再裁」,已留的行会一直躺在库里,直到 `dsh-mneme reclaim`(它不读这个键,手动可删)或重新开启后的启动裁剪。所以「关掉就干净了」是误读,诚实的表述是「关掉 = 不再经 HTTP 提供」。 + ## 注入 | 键 | 默认 | 作用 | 开启后果 / 冲突 | diff --git a/dsh-mneme/docs/DAEMON.md b/dsh-mneme/docs/DAEMON.md index c365ec4d..24dc0d98 100644 --- a/dsh-mneme/docs/DAEMON.md +++ b/dsh-mneme/docs/DAEMON.md @@ -14,7 +14,11 @@ daemon 是**数据面**,不是第二个宿主: ## 2. 对外接口 -路由面 = `src/api-standalone.js` 全表(health/status/profile/rules/memories 读写/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)/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 这一面就读得到。 + +残留不对称要知道:daemon 结构上看不到宿主的 **bundle config**(第一期不吃宿主 config schema),所以「只写在部署侧 bundle 配置里打开、从没在面板点过」的用户,宿主认、daemon 不认——daemon 会对每个请求回 409。这不是故障,是 daemon 装配面的已知边界;要让 daemon 也认,去面板里把开关显式打开一次(面板的「待重启」提示同样适用:开关是启动快照,改完要重启才生效)。 `/context` 的档位信号(#383):响应多一个 `tier`(`full` 语义档 / `degraded` 规则档 / `unknown` 语义路未启用或判不出来,fail-closed),**具体降级原因只在 stderr**——`dsh-mneme: /context degraded to the rule tier ()`,不进响应体。原因是给运维的读数,六值互不吞并(成例「修法不同就分开」):`embed-unavailable` 无可用接口(embedder 对象在、缺 `embedSingle`/`embed`,修适配器)/ `embed-empty` 空向量 / `embed-timeout` 挂起 / `embed-error` 抛错 / `embed-dimension` 维度不符(有向量但没有一条与查询同维,修法是重建索引)/ `no-embedder` 压根没装配 embedder(`--embed off` / lightMode / 宿主未装配,**预期态不是故障**,修法是把嵌装扮上或开起来)。`tier` 是给调用方的读数,两套消费者分开;六值的唯一真源是 `src/api-standalone.js` 的 `CONTEXT_DEGRADE_REASONS`,契约测试从它派生。`unknown` 之下(维度探针没跑成 / 有行却一条向量都读不出来)另有一行 `dsh-mneme: /context tier left unknown: `,**不是**那六值的一员(`tier` 不是 degraded,没有降级原因可记),运维程序不要按原因枚举解析它——没有它,调用方对 unknown 的策略是「不告警」时,一次真实的判档故障会整个消失。`no-embedder` 不并进 `embed-unavailable` 是故意的:那两者共用「语义路没通」的结果,但一个是适配器坏了(异常)、一个是语义路没开(设计内常态),并成一个值会让按 `embed-unavailable` 告警的面板对每个轻量模式安装报警;用词则与宿主面 `/api/dsh-mneme/semantic` 的 `reason` 对齐(那边是另一套枚举、另一个消费者,别顺手统一)。这里最值得盯的是 `embed-dimension`:嵌入接口一切正常,但库里是旧维度向量,`searchVector` 直接跳过、不抛错,召回静默退回关键词档——修法是重建索引,不是查端点。判档有成本:`q` 非空且装了 embedder 时一次 `/context` 会真起一次查询嵌入(一次往返 + `embedTimeoutMs` 窗口),维度探针本身是**按匹配短路的存在性判据**(有没有一条活跃向量与查询同维,不是 `LIMIT 1` 读一行——混合维度库里那会抽错维误报 degraded、首条损坏又会漏报 full):一次无 `LIMIT` 的全 id 扫描 + 短路解析,同维度健康库只解析 1 个向量,只有要报 degraded 时才走完全表;不复用 `/health`(它只回 `{ok:true}`)也不做全表扫。 diff --git a/dsh-mneme/docs/DISTILL.md b/dsh-mneme/docs/DISTILL.md index c39d3a41..98792b21 100644 --- a/dsh-mneme/docs/DISTILL.md +++ b/dsh-mneme/docs/DISTILL.md @@ -165,3 +165,60 @@ cleared: { dream_run_inputs, archived_embeddings, distill_records } - **启动期裁剪**:读装配后的 cfg(面板改了保留期也认),且默认关时不动手; - **白名单与往返**:两个键过 `settings` 校验(含范围外整数被拒),PUT 往返后 effective 读得回; - **轻量模式**:预设关 `enabled`、保留用户显式设的 `retentionDays`、不污染用户对象。 + +## 10. 读出口:`/memory/provenance` 与 `distillRecords.enabled` 门(#421 写、#509 整改) + +两张表自 #399 起只有写入路径:库里有答案、`service.provenanceFor` 能查,外部拿不到—— +「这条记忆是从哪几句蒸出来的」只能开 sqlite 手写 JOIN。#421 补上读出口,两个入口共用 +一份实现: + +- 宿主内面板路由 `GET /api/dsh-mneme/memory/provenance`(`policy: token`); +- Bearer 数据面 `GET /memory/provenance?id=`(daemon 与宿主外部访问同享)。 + +口径只写在 `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[]}`, +`records` 是区间内的逐条会话原文(按 `seq` 升序、不越界)。没有链接的记忆返回**空 +`windows`**——「不是蒸馏来的」是有效答案,不是错误;未知 id 同样回空 `windows`(读端点 +不替调用方判断记忆是否存在)。到期裁剪后链接保留、指向空区间,面板据此显示「原文已过期」。 + +**开关门(#509 评审整改)**:读出口吃 `distillRecords.enabled`——与写侧**同一枚开关、同一 +个判据表达式**(`config?.distillRecords?.enabled === true`,读侧唯一一份在 +`isDistillRecordsEnabled`)。此前全树只有 `src/index.js:270`(启动期裁剪)与 +`src/summarize.js:988`(写入)行为性读它,读出口一处都不读,于是「把 distill_records 关 +掉」的实际语义是*不再写、也永久不再删*,却还多一个能把历史原文读出去的口。状态码阶梯: + +| 状态码 | 含义 | 为什么不是别的 | +|---|---|---| +| `401` | 没带 token | 两个壳各自的门,在共用模块之前 | +| `400 {"error":"missing-id"}` | 缺 `id` | 语法畸形,与服务端状态无关,所以排在功能开关之前(与 reclaim 的 `invalid-json` 同档) | +| `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` = 不是蒸馏来的 | + +**为什么不是 404**:本端点被明确设计成永远不就「这条记忆是否存在」作断言(缺 id 是 400、 +未知 id 是 200 + 空 windows),而 404 在本仓已有稳定含义——未知路由与资源不存在 +(`/memories/:id`、`entity-not-found`)。把「功能关了」塞进 404,调用方会在状态码这个 +被代理 / 监控 / 重试逻辑唯一读取的通道上,把它读成「这条记忆不存在」;那两种误读的后续 +动作完全不同(一个去开开关,一个去查这条记忆)。409 也不给 200 的形状加说明性键——响应 +体只多一个 `windows`,开关状态走状态码通道。 + +**生效节奏**:`cfg` 是启动期冻结值(面板改开关与 `panel_mode` 一样在下次启动生效),读写 +两侧取的是同一份快照,所以两道门永不分歧;该键在 settings 白名单与 `NESTED_FLAG_PATHS` +里都登记着,面板会显示「待重启」(#481「面板开着、功能却不注入」的教训)。 + +**daemon 这一面**:`src/serve.js` 把 `feature_flags` 里的 `distillRecords.enabled` 展开进 +cfg(与宿主 `index.js:217-233` 同款展开,只取蒸馏这一组),与宿主共用同一个 memory.db 与 +同一份 kv——面板开着,daemon 就读得到。残留不对称:daemon 结构上看不到宿主的 bundle +config,所以「只写在部署侧 bundle 配置里打开、从没在面板点过」的用户,宿主认、daemon 不 +认(每个请求回 409)。这不是故障,写进 `docs/DAEMON.md` 的那条已知边界。 + +**关开关 ≠ 删除已留存原文**(本轮之后唯一诚实的表述):这道门只封 HTTP 读路径。关掉后 +`src/index.js:270` 跳过启动期裁剪,已留的 `distill_records` 行永久冻在盘上,任何能摸到 +memory.db 的进程仍可读;唯一出路是 `dsh-mneme reclaim`(`src/maintenance.js` 不读这个 +键,手动可删)或重新开启后的启动裁剪。把「关 = 删掉」写进任何文档都是谎话。后续可做的 +是让裁剪与写开关解耦(`retentionDays` 有值就裁,无论 `enabled`),那是让开关语义完全诚实 +的唯一修法,超出本轮范围。 diff --git a/dsh-mneme/docs/STORAGE.md b/dsh-mneme/docs/STORAGE.md index 459565b2..7b15f250 100644 --- a/dsh-mneme/docs/STORAGE.md +++ b/dsh-mneme/docs/STORAGE.md @@ -36,7 +36,7 @@ dsh-mneme reclaim --json # 原始 JSON,给脚本用 | 活跃行的向量 | 不动 | 检索正在用 | | `distill_records`(窗口外,#399) | 删行 | 蒸馏原文留存是 opt-in 旁路索引;到期即弃。**`memory_provenance` 链接保留**指向空区间(面板显示「来源已过期」),记忆与链接都不删——原文本就是「回查用」的,保留期到了就该走 | -`distill_records` 只在 `distillRecords.enabled=true` 时才有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。 +`distill_records` 只在 `distillRecords.enabled=true` 时才会有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。**曾经开过再关的库有行**:关掉开关只是不再写、也不再经 HTTP 读出去,已留的行一直躺在库里,直到 `dsh-mneme reclaim`(不读这个键)或重新开启后的启动裁剪(见 [蒸馏原文留存](DISTILL.md))。 取消归档时(`memory_archive` 还原)服务端会重新排队嵌入,所以被回收的向量能拿回来。 diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js index 5eb3410e..97dbdd0c 100644 --- a/dsh-mneme/lib/api-standalone.js +++ b/dsh-mneme/lib/api-standalone.js @@ -21,6 +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"; const DEFAULT_PORT = 8790; const DEFAULT_HOST = "127.0.0.1"; @@ -342,6 +343,11 @@ function scopeFromSearchParams(url) { * - embedTimeoutMs: /context query-embed guard (default 3000) — a hung * embedder must not hold the HTTP connection; timeout degrades to the rule * tier. Factory option (like strictPort), not a user config key. + * - config: 缺省 `{}` 对 /memory/provenance 意味着**功能关着**——该读出口的门读 + * config.distillRecords.enabled(判据见 src/provenance-http.js),而它返回的是 + * 蒸馏窗口的逐字原文,所以「不传即关」是故意的 fail-closed 方向(reclaim-http + * 「没接线如实报」同族)。将来新增第三个构造点忘传 config,这个端点会安静地 + * 回 409 而不是把原文读出去——那是想要的行为,不要修成默认开。 * Returns { server, port, host, token, ready }: `port` is the effective bound * port (updated to the OS-assigned one after `ready` resolves when asked to * bind port 0), `ready` resolves once listening and rejects if the bind fails. @@ -475,6 +481,32 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti return; } + // --- GET /memory/provenance: 记忆级溯源读出口(#399 写、#421 读)-------- + // 与宿主内 /api/dsh-mneme/memory/provenance 同一份实现口径,就在 + // src/provenance-http.js 里写一份(#411 的教训:两张皮整段复制必然漂移)。 + // 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回 + // 空 windows——「不是蒸馏来的」是有效答案,不是错误。 + // + // 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次 + // open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。 + // + // distillRecords.enabled 门(#509 评审整改):config 原样转交给共用模块, + // 判据只在那边算一次(isDistillRecordsEnabled,与写侧 src/summarize.js:988 + // 同源,本文件不重写)。config 缺省 {} → 判据为假 → 409,错误码取共用模块导出的 + // DISTILL_RECORDS_DISABLED:「不传即关」对这个返回逐字原文的端点是正确的失败 + // 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见 + // provenance-http.js 文件头。 + if (req.method === "GET" && pathname === "/memory/provenance") { + const parsed = parseProvenanceQuery(url.searchParams.get("id")); + 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) }); + sendJson(res, status, body); + return; + } + // --- GET/PUT/DELETE /memories/:id -------------------------------------- const idMatch = pathname.match(/^\/memories\/([^/]+)$/); if (idMatch) { diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index 9ecf6485..6334e876 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -14,6 +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 { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js"; import { hostModulesDir, provisionRuntime } from "./runtime/provision.js"; import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js"; @@ -1307,6 +1308,52 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- 记忆级溯源读出口(#399 写、#421 读)----------------------------------- + // #399 把蒸馏窗口的原文落进 distill_records、把 memory → (session, seq 区间) + // 的归因写进 memory_provenance,但只做了写:库里有答案、service.provenanceFor + // 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来 + // 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。 + // + // 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆 + // (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是 + // 有效答案,不是错误,所以不 404。 + // + // policy 是 **token**,不是 open(#509 维护者评审定):records 带的是蒸馏窗口的 + // 逐条会话原文,而 /list 是 open 且逐条带 id——两者串起来等于「先枚举 id、再按 id + // 捞回原文」,可达结果与 /export(一次拿全库,本仓 2026-10 审计后列 gated)同量级, + // 只是换了形状。抽稀不该靠「调用方不知道有这条路」。 + // + // 口径只在 src/provenance-http.js 写一份,两个入口都调它(#411 的教训:两张皮 + // 整段复制必然漂移)。 + // + // distillRecords.enabled 门(#509 评审整改):这个读出口吃的是**写侧同一枚开关、 + // 同一个判据**(isDistillRecordsEnabled,与 src/summarize.js:988 逐字同源;本文件 + // 不重写那个表达式,静态锁盯着)。关着时回 409,错误码取共用模块导出的 + // DISTILL_RECORDS_DISABLED——「用户关了」而不是「装配坏了」(后者是 503, + // src/index.js:589-591 立的可区分性原则),也不是 404(本端点刻意不就「这条记忆 + // 是否存在」作断言)。cfg 是启动期冻结值,所以面板改开关要到下次重启才对这里生效; + // 该键在 settings 白名单与 NESTED_FLAG_PATHS 里都登记着,面板会显示「待重启」 + // (#481 的教训)。阶梯全貌见 provenance-http.js 文件头。 + register({ + kind: "exact", + path: "/api/dsh-mneme/memory/provenance", + policy: "token", + handler(req, res) { + try { + const url = new URL(req.url, "http://localhost"); + const parsed = parseProvenanceQuery(url.searchParams.get("id")); + 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) }); + sendJson(res, status, body); + } catch { + sendJson(res, 500, { error: "internal" }); + } + } + }); + // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) --- // Read-only like list/search/semantic, so it stays open when apiToken is set. // BFS from the root entity over entity_relations (both directions; the diff --git a/dsh-mneme/lib/provenance-http.js b/dsh-mneme/lib/provenance-http.js new file mode 100644 index 00000000..0ee3faab --- /dev/null +++ b/dsh-mneme/lib/provenance-http.js @@ -0,0 +1,126 @@ +// 记忆级溯源读出口的 HTTP 口径 —— 唯一实现。 +// +// 为什么要单独一个模块:这张脸有两个入口——宿主内的面板路由(src/api.js 的 +// /api/dsh-mneme/memory/provenance)与 Bearer 数据面(src/api-standalone.js 的 +// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillRecordsFor), +// 差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,于是「改一边忘一边」 +// 在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,本模块照那次的结论办。 +// +// 口径(错误码、两种「空」的区分、窗口形状)现在只写在这里,两个入口都调它。 +// 真要再写第二份,test/provenance-http.test.js 的静态锁会直接转红。 +// +// 这里只做协议层的事:解析 id、判开关、组装窗口、区分「用户关了」「没接线」与 +// 「没有来源」。一行数据都不改——读取在 src/store.js,透传在 src/service.js。 +// +// 状态码阶梯(自前而后,先匹配的先回): +// 401 未鉴权 ——两个壳各自的门(api.js 的 register 包装 / standalone 的 +// isAuthorized),在本模块之前,不在这里重复。 +// 400 缺 id ——语法畸形,与服务端状态无关,所以排在功能开关之前;与 +// reclaim 的 invalid-json 同档(reclaim-http.js:30-33 立的是 +// 「503 要赢过**字段校验**的 400」,缺 id 不是字段校验而是请求 +// 本身不成立,所以不适用那条)。判断在壳里 +// (parseProvenanceQuery),走到本模块时 id 一定非空。 +// ⚠ 将来要加**语义类** 400(如 limit 越界、id 格式不合法), +// 必须挪到接线检查之后——那条 reclaim 口径正是为它立的。 +// 409 功能关了 ——distillRecords.enabled !== true。回的是「端点的属性」, +// 与 src/api.js:525 的 profileExtractEnabled 同构:用户关了 +// 某功能 = 409,装配坏了 = 503,两者必须是可区分的答案 +// (src/index.js:589-591 的设计原话)。 +// 503 装配没接线 ——service 上没有 provenanceFor / distillRecordsFor。 +// 读(200 / 500) +// 刻意不用 404:本端点永远不就「这条记忆是否存在」作断言(缺 id 是 400、未知 id +// 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在 +// (src/api-standalone.js:506/515、src/api.js:1367/1445)。把「功能关了」塞进 +// 404,调用方会在状态码这个唯一被代理 / 监控 / 重试逻辑读取的通道上,把它读成 +// 「这条记忆不存在」。 + +/** + * 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有 provenanceFor 或 + * distillRecordsFor,那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数, + * 否则调用方看到一个空 windows 会以为拿到了答案。 + */ +export const PROVENANCE_UNAVAILABLE = "provenance-unavailable"; + +/** + * 「用户把蒸馏原文留存关掉了」的错误码。distillRecords.enabled 既是写侧的总闸 + * (src/summarize.js:988)也是本读出口的总闸(#509 评审整改):关着时盘上可能 + * 还有历史原文(保留期裁剪与写入同在 src/index.js:270 一道门里),但本端点不再 + * 把它们读出去。与 api.js:525 的 "disabled" 分开:那个面只有一个功能开关,本仓 + * 数据面上会有多个,特定码才让调用方分得清是哪一个被关。 + */ +export const DISTILL_RECORDS_DISABLED = "distill-records-disabled"; + +/** + * 读侧唯一的开关判据,**与写侧 src/summarize.js:988 逐字同源**。 + * + * 为什么不放在两个壳里各算一次:那句话已经是本仓第 3、第 4 份拷贝了 + * (summarize.js:988、index.js:270),而 #411 的结论就是「两张皮整段复制必然 + * 漂移」——读写两道门一旦漂移,就会出现「以为关了却还能读出去」或「开着却读不到」 + * 这两种都更难排查的状态。壳只转发 config,判据只在这里出现一次。 + * + * fail-closed:config 缺失(null / undefined)、distillRecords 组缺失、值不是 + * 严格的 true("true" 字符串 / 1 / undefined)一律算关。这个端点返回的是蒸馏 + * 窗口的逐字会话原文,判不出来时必须往「不读」那一侧倒。 + * + * @param {object|null|undefined} config 启动期冻结的装配后 cfg(宿主 index.js:597 + * 的 createApi 第 8 参 / index.js:621 的 createStandaloneApi config / daemon + * serve.js 的最小 cfg)。 + * @returns {boolean} + */ +export function isDistillRecordsEnabled(config) { + return config?.distillRecords?.enabled === true; +} + +/** 溯源读取失败的 warn 文案。两个入口共用,日志里不会出现两种拼法。 */ +export function provenanceWarnMessage(error) { + return `[dsh-mneme] memory provenance read failed: ${String(error)}`; +} + +/** + * 解析 ?id。空 / 缺失 → 400;其余原样交给 service(id 不存在时返回空 windows, + * 那是有效答案,不是错误——读端点不替调用方判断记忆是否存在)。 + * + * @returns {{ok: true, id: string} | {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 }; +} + +/** + * 组装答复:一个链接一个窗口(区间 + 区间内原文行,按 seq 升序)。 + * + * 顺序敏感(阶梯全貌见文件头):409 功能关了 → 503 装配没接线 → 读。 + * 503 要赢过空结果——装配没接线时报「这条记忆没有来源」是误导,与 reclaimReply 的 + * maintenance-unavailable 同构;而 409 要赢过 503——「用户关了」比「装配坏了」更 + * 具体,把前者报成后者会让人去查一个没坏的部署。 + * + * @param {{service: object, id: string, config?: object|null, warn?: Function}} args + * config 缺省即关(fail-closed),见 isDistillRecordsEnabled。 + * @returns {{status: number, body: object}} + */ +export function provenanceReply({ service, id, config = null, warn = null }) { + if (!isDistillRecordsEnabled(config)) { + return { status: 409, body: { error: DISTILL_RECORDS_DISABLED } }; + } + if (typeof service?.provenanceFor !== "function" || typeof service?.distillRecordsFor !== "function") { + return { status: 503, body: { error: PROVENANCE_UNAVAILABLE } }; + } + try { + const links = service.provenanceFor(id); + const windows = links.map((link) => ({ + session_id: link.session_id, + seq_start: link.seq_start, + seq_end: link.seq_end, + created_at: link.created_at, + // 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的 + // 语义),面板据此显示「原文已过期」而非「没有来源」。 + records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end) + })); + return { status: 200, body: { memoryId: id, windows } }; + } catch (error) { + warn?.(provenanceWarnMessage(error)); + return { status: 500, body: { error: "internal" } }; + } +} diff --git a/dsh-mneme/lib/serve.js b/dsh-mneme/lib/serve.js index df8a8d09..7620c767 100644 --- a/dsh-mneme/lib/serve.js +++ b/dsh-mneme/lib/serve.js @@ -61,6 +61,19 @@ export async function createServeRuntime({ // documentMemoryEnabled 关掉 document 子系统(daemon 不建 documentIndex,service // 内部 if-guard 安全,index.js:261 的 documentIndex 仅宿主装配)。 const cfg = { language: "zh", documentMemoryEnabled: false }; + // #509 评审整改补的一格:GET /memory/provenance 的读门读 cfg.distillRecords.enabled + // (判据在 src/provenance-http.js,与写侧 src/summarize.js:988 同源)。daemon 与宿主 + // 共用同一个 memory.db、同一份 feature_flags kv,所以这里把用户显式开关展开进 cfg + // ——index.js:217-233 同款展开,只取蒸馏这一组(daemon 其余键仍刻意不吃宿主 config + // schema)。不展开的后果:daemon 对这一面恒定回「功能关了」,哪怕用户在面板里开着、 + // 库里明明有原文——对这个端点那是一句谎话,而「两种空必须分开读数」正是本仓纪律。 + // 残留不对称(写进 docs/DAEMON.md 与 PR):只靠 bundle 配置打开、从没在面板点过的 + // 用户,宿主认、daemon 不认——daemon 结构上看不到宿主的 bundle config,这是它看不见 + // 的那一半,不是本处能补的。 + const persistedFlags = settings.getFeatureFlags?.() ?? {}; + if (typeof persistedFlags["distillRecords.enabled"] === "boolean") { + cfg.distillRecords = { ...(cfg.distillRecords ?? {}), enabled: persistedFlags["distillRecords.enabled"] }; + } const mirror = createMirror(dir, langOf(cfg)); const service = createService({ store, mirror, config: cfg, logger }); diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 8239b99e..47f07e6d 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -2645,6 +2645,9 @@ export function createService({ store, mirror, config, onWrite, logger, document saveDistillRecords: (records) => store.saveDistillRecords(records), saveMemoryProvenance: (link) => store.saveMemoryProvenance(link), provenanceFor: (memoryId) => store.provenanceFor(memoryId), + // #421 读出口:按 (session, seq 区间) 取蒸馏原文。与 provenanceFor 成对—— + // 一个给区间、一个给区间里的原文,合起来才是「这条记忆从哪几句蒸出来的」。 + distillRecordsFor: (sessionId, seqStart, seqEnd) => store.distillRecordsFor(sessionId, seqStart, seqEnd), enqueue, setDreamHook(fn) { dreamHook = fn; }, setSleepHook(fn) { sleepHook = fn; }, diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 8c452ebf..15f5af22 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -2657,6 +2657,22 @@ export function createStore(path) { ).all(memoryId); } + /** + * 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。 + * + * 按 seq 升序——原文的先后顺序就是区间顺序,乱序会让「这句从哪来」读不出来。 + * 到期裁剪后区间可能变空,那时返回 []:链接刻意保留并指向空区间 + * (见 clearDistillRecords 注释),面板据此显示「原文已过期」而非「没有来源」。 + */ + function distillRecordsFor(sessionId, seqStart, seqEnd) { + return db.prepare( + `SELECT seq, role, text, window_id, distilled_at + FROM distill_records + WHERE session_id = ? AND seq >= ? AND seq <= ? + ORDER BY seq` + ).all(sessionId, seqStart, seqEnd); + } + // --- 存储生命周期:无损回收(#275 第一批)-------------------------------- // // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。 @@ -3642,6 +3658,7 @@ export function createStore(path) { clearDistillRecords, saveMemoryProvenance, provenanceFor, + distillRecordsFor, // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。 dreamRunInputStats, clearDreamRunInputs, diff --git a/dsh-mneme/src/api-standalone.js b/dsh-mneme/src/api-standalone.js index 5eb3410e..97dbdd0c 100644 --- a/dsh-mneme/src/api-standalone.js +++ b/dsh-mneme/src/api-standalone.js @@ -21,6 +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"; const DEFAULT_PORT = 8790; const DEFAULT_HOST = "127.0.0.1"; @@ -342,6 +343,11 @@ function scopeFromSearchParams(url) { * - embedTimeoutMs: /context query-embed guard (default 3000) — a hung * embedder must not hold the HTTP connection; timeout degrades to the rule * tier. Factory option (like strictPort), not a user config key. + * - config: 缺省 `{}` 对 /memory/provenance 意味着**功能关着**——该读出口的门读 + * config.distillRecords.enabled(判据见 src/provenance-http.js),而它返回的是 + * 蒸馏窗口的逐字原文,所以「不传即关」是故意的 fail-closed 方向(reclaim-http + * 「没接线如实报」同族)。将来新增第三个构造点忘传 config,这个端点会安静地 + * 回 409 而不是把原文读出去——那是想要的行为,不要修成默认开。 * Returns { server, port, host, token, ready }: `port` is the effective bound * port (updated to the OS-assigned one after `ready` resolves when asked to * bind port 0), `ready` resolves once listening and rejects if the bind fails. @@ -475,6 +481,32 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti return; } + // --- GET /memory/provenance: 记忆级溯源读出口(#399 写、#421 读)-------- + // 与宿主内 /api/dsh-mneme/memory/provenance 同一份实现口径,就在 + // src/provenance-http.js 里写一份(#411 的教训:两张皮整段复制必然漂移)。 + // 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回 + // 空 windows——「不是蒸馏来的」是有效答案,不是错误。 + // + // 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次 + // open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。 + // + // distillRecords.enabled 门(#509 评审整改):config 原样转交给共用模块, + // 判据只在那边算一次(isDistillRecordsEnabled,与写侧 src/summarize.js:988 + // 同源,本文件不重写)。config 缺省 {} → 判据为假 → 409,错误码取共用模块导出的 + // DISTILL_RECORDS_DISABLED:「不传即关」对这个返回逐字原文的端点是正确的失败 + // 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见 + // provenance-http.js 文件头。 + if (req.method === "GET" && pathname === "/memory/provenance") { + const parsed = parseProvenanceQuery(url.searchParams.get("id")); + 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) }); + sendJson(res, status, body); + return; + } + // --- GET/PUT/DELETE /memories/:id -------------------------------------- const idMatch = pathname.match(/^\/memories\/([^/]+)$/); if (idMatch) { diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index 9ecf6485..6334e876 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -14,6 +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 { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js"; import { hostModulesDir, provisionRuntime } from "./runtime/provision.js"; import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js"; @@ -1307,6 +1308,52 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- 记忆级溯源读出口(#399 写、#421 读)----------------------------------- + // #399 把蒸馏窗口的原文落进 distill_records、把 memory → (session, seq 区间) + // 的归因写进 memory_provenance,但只做了写:库里有答案、service.provenanceFor + // 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来 + // 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。 + // + // 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆 + // (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是 + // 有效答案,不是错误,所以不 404。 + // + // policy 是 **token**,不是 open(#509 维护者评审定):records 带的是蒸馏窗口的 + // 逐条会话原文,而 /list 是 open 且逐条带 id——两者串起来等于「先枚举 id、再按 id + // 捞回原文」,可达结果与 /export(一次拿全库,本仓 2026-10 审计后列 gated)同量级, + // 只是换了形状。抽稀不该靠「调用方不知道有这条路」。 + // + // 口径只在 src/provenance-http.js 写一份,两个入口都调它(#411 的教训:两张皮 + // 整段复制必然漂移)。 + // + // distillRecords.enabled 门(#509 评审整改):这个读出口吃的是**写侧同一枚开关、 + // 同一个判据**(isDistillRecordsEnabled,与 src/summarize.js:988 逐字同源;本文件 + // 不重写那个表达式,静态锁盯着)。关着时回 409,错误码取共用模块导出的 + // DISTILL_RECORDS_DISABLED——「用户关了」而不是「装配坏了」(后者是 503, + // src/index.js:589-591 立的可区分性原则),也不是 404(本端点刻意不就「这条记忆 + // 是否存在」作断言)。cfg 是启动期冻结值,所以面板改开关要到下次重启才对这里生效; + // 该键在 settings 白名单与 NESTED_FLAG_PATHS 里都登记着,面板会显示「待重启」 + // (#481 的教训)。阶梯全貌见 provenance-http.js 文件头。 + register({ + kind: "exact", + path: "/api/dsh-mneme/memory/provenance", + policy: "token", + handler(req, res) { + try { + const url = new URL(req.url, "http://localhost"); + const parsed = parseProvenanceQuery(url.searchParams.get("id")); + 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) }); + sendJson(res, status, body); + } catch { + sendJson(res, 500, { error: "internal" }); + } + } + }); + // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) --- // Read-only like list/search/semantic, so it stays open when apiToken is set. // BFS from the root entity over entity_relations (both directions; the diff --git a/dsh-mneme/src/provenance-http.js b/dsh-mneme/src/provenance-http.js new file mode 100644 index 00000000..0ee3faab --- /dev/null +++ b/dsh-mneme/src/provenance-http.js @@ -0,0 +1,126 @@ +// 记忆级溯源读出口的 HTTP 口径 —— 唯一实现。 +// +// 为什么要单独一个模块:这张脸有两个入口——宿主内的面板路由(src/api.js 的 +// /api/dsh-mneme/memory/provenance)与 Bearer 数据面(src/api-standalone.js 的 +// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillRecordsFor), +// 差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,于是「改一边忘一边」 +// 在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,本模块照那次的结论办。 +// +// 口径(错误码、两种「空」的区分、窗口形状)现在只写在这里,两个入口都调它。 +// 真要再写第二份,test/provenance-http.test.js 的静态锁会直接转红。 +// +// 这里只做协议层的事:解析 id、判开关、组装窗口、区分「用户关了」「没接线」与 +// 「没有来源」。一行数据都不改——读取在 src/store.js,透传在 src/service.js。 +// +// 状态码阶梯(自前而后,先匹配的先回): +// 401 未鉴权 ——两个壳各自的门(api.js 的 register 包装 / standalone 的 +// isAuthorized),在本模块之前,不在这里重复。 +// 400 缺 id ——语法畸形,与服务端状态无关,所以排在功能开关之前;与 +// reclaim 的 invalid-json 同档(reclaim-http.js:30-33 立的是 +// 「503 要赢过**字段校验**的 400」,缺 id 不是字段校验而是请求 +// 本身不成立,所以不适用那条)。判断在壳里 +// (parseProvenanceQuery),走到本模块时 id 一定非空。 +// ⚠ 将来要加**语义类** 400(如 limit 越界、id 格式不合法), +// 必须挪到接线检查之后——那条 reclaim 口径正是为它立的。 +// 409 功能关了 ——distillRecords.enabled !== true。回的是「端点的属性」, +// 与 src/api.js:525 的 profileExtractEnabled 同构:用户关了 +// 某功能 = 409,装配坏了 = 503,两者必须是可区分的答案 +// (src/index.js:589-591 的设计原话)。 +// 503 装配没接线 ——service 上没有 provenanceFor / distillRecordsFor。 +// 读(200 / 500) +// 刻意不用 404:本端点永远不就「这条记忆是否存在」作断言(缺 id 是 400、未知 id +// 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在 +// (src/api-standalone.js:506/515、src/api.js:1367/1445)。把「功能关了」塞进 +// 404,调用方会在状态码这个唯一被代理 / 监控 / 重试逻辑读取的通道上,把它读成 +// 「这条记忆不存在」。 + +/** + * 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有 provenanceFor 或 + * distillRecordsFor,那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数, + * 否则调用方看到一个空 windows 会以为拿到了答案。 + */ +export const PROVENANCE_UNAVAILABLE = "provenance-unavailable"; + +/** + * 「用户把蒸馏原文留存关掉了」的错误码。distillRecords.enabled 既是写侧的总闸 + * (src/summarize.js:988)也是本读出口的总闸(#509 评审整改):关着时盘上可能 + * 还有历史原文(保留期裁剪与写入同在 src/index.js:270 一道门里),但本端点不再 + * 把它们读出去。与 api.js:525 的 "disabled" 分开:那个面只有一个功能开关,本仓 + * 数据面上会有多个,特定码才让调用方分得清是哪一个被关。 + */ +export const DISTILL_RECORDS_DISABLED = "distill-records-disabled"; + +/** + * 读侧唯一的开关判据,**与写侧 src/summarize.js:988 逐字同源**。 + * + * 为什么不放在两个壳里各算一次:那句话已经是本仓第 3、第 4 份拷贝了 + * (summarize.js:988、index.js:270),而 #411 的结论就是「两张皮整段复制必然 + * 漂移」——读写两道门一旦漂移,就会出现「以为关了却还能读出去」或「开着却读不到」 + * 这两种都更难排查的状态。壳只转发 config,判据只在这里出现一次。 + * + * fail-closed:config 缺失(null / undefined)、distillRecords 组缺失、值不是 + * 严格的 true("true" 字符串 / 1 / undefined)一律算关。这个端点返回的是蒸馏 + * 窗口的逐字会话原文,判不出来时必须往「不读」那一侧倒。 + * + * @param {object|null|undefined} config 启动期冻结的装配后 cfg(宿主 index.js:597 + * 的 createApi 第 8 参 / index.js:621 的 createStandaloneApi config / daemon + * serve.js 的最小 cfg)。 + * @returns {boolean} + */ +export function isDistillRecordsEnabled(config) { + return config?.distillRecords?.enabled === true; +} + +/** 溯源读取失败的 warn 文案。两个入口共用,日志里不会出现两种拼法。 */ +export function provenanceWarnMessage(error) { + return `[dsh-mneme] memory provenance read failed: ${String(error)}`; +} + +/** + * 解析 ?id。空 / 缺失 → 400;其余原样交给 service(id 不存在时返回空 windows, + * 那是有效答案,不是错误——读端点不替调用方判断记忆是否存在)。 + * + * @returns {{ok: true, id: string} | {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 }; +} + +/** + * 组装答复:一个链接一个窗口(区间 + 区间内原文行,按 seq 升序)。 + * + * 顺序敏感(阶梯全貌见文件头):409 功能关了 → 503 装配没接线 → 读。 + * 503 要赢过空结果——装配没接线时报「这条记忆没有来源」是误导,与 reclaimReply 的 + * maintenance-unavailable 同构;而 409 要赢过 503——「用户关了」比「装配坏了」更 + * 具体,把前者报成后者会让人去查一个没坏的部署。 + * + * @param {{service: object, id: string, config?: object|null, warn?: Function}} args + * config 缺省即关(fail-closed),见 isDistillRecordsEnabled。 + * @returns {{status: number, body: object}} + */ +export function provenanceReply({ service, id, config = null, warn = null }) { + if (!isDistillRecordsEnabled(config)) { + return { status: 409, body: { error: DISTILL_RECORDS_DISABLED } }; + } + if (typeof service?.provenanceFor !== "function" || typeof service?.distillRecordsFor !== "function") { + return { status: 503, body: { error: PROVENANCE_UNAVAILABLE } }; + } + try { + const links = service.provenanceFor(id); + const windows = links.map((link) => ({ + session_id: link.session_id, + seq_start: link.seq_start, + seq_end: link.seq_end, + created_at: link.created_at, + // 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的 + // 语义),面板据此显示「原文已过期」而非「没有来源」。 + records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end) + })); + return { status: 200, body: { memoryId: id, windows } }; + } catch (error) { + warn?.(provenanceWarnMessage(error)); + return { status: 500, body: { error: "internal" } }; + } +} diff --git a/dsh-mneme/src/serve.js b/dsh-mneme/src/serve.js index df8a8d09..7620c767 100644 --- a/dsh-mneme/src/serve.js +++ b/dsh-mneme/src/serve.js @@ -61,6 +61,19 @@ export async function createServeRuntime({ // documentMemoryEnabled 关掉 document 子系统(daemon 不建 documentIndex,service // 内部 if-guard 安全,index.js:261 的 documentIndex 仅宿主装配)。 const cfg = { language: "zh", documentMemoryEnabled: false }; + // #509 评审整改补的一格:GET /memory/provenance 的读门读 cfg.distillRecords.enabled + // (判据在 src/provenance-http.js,与写侧 src/summarize.js:988 同源)。daemon 与宿主 + // 共用同一个 memory.db、同一份 feature_flags kv,所以这里把用户显式开关展开进 cfg + // ——index.js:217-233 同款展开,只取蒸馏这一组(daemon 其余键仍刻意不吃宿主 config + // schema)。不展开的后果:daemon 对这一面恒定回「功能关了」,哪怕用户在面板里开着、 + // 库里明明有原文——对这个端点那是一句谎话,而「两种空必须分开读数」正是本仓纪律。 + // 残留不对称(写进 docs/DAEMON.md 与 PR):只靠 bundle 配置打开、从没在面板点过的 + // 用户,宿主认、daemon 不认——daemon 结构上看不到宿主的 bundle config,这是它看不见 + // 的那一半,不是本处能补的。 + const persistedFlags = settings.getFeatureFlags?.() ?? {}; + if (typeof persistedFlags["distillRecords.enabled"] === "boolean") { + cfg.distillRecords = { ...(cfg.distillRecords ?? {}), enabled: persistedFlags["distillRecords.enabled"] }; + } const mirror = createMirror(dir, langOf(cfg)); const service = createService({ store, mirror, config: cfg, logger }); diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 8239b99e..47f07e6d 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -2645,6 +2645,9 @@ export function createService({ store, mirror, config, onWrite, logger, document saveDistillRecords: (records) => store.saveDistillRecords(records), saveMemoryProvenance: (link) => store.saveMemoryProvenance(link), provenanceFor: (memoryId) => store.provenanceFor(memoryId), + // #421 读出口:按 (session, seq 区间) 取蒸馏原文。与 provenanceFor 成对—— + // 一个给区间、一个给区间里的原文,合起来才是「这条记忆从哪几句蒸出来的」。 + distillRecordsFor: (sessionId, seqStart, seqEnd) => store.distillRecordsFor(sessionId, seqStart, seqEnd), enqueue, setDreamHook(fn) { dreamHook = fn; }, setSleepHook(fn) { sleepHook = fn; }, diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 8c452ebf..15f5af22 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -2657,6 +2657,22 @@ export function createStore(path) { ).all(memoryId); } + /** + * 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。 + * + * 按 seq 升序——原文的先后顺序就是区间顺序,乱序会让「这句从哪来」读不出来。 + * 到期裁剪后区间可能变空,那时返回 []:链接刻意保留并指向空区间 + * (见 clearDistillRecords 注释),面板据此显示「原文已过期」而非「没有来源」。 + */ + function distillRecordsFor(sessionId, seqStart, seqEnd) { + return db.prepare( + `SELECT seq, role, text, window_id, distilled_at + FROM distill_records + WHERE session_id = ? AND seq >= ? AND seq <= ? + ORDER BY seq` + ).all(sessionId, seqStart, seqEnd); + } + // --- 存储生命周期:无损回收(#275 第一批)-------------------------------- // // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。 @@ -3642,6 +3658,7 @@ export function createStore(path) { clearDistillRecords, saveMemoryProvenance, provenanceFor, + distillRecordsFor, // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。 dreamRunInputStats, clearDreamRunInputs, diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 7f5dcbfe..1f1089ea 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -8,7 +8,7 @@ import { createApi } from "../src/api.js"; import { createInjector } from "../src/inject.js"; import { createSettings } from "../src/settings.js"; import { createVectorIndex } from "../src/vector-index.js"; -import { Config } from "../src/config.js"; +import { Config, applyLightModePreset } from "../src/config.js"; import { parseHumanEdits } from "../src/mirror.js"; // 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。 @@ -2206,3 +2206,255 @@ test("GET /api/dsh-mneme/pipeline 带观察旁挂:observationsById 真的进 rmSync(root, { recursive: true, force: true }); } }); + +// --- 记忆级溯源读出口(#399 写、#421 读)--------------------------------------- +// distill_records 与 memory_provenance 自 #399 起就只有写入路径:库里有答案、 +// service.provenanceFor 能查,但外部(面板 / standalone API / 脚本)拿不到—— +// 「这条记忆是从哪几句蒸出来的」只能开 sqlite 手写 JOIN。 +// +// #509 评审整改:读出口吃 distillRecords.enabled(与写侧同一个开关、同一个判据)。 +// 关着时 409 {error:"distill-records-disabled"}。所以这一族的用例必须显式把开关 +// 打开——setup 的 config 缺省 null,而 null 在这道门上的含义就是「关」(fail-closed)。 +const RECORDS_ON = { distillRecords: { enabled: true } }; + +test("GET /api/dsh-mneme/memory/provenance 返回窗口区间与原文行", async () => { + const { routes, service, store } = setup(null, "secret-token", RECORDS_ON); + // 直接落溯源链接与原文:读取面不关心是谁写的,只关心库里的形状。 + service.saveWithDedupe({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const memory = service.all()[0]; + store.saveMemoryProvenance({ + memoryId: memory.id, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "2026-10-23T00:00:00Z" + }); + store.saveDistillRecords([ + { sessionId: "s1", seq: 10, role: "user", text: "第一句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }, + { sessionId: "s1", seq: 11, role: "assistant", text: "第二句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }, + { sessionId: "s1", seq: 12, role: "user", text: "第三句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }, + // 区间外的一句:不该被带出来 + { sessionId: "s1", seq: 99, role: "user", text: "区间外", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" } + ]); + + const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + assert.equal(route.policy, "token", "records 带会话原文,与开放的 /list 串起来即可枚举 id 后捞回原文——必须受门控"); + + const res = new FakeRes(); + const r = req("/api/dsh-mneme/memory/provenance?id=" + memory.id); + 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.memoryId, memory.id); + assert.equal(data.windows.length, 1, "一个链接一个窗口"); + const w = data.windows[0]; + assert.equal(w.session_id, "s1"); + assert.equal(w.seq_start, 10); + assert.equal(w.seq_end, 12); + assert.equal(w.created_at, "2026-10-23T00:00:00Z"); + assert.deepEqual(w.records.map((r) => r.text), ["第一句原文", "第二句原文", "第三句原文"], + "原文按 seq 升序,且不越出区间"); + assert.deepEqual(w.records.map((r) => r.seq), [10, 11, 12]); +}); + +test("GET /api/dsh-mneme/memory/provenance:无 token 401,装配没接线 503", async () => { + const { routes, service } = setup(null, "secret-token", RECORDS_ON); + service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }); + const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + + // 没 token → 401(policy=token 的机械结果) + const noAuth = new FakeRes(); + await route.handler(req("/api/dsh-mneme/memory/provenance?id=x"), noAuth); + assert.equal(noAuth.statusCode, 401); + + // 装配没接线(headless / 旧装配:service 上没有这两个方法)→ 503。 + // 这与「这条记忆没有来源」是两件事,必须分开读数——后者是事实,前者是接线坏了。 + const store = createStore(":memory:"); + const settings = createSettings(store.db); + const bare = createService({ store, mirror: null, config: {} }); + delete bare.provenanceFor; + delete bare.distillRecordsFor; + const bareRoutes = []; + createApi( + { webServer: { register(r) { bareRoutes.push(r); return () => {}; } } }, + bare, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token", + RECORDS_ON + ); + const bareRoute = bareRoutes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + const res503 = new FakeRes(); + const authed = req("/api/dsh-mneme/memory/provenance?id=x"); + authed.headers = { authorization: "Bearer secret-token" }; + await bareRoute.handler(authed, res503); + assert.equal(res503.statusCode, 503, "装配缺方法 → provenance-unavailable,不是空 windows"); + assert.equal(JSON.parse(res503.body).error, "provenance-unavailable"); + store.close(); +}); + +test("GET /api/dsh-mneme/memory/provenance:非蒸馏来源的记忆返回空窗口而非报错", async () => { + // memory_save 写进来的记忆没有溯源链接——「这条不是蒸馏来的」本身是有效答案, + // 不能 404/500。契约:空 windows,不是错误。 + const { routes, service } = setup(null, "secret-token", RECORDS_ON); + service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }); + const memory = service.all()[0]; + 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=" + memory.id); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + assert.equal(res.statusCode, 200); + const data = JSON.parse(res.body); + assert.deepEqual(data.windows, [], "没有链接 → 空窗口,不是错误"); +}); + +test("GET /api/dsh-mneme/memory/provenance 缺 id 报 400,未知 id 返回空窗口", async () => { + const { routes } = setup(null, "secret-token", RECORDS_ON); + const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + // 400 要赢过 401:调用方发坏了,报「没鉴权」是误导(reclaimReply 同款顺序)。 + const bad = new FakeRes(); + const badReq = req("/api/dsh-mneme/memory/provenance"); + badReq.headers = { authorization: "Bearer secret-token" }; + await route.handler(badReq, bad); + assert.equal(bad.statusCode, 400); + + // 库里有 distill 数据但这条 id 不存在:读端点不替调用方判断记忆是否存在, + // 「没有链接」对未知 id 与对非蒸馏记忆是同一个答案。 + const unknown = new FakeRes(); + const unknownReq = req("/api/dsh-mneme/memory/provenance?id=nope"); + unknownReq.headers = { authorization: "Bearer secret-token" }; + await route.handler(unknownReq, unknown); + assert.equal(unknown.statusCode, 200); + assert.deepEqual(JSON.parse(unknown.body).windows, []); +}); + +test("GET /api/dsh-mneme/memory/provenance:读失败要留痕,500 不能把 warn 一起吞掉", async () => { + // 共用模块的测试已经锁了「抛错 → 调 warn」(provenance-http.test.js),但锁不住 + // **壳把哪个 logger 递进去**。#509 第二次迭代在这里栽过:壳里写的是裸 `logger`, + // 而 createApi 的作用域只有 ctx.logger——warn 回调自己抛 ReferenceError,被外层 + // catch 吞掉,响应还是 500,但「为什么失败」一个字都不留。下面这条把接线本身锁住。 + const store = createStore(":memory:"); + const settings = createSettings(store.db); + const service = createService({ store, mirror: null, config: {} }); + const realFor = service.provenanceFor; + service.provenanceFor = (id) => { realFor(id); throw new Error("db locked"); }; + + const routes = []; + const warnings = []; + createApi( + { + webServer: { register(r) { routes.push(r); return () => {}; } }, + logger: { warn: (m) => warnings.push(m) } + }, + service, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "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"); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + + assert.equal(res.statusCode, 500, "读失败回 500"); + assert.equal(JSON.parse(res.body).error, "internal"); + assert.equal(warnings.length, 1, "500 必须伴随一条 warn——否则失败原因只存在于调用方的猜测里"); + assert.match(warnings[0], /memory provenance read failed/, "留痕要带原始异常"); + assert.match(warnings[0], /db locked/); + store.close(); +}); + +// --- distillRecords.enabled 门(#509 评审整改)-------------------------------- +// 维护者在 #509 里指出的缺口:读出口此前一处都不读这个开关,于是「把 distill_records +// 关掉」的语义是不再写、也不再删,却还多一个能读出去的口。门落在共用模块,两个壳 +// 都吃它;这里锁宿主面的外壳与顺序。 + +test("GET /api/dsh-mneme/memory/provenance:distillRecords.enabled 关着 → 409,不是 404 也不是空 windows", async () => { + const { routes, service } = setup(null, "secret-token", { distillRecords: { enabled: false } }); + service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }); + const memory = service.all()[0]; + 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=" + memory.id); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + + assert.equal(res.statusCode, 409, "用户关了功能 = 409(src/api.js:525 的 profileExtract 同型先例)"); + assert.deepEqual(JSON.parse(res.body), { error: "distill-records-disabled" }); +}); + +test("GET /api/dsh-mneme/memory/provenance:config 整个缺席也按关处理(fail-closed)", async () => { + // 旧调用方直连 createApi 不传第 8 参、或宿主没装配 cfg:这个端点返回逐字原文, + // 判不出来时必须往「不读」那一侧倒。 + const { routes } = setup(null, "secret-token"); + 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=whatever"); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + assert.equal(res.statusCode, 409); + assert.deepEqual(JSON.parse(res.body), { error: "distill-records-disabled" }); +}); + +test("GET /api/dsh-mneme/memory/provenance:400 赢 409——缺 id 是语法畸形,与服务端状态无关", async () => { + const { routes } = setup(null, "secret-token", { distillRecords: { enabled: false } }); + const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + const res = new FakeRes(); + const r = req("/api/dsh-mneme/memory/provenance"); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + assert.equal(res.statusCode, 400, "请求本身不成立时报「功能关了」是误导"); + assert.equal(JSON.parse(res.body).error, "missing-id"); +}); + +test("GET /api/dsh-mneme/memory/provenance:409 赢 503——功能关比装配坏更具体", async () => { + // 关着 + service 上连方法都没有:503 会让人去查一个没坏的部署。 + const store = createStore(":memory:"); + const settings = createSettings(store.db); + const bare = createService({ store, mirror: null, config: {} }); + delete bare.provenanceFor; + delete bare.distillRecordsFor; + const bareRoutes = []; + createApi( + { webServer: { register(r) { bareRoutes.push(r); return () => {}; } } }, + bare, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token", + { distillRecords: { enabled: false } } + ); + const route = bareRoutes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + const res = new FakeRes(); + const r = req("/api/dsh-mneme/memory/provenance?id=x"); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + assert.equal(res.statusCode, 409); + assert.equal(JSON.parse(res.body).error, "distill-records-disabled"); + store.close(); +}); + +test("GET /api/dsh-mneme/memory/provenance:关着时对已知 id 与未知 id 同一个答案——不当存在性预言机", async () => { + // 如果关着只对「有原文的 id」回 409、对别的 id 回空 windows,调用方就能拿这个 + // 端点枚举「哪些记忆有蒸馏原文」。功能关是端点的属性,每个 id 答案必须一样。 + const { routes, service } = setup(null, "secret-token", { distillRecords: { enabled: false } }); + service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }); + const memory = service.all()[0]; + const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance"); + for (const id of [memory.id, "nope", ""]) { + const res = new FakeRes(); + const r = req("/api/dsh-mneme/memory/provenance?id=" + id); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + if (id === "") { + assert.equal(res.statusCode, 400, "空 id 仍是语法问题,409 不该吃掉它"); + continue; + } + assert.equal(res.statusCode, 409, `${id} → 409`); + assert.deepEqual(JSON.parse(res.body), { error: "distill-records-disabled" }); + } +}); + +test("GET /api/dsh-mneme/memory/provenance:lightMode 预设把关掉 → 409(与写侧同源)", async () => { + // applyLightModePreset 把 distillRecords.enabled 置 false(pre-inject-gate.test.js + // 锁的是写入侧那个方向);读侧必须跟着变,否则轻量模式下用户以为自己关了、原文 + // 却还能从 HTTP 读出去。 + const { routes } = setup(null, "secret-token", applyLightModePreset({ lightMode: true, distillRecords: { enabled: true } })); + 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"); + r.headers = { authorization: "Bearer secret-token" }; + await route.handler(r, res); + assert.equal(res.statusCode, 409); + assert.equal(JSON.parse(res.body).error, "distill-records-disabled"); +}); diff --git a/dsh-mneme/test/provenance-http.test.js b/dsh-mneme/test/provenance-http.test.js new file mode 100644 index 00000000..5f8f61dc --- /dev/null +++ b/dsh-mneme/test/provenance-http.test.js @@ -0,0 +1,184 @@ +// 记忆级溯源读出口的 HTTP 口径 —— 共用模块自己的锁。 +// +// 这个文件盯三类回归: +// a) 口径本身(id 解析、两种「空」的区分、窗口形状、原文按序); +// b) distillRecords.enabled 门(#509 评审整改):判据 fail-closed、409 赢 503、 +// 关着时一次都不碰 service; +// c) **不许再长出第二份实现**。#509 第一版曾把整段复製到 api.js 与 +// api-standalone.js 各一份,维护者在 review 里点名(本仓 #411 之后不收这个 +// 形状)。静态锁把三件事写成断言:口径字面量与开关判据只许出现在本模块、两个 +// 壳都真的把 config 转交进来、读侧判据与写侧 summarize.js 逐字同源。谁要是再 +// 复制一段出去,这里直接转红。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DISTILL_RECORDS_DISABLED, + PROVENANCE_UNAVAILABLE, + isDistillRecordsEnabled, + parseProvenanceQuery, + provenanceReply +} from "../src/provenance-http.js"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须 +// 显式把开关打开——不传就是关(fail-closed),没有「默认开」这回事。 +const RECORDS_ON = { distillRecords: { enabled: true } }; + +function fakeService(over = {}) { + return { + provenanceFor: (id) => over.links ?? [], + 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)} 不该通过`); + assert.equal(r.status, 400); + assert.equal(r.error, "missing-id"); + } + assert.deepEqual(parseProvenanceQuery(" m1 "), { ok: true, id: "m1" }, "id 两端空白要裁掉"); +}); + +test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => { + const seen = []; + const service = { + provenanceFor: () => [{ session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-10-23T00:00:00Z" }], + distillRecordsFor: (sessionId, from, to) => { + seen.push([sessionId, from, to]); + return [{ seq: 12 }, { seq: 10 }, { seq: 11 }]; + } + }; + const r = provenanceReply({ service, id: "m1", config: RECORDS_ON }); + assert.equal(r.status, 200); + assert.equal(r.body.memoryId, "m1"); + assert.equal(r.body.windows.length, 1); + const w = r.body.windows[0]; + assert.equal(w.session_id, "s1"); + assert.equal(w.seq_start, 10); + assert.equal(w.seq_end, 12); + assert.equal(w.created_at, "2026-10-23T00:00:00Z"); + assert.deepEqual(w.records.map((x) => x.seq), [12, 10, 11], "次序由 store 的 ORDER BY seq 保证,答复层不改序"); + assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store"); +}); + +test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => { + const r = provenanceReply({ service: fakeService(), id: "m1", config: RECORDS_ON }); + assert.equal(r.status, 200); + assert.deepEqual(r.body, { memoryId: "m1", windows: [] }); +}); + +test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => { + // headless / 旧装配:service 上没有这两个方法。 + const r = provenanceReply({ service: {}, id: "m1", config: RECORDS_ON }); + assert.equal(r.status, 503); + assert.equal(r.body.error, PROVENANCE_UNAVAILABLE); +}); + +test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用方", () => { + const warnings = []; + const service = { + provenanceFor: () => { throw new Error("db locked"); }, + distillRecordsFor: () => [] + }; + const r = provenanceReply({ service, id: "m1", config: RECORDS_ON, warn: (m) => warnings.push(m) }); + assert.equal(r.status, 500); + assert.equal(r.body.error, "internal"); + assert.equal(warnings.length, 1, "失败要留痕"); + assert.match(warnings[0], /memory provenance read failed/); +}); + +// --- distillRecords.enabled 门(#509 评审整改)-------------------------------- +// 写侧(src/summarize.js:988)早就吃这个开关,读出口此前一处都不读:关着时历史 +// 原文照旧留在库里、还多一个能读出去的口。门加在共用模块里,两个壳都吃它。 + +test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail-closed)", () => { + assert.equal(isDistillRecordsEnabled({ distillRecords: { enabled: true } }), true); + for (const cfg of [ + null, undefined, {}, { distillRecords: null }, { distillRecords: {} }, + { distillRecords: { enabled: false } }, + // 非布尔值:字符串 / 数字都不该被当成开——那是配置写错了,不是用户开了 + { distillRecords: { enabled: "true" } }, + { distillRecords: { enabled: 1 } } + ]) { + assert.equal(isDistillRecordsEnabled(cfg), false, `${JSON.stringify(cfg)} 必须算关`); + } +}); + +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 }); + assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`); + assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED }, + "body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」"); + } +}); + +test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => { + // 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。 + const r = provenanceReply({ service: {}, id: "m1", config: { distillRecords: { enabled: false } } }); + assert.equal(r.status, 409); + assert.equal(r.body.error, DISTILL_RECORDS_DISABLED); +}); + +test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)", () => { + // 门要在读之前:关着还被调用方当成「能读」是次要的,白跑一趟库才是真浪费。 + let touched = 0; + const service = fakeService({ service: { + provenanceFor: () => { touched++; return []; }, + distillRecordsFor: () => { touched++; return []; } + } }); + provenanceReply({ service, id: "m1", 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 }); + assert.equal(on.status, 200); + assert.equal(on.body.windows.length, 1); + const off = provenanceReply({ service, id: "m1", config: { distillRecords: { enabled: false } } }); + assert.equal(off.status, 409); + assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED }); +}); + +test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => { + // #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。 + for (const name of ["api.js", "api-standalone.js"]) { + const src = readFileSync(join(ROOT, "src", name), "utf8"); + assert.ok(src.includes("provenance-http.js"), `src/${name} 必须从共用模块取口径`); + assert.ok(src.includes("provenanceReply("), `src/${name} 要真的走共用答复`); + assert.ok(!src.includes("provenance-unavailable"), `src/${name} 不准自己写错误码字面量`); + assert.ok(!src.includes("distill-records-disabled"), `src/${name} 不准自己写「功能关了」的错误码字面量`); + // 判据也不许重写:两个壳各自算一遍 config?.distillRecords?.enabled === true, + // 就是读写两道门漂移的形状(本文件上面的注释写过这笔账)。 + assert.ok(!src.includes("distillRecords?.enabled === true"), + `src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`); + // 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。 + assert.match(src, /provenanceReply\(\{ service, id: parsed\.id, config,/, + `src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`); + } +}); + +test("🔴 静态锁:读侧判据与写侧 src/summarize.js 逐字同源", () => { + // 「读写两道门永不分歧」不能只活在注释里:写侧那句话在 summarize.js,读侧在本 + // 模块,两边必须是同一个字符串。谁改了其中一边,这里转红。 + const shared = readFileSync(join(ROOT, "src", "provenance-http.js"), "utf8"); + const writeSide = readFileSync(join(ROOT, "src", "summarize.js"), "utf8"); + const readSide = readFileSync(join(ROOT, "src", "index.js"), "utf8"); + const criterion = "config?.distillRecords?.enabled === true"; + assert.ok(shared.includes(criterion), "共用模块要写判据(读侧唯一一份)"); + assert.ok(writeSide.includes(criterion), "写侧 summarize.js 要还在用同一个判据"); + // 读侧只准有一份:两个壳各写一遍的漂移形状,静态锁上一节已经禁了壳,这里禁 + // 共用模块自己长出第二份。 + assert.equal(shared.split(criterion).length - 1, 1, "共用模块里判据只许出现一次"); + // 写侧那一处(启动期裁剪)与它同源,所以三处各一份、彼此逐字相同。 + assert.ok(readSide.includes("cfg.distillRecords?.enabled === true"), "index.js 的启动裁剪判据应保持同族"); +}); diff --git a/dsh-mneme/test/serve.test.js b/dsh-mneme/test/serve.test.js index 3104691a..c06a0bc3 100644 --- a/dsh-mneme/test/serve.test.js +++ b/dsh-mneme/test/serve.test.js @@ -4,6 +4,8 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServeRuntime } from "../src/serve.js"; +import { createStore } from "../src/store.js"; +import { createSettings } from "../src/settings.js"; // daemon 装配面锁(test/serve-bin.test.js 另有真子进程 + 多进程共存): // in-process 起真 HTTP(port 0,OS 分配),fetch 走 health / 401 / save / search / @@ -194,3 +196,63 @@ test("serve: 非法 embed provider 拒装配;embed=off 时 semantic 为 null", a try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ } } }); + +// --- GET /memory/provenance 的 distillRecords.enabled 门(#509 评审整改)-------- +// 单独立一条 daemon 级用例的理由:standalone 的既有用例是手搭 config 骗过门的, +// 而 daemon 的 cfg 来自 serve.js 自己装配(原本是最小集,一个 distillRecords 键都没有) +// ——不测真实装配,就会漏掉「daemon 恒定回功能关了」这一整类回归。 +async function seedFlags(dir, patch) { + const seed = createStore(join(dir, "memory.db")); + createSettings(seed.db).setFeatureFlags(patch); + seed.close(); +} + +test("serve: /memory/provenance 跟随面板开关(开着读得到)", async () => { + const dir = tmpDir(); + await seedFlags(dir, { "distillRecords.enabled": true }); + const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" }); + await rt.api.ready; + try { + const base = `http://127.0.0.1:${rt.api.port}`; + const auth = { authorization: `Bearer ${rt.api.token}` }; + // 先经数据面写一条记忆,再直接落溯源链接与原文(写入面不关心是谁写的)。 + const saved = await fetch(`${base}/memories`, { + method: "POST", headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }) + }); + const savedBody = await saved.json(); + const memoryId = savedBody.memory?.id ?? savedBody.id; + rt.store.saveMemoryProvenance({ memoryId, sessionId: "s1", seqStart: 10, seqEnd: 11, createdAt: "2026-10-23T00:00:00Z" }); + rt.store.saveDistillRecords([ + { sessionId: "s1", seq: 10, role: "user", text: "第一句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }, + { sessionId: "s1", seq: 11, role: "assistant", text: "第二句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" } + ]); + + const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(memoryId)}`, { headers: auth }); + assert.equal(res.status, 200, "面板开着的留存,daemon 必须读得到(serve.js 把开关展开进了 cfg)"); + const body = await res.json(); + assert.deepEqual(body.windows[0].records.map((r) => r.text), ["第一句原文", "第二句原文"]); + } finally { + rt.dispose(); + try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ } + } +}); + +test("serve: 面板从没设过 distillRecords.enabled → daemon 这一面按关处理(409)", async () => { + // 残留不对称(写进 docs/DAEMON.md):只靠 bundle 配置打开、从没在面板点过的用户, + // 宿主认、daemon 不认——daemon 结构上看不到宿主的 bundle config。这是已知的那一半, + // 不是本用例要修的;这里锁的是「没设过 → 关」这个 fail-closed 方向本身。 + const dir = tmpDir(); + const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" }); + await rt.api.ready; + try { + const res = await fetch(`http://127.0.0.1:${rt.api.port}/memory/provenance?id=m1`, { + headers: { authorization: `Bearer ${rt.api.token}` } + }); + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { error: "distill-records-disabled" }); + } finally { + rt.dispose(); + try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ } + } +}); diff --git a/dsh-mneme/test/standalone-api.test.js b/dsh-mneme/test/standalone-api.test.js index a9251a72..17f78376 100644 --- a/dsh-mneme/test/standalone-api.test.js +++ b/dsh-mneme/test/standalone-api.test.js @@ -1145,3 +1145,126 @@ test("维度探针状态机:五种读数各自归位(含 invalid 的 fail-cl close(); } }); + +// --- GET /memory/provenance:记忆级溯源读出口(#399 写、#421 读)------------- +// daemon 侧此前和宿主内 API 一样只有写入路径:库里有答案,外部拿不到。 +// +// #509 评审整改:这个面吃 distillRecords.enabled(与写侧同一个开关、同一个判据), +// 关着时 409 {error:"distill-records-disabled"}。createStandaloneApi 的 config 缺省 +// {},而 {} 在这道门上的含义就是「关」(fail-closed)——所以下面的用例必须显式把 +// 开关打开,没有一个「默认开」的路径。 +const RECORDS_ON = { distillRecords: { enabled: true } }; + +test("GET /memory/provenance returns windows with their original rows", async () => { + const { base, auth, store, close } = await setup({ config: RECORDS_ON }); + try { + const saved = await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const memoryId = saved.memory?.id ?? saved.id; + store.saveMemoryProvenance({ + memoryId, sessionId: "s1", seqStart: 10, seqEnd: 11, createdAt: "2026-10-23T00:00:00Z" + }); + store.saveDistillRecords([ + { sessionId: "s1", seq: 10, role: "user", text: "第一句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }, + { sessionId: "s1", seq: 11, role: "assistant", text: "第二句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" } + ]); + + const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(memoryId)}`, { headers: auth }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.memoryId, memoryId); + assert.equal(body.windows.length, 1); + assert.equal(body.windows[0].session_id, "s1"); + assert.deepEqual(body.windows[0].records.map((r) => r.text), ["第一句原文", "第二句原文"], "原文按 seq 升序"); + } finally { + close(); + } +}); + +test("GET /memory/provenance: a memory with no link answers with empty windows, not an error", async () => { + const { base, auth, close } = await setup({ config: RECORDS_ON }); + try { + const saved = await saveMemory(base, auth, { type: "preference", title: "语言", content: "中文" }); + const memoryId = saved.memory?.id ?? saved.id; + const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(memoryId)}`, { headers: auth }); + assert.equal(res.status, 200); + assert.deepEqual((await res.json()).windows, [], "「不是蒸馏来的」是有效答案,不是错误"); + } finally { + close(); + } +}); + +test("GET /memory/provenance is auth-gated and 400s on a missing id", async () => { + const { base, auth, close } = await setup({ config: RECORDS_ON }); + try { + const noToken = await fetch(`${base}/memory/provenance?id=x`); + assert.equal(noToken.status, 401, "只读端点也在 apiToken 之下(与 /memories 同口径)"); + + const missing = await fetch(`${base}/memory/provenance`, { headers: auth }); + assert.equal(missing.status, 400); + } finally { + close(); + } +}); + +test("GET /memory/provenance: distillRecords.enabled off answers 409, not 404 and not empty windows", async () => { + // #509 评审整改。四个刻度必须分开:401 没鉴权 / 400 缺 id / 409 用户关了 / + // 503 装配没接线 / 200 空 windows = 「这条记忆不是蒸馏来的」。把「关了」报成 404, + // 调用方会按本仓既有的「404 = 命名的东西不存在」(/memories/:id)读成「这条记忆 + // 不存在」——而这两种误读的后续动作完全不同(一个去开开关,一个去查这条记忆)。 + const { base, auth, close } = await setup({ config: { distillRecords: { enabled: false } } }); + try { + const saved = await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const memoryId = saved.memory?.id ?? saved.id; + const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(memoryId)}`, { headers: auth }); + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { error: "distill-records-disabled" }, "body 只准 error 一个键"); + } finally { + close(); + } +}); + +test("GET /memory/provenance: no config at all is fail-closed (409)", async () => { + // 构造时不传 config(createStandaloneApi 的默认 {}):这个端点返回逐字原文, + // 默认方向必须是「不读」。别把 409 当 bug 修成默认开。 + const { base, auth, close } = await setup(); + try { + const res = await fetch(`${base}/memory/provenance?id=m1`, { headers: auth }); + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { error: "distill-records-disabled" }); + } finally { + close(); + } +}); + +test("GET /memory/provenance: 400 beats 409 and 409 is not an existence oracle", async () => { + const { base, auth, close } = await setup({ config: { distillRecords: { enabled: false } } }); + try { + const missing = await fetch(`${base}/memory/provenance`, { headers: auth }); + assert.equal(missing.status, 400, "缺 id 是语法畸形,排在功能开关之前"); + + // 关着时已知 id 与未知 id 同一个答案,否则这个端点能用来枚举「哪些记忆有原文」。 + const saved = await saveMemory(base, auth, { type: "preference", title: "语言", content: "中文" }); + const known = saved.memory?.id ?? saved.id; + for (const id of [known, "nope"]) { + const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(id)}`, { headers: auth }); + assert.equal(res.status, 409, `${id} → 409`); + assert.deepEqual(await res.json(), { error: "distill-records-disabled" }); + } + } finally { + close(); + } +}); + +test("GET /memory/provenance: 409 beats 503 — feature off is more specific than unwired", async () => { + // 关着 + service 上连 distillRecordsFor 都没有(旧装配 / headless):报 503 会让人 + // 去查一个没坏的部署。与 api.js:525-526 立的两段式同构。 + const { base, auth, service, close } = await setup({ config: { distillRecords: { enabled: false } } }); + try { + delete service.distillRecordsFor; + const res = await fetch(`${base}/memory/provenance?id=x`, { headers: auth }); + assert.equal(res.status, 409); + assert.equal((await res.json()).error, "distill-records-disabled"); + } finally { + close(); + } +});