@modusensus/dsh-mneme 0.9.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +9 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +25 -5
  9. package/lib/client.js +137 -10
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/index.js +15 -6
  17. package/lib/inject.js +9 -4
  18. package/lib/lang.js +17 -12
  19. package/lib/pipeline-nudge-auto.js +3 -0
  20. package/lib/pipeline-nudge.js +41 -8
  21. package/lib/pipeline-runner.js +15 -0
  22. package/lib/pre-inject-gate.js +14 -0
  23. package/lib/profile-extract.js +4 -1
  24. package/lib/provenance-http.js +162 -37
  25. package/lib/service.js +30 -0
  26. package/lib/session-gate.js +91 -0
  27. package/lib/settings.js +13 -1
  28. package/lib/store.js +526 -18
  29. package/lib/summarize.js +152 -15
  30. package/lib/tools.js +26 -6
  31. package/package.json +4 -2
  32. package/src/api-standalone.js +11 -3
  33. package/src/api.js +25 -5
  34. package/src/config.js +33 -1
  35. package/src/continuity.js +11 -3
  36. package/src/dream/decisions.js +88 -27
  37. package/src/dream/sleep.js +467 -81
  38. package/src/dream.js +110 -13
  39. package/src/fence.js +24 -0
  40. package/src/index.js +15 -6
  41. package/src/inject.js +9 -4
  42. package/src/lang.js +17 -12
  43. package/src/pipeline-nudge-auto.js +3 -0
  44. package/src/pipeline-nudge.js +41 -8
  45. package/src/pipeline-runner.js +15 -0
  46. package/src/pre-inject-gate.js +14 -0
  47. package/src/profile-extract.js +4 -1
  48. package/src/provenance-http.js +162 -37
  49. package/src/service.js +30 -0
  50. package/src/session-gate.js +91 -0
  51. package/src/settings.js +13 -1
  52. package/src/store.js +526 -18
  53. package/src/summarize.js +152 -15
  54. package/src/tools.js +26 -6
  55. package/test/api.test.js +153 -4
  56. package/test/client.test.js +215 -0
  57. package/test/config.test.js +8 -0
  58. package/test/conflict-reviewed.test.js +79 -0
  59. package/test/continuity.test.js +29 -1
  60. package/test/dependency-overrides.test.js +78 -0
  61. package/test/differentiate-feedback.test.js +248 -0
  62. package/test/dream-decision-evidence.test.js +314 -0
  63. package/test/dream-fence.test.js +43 -0
  64. package/test/inject-pools.test.js +2 -1
  65. package/test/mint-source.test.js +83 -0
  66. package/test/pipeline-nudge-auto.test.js +26 -2
  67. package/test/pipeline-nudge.test.js +87 -0
  68. package/test/pipeline-runner.test.js +26 -0
  69. package/test/prompt-stance.test.js +128 -0
  70. package/test/provenance-http.test.js +200 -17
  71. package/test/release-docs.test.js +7 -1
  72. package/test/security-doc.test.js +43 -4
  73. package/test/session-gate.test.js +264 -0
  74. package/test/settings.test.js +4 -0
  75. package/test/sleep-action-set.test.js +16 -11
  76. package/test/sleep-catchup.test.js +315 -0
  77. package/test/sleep-failure-backoff.test.js +156 -0
  78. package/test/sleep.test.js +293 -0
  79. package/test/standalone-api.test.js +50 -0
  80. package/test/store.test.js +220 -0
  81. package/test/summarize.test.js +172 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-2541%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-2660%20passed-success)](https://github.com/slow-stack/mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -214,6 +214,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
214
214
 
215
215
  每次**后台 LLM 调用**都会写入 `llm_audit_logs` 表:`tokens` / `duration` / `status` / `source`(由哪个触发产生)。失败调用记为 `status=error`,绝不阻塞功能本体;`retentionDays`(默认 90)在启动时清理超期行。
216
216
 
217
+ 巩固那一轮还有一层**事后判决**:调用成功不等于决策可用,而校验发生在调用之后。判决以 `metadata.decision_validation`(`verdict` / `error_count` / `skipped_count` / `survivor_count` / `invalid_actions`)浅合并回同一行——`status`、tokens、时间戳一律不改,它们是调用层的事实。`verdict` 四档:`unparsed`(没解析出 JSON 数组)/ `rejected`(整单拒绝)/ `degraded`(只应用了合法子集)/ `ok`。有了这一层,「调用成功率」与「整轮有效率」才对得上账。
218
+
217
219
  覆盖的后台调用(`source` 即触发源,`operation_type` 区分同一源下的不同调用):
218
220
 
219
221
  | `source` | `operation_type` | 说明 |
@@ -281,7 +283,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
281
283
  | **v0.9.2**(2026-10-19) | 让「看不见的失败」显形:功能开关的第三态「待重启」把「面板显示已改 ≠ 运行时已改」摆到界面上(#481);作用域级可逆闸门让一路记忆能被单独按下与松开、不必改配置重启(#471,默认关);巩固用的 `dreamMaxTokens` 默认值撞服务商上限、八天 180 次全失败,面板现在能改、超上限还会自动降级重试(#478);CLI 把 `--` 开头的值当新旗标的真实缺陷(一次 1/4096 概率的 CI 红揪出来的)、人格卡删除 × 永远不可见、自动蒸馏写入的行 100% 拿不到向量(#477) |
282
284
  | **v0.9.3**(2026-10-20) | M.O. 从「挖得出来」到「会被叫去挖」:沉淀链算法自 v0.9.1 起就完整实现(多帧 zstd → 重复序列 → 缺席对照 → draft 草稿),运行时却没有任何调用点——机器挖得出来、却永远不会被叫去挖。这一版把归属与触发接上:按会话头 `cwd` 反查项目桶(桶名是路径的有损变形,查不到整跑跳过而不是降级成全局普查)、挂 `turn/end`、冷却 10 分钟、单次预算 4 秒、延到宏任务、一次一个项目、抛错只 `warn`,**只产提议不产草稿**(`actionNudgeEnabled`,beta,默认关);面板加「现在沉淀一次」,与自动跑同一条入口,区别只有它不改仓库;另修作用域反查自 v0.8.0 起每次都抛错走降级(#487)、`/nudge` 三处「没看全」被报成「确实没有」(#489 评审)、多帧 zstd 反证断言绑了 Node 整块解语义(#490),并把整个 M.O. 的「手法」改回「工作流」 |
283
285
  | **v0.9.4**(2026-10-21) | 三条链各自补上「最后一环」——东西早就造好了,只差没人能叫它:**agent 主动整理**(`memory_organize`,先 dryRun 出比对报告 → agent 判断 → apply 落库,筛除只归档不删、全程留审计回执;`organizeEnabled` 默认关,关时工具根本不注册);**画像提取草稿**(面板按钮读活跃记忆 + 一次模型调用,产一段「助手眼中的用户」草稿,`profileExtractEnabled` 默认关)——**只出草稿**,采用是用户再点一次,手写的画像原文不会被模型改写,空库与解析失败都直接报错、绝不回空草稿冒充「没提炼出东西」;**人格寻址收口**(`default` 卡以前无条件落给所有没建卡的身份,宿主里自带身份的 bot 每帧收到两句「你是谁」;`personaStrictAddressing` 默认关,把「没建卡」与「卡被显式关掉」都收回为不注入)。同版补蒸馏的**转录围栏 + 解析失败护栏**(#494:一条会话被同一窗口卡死、重试 20 次越试越贵的根因;失败行现在恒带原始输出形状),把面板自称「注入生效文本」的只读区改成后端单源渲染(并修它从不显示人格行),评审另收掉两处「坏了却不报」:整理报告侧的存在性探测口(`strictScope` 下按调用方可见性过滤落点并重算计数)、画像保存不判 `res.ok` 却提示已保存 |
284
- | **v0.9.5**(2026-10-30) | 记忆能安全地递给别人,也能整包收回来:**分享包 `mneme.share/v1`**(`dsh-mneme share` / `receive` / `undo-share`,面板「⋯」里同一批三件事)只含活跃行,`agent_scope` / `workspace_scope` 与本机 home 前缀一律不进包,命中密钥 / PII 的行**整条不进**(不打码);出包先给报告(进包数 / 折叠数 / 六个跳过原因)再给文件,3 MiB 水位量在紧凑序列化的字节上,超限回 413 与同一份报告,分批靠 `--type` / `--tag`;入包一条不合格**整包拒**、干跑是默认、按 `share_external_id` 幂等,整包退出是物删而不是归档(归档会占住去重键,重导即撞)。「本机巩固不许改写别人导入的原文」由单一谓词 `isImportedRow` 守在 `saveWithDedupe` 汇流口与九个行池上。同版给记忆来源加了读出口 `GET /memory/provenance`(#421,@z2Ace0107 实现,依附既有 `distillRecords.enabled`:409 = 用户关了、503 = 装配坏了),修掉同标题记忆被重复写第二份(#505)、模式扫描谎报「没东西可扫」(#515)、五处巩固 prompt 裸放导致的 46K token 空转(#501 / #493)与 dream 合并护栏的整类误拦(#521),面板新增设置页粘顶分组目录(#518)与宿主极简预设的在场说明(#514) |
286
+ | **v0.9.5**(2026-10-30) | 记忆能安全地递给别人,也能整包收回来:**分享包 `mneme.share/v1`**(`dsh-mneme share` / `receive` / `undo-share`,面板「⋯」里同一批三件事)只含活跃行,scope 与 home 前缀不进包,命中密钥 / PII 的行整条不进;出包先给报告再给文件,超 3 MiB 水位回 413、按 `--type` / `--tag` 分批;入包一条不合格整包拒、干跑为默认、按 `share_external_id` 幂等,整包退出走物删。「本机巩固不许改写别人导入的原文」由单一谓词 `isImportedRow` 守在汇流口与九个行池上。同版给记忆来源加了读出口 `GET /memory/provenance`(#421,@z2Ace0107 实现;409 = 用户关了、503 = 装配坏了),修掉同标题重复入库(#505)、扫描谎报「没东西可扫」(#515)、五处未围栏的巩固 prompt(#501)与 dream 合并护栏整类误拦(#521),面板新增设置页分组目录(#518) |
287
+ | **v0.10.0**(2026-11-02) | 归因与闸门——给已经发生的事补上它自己的身份:**蒸馏有了运行身份**(窗口级 `distill_runs` 记 `ok`/`degraded`/`noop`/`failed`/`aborted` 与 `audit_id`,行级 `memory_mints` 记「这条记忆出生自哪一轮巩固」;历史行留空**不猜**,失败轮也真记一行),此前归因只到「会话区间」——那是输入的坐标不是运行的身份;**溯源补上反方向** `GET /memory/provenance?sessionId=`(两个主键互斥,`limit` 越界 400 不静默截断,撞上限带 `truncated: true`);**会话级无痕闸门** `sessionGateEnabled`(指定会话与子代理不进不出,其余会话照常;子代理判据只认宿主委派标记,`parentSession` 不是判据——本机 641 个会话头实测会误判 3 个 fork 主会话);**防谄媚补到写入面**(蒸馏与总览加「立场纪律」四条与「叙述纪律」,排在与时间换算的指令之前)。另修 `differentiate` 正反馈闭环(注记改落 `differentiated_pairs` 账本、正文一个字节都改不了,实测单条曾堆 19 段)、sleep 冲突相位的输入侧预算与续排(分批后不再隔夜才轮到)、面板「最近巩固」区分 organize 回执(#495)、面板首次有移动端与 481–519px 中间带断点(#542) |
285
288
 
286
289
  | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
287
290
 
@@ -533,7 +536,8 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz"
533
536
  | `GET` | `/profile` | 用户画像,返回 `{profile:"..."}`;未设置时为空字符串 |
534
537
  | `GET` | `/rules` | 行为规则列表,返回 `{rules:[...]}`;未设置时为空数组 |
535
538
  | `GET` | `/memories?limit&offset&type&minImportance&source&order=chrono&include_archived&occurred_from&occurred_to` | 分页列出记忆(`include_archived=true` 含归档;occurred 时间窗按 `occurred_at` 回退 `created_at`) |
536
- | `GET` | `/memory/provenance?id=<memoryId>` | 记忆级溯源读出口(#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) |
539
+ | `GET` | `/memory/provenance?id=<memoryId>` 或 `?sessionId=<sid>`(可选 `&limit=`,默认 20、上限 100) | 溯源读出口,两个方向共用一条路径(#421 拆出两条:方向 1 记忆→窗口由 #509 交付,方向 2 会话→窗口由 #524 补上;宿主内面板路由 `/api/dsh-mneme/memory/provenance` 同一口径)。**两个主键互斥**:都缺 `400 {"error":"missing-id"}`、都给 `400 {"error":"ambiguous-id"}`(刻意不静默择一——两个方向的结果形状不同,猜一个只会在出事之后才发现)。方向 1 是一条溯源链接一个窗口 `{session_id, seq_start, seq_end, created_at, distill_run_id, run_type, trigger_source, run_status, records[]}`(#512 补运行归因:run 是归因主体,窗口只是它的一批输入;读不到整轮归因时整组 `null`,**不从区间或时间戳反推**),答复级另带 `mints[]`(行级归因:这条记忆出生自哪一轮 dream/sleep,只填 create);方向 2 把同一个 `(session_id, seq_start, seq_end)` 合并成一项(`GROUP BY`),带 `link_count`=这个窗口派生出几条记忆,按 `seq_start` 排——逐链接返回会让「这个会话蒸了几次」被重复行淹没。两个方向都带 `truncated`(撞上 limit 时说「还有更多」;少了它,被截断的答复与「本来就这么少」逐字节相同)。`records` 是那段蒸馏窗口的**逐条会话原文**(按 `seq` 升序、不越出区间)。没有链接返回**空 `windows`**——「不是蒸馏来的」是有效答案,不是错误。状态码阶梯:`401` 无 token → `400` 缺参/多参/`invalid-limit` → `409 {"error":"distill-records-disabled"}`(`distillRecords.enabled` 关着;该开关是**启动快照**,面板改完要到下次启动生效,面板会显示「待重启」)→ `503 {"error":"provenance-unavailable"}`(该方向要用的方法没接线,**按方向判**——只缺 session 侧方法时方向 1 仍然 200)→ `200`。`409` 与 `503` 必须分开读数:前者是「用户关了」,后者是「装配坏了」。**关开关 ≠ 删掉已留存原文**——只是不再经 HTTP 提供,盘上的行要 `dsh-mneme reclaim`(或重新开启后的启动裁剪)才走,详见 [docs/DISTILL.md](docs/DISTILL.md) |
540
+
537
541
  | `GET` | `/memories/:id` | 单条记忆 |
538
542
  | `POST` | `/memories` | 新增记忆 `{type,title,content,importance?,tags?,source?,sensitivity?,occurred_at?,agent_scope?,workspace_scope?}`;响应含 `action: created\|merged` |
539
543
  | `PUT` | `/memories/:id` | 局部更新 `{title?,content?,type?,importance?,tags?,reason?,agent_scope?,workspace_scope?}`;content 改写按 human_override 入档 |
@@ -717,7 +721,7 @@ src/
717
721
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
718
722
  └── index.js # 插件接线
719
723
  lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
720
- test/ # 2541 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
724
+ test/ # 2660 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
721
725
  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 运行时清单
722
726
  ```
723
727
 
@@ -726,7 +730,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
726
730
  ```bash
727
731
  cd dsh-mneme
728
732
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
729
- npm test # 运行 2541 个测试
733
+ npm test # 运行 2660 个测试
730
734
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
731
735
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
732
736
  ```
@@ -21,6 +21,22 @@
21
21
  | `memoryDir` | `~/.dsh/memory` | SQLite 记忆库目录 | WAL + busy_timeout 已为多进程就绪;两个宿主共用库时 `externalApiEnabled` 与 `autoDream` 只能一边开 |
22
22
  | `lightMode` | `false` | 轻量档总闸:一键关掉全部重型路径,保留核心循环(见文末清单) | 面板 `panel_mode=light` 等效为 `true` 且优先于 bundle 配置 |
23
23
 
24
+ ## 逐会话无痕(issue #539)
25
+
26
+ 命中会话后,Mneme 不向该会话注入记忆、用户设置或工具指引,不接受其 `memory_*` / `set_scope_state` 工具调用,不对它蒸馏,也不由压缩抢救、M.O. 记账或自动提议路径保存该会话的内容。
27
+
28
+ 自动匹配**只认宿主自己的委派标记**:会话头里 `origin === "subagent"`,或 `delegationDepth` 是正数。**`parentSession` 不是判据**——`sessions.fork()` 造出来的主会话分支同样写这个字段(fork 只写 cwd / parentSession / isSeeded,不写 origin、不带 delegationDepth),把它当判据就是「fork 一下就整体失忆」。兼容别名 `parentSessionId` 同理不读;旧形状 `requestHeader().config` 仍作为取头的第二条路。captain 会话不会因其有子代理而自动命中,队长侧对交付物的蒸馏仍由队长会话本身决定(#211 的边界)。
29
+
30
+ | 键 | 默认 | 作用 | 开启后果 / 冲突 |
31
+ |---|---|---|---|
32
+ | `sessionGateEnabled` | `false` | 逐会话无痕总开关;可通过 bundle 配置或 `/api/dsh-mneme/features` 设置 | opt-in;关闭时恢复既有 Mneme 行为。feature flag 按现有设置契约在重启后生效 |
33
+ | `sessionGateSubagents` | `true` | 总开关开启时,自动命中子代理会话;可通过 bundle 配置或 `/api/dsh-mneme/features` 设置 | 关闭后仍保留手动名单匹配;单独打开本键不生效 |
34
+ | `sessionGateSessions` | `[]` | 手动指定精确 session id 的数组,只能写在 bundle 配置 | 手动匹配与子代理识别互相独立;改完配置后由宿主重新装载插件生效 |
35
+
36
+ 逐会话消费点会直接检查当前 `Session`,因此首个 `session/event` 到达前也能识别完整会话头;**拿不到 id 也照判**(注入面在上下文组装期就跑在这条路径上,那时未必有可用 id)。事件订阅另外缓存已观察到的子会话 id,供会话日志扫描过滤——那张表只服务「拿得到 id、却解析不出头」的退路,有界淘汰,淘汰只影响那一次日志过滤。面板的 `/features` 只支持标量开关,不持久化 `sessionGateSessions` 数组。
37
+
38
+ 边界:不带调用会话上下文的管理 API(如手动 `/nudge`)仍按管理操作处理,不继承某一个会话的闸门。
39
+
24
40
  ## scope 隔离(issue #17)
25
41
 
26
42
  | 键 | 默认 | 作用 | 开启后果 / 冲突 |
@@ -124,6 +140,8 @@
124
140
 
125
141
  `enabled` 这一格**同时管读写两侧**(#509 评审整改后):写侧是蒸馏时是否落盘(`src/summarize.js:988`),读侧是溯源读出口是否把原文读出去(判据在 `src/provenance-http.js`,与写侧逐字同源,只此一份)。关着时读出口回 `409 {"error":"distill-records-disabled"}`——但**盘上的原文不会因此消失**:保留期裁剪与写入同在 `src/index.js:270` 一道门里,关掉开关意味着「不再写、启动期也不再裁」,已留的行会一直躺在库里,直到 `dsh-mneme reclaim`(它不读这个键,手动可删)或重新开启后的启动裁剪。所以「关掉就干净了」是误读,诚实的表述是「关掉 = 不再经 HTTP 提供」。
126
142
 
143
+ **运行归因(#512)不加开关**:溯源窗口另外带 `distill_run_id / run_type / trigger_source / run_status`,答复级另带 `mints[]`(这条记忆出生自哪一轮 dream / sleep)。它记的是归因元数据、不含任何会话正文,所以不依附 `distillRecords.enabled`——上面那枚开关关着时端点照旧 409,但 `distill_runs` 与 `memory_mints` 两张表仍在记账。改造之前写下的行刻意留 NULL、不补:反推一个「看起来能追」的合成 id 比没有归因更坏。
144
+
127
145
  ## 注入
128
146
 
129
147
  | 键 | 默认 | 作用 | 开启后果 / 冲突 |
@@ -196,6 +214,9 @@
196
214
  | `sleepProvider` / `sleepModel` | 空 | sleep 专用路由 | 空 = 用巩固路由 / 当前模型 |
197
215
  | `sleepReasoningEffort` | 未配置 | 同 `dreamReasoningEffort` | — |
198
216
  | `sleepMaxTokens` | `8192` | 冲突/模式阶段输出预算(#257) | 原硬编码 2048 对 `full` 档必然截断 |
217
+ | `sleepConflictMaxPairs` | `12` | **单批**送审的冲突对上限(0–500,0 = 不设上限回退旧行为) | 输入侧预算(#529):整池一次塞进一个 prompt 实测 163,054 token(占 `--ctx-size` 99.5%) |
218
+ | `sleepConflictMaxPairsPerRun` | `60` | **单轮**最多送审多少对(0–5000,0 = 不设上限) | 与上一条分工:一个管批次大小、一个管整轮总量;剩下的一轮留给下一轮 |
219
+ | `sleepFailureBackoff` | `false` | sleep 连续失败退避(#529) | 有效间隔 = `sleepMinIntervalHours` × 2^连败数(成功清零,封顶 `max(sleepMinIntervalHours, 24h)`);opt-in |
199
220
  | `sleepHeatThreshold` | `0.05` | 降级联合判定的热度下限(heat < 值且 importance<5 才降) | `heatEnabled` 关时退回纯时间分层 |
200
221
 
201
222
  ## 反思与冲突(dream 配套)
package/docs/DAEMON.md CHANGED
@@ -14,9 +14,13 @@ daemon 是**数据面**,不是第二个宿主:
14
14
 
15
15
  ## 2. 对外接口
16
16
 
17
- 路由面 = `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)。
17
+ 路由面 = `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)。
18
18
 
19
- `/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 这一面就读得到。
19
+ `/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`。
20
+
21
+ 两个读方向共用同一条路径(#524 补上方向 2):`?id=<memoryId>` 是「这条记忆派生自哪些窗口」,`?sessionId=<sid>` 是「这个会话蒸出了哪些窗口」;两个主键**互斥**,都缺与都给都回 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 这一面就读得到。
22
+
23
+ `/memory/provenance` 的响应形状(#512):窗口在 `session_id/seq_start/seq_end/created_at/records` 之外另带 `distill_run_id / run_type / trigger_source / run_status`(读不到整轮归因时整组 `null`,不从区间或时间戳反推),答复级再带 `mints[]`(行级归因:这条记忆出生自哪一轮 dream/sleep)。归因元数据不受 `distillRecords.enabled` 约束——它不含任何会话正文,所以开关关着时这个端点照旧 409,但库里那两张表仍在记账。
20
24
 
21
25
  残留不对称要知道:daemon 结构上看不到宿主的 **bundle config**(第一期不吃宿主 config schema),所以「只写在部署侧 bundle 配置里打开、从没在面板点过」的用户,宿主认、daemon 不认——daemon 会对每个请求回 409。这不是故障,是 daemon 装配面的已知边界;要让 daemon 也认,去面板里把开关显式打开一次(面板的「待重启」提示同样适用:开关是启动快照,改完要重启才生效)。
22
26
 
package/docs/DISTILL.md CHANGED
@@ -166,25 +166,47 @@ cleared: { dream_run_inputs, archived_embeddings, distill_records }
166
166
  - **白名单与往返**:两个键过 `settings` 校验(含范围外整数被拒),PUT 往返后 effective 读得回;
167
167
  - **轻量模式**:预设关 `enabled`、保留用户显式设的 `retentionDays`、不污染用户对象。
168
168
 
169
- ## 10. 读出口:`/memory/provenance` 与 `distillRecords.enabled` 门(#421 写、#509 整改)
169
+ ## 10. 读出口:`/memory/provenance` 与 `distillRecords.enabled` 门(#421 写、#509 整改、#524 补方向 2)
170
170
 
171
171
  两张表自 #399 起只有写入路径:库里有答案、`service.provenanceFor` 能查,外部拿不到——
172
172
  「这条记忆是从哪几句蒸出来的」只能开 sqlite 手写 JOIN。#421 补上读出口,两个入口共用
173
173
  一份实现:
174
174
 
175
175
  - 宿主内面板路由 `GET /api/dsh-mneme/memory/provenance`(`policy: token`);
176
- - Bearer 数据面 `GET /memory/provenance?id=<memoryId>`(daemon 与宿主外部访问同享)。
176
+ - Bearer 数据面 `GET /memory/provenance`(daemon 与宿主外部访问同享)。
177
+
178
+ **两个读方向共用同一条路径**(#524):`?id=<memoryId>` 是「这条记忆派生自哪些窗口」
179
+ (方向 1,`store.provenanceFor`),`?sessionId=<sid>` 是「这个会话蒸出了哪些窗口」
180
+ (方向 2,`store.distillWindowsFor`)。两个主键**互斥**:都缺 `400 missing-id`、都给
181
+ `400 ambiguous-id`。**刻意不做「都给则择一」**——择一意味着调用方可以发一个自己也不
182
+ 知道会被读哪个主键的请求,而两个方向的答复形状不同(`memoryId` / `sessionId` 各认
183
+ 领自己那一侧),那种静默选择只会在出事之后才被发现。
184
+
185
+ 为什么不新开一条路径:多一条路径就多四处要登记的地方(`SECURITY.md` 两张表、包
186
+ README 路由表、`test/security-doc.test.js` 的清单锁),而两个方向读的是**同一批数
187
+ 据**,只是入口不同。`SECURITY.md` 只登记路径、不登记 query 形状,所以方向 2 不需要
188
+ 在那两张表里再加一行。
177
189
 
178
190
  口径只写在 `src/provenance-http.js` 一处,两个壳都调它——#411 已经为 reclaim / share 各
179
191
  踩过一次「两张皮整段复制必然漂移」,`test/provenance-http.test.js` 的静态锁把「错误码字面
180
192
  量与开关判据只许出现在本模块、两个壳都真的把 `config` 转交进来、读侧判据与写侧
181
193
  `src/summarize.js:988` 逐字同源」写成断言。
182
194
 
183
- **形状**:一个链接一个窗口 `{session_id, seq_start, seq_end, created_at, records[]}`,
195
+ **形状**:窗口在 `{session_id, seq_start, seq_end, created_at, records[]}` 之外按方向分叉——方向 1(`?id=`)每行是一个溯源链接,另带 `distill_run_id / run_type / trigger_source / run_status`(后四个字段见 §11),答复级再带 `mints[]`;方向 2(`?sessionId=`)把同一窗口的多条链接合并成一行,另带 `link_count`。**run 归因只挂在方向 1 上**:方向 2 的一行是一个窗口、通常派生多条记忆,没有单一归因主体,要逐条查仍走方向 1。
184
196
  `records` 是区间内的逐条会话原文(按 `seq` 升序、不越界)。没有链接的记忆返回**空
185
197
  `windows`**——「不是蒸馏来的」是有效答案,不是错误;未知 id 同样回空 `windows`(读端点
186
198
  不替调用方判断记忆是否存在)。到期裁剪后链接保留、指向空区间,面板据此显示「原文已过期」。
187
199
 
200
+ `link_count` 只有方向 2 会真的不等于 1(方向 1 的每一行本来就是「一条记忆 → 一个窗
201
+ 口」,用 `?? 1` 兜住,免得答复凭空多一个 null 键)。方向 2 的存储读法是
202
+ `GROUP BY session_id, seq_start, seq_end`——`memory_provenance` 的粒度是「一条记忆 →
203
+ 一个窗口」,一次蒸馏窗口通常产出**多条**记忆,逐链接返回会让「这个会话蒸了几次」这
204
+ 个唯一有意义的计数被重复行淹没。`link_count` 是按窗口看的第二维;**刻意不回
205
+ `memory_id` 列表**——方向 1 已经是「记忆 → 窗口」,把 id 也塞进这一侧会让两个方向不
206
+ 再正交(同一份映射有两条读法就得有两处维护),真要逐条查仍走方向 1。
207
+ `ORDER BY seq_start` 而不是 `created_at`:seq 是事件序号、就是会话内的先后,按它排才
208
+ 是「这个会话按时间蒸了哪几段」,按 `created_at` 会把同一批重跑的行打散。
209
+
188
210
  **开关门(#509 评审整改)**:读出口吃 `distillRecords.enabled`——与写侧**同一枚开关、同一
189
211
  个判据表达式**(`config?.distillRecords?.enabled === true`,读侧唯一一份在
190
212
  `isDistillRecordsEnabled`)。此前全树只有 `src/index.js:270`(启动期裁剪)与
@@ -194,10 +216,26 @@ cleared: { dream_run_inputs, archived_embeddings, distill_records }
194
216
  | 状态码 | 含义 | 为什么不是别的 |
195
217
  |---|---|---|
196
218
  | `401` | 没带 token | 两个壳各自的门,在共用模块之前 |
197
- | `400 {"error":"missing-id"}` | 缺 `id` | 语法畸形,与服务端状态无关,所以排在功能开关之前(与 reclaim 的 `invalid-json` 同档) |
219
+ | `400 {"error":"missing-id"}` | 两个主键一个都没给 | 语法畸形,与服务端状态无关,所以排在功能开关之前(与 reclaim 的 `invalid-json` 同档) |
220
+ | `400 {"error":"ambiguous-id"}` | 两个主键都给了 | 不静默择一,理由见上;同为语法类,同样排在开关之前 |
221
+ | `400 {"error":"invalid-limit"}` | `limit` 畸形或越界(只收十进制整数字面量,1–100) | **不用 `Number()`**:它把 `""` / `" "` / `0x10` / `1e3` 都化成数,于是 `?limit=` 会静默变成 0 个窗口——一个畸形输入拿到最坏解释。越界也给 400 而不是截到 100:调用方要 1000 个窗口,实际给 100 却不吭声比直接拒更糟 |
198
222
  | `409 {"error":"distill-records-disabled"}` | 用户关了 `distillRecords.enabled` | 与 `src/api.js:525` 的 `profileExtractEnabled` 同型:**用户关了 = 409,装配坏了 = 503**,两个答案必须可区分(`src/index.js:589-591`) |
199
- | `503 {"error":"provenance-unavailable"}` | service 上没这两个方法(headless / 旧装配) | 「没接线」不是「这条记忆没有来源」,两种空必须分开读数 |
200
- | `200` | `{memoryId, windows}` | 空 `windows` = 不是蒸馏来的 |
223
+ | `503 {"error":"provenance-unavailable"}` | service 上缺**该方向**要用的方法(headless / 旧装配) | 「没接线」不是「这条记忆没有来源」,两种空必须分开读数。**按方向判**:只缺 session 那侧的方法时方向 1 仍然 200——把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署 |
224
+ | `200` | `{memoryId\|sessionId, windows, truncated}` | 空 `windows` = 不是蒸馏来的;`truncated` 见下 |
225
+
226
+ **limit 与 truncated(#524 的第 2 条前置)**:按会话读是这张脸里天然最容易放大
227
+ 的方向——一个会话几十个窗口,每个窗口都是整段逐字原文,不限量就是「按会话导出全量转
228
+ 录」,与 `/export`(一次拿全库)同量级,只是换了形状,而它同样受门控。所以 `limit`
229
+ 不是可选的礼貌参数:默认 20、上限 100,两个方向共用同一个读数。20 这个数的取法是本
230
+ 机实测一个蒸馏会话的窗口数在个位数,20 已覆盖正常排查;真要更多就显式往上要,**不
231
+ 许**靠缺省值默默给全量。100 的上界比列表类接口(`MAX_PAGE_LIMIT` 1000 / `MAX_SEARCH_TOP_K`
232
+ 200)小一档,因为每个窗口带的是一整段原文而不是一行元数据。
233
+
234
+ 撞上 limit 时答复带 `truncated: true`。**少了它,被截断的答复与「本来就只有这么多」
235
+ 逐字节相同**,调用方会把半份转录当成全部——那正是这张脸最容易骗人的地方。实现上
236
+ session 侧向 store 要 `limit + 1` 行、多出来那条只当探针不出口;memory 侧
237
+ `provenanceFor` 本来就全量返回,按长度判即可。store 侧只照 limit 取,**不替调用方判
238
+ 断「还有没有」**——那是协议层的事,判据只许有一份。
201
239
 
202
240
  **为什么不是 404**:本端点被明确设计成永远不就「这条记忆是否存在」作断言(缺 id 是 400、
203
241
  未知 id 是 200 + 空 windows),而 404 在本仓已有稳定含义——未知路由与资源不存在
@@ -222,3 +260,49 @@ memory.db 的进程仍可读;唯一出路是 `dsh-mneme reclaim`(`src/mainte
222
260
  键,手动可删)或重新开启后的启动裁剪。把「关 = 删掉」写进任何文档都是谎话。后续可做的
223
261
  是让裁剪与写开关解耦(`retentionDays` 有值就裁,无论 `enabled`),那是让开关语义完全诚实
224
262
  的唯一修法,超出本轮范围。
263
+
264
+ ## 11. 运行归因:从「会话区间」到「哪一次蒸馏」(#512)
265
+
266
+ §10 的窗口只回答「这条记忆蒸自哪一段原文」。同一段区间被重蒸馏过几次、哪一轮失败了、
267
+ 失败在哪一步——这些在 §10 的形状里读不出来,因为 `memory_provenance` 的粒度是
268
+ (memory, 会话, 区间),而区间是**输入**的坐标,不是**运行**的身份。
269
+
270
+ 拆成两层,因为它们回答的不是同一个问题:
271
+
272
+ | 层 | 表 | 回答 | 谁能填 |
273
+ |---|---|---|---|
274
+ | 窗口级 | `memory_provenance.distill_run_id` → `distill_runs` | 这条记忆是**哪一轮蒸馏**从这段原文里提出来的 | 只有按窗口写入的 autoSummarize 路径 |
275
+ | 行级 | `memory_mints(memory_id, run_id)` | 这条记忆**出生自哪一轮巩固** | dream / sleep 的 create 动作 |
276
+
277
+ **为什么行级要另开一张表**:dream / sleep 铸造的 pattern 没有会话区间——session 与 seq
278
+ 都不存在。把合成区间塞进 `memory_provenance` 就是编造坐标,而一个错的坐标比没有坐标更坏
279
+ (它看起来完全合法,会让「能追到」变成谎话)。所以那一层只记 run id,不记区间。
280
+
281
+ **为什么行级只填 create**:merge / conflict / update / supersede 动的是**既有行**,
282
+ `receipt_chain` 已按 (run_id, record_id) 记过一遍。再造一份就是第二套账,两边迟早对不上。
283
+ create 是那边唯一的洞:`buildOutcome` 刻意不给 create 派 disposition,`receipt_chain`
284
+ 也就不产行。
285
+
286
+ **为什么不复用 `llm_audit_logs.id` 当 run id**:`llmAudit.retentionDays` 到期裁剪会删掉
287
+ audit 行,归因会跟着悬空;而 `memory_provenance` 刻意活得比原文更久(§10 的「来源已过期」
288
+ 就是这条设计的产物)。所以 run 是**归因主体**,audit 行只是它的**开销读数**,两边靠
289
+ `distill_runs.audit_id` 连——不是靠时间戳猜。
290
+
291
+ **为什么不复用 `dream_runs`**:那张表每一列都是巩固语义(`snapshot_hash` / `input_count` /
292
+ `decisions` / `outcome` / `receipt`),「每行都能回放一次巩固」是它对消费方的契约。硬塞
293
+ `run_type='autoSummarize'` 会让这条契约失效,而一个字段两种含义是这套 schema 里最容易腐烂
294
+ 的那种设计。dream / sleep 的轮次仍只记在 `dream_runs`,不复制进 `distill_runs`——两张表
295
+ 各说一件事,比一张表说两件事好。
296
+
297
+ **历史行不猜**:迁移只加列/加表,存量 `memory_provenance.distill_run_id` 一律 NULL。反推
298
+ 一个「看起来能追」的合成 id 是最坏的一种坏:读数正常,实际是编的。面板遇到 NULL 显示
299
+ 「无运行归因(历史行)」,不是空白。
300
+
301
+ **失败轮也记账**:`distill_runs.status` 取 `ok | degraded | noop | failed | aborted`。
302
+ 模型侧流失败与解析失败都会留一行 `failed`——否则「这个窗口为什么一直没有记忆」在台账上
303
+ 查不到原因。只有 dispose / 会话关闭才是 `aborted`(这一轮被主动打断),两者必须分得开。
304
+
305
+ **读出口**:`GET /api/dsh-mneme/memory/provenance?id=` 的窗口按上表带上
306
+ `distill_run_id / run_type / trigger_source / run_status`(读不到整组 null),答复级另带
307
+ `mints[]`(`{run_id, run_type, trigger_source, created_at}`)。面板详情抽屉有一个
308
+ 「蒸发归因」区块渲染这两层。
package/docs/SLEEP.md CHANGED
@@ -42,6 +42,9 @@ flowchart TD
42
42
  | `sleepModeEnabled` | `false` | 全局开关(Opt-in),开启后激活调度器 |
43
43
  | `sleepIdleMinutes` | `5` | 连续无操作触发睡眠的阈值(分钟) |
44
44
  | `sleepMinIntervalHours` | `8` | 两次 Sleep Run 的最小时间间隔(小时) |
45
+ | `sleepFailureBackoff` | `false` | 连续失败退避(#529,opt-in):有效间隔 = `sleepMinIntervalHours` × 2^连败数,成功清零,封顶 `max(sleepMinIntervalHours, 24h)`(配得比 24h 大就按配的算——退避只放大间隔,不压缩用户自己设的基数)。判据看整轮 `status === "failed"`——`noop`(通路正常、只是没活干)不算失败,否则在自己工作良好的库里也会越退越长 |
46
+ | `sleepConflictMaxPairs` | `12` | **单批**送审的冲突对上限(0 = 不限,回退旧行为) |
47
+ | `sleepConflictMaxPairsPerRun` | `60` | **单轮**最多送审多少对(0 = 不限)。与上一条分工:一个管批次大小、一个管整轮总量 |
45
48
  | `sleepConflictStrictness` | `'normal'` | 冲突消解严格度:`'gentle'`(0.92) / `'normal'`(0.85) / `'aggressive'`(0.75) |
46
49
  | `sleepArchiveDays` | `30` | 进入冷存储/摘要降级的天数阈值 |
47
50
  | `sleepCompressDays` | `90` | 进入完全归档(Archive)的天数阈值 |
@@ -60,12 +63,21 @@ flowchart TD
60
63
  - **目标**:清理向量空间中的重复、矛盾或过时记忆。
61
64
  - **输入**:全量/增量记忆向量索引、`sleepConflictStrictness` 阈值。
62
65
  - **算法**:
63
- 1. 向量检索 `findPotentialConflicts` 获取高相似度对。
64
- 2. LLM 仲裁:基于严格度阈值生成 `keep/merge/discard` 决策。
65
- 3. `validateDecisions`:严格校验决策合法性,缺省策略为 `keep`。
66
- 4. `applyDecisions`:批量执行状态变更。
67
- - **输出**:冲突消解报告(决策明细)。
66
+ 1. 向量检索 `findPotentialConflicts` 获取高相似度对;`sleepActionSet: "full"` 时再补一遍跨类型对。
67
+ 2. **已裁决对先压掉**:`differentiated_pairs` 账本里记着「这一对判过"互补、两条都留"」,两侧正文的 `content_hash` 与记账时一致就不再送模型(任一侧被改写则重新进候选)。这一轮被压掉几对记在 `phase.suppressed`,与「一对都没检测到」(`no conflicts found`)分开报。
68
+ 3. **台账预筛**(#529):查 `conflict_reviewed`,把「已经判过、且两侧 `updated_at` 都不晚于 `judged_at`」的对从候选池里剔掉——它们上一轮已有结论,两侧都没动就没必要再问一遍。任一字段更新即重新入池(与 `conflict_pending` 复核项 4 同一条宽松代理)。两条边界:跨 scope 对**不筛**(它们本来就要停车进人工队列,不能被机器台账吞掉),`conflictFreezeEnabled` 档**不筛**(那一档只停车不送 LLM)。
69
+ 4. **去重**:每条记忆至多参与一对,相似度高者优先。**必须排在 3 之后**——先占位再去掉,一个已审的对会把它那一端记忆的配对名额占住,而它真正的对手(另一条没审过的记忆)因此进不了这一轮。被台账挡下的对数记在 `phase.ledgerSkipped`。
70
+ 5. **每轮总量上限**(`sleepConflictMaxPairsPerRun`,默认 60):没进这一轮的对留在下一轮,既不丢也不记账。
71
+ 6. **分批**(`sleepConflictMaxPairs`,默认 12):每批单独一次 LLM 往返、单独 `validateDecisions` + `applyDecisions`。某批撞上下文或输出坏掉只让**这一批**失败并计入 `degraded`,其余批照常落地;只有一批都没成功才整轮 `failed`。
72
+ 7. LLM 仲裁:基于严格度阈值生成决策(默认档 `keep/merge/conflict/create/archive/update`,full 档另开 `supersede/differentiate`)。
73
+ 8. `validateDecisions`:严格校验决策合法性,缺省策略为 `keep`。**`reflectionUpdateMaxPerRun` 是整轮上限,不是每批上限**——额度由分批循环持有,每批只领剩余额度,校验通过后按本批通过校验的 `update` 数扣减;额度耗尽的那批若还含 `update` 会被校验器整单拒绝(记 `failed`),其余批照常继续。
74
+ 9. `applyDecisions`:批量执行状态变更。
75
+ 10. **台账记账**:本批**全部送审对**都写一行 `conflict_reviewed`(不只记被裁决成冲突的那几对,判 `keep` 的正是下一轮重复送审的元凶),`outcome` 取两端里非 `keep` 的那一侧。记账是幂等 upsert,按 `(memory_a, memory_b)` 排序归一化。两个防"假已审"的细节:`judged_at` 取**发送本批之前**的时刻(不是落地之后——LLM 往返期间若一侧被外部改动,它的 `updated_at` 会晚于 `judged_at`,下一轮自然重新入池;按落地时刻封盘等于用陈旧结论锁死这一对),且取 `max(墙钟, 本批两侧最新的 updated_at)`——`updated_at` 用的是 store 的**单调时钟**(`src/store.js` 的 `nowIso` 在同一毫秒内的连续写入上逐次 +1ms),它可能领先墙钟几毫秒,两列不同源的话 `alreadyJudged` 会把「刚判过」读成「判过之后又被改过」,判 `keep` 的死循环原样复活;本批 `failures` / `conflicts`(CAS 冲突)/ `skipped` 里出现过的 id,其所在对**不记账**——没落地的裁决记成已审,会让这一对从此不再送审,沉默地永久压制一个真实冲突。
76
+ - **`differentiate` 不改正文**(#533):`#126` 曾把同一段差异注记**同时**追加到两侧,指望注记进 embedding 后下一轮不再判重复。离线复现证伪了这个假设——两侧共享同一段后缀会让两条向量**同向靠拢**(余弦 0.876 → 0.960 只用 5 轮,注记截到 0 字照样往上爬),于是同一对每轮又被判成重复、又追加一段(实测单条堆到 19 段,正文 150 字膨胀到 2200+)。现在只落 `differentiated_pairs` 账本(配对 + 两侧当时的 `content_hash` + 模型给的 `distinctions`),正文一个字节都不动。存量库里仍带旧注记块的行按「已裁决」处理,升级不清账。
77
+ - **输出**:冲突消解报告(决策明细 + `pairs` / `batches` / `ledgerSkipped` / `perRunSkipped`)。**失败轮同样带 `pairs` 与 `batches`**——此前只有成功路径带,85 条失败审计行连"送了多少对"都查不到。批间被用户活动打断时报 `aborted`(不是 `noop`:`noop` 的语义是"看过、没冲突",而没送出去的批根本没看过),整轮 `deriveStatus` 也沿用同一个词,不再折回 `noop`。
68
78
  - **验收**:无合法记忆被误删;仲裁结果符合配置的严格度阈值;决策可回滚。
79
+ - **为什么要有预算(#529 的现场)**:整池相似对一次拼进一个 prompt,实测单请求 **163,054** 输入 token(服务端 `--ctx-size` 163,840,占 99.5%),撞墙后按冷却重排,送进去的还是同一份输入,于是连续 **23 轮** `llm failed`;而 `keep` 当时是彻底的 no-op(不留痕),同一对每个冷却周期都被重新问一遍。三处一起改之后单请求降到 806–2,011 token、连续失败 23 轮 → 0 轮。
80
+ - **台账为什么不复用 `conflict_pending`**:那张表是**人工**待裁决队列,面板徽标数就是 `countConflictPending()`(`resolved_at IS NULL` 的行数)。机器每判一对写一行会把这个数字淹掉,人就再也看不出"有哪些待我裁决"。两张表分工:`conflict_pending` = 人要看,`conflict_reviewed` = 机器记账,零界面。
69
81
 
70
82
  ### 2. `archival_demotion`(归档降级)
71
83
  - **目标**:按时间衰减对记忆进行分层压缩,释放热存储压力。
@@ -110,7 +122,9 @@ flowchart TD
110
122
 
111
123
  ## 调度器与可中断机制
112
124
  - **`noteWrite()` 心跳重置**:每次用户写入/读取记忆时触发。必须先 `clearTimeout` 清除旧计时器,再重新设置 Idle Timer,彻底杜绝 Stale Timer 导致的误触发。
113
- - **`maybeSchedule()` 准入控制**:串行检查 `sleepModeEnabled` → 空闲时长 `>= sleepIdleMinutes` → 距上次运行 `>= sleepMinIntervalHours` → 状态非 `running/disposed`。任一不满足则放弃调度。
125
+ - **`maybeSchedule()` 准入控制**:串行检查 `sleepModeEnabled` → 空闲时长 `>= sleepIdleMinutes` → 距上次运行 `>= 有效最小间隔` → 状态非 `running/disposed`。任一不满足则放弃调度。
126
+ - **有效最小间隔**(#529):`sleepFailureBackoff` 关(默认)时逐字节等于 `sleepMinIntervalHours`;开启时按连续失败次数指数推迟,封顶 `max(sleepMinIntervalHours, 24h)`——不是硬 24h:基数允许配到 168 小时,那时封顶跟着基数走,退避只能放宽间隔、不会把用户设的基数压小。`armNextWindow()` 排下一次闹钟用的是同一个函数——两处口径必须一致,否则退避期闹钟会在基数到点响、被 `shouldRun` 否掉、再重排一次,白转一轮。
127
+ - 退避**不是**按"同一份输入又失败"判的:`runSleep` 里那个 `snapshotHash` 是跑完之后对 `phases` 结果算的摘要哈希,不是输入快照,拿它当判据会把"同样的输入"认成"不同的结果"。改用连续失败计数,与 `autoDream` 的失败退避(#292)同构。
114
128
  - **`service.enqueue` 串行队列**:所有 Sleep Run 必须进入全局串行队列执行,与 `autoDream` 严格互斥,避免并发写入冲突与资源争抢。
115
129
  - **`AbortController` 可中断**:调度器持有 AbortController 实例。一旦检测到用户活动(`noteWrite` 或显式交互),立即调用 `abort()` 中断当前 `runSleep` 的执行上下文,保证实时性优先。
116
130
 
package/docs/STORAGE.md CHANGED
@@ -31,6 +31,7 @@ dsh-mneme reclaim --json # 原始 JSON,给脚本用
31
31
  |---|---|---|
32
32
  | `dream_runs.input`(窗口外) | 置空 | 它是当时的记忆库快照,可由记忆库重建 |
33
33
  | `dream_runs` 的骨架 / `decisions` / `outcome` / `receipt` | 不动 | 不可重建:拆了就回放不了那次裁决 |
34
+ | `dream_runs.raw_output` | 不动 | 失败轮的诊断摘要(#534):收到文本但解析或校验失败时留下的原文头尾,只在那一次调用里存在过,日志滚掉就再也拿不回来。**它不参与回放**——删了不会让保留的决策放不出来,只是丢掉「模型当时到底吐了什么」这条证据;无正文的流错误本来就是 `NULL` |
34
35
  | `memories.embedding`(`archived = 1`) | 置空 | 检索恒带 `archived = 0`,按定义不可达 |
35
36
  | `memories` 的行 | 一条不删 | 本入口止体积不止条数;条数与重复归 #254 与整理动作面 |
36
37
  | 活跃行的向量 | 不动 | 检索正在用 |
@@ -21,7 +21,7 @@ import { bootstrapFromDirectory } from "./bootstrap.js";
21
21
  import { PACKAGE_VERSION } from "./version-check.js";
22
22
  import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
23
23
  import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
24
- import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
24
+ import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
25
25
 
26
26
  const DEFAULT_PORT = 8790;
27
27
  const DEFAULT_HOST = "127.0.0.1";
@@ -487,6 +487,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
487
487
  // 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
488
488
  // 空 windows——「不是蒸馏来的」是有效答案,不是错误。
489
489
  //
490
+ // 两个读方向共一条路径(#524):?id=<memoryId> 走方向 1(记忆 → 窗口),
491
+ // ?sessionId=<sid> 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
492
+ // 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
493
+ //
490
494
  // 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
491
495
  // open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
492
496
  //
@@ -497,12 +501,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
497
501
  // 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
498
502
  // provenance-http.js 文件头。
499
503
  if (req.method === "GET" && pathname === "/memory/provenance") {
500
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
504
+ const parsed = parseProvenanceSelector({
505
+ memoryId: url.searchParams.get("id"),
506
+ sessionId: url.searchParams.get("sessionId"),
507
+ limit: url.searchParams.get("limit")
508
+ });
501
509
  if (!parsed.ok) {
502
510
  sendJson(res, parsed.status, { error: parsed.error });
503
511
  return;
504
512
  }
505
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => logger?.warn?.(m) });
513
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
506
514
  sendJson(res, status, body);
507
515
  return;
508
516
  }
package/lib/api.js CHANGED
@@ -14,7 +14,7 @@ import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallba
14
14
  import { summarizeSkipped } from "./dream/decisions.js";
15
15
  import { getInjectionSnapshot } from "./inject.js";
16
16
  import { renderUserSettingsBlock } from "./user-settings.js";
17
- import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
17
+ import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
18
18
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
19
19
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
20
20
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
@@ -1314,6 +1314,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1314
1314
  // 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来
1315
1315
  // 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。
1316
1316
  //
1317
+ // 两个读方向共一条路径(#524):?id=<memoryId> 是「这条记忆从哪几句蒸出来的」,
1318
+ // ?sessionId=<sid> 是「这个会话蒸出了哪几段」。两个主键互斥——都缺 400、都给
1319
+ // 400(择一意味着调用方发一个自己也不知道会被读哪个主键的请求,而两边结果形状
1320
+ // 不同)。共用路径是有意的:多一条路径就多一处要登记的地方(SECURITY.md 两张
1321
+ // 表、README 路由表、security-doc 的清单锁),而两者读的是同一批数据。
1322
+ //
1317
1323
  // 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆
1318
1324
  // (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是
1319
1325
  // 有效答案,不是错误,所以不 404。
@@ -1341,12 +1347,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1341
1347
  handler(req, res) {
1342
1348
  try {
1343
1349
  const url = new URL(req.url, "http://localhost");
1344
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
1350
+ const parsed = parseProvenanceSelector({
1351
+ memoryId: url.searchParams.get("id"),
1352
+ sessionId: url.searchParams.get("sessionId"),
1353
+ limit: url.searchParams.get("limit")
1354
+ });
1345
1355
  if (!parsed.ok) {
1346
1356
  sendJson(res, parsed.status, { error: parsed.error });
1347
1357
  return;
1348
1358
  }
1349
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => ctx.logger?.warn?.(m) });
1359
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => ctx.logger?.warn?.(m) });
1350
1360
  sendJson(res, status, body);
1351
1361
  } catch {
1352
1362
  sendJson(res, 500, { error: "internal" });
@@ -1759,7 +1769,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1759
1769
  policy: "open",
1760
1770
  handler(req, res) {
1761
1771
  try {
1762
- const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
1772
+ const toRun = (r) => ({
1763
1773
  created_at: r.created_at,
1764
1774
  status: r.status,
1765
1775
  provider: r.provider ?? null,
@@ -1777,7 +1787,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1777
1787
  // 被发现。这里只回聚合(原因 + 动作 + 条数),明细仍留在库里。
1778
1788
  skippedCount: Array.isArray(r.skipped) ? r.skipped.length : 0,
1779
1789
  skippedReasons: summarizeSkipped(r.skipped)
1780
- }));
1790
+ });
1791
+ // Issue #495:organize 上线后 runs 会混进 run_type="organize",而面板那张
1792
+ // 卡的标题是「最近巩固」——一次 agent 主动整理就会把它顶成整理回执。
1793
+ // 两条路里选了「标注」而不是过滤:runs 仍是全类型 5 条(审计面是证据,
1794
+ // 不因显示面积省略),另给一个只含 auto/sleep 的 lastConsolidation 供那张
1795
+ // 卡取数;面板再用 lastRun 是否为 organize 决定要不要标一句脚注。
1796
+ // 这里不能只靠 runs[0] 的 run_type 过滤——一次整理写两行(dryRun 报告 +
1797
+ // apply 回执),挤空 5 个名额时 runs 里可能一条巩固都没有。
1798
+ const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map(toRun);
1799
+ const lastConsolidation = service.listDreamRuns?.({ limit: 1, excludeRunType: "organize" })?.[0];
1781
1800
  const pendingConflicts = service.countConflictPending?.() ?? 0;
1782
1801
  const ids = new Set();
1783
1802
  for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
@@ -1788,6 +1807,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1788
1807
  }
1789
1808
  sendJson(res, 200, {
1790
1809
  lastRun: runs[0] ?? null,
1810
+ lastConsolidation: lastConsolidation ? toRun(lastConsolidation) : null,
1791
1811
  runs,
1792
1812
  pendingConflicts,
1793
1813
  pendingMemoryIds: [...ids].slice(0, 200)