@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
package/docs/DSH-GO.md ADDED
@@ -0,0 +1,97 @@
1
+ # dsh-go 原生面板
2
+
3
+ > 客户端仓库:[ccch1mneyyy/dsh-Go](https://github.com/ccch1mneyyy/dsh-Go)
4
+
5
+ > dsh-mneme 一直是「Web 面板 + 11 个记忆工具 + 自动注入」。手机 App **[dsh-go](https://github.com/ccch1mneyyy/dsh-Go)**(DSH 的 Android 客户端:Flutter 原生壳,手机本机跑引擎,也能遥控电脑)是 Flutter 原生界面,看不到 Web 面板——这个域就是给它的原生面板:与电脑端同一套功能划分(记忆 / 实体 / 设置 / 状态)、同一批后端路由。
6
+ >
7
+ > 记忆库是**按宿主独立存放**的:面板读写的是它所在那台宿主的 `memory.db`,与另一台设备上的库不互通。目前没有在线同步,只能通过分享包 `mneme.share/v1` 手动迁移,且不是实时同步。
8
+
9
+ ## 职责边界
10
+
11
+ - **只做展示与转发**:取数、写回都走 `src/api.js` 已注册的 HTTP 路由 handler(进程内派发),不新增路由、不直连 SQLite、不写第二套口径。
12
+ - **不碰记忆本体**:面板不参与蒸馏、注入、巩固、冲突判定;它只是这些能力的一个操作面(和 Web 面板同级)。
13
+ - **可选能力**:桌面 / headless 宿主没有 [dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 桥,这个域整块不加载(`ctx.inject(["dshGo"], …)` 的回调不会被调度)。
14
+ - **不重复 Web 面板**:`lib/client.js`(浏览器 bundle)照旧是 Web 面板的唯一实现;两边共享的是**后端**,不是视图代码。
15
+
16
+ ## 对外接口
17
+
18
+ ### 1. 包清单
19
+
20
+ `package.json` 的 `dshGo` 字段([dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 桥扫描已启用插件的 package.json,见 `dsh-go-bridge/lib/native/manifest.js`):
21
+
22
+ ```json
23
+ "dshGo": { "v": 1, "title": {...}, "icon": "book", "contributes": { "panels": [{ "id": "mneme", ... }] } }
24
+ ```
25
+
26
+ 面板的 `pkg` 由桥按「注册调用所在 fiber 所属的 loader entry」反查,所以**不需要独立包**:在 mneme 自己的插件上下文里调 `ctx.dshGo.panel("mneme", …)`,归谁一目了然。
27
+
28
+ ### 2. 挂载点
29
+
30
+ `src/index.js` 在 webServer 块内、`createApi` 之后:
31
+
32
+ ```js
33
+ ctx.inject(["dshGo"], (dshCtx) => {
34
+ import("./dsh-go/panel.js")
35
+ .then((panel) => panel.mountDshGoPanel(dshCtx, { apiToken: cfg.apiToken }))
36
+ .catch((error) => ctx.logger?.warn?.(`[dsh-mneme] dsh-go 面板未加载:${error?.message ?? error}`));
37
+ });
38
+ ```
39
+
40
+ 两处细节都是刻意的:
41
+
42
+ - **动态 import**:静态导入会在插件加载时就把面板模块跑一遍,「没有 dsh-go 就不加载这个域」这句承诺就不成立了。
43
+ - **把 `cfg.apiToken` 交给面板**:`createApi` 给 token / token-write 路由包了鉴权,进程内调用不带令牌会被自家路由拦成 401。令牌以 `x-dsh-mneme-token` 头随请求走(`src/dsh-go/host.js`),不给进程内调用开白名单。
44
+
45
+ ### 3. 视图协议(v2)
46
+
47
+ `buildView(state)` 返回 `{v:2, type:"stack", accent:"app", children:[…]}`,顶层是 App 原生 `tabs`:记忆 / 实体 / 设置。设置页顶部两格 `segmented`(设置 | 状态)——电脑端是四个页签,但 App 的页签条每格有 80dp 下限,四格在手机上会折行,所以放三格。
48
+
49
+ ### 4. 0.11.0 补上的两块面板面
50
+
51
+ mneme 0.11.0 把「Agent 眼中的你」自动事实(`profile_facts` 三表)和 chorography 地志搬上了 Web 面板。手机面板跟着补,仍走同一批路由:
52
+
53
+ | 面板位置 | 路由 | 说明 |
54
+ |---|---|---|
55
+ | 设置 › 个性化 › Agent 眼中的你 | `GET /profile-facts?agent=` | 一次拿到 `cards` / `facts`(含归档)/ `journal` / `runs`;flag 关着也照读,历史上攒下的事实要能看见 |
56
+ | 同上(增 / 改 / 归档) | `POST /profile-facts/apply` | body `{agent, ops}`,op 形状与自动轮共用:`add` 必须带 `content`,`update`/`archive` 必须带 `id` |
57
+ | 同上(立即更新) | `POST /profile-facts/run` | 一次 LLM 维护轮,慢且串行,面板按 pending 处理(60s);`409` = 功能关着,`503` = 没配模型,`502` = 模型答得不成形 |
58
+ | 设置 › 记忆引擎 › 代码库地志 | `GET /choro/repos`、`/choro/modules?repo=`、`/choro/page?repo=&slug=` | 仓库 → 模块骨架 → 页正文;都受 `chorographyEnabled` 闸门(关着回 `409 disabled`) |
59
+ | 同上(重建骨架) | `POST /choro/run` `{repo}` | 只跑 T0 apply(秒级);T1 富化仍需命令行 `--t1`,所以重建会把已富化的语义段退回骨架 |
60
+
61
+ 功能开关这块也换了一半口径:
62
+
63
+ - 枚举型旗标(`chorographyInject` 的 `light/full/off`)用下拉,`PUT /features` 收字符串值——同一个动作 id 既服务开关也服务下拉。
64
+ - 新增「其它」分组:把 `/features` 的 `effective` 里**我们还没给中文名**的布尔项动态列成数据行(`list` + `items` 绑定),点一行翻转一项。上游每加一个开关,手机面板不会再静默漏掉它;数值/字符串键(模型名、预算)不放这里,它们要输入框。
65
+
66
+ ## 内部文件
67
+
68
+ | 文件 | 一句话 |
69
+ |---|---|
70
+ | `src/dsh-go/panel.js` | 状态机、取数、动作(`mountDshGoPanel`)。动作跑完自己把新数据推给所有打开的 feed |
71
+ | `src/dsh-go/host.js` | 进程内派发:`ctx.get("webServer").match(path)` 拿到 mneme 自己的 handler,伪造 req/res 调用 |
72
+ | `src/dsh-go/text.js` | 类型标签、开关分组、时间与 scope 格式化 |
73
+ | `src/dsh-go/views/root.js` | `buildView`:顶层页签组合 |
74
+ | `src/dsh-go/views/{common,memory,entity,settings,status}.js` | 各分区视图;`common.js` 是小组件构造器 |
75
+
76
+ 文案取自 Web 面板的 zh i18n(`lib/client.js` 里的 `memory.*` 键),面板不再自造解释性文案。
77
+
78
+ ## 已知坑
79
+
80
+ - **feed 是推送式的**:`data()` 每个面板实例只跑一次,`refresh` 效果只让 App 重画视图、不会重跑 `data()`。所以每个动作结尾都要 `broadcast()`(向所有 emit 推新 payload),否则点了按钮界面还是旧数据。推送节流 4/s。
81
+ - **不能走 HTTP**:[dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 桥的路由守卫(`installFallbackGuard`)对没有合法签名的请求直接 401(`x-dsh-go-guard: reject`),浏览器 cookie 是宿主签的、进程内也拿不到。进程内 `webServer.match()` 绕开这一层,且复用同一批 handler。
82
+ - **`/external-api` 的 GET 有副作用**:不带参数时会顺手生成 Bearer token 并落库。面板读配置用 `?token=omit`(只回 `enabled/port/host`,不铸造也不落库),不读令牌。
83
+ - **进程内调用也要带令牌**:宿主配了 `apiToken` 时,`/update`、`/delete`、`/features` 这些 token 路由对进程内请求同样校验;令牌走 `x-dsh-mneme-token` 头。
84
+ - **两种响应节奏都要等**:多数路由在返回的 promise 里 `res.end`,少数(`/version-check`)立即 `return` 再在回调里写响应。`host.js` 同时等 handler 的返回值和响应结束,只 race 的话后者会被当成空结果。
85
+ - **`/search` 不认识 `type`**:它是全库语义检索,面板在拿到结果后再按所选类型收一遍。
86
+ - **`/entities` 的 `q`**:名字过滤必须下推到 store(`LIKE`,`%`/`_` 转义)。面板若自己取 limit 条再本地过滤,排在 limit 之外的实体永远搜不到。
87
+ - **删除只能按状态认人**:`confirm` 节点没有参数位(只有 id 与两个按钮文案),所以「删哪一条」只能靠状态;状态是所有面板共用的,因此面板按 `scope` 记下自己打开的那条,确认时与本打开的条目核对,不一致就拒绝。
88
+ - **`/update` 不接受 `type`**:编辑已有记忆时类型只读展示,新建才给选择器。
89
+ - **保存只发差集**:`/update` 没有版本号校验,整份提交会把别处(工具、Web 面板)刚写进去的新值盖掉。面板在编辑页记住打开时的快照,只提交改动过的字段,`updated_at` 对不上就中止并让用户再看一眼。
90
+ - **`/create` 不接受 scope**:归属 Agent / 工作区两个输入只在编辑已有记忆时出现。
91
+ - **节点预算**:面板 120 节点 / 8 层 / 32 KiB,`data` 32 KiB。列表行只下发 `summary`(`content` 截 120 字),全文留在详情取,否则条目一多就超预算。
92
+ - **地志与事实都有闸门**:`/choro/*` 在 `chorographyEnabled` 关闭时回 `409 {error:"disabled"}`,`/profile-facts/run` 在 `profileFactsEnabled` 关闭时同样 `409`;两个读口(`/profile-facts`、`/choro/repos` 之外的模块/页)不受开关影响,面板要能展示历史数据。
93
+ - **事实的 op 形状是硬约定**:`add` 缺 `content` 会把 `"undefined"` 写进事实正文并从此每帧注入;面板在本地就拦「正文不能空」,不把脏 op 递过去。
94
+ - **重建骨架只到 T0**:`POST /choro/run` 是同步秒级回执,不带 T1 富化;面板文案要说明「语义段退回骨架」,否则用户会以为富化丢了。
95
+ - **`refresh` 会重建视图树,页签选择会丢**:顶层 `tabs` 的选中项是 App 的本地状态(视图协议没有绑定口),App 收到 `refresh` 重新拉一遍视图,树一重建就回到第一个页签。所以「只改一个绑定值」的动作(开关、档位下拉、外部访问开关、事实增改、地志翻页)不要申请重画,只推数据;只有真的改变了「哪些节点存在」的动作(进子页、换分区、换分组、翻页、搜索)才要 refresh。
96
+ - **`nestFlags` 不能把值压成布尔**:枚举型旗标(`chorographyInject` = light/full/off)以前被写成 `value === true`,下拉永远只拿到 true/false。视图绑定要原样透传。
97
+ - **不要用 `metric.caption` 放单位**:`caption` 固定渲染在数值下方一行;v2 的 metric 整串数值同一字号,所以单位拼进 value(`57 条`),App 侧只有自带模型卡才会拆小字单位。
package/docs/ENTITIES.md CHANGED
@@ -62,7 +62,7 @@ CREATE TABLE IF NOT EXISTS entity_relations (
62
62
  id TEXT PRIMARY KEY,
63
63
  from_entity TEXT NOT NULL,
64
64
  to_entity TEXT NOT NULL,
65
- relation_type TEXT NOT NULL, -- uses|depends_on|part_of|related_to|supersedes
65
+ relation_type TEXT NOT NULL, -- uses|depends_on|part_of|related_to|supersedes|affective_context(末者仅由 sleep 情感相位写入)
66
66
  memory_id TEXT,
67
67
  created_at TEXT NOT NULL,
68
68
  metadata TEXT -- JSON 自由字段
@@ -149,7 +149,9 @@ saveAttr(entity=X, key=role, value="前端工程师")
149
149
 
150
150
  1. `buildSystemPrompt` — 约束上限(`entityExtractionMaxEntities` / `entityExtractionMaxAttrs`)、禁编造、用规范化名
151
151
  2. `extractJsonFromText` — 先直接 `JSON.parse`,失败再抓首个 `{...}` 块
152
- 3. `sanitizeExtractedData` — 非法 type → `concept`;非法 relation type → `related_to`;非法 confidence → 0.9;超限截断;relations 的 from/to 必须引用实体清单
152
+ 3. `sanitizeExtractedData` — 非法 type → `concept`;**非法 relation type → 拒收**(#619:不再静默降级成 `related_to`,丢弃的类型名进返回值 `rejectedRelationTypes` 并 `logger.warn`,让"模型抽了但类型不认识"在审计里看得见);非法 confidence → 0.9;超限截断;relations 的 from/to 必须引用实体清单
153
+
154
+ > **`affective_context` 不在这条管线里。** 它只有一个写入者:sleep 的情感归因相位(`docs/SLEEP.md` §5)。实体抽取发生在写入当下,手里没有会话边界,判不了"一次场合"和"反复出现的特质";放它进白名单,等于在同一张 `entity_relations` 表里造第二种 schema(没有 `emotion` / `attribution` / `sessionCount`)。抽取器遇到它会当未知类型拒收并 warn。
153
155
  4. `resolveEntity` — **同名去重**:`findEntityByName` 命中 → `updateEntity(id, {})`(`mention_count+1`),未命中 → 建新实体
154
156
  5. 逐条 `saveAttr`(挂 `memory_id` + `source: "llm_extract"`)→ 逐条 `saveRelation`
155
157
  6. 单实体/属性/关系失败只 `skipCount++` 并告警,不中断整批
@@ -157,7 +159,7 @@ saveAttr(entity=X, key=role, value="前端工程师")
157
159
  ### 返回 / fail-safe
158
160
 
159
161
  ```js
160
- { ok: true, entities: [{ name, type, attrs, entity_id }], attrs: [...], relations: [...], skipped: n }
162
+ { ok: true, entities: [{ name, type, attrs, entity_id }], attrs: [...], relations: [...], skipped: n, rejectedRelationTypes: [...] }
161
163
  { ok: false, error: "..." }
162
164
  ```
163
165
 
package/docs/FORGET.md ADDED
@@ -0,0 +1,77 @@
1
+ # 选择性遗忘与事实更正(#532)
2
+
3
+ > 这篇回答 #532 的三个确认请求:现象的根因链、当前机制能承诺什么、以及修复方向需要维护者拍板的取舍。全部结论出自 `main`(514d238)源码逐行核实与一个**零 LLM 的合成轨迹**——不需要真实用户库,任何人按 §4 的步骤能在空库上重现同一结构性结果。
4
+
5
+ ## 1. 现象与根因链
6
+
7
+ #532 的观察:更正两个事实(颜色 + 整理时间)→ 只要求忘掉颜色 → 新对话里颜色确实不知道,**但整理时间答回了更正前的旧值**。
8
+
9
+ 根因是三段机制的接缝,每一段单独看都符合自己的契约:
10
+
11
+ 1. **更正落成新行,旧行保持活跃。** `autoSummarize` 写入走 `saveWithDedupe` 的 `(type, title, scope)` 三元组去重——更正行的标题与原行不同(蒸馏器按内容起标题),去重不命中,于是**同一事实的两个版本同时活跃**。会清理这种并存的是 dream 的冲突裁决(loser 归档)——但它是阈值门控的(`dreamThresholdCount` 默认 100,#532 现场 13 条、`dream_runs` 0 行,裁决从未运行)。`summarizeDedupeMode`(#127,默认 off)帮不上:它只在**同会话**的时间窗内并(`findSessionDuplicate` 按 `type+source+时间窗` 找候选),而 #532 的更正发生在另一个会话。
12
+ 2. **`memory_forget` 是行粒度的。** 它按 ID 翻 `forgotten` 标志(抑制注入/检索/列表,原文留存可恢复)——这是接口契约,**不是**「忘掉某个事实」:事实散在几行里,它就只管被点名的那一行。#532 的更正把两个新值合流在一行(蒸馏器把「颜色改成浅橙色 + 时间改成周六 09:00」写进同一条),忘掉「颜色」的操作抑制了这一行,**新的整理时间跟它一起退出可见面**。
13
+ 3. **旧值行成了唯一幸存的载体。** 旧值(雾蓝色 + 周三 20:00)至少还活在另一行里(6 次蒸馏 × 每次 ≤3 条 = 13 行的库完全装得下独立的旧时间行)。新值行被抑制后,检索/注入面只剩旧值行——模型如实回答了它唯一能看到的东西。旧值行里同样有旧颜色,模型答「不知道颜色」是因为该行也已被抑制(或未入召回窗)——两半行为各自都有解释,合起来才是「保留的更正事实答回旧值」。
14
+
15
+ **一句话定性:这不是某一行写错了,而是「更正的失效关系」在 dream 第一次运行之前根本不存在。** 旧行与新行之间没有任何持久链接,任何一方的隐藏都不会波及另一方;趁虚而入的是结构,不是某个 bug 的触发。
16
+
17
+ ## 2. 三个确认请求的源码级回答
18
+
19
+ **Q1:更正两个事实、只忘一个时,推荐怎样保住另一个?**
20
+
21
+ 当前机制下的推荐做法(不需要新配置):
22
+
23
+ - **更正时点名旧行**:用 `memory_update` 直接改原行的内容(同标题走 `saveWithDedupe` 的更新分支,`content_history` 留痕),或用 `memory_archive` 归档旧行后再让新事实自然入库——归档可恢复,且两行不再并存。
24
+ - **遗忘时按行操作并核对行内容**:`memory_forget` 之前先 `memory_get` 看这一行还装着什么;一行装着多个要分开处置的事实时,`memory_update` 先拆、再对单行遗忘。
25
+ - 工具描述已补一句行粒度警示(本 PR):模型在「忘掉 X」的请求下,应当先意识到一行可能载着多个事实,并在回复里说清它抑制的是哪一行、连带了什么。
26
+
27
+ **Q2:更正所取代的旧事实,有没有持久失效关系,使隐藏新条目后旧值不会重新成为当前答案?**
28
+
29
+ **没有——这是插件层面的真实缺口,不是配置遗漏。** 失效关系今天只存在于两处:① `memory_register_document` 的同路径/同标题 supersede(#230,只覆盖 document 行);② dream 冲突裁决的 loser 归档(阈值门控)。普通蒸馏行在 dream 第一次运行之前,旧行与新行之间**没有任何链接**。历史回溯与「当前答案」也因此无法区分:检索面对两行一视同仁,先后关系只体现在 `updated_at` 的排序权重里——模型答对(如 #532 对话 C)靠的是模型自己对比两行,不是机制保证。
30
+
31
+ **Q3:配置是否遗漏了该语义必需的原生设置?**
32
+
33
+ 没有。`lightMode` / 阈值配置都在正常范围内;0.9.5 的已发变更(溯源读出口 #509、蒸馏围栏 #501、merge 逃生门 #521 等)都不触碰这条接缝。**#532 报告的现象在当前 main 上结构性地成立**,与 0.9.4 还是 0.9.5 无关。
34
+
35
+ ## 3. 修复方向的取舍(需要维护者拍板,本 PR 不擅自实现)
36
+
37
+ | 方向 | 能修掉什么 | 代价 / 风险 |
38
+ |---|---|---|
39
+ | **A. 蒸馏提示词拆分粒度**(一事实一条) | 合流行被整条遗忘的连带伤 | 提示词级、行为面全变;行数增多(注入预算/检索排序全受影响);LLM 遵从度不确定 |
40
+ | **B. 写侧更正失效关系**(蒸馏窗口内识别「同主题改值」→ 归档被取代行) | Q2 的缺口本体 | 「同主题」判据是 LLM 判断(dream 冲突裁决同款),放写入热路径要配阈值与误杀面测量——正是 dream 已经在做的事,重复建一套不如把 dream 的触发条件前移 |
41
+ | **C. dream 触发前移**(更正类写入直接计 dream 优先级 / 降低阈值) | 让现成的裁决机制更早介入 | dream 有成本(LLM 调用);「更正类写入」的确定性判据同样不存在 |
42
+ | **D. 遗忘回执带同侪提示**(`memory_forget` 答复列出同 type+scope 的高相似活跃行) | 让模型在遗忘当下看到「别处还有旧值」 | 相似度判据要么依赖嵌入(可能未配)、要么是弱启发(误报面要测);对已发生的遗忘只是事后提示 |
43
+
44
+ 本 PR 只落地了零争议的一小块(工具描述的行粒度警示,见 §5);A–D 的取舍涉及 LLM 判断进入哪条路径、误杀面怎么量,按 R2/R3 纪律停下等人拍板。
45
+
46
+ ## 4. 有界复测的最小合成轨迹(零 LLM)
47
+
48
+ 不依赖任何模型行为,直接用存储/服务层复现「更正落新行 → 遗忘新行 → 旧值行幸存」:
49
+
50
+ ```js
51
+ // 空库;严格 scope 不影响本现象,默认配置即可
52
+ const store = createStore(":memory:");
53
+ const service = createService({ store, mirror: null, config: {} });
54
+ // ① 原始陈述(模拟对话 A 的蒸馏产物:颜色 + 时间合流一行)
55
+ const r1 = service.saveWithDedupe({ type: "preference", title: "纸模工具的收纳与整理",
56
+ content: "纸模工具放在雾蓝色收纳盒,每周三晚上八点整理一次", importance: 3 });
57
+ // ② 更正(模拟对话 B:标题由蒸馏器另起 → 三元组不命中 → 新行)
58
+ const r2 = service.saveWithDedupe({ type: "preference", title: "收纳盒颜色与整理时间的更正",
59
+ content: "收纳盒换成浅橙色,整理时间改为每周六上午九点", importance: 3 });
60
+ // ③ 结构断言:两行同时活跃,二者之间没有任何失效链接
61
+ const activeCount = () => store.db.prepare(
62
+ "SELECT COUNT(*) AS n FROM memories WHERE forgotten = 0 AND archived = 0"
63
+ ).get().n;
64
+ assert.equal(activeCount(), 2);
65
+ // ④ 「忘掉颜色」= 按行遗忘新值行(模型在 R1/R2 里看到的颜色载体)
66
+ service.setForget(r2.memory.id, true);
67
+ // ⑤ 现象复现:新值行退出注入/检索面后,旧值行仍是唯一幸存载体
68
+ assert.equal(store.getById(r1.memory.id).forgotten, false, "旧行从未被任何机制波及");
69
+ assert.match(store.getById(r1.memory.id).content, /每周三晚上八点/, "旧值仍在召回面");
70
+ ```
71
+
72
+ 完整可跑版本见 `test/forget-corrections.test.js`(本 PR 附),其中 ④⑤ 两条断言就是 #532 现象的存储层等价物——机制修复(§3 的 B/C)落地时,这条测试的 ⑤ 应当翻转:更正行写入后,旧行要么被记上失效关系、要么退出活跃面;遗忘新行不应让旧值重新成为「当前答案」。
73
+
74
+ ## 5. 本 PR 落地的零争议部分
75
+
76
+ - **`memory_forget` 工具描述补行粒度警示**(`src/tools.js`):「抑制的是**一行**——多个事实合流在同一行里时,它们会一起退出可见面;请求只针对其中一个事实时,先看行内容、用 `memory_update` 拆分或在回复里说清连带了什么」。这是 #532 对话 5 里模型误用工具的直接对策:请求的语义粒度(一个事实)与工具的能力粒度(一行)之间的落差,模型无从得知,除非描述告诉它。
77
+ - **本文档**与 §4 的合成轨迹测试。
package/docs/KB.md ADDED
@@ -0,0 +1,110 @@
1
+ # 知识库(Knowledge base,第二层)
2
+
3
+ 给 agent 一个「用户自己的参考资料」检索面:本地文件、网页、仓库地志页进来,分块后用 BM25 检索。
4
+ 与记忆库(第一层)**物理隔离**——它不是记忆,不参与注入、不参与巩固、不参与质量过滤。
5
+
6
+ ## 职责边界
7
+
8
+ **它是**:一个只读给模型的语料检索面(三个只读工具)+ 一组面板可管理的集合/文档(写路由)。
9
+ **它不是**:文件的真源、向量库、记忆库的旁支。
10
+
11
+ - 真源在 SQLite 的 `kb_*` 三张表里,**不在磁盘上**。用户随时可能把原文件挪走或删掉,
12
+ 检索面不能跟着消失——所以 `readDoc` 从库里拼回正文,读侧零 IO(`src/kb/index.js` 顶部有同样的话)。
13
+ - 与 chorography 的区别:地志以盘上的 MD 为真源(重扫即重建),知识库收的是外来原文的副本。
14
+ - **绝不写 `memories`**,也绝不进 `saveWithDedupe` / `dream` / 质量过滤的任何行池。
15
+ 这条与 wiki 行同源,是 §3.3 的红线;`test/kb-collections.test.js` 第一条用例钉它。
16
+
17
+ ## 对外接口
18
+
19
+ ### 程序面(`src/kb/index.js`)
20
+
21
+ ```js
22
+ const kb = createKb({ store, embedder = null, cfg = {} });
23
+ ```
24
+
25
+ | 方法 | 形状 |
26
+ |---|---|
27
+ | `listCollections()` | `[{key,title,description,docs,chunks,updatedAt}]` |
28
+ | `upsertCollection({key,title,description})` | `{key,title,description}`;同 key 再调 = 改名,`created_at` 不动 |
29
+ | `deleteCollection(key)` | `boolean`;连带删它的文档与分块(一个事务,不留孤儿行) |
30
+ | `listDocs({collection})` | `[{relKey,kind,title,bytes,indexedAt,status,error}]` |
31
+ | `ingest({collection,kind,source,title?})` | `{docId,chunks,skipped,error?}` |
32
+ | `search({query,collection?,limit?})` | `{hits:[{collection,relKey,title,heading,line,excerpt,score}],unreadable:[]}` |
33
+ | `readDoc({collection,relKey})` | `{title,content,kind,relKey,status,error}` 或 `null` |
34
+ | `dispose()` | 清索引缓存;可重复调用,不丢数据 |
35
+
36
+ `kind` 三选一:`file` / `url` / `choro_page`。`url` 走 `fetch`;`choro_page` 的 `source`
37
+ 就是该地志页 MD 的**绝对路径**,读取路径与 `file` 完全一致——不为它去跨域读 `choro_*` 表,
38
+ 那会把两条线的写作用域搅在一起。
39
+
40
+ ### HTTP 面(`src/kb/routes.js`,契约 §3.6)
41
+
42
+ | 方法 | 路径 | policy |
43
+ |---|---|---|
44
+ | GET | `/api/dsh-mneme/kb/collections` | `token-write`(与 POST/DELETE 同路径,取更严的) |
45
+ | POST | `/api/dsh-mneme/kb/collections` `{key,title,description}` | 同上 |
46
+ | DELETE | `/api/dsh-mneme/kb/collections?key=<key>` | 同上 |
47
+ | GET | `/api/dsh-mneme/kb/docs?collection=<key>` | `token` |
48
+ | GET | `/api/dsh-mneme/kb/doc?collection=<key>&relKey=<rel>` | `token` |
49
+ | POST | `/api/dsh-mneme/kb/ingest` `{collection,kind,source,title?}` | `token-write` |
50
+ | POST | `/api/dsh-mneme/kb/search` `{query,collection?,limit?}` | `token-write` |
51
+
52
+ `/kb/collections` 一条路径上读和写混着,只能声明一个 policy,取更严的 `token-write`
53
+ (与 `/profile` 同款)。`register` 对同一 `kind+path` 重复登记直接抛错,所以不能拆两条。
54
+
55
+ **状态码语义**(面板据此渲染,改之前先想清楚空态):
56
+
57
+ - `409 {error:"disabled",reason:"kb-not-enabled"}` — 开关没开。判定在 `src/api.js` 的 `kbGate`,
58
+ 本模块**不自己判** `cfg.kbEnabled`(一处判定,不两处分叉)。
59
+ - `503 {error:"unavailable"}` — 开了开关但 kb 没装配(headless / 测试)。#497 先例:503 = 环境缺件,409 = 用户没开。
60
+ - `404 {error:"collection-not-found"}` — 入库目标集合不存在。**不静默建集合**:拼错的 key
61
+ 会把资料塞进一个凭空出现的集合,用户永远找不到。
62
+ - `404 {error:"doc-not-found"}` — 读整篇查不到。
63
+ - `400` — 缺必填字段 / `bad-kind` / `query-required`。
64
+ - 入库**来源级**失败(`file-not-found` / `too-large` / `http-404` …)回 **200 + `ok:false` + `error`**,
65
+ 不是 4xx。理由:`kb_docs` 里已经落了 `status='error'` 的留痕行,面板要能显示「这份为什么没进来」;
66
+ 回 4xx 会让面板把它当成「请求本身错了」而丢掉那条记录。
67
+
68
+ ### 模型面(`src/kb/tools.js`)
69
+
70
+ 三个**只读**工具,返回 `defineTool()` 兼容的描述对象数组,由 `src/tools.js` 在
71
+ `kbEnabled` 为真时展开进自己的数组(本文件不 import `defineTool`,装配顺序与开关归调用方):
72
+
73
+ - `kb_list_collections` — 集合清单 + 文档/分块数。
74
+ - `kb_search` — BM25 检索,回 `collection/relKey/heading/line/excerpt`。
75
+ - `kb_read_doc` — 按 `collection + relKey` 读整篇。
76
+
77
+ 写操作一个都不给模型:建集合、入库都是用户在面板上按的按钮。知识库收的是用户自己的资料,
78
+ 模型不该有权决定往里塞什么(与 chorography「生成只走 CLI」同一条红线)。
79
+
80
+ ## 内部文件
81
+
82
+ - `src/kb/index.js` — `createKb`、切块(`chunkContent`)、检索(`search`)、读整篇。
83
+ - `src/kb/routes.js` — `registerKbRoutes`,七个 handler 全在这里。
84
+ - `src/kb/tools.js` — `kbToolDefinitions`,三个工具描述对象 + render。
85
+ - `src/store.js` — `kb_collections` / `kb_docs` / `kb_chunks` 三张表与十个 `kb*` 方法
86
+ (`kbCollectionByKey` / `listKbCollections` / `upsertKbCollection` / `deleteKbCollection` /
87
+ `listKbDocs` / `kbDocByRelKey` / `kbChunksByDoc` / `replaceKbDoc` / `markKbDocError` /
88
+ `kbChunksForSearch`)。
89
+ - `test/kb-collections.test.js` — 建表/集合/幂等/重切块/检索/readDoc/失败留痕/dispose。
90
+ - `test/kb-routes.test.js` — 路由表形状/policy/状态码/工具描述对象。
91
+
92
+ ## 已知坑(都是踩过或刻意设计成这样的)
93
+
94
+ 1. **切块必须严格按行连续、一个空行都不剥**。这是 `readDoc` 能逐字节还原原文、以及
95
+ `search` 能回算准确行号(起始行 = 1 + 前面所有块的行数)的**共同前提**。破坏它两件事一起坏。
96
+ 空行不产生 token,对 BM25 的 `len` 分母零成本,剥它纯赔。
97
+ 2. **幂等判据是正文 sha256,不是 mtime / size**。同 rel_key + 同 sha256 = 整件事 no-op
98
+ (回执 `skipped:true`,`chunks` 是库里现有块数)。否则编辑器保存一次(mtime 变、内容没变)
99
+ 就要全量重切块,库和索引一起白忙。上一版 `status='error'` 的行不算命中——重来必须真跑。
100
+ 3. **`## ` 是唯一的节边界**。一级标题 `# ` 不切节,它属于文件头那一块。这就是为什么
101
+ `## 部署` 那块的起始行是标题自己的行号,而不是上一行。
102
+ 4. **不带 `return` 的 `readBody().then(...)` 会吞掉回执**。handler 里凡是异步的那几条
103
+ (collections 的 POST、ingest、search)都必须把 Promise 整条链 `return` 出去;不 return 的话
104
+ 请求会在响应发出之前就「完成」,面板拿到空 body。choro/search 踩过同一个坑
105
+ (`src/api.js:2787-2788` 有注释)。
106
+ 5. **索引缓存按集合懒物化,写路径必须 `invalidate`**。`ingest` / `upsertCollection` /
107
+ `deleteCollection` 都会失效对应缓存(`markKbDocError` 走的是同一处)。漏一处 = 检索命中
108
+ 已删除的段落。
109
+ 6. **Windows 上临时目录会被 Defender 短暂持锁**,`rmSync` 撞 `EPERM` 是抖动不是失败,
110
+ 测试里用 `rmTempEventually` 重试(与 `test/wiki-store.test.js` 同源)。
package/docs/SLEEP.md CHANGED
@@ -25,9 +25,10 @@ flowchart TD
25
25
  P1 --> P2[Phase 2: archival_demotion]
26
26
  P2 --> P3[Phase 3: pattern_discovery]
27
27
  P3 --> P4[Phase 4: relation_completion]
28
+ P4 --> P5[Phase 5: affective_attribution]
28
29
  end
29
30
 
30
- P4 -->|独立 try/catch 隔离| Receipt[buildReceipt]
31
+ P5 -->|独立 try/catch 隔离| Receipt[buildReceipt]
31
32
  Receipt -->|saveDreamRun run_type='sleep'| Audit[(dream_runs)]
32
33
 
33
34
  User -->|活动触发| Abort[AbortController.abort]
@@ -51,12 +52,15 @@ flowchart TD
51
52
  | `sleepPatternMinMemories` | `100` | 模式发现阶段扫描的最近记忆条数下限 |
52
53
  | `sleepPatternLookbackDays` | `30` | 模式发现回溯的时间窗口(天) |
53
54
  | `sleepMaxPatternPerRun` | `3` | 单次运行最多创建的模式数量上限 |
55
+ | `sleepAffectiveEnabled` | `false` | 情感归因相位(#619,opt-in):情境情绪写成 `affective_context` 关系,跨多次独立会话的稳定倾向才铸成 pattern。是 `sleepModeEnabled` 的子相,父开关关着整个 sleep 不跑 |
56
+ | `sleepAffectiveTraitMinSessions` | `3` | 判「稳定倾向」所需的**独立会话**数(2–10)。下界 2 是故意的:N=1 就是单次场合,正是必须禁止升成特质的那个情形 |
57
+ | `sleepAffectiveMaxRelationsPerRun` | `10` | 单轮最多写多少条 `affective_context` 关系(0–50,0 = 一条不写) |
54
58
  | `sleepProvider` | `''` | 睡眠模式专用 LLM Provider(留空则复用默认) |
55
59
  | `sleepModel` | `''` | 睡眠模式专用 LLM Model(留空则复用默认) |
56
60
  | `sleepReasoningEffort` | (未配置) | 睡眠 LLM 推理强度透传:`off` / `low` / `medium` / `high` / `none`。未配置 = 自动取模型支持的最低档(#135:避免思考模型用自带默认档烧光预算返回空体);显式 `none` = 不传该字段,使用模型默认(与 `dreamReasoningEffort` 语义一致) |
57
61
  | `sleepMaxTokens` | `8192` | 冲突消解与模式发现阶段的 LLM 输出预算上限(token,#257:原硬编码 2048——`sleepActionSet: full` 实测 24 对需约 7000,截断即整轮裁决失败)。流式计费按实际用量,调大不增加成本 |
58
62
 
59
- ## 四阶段详解
63
+ ## 五阶段详解
60
64
  每个阶段独立包裹 `try/catch`,任一阶段失败仅跳过该阶段,不阻塞后续流程。四个相位读取的记忆行池都先剔掉 `source='imported'` 的外来行(`src/dream/sleep.js`)——外来行不经本方蒸馏与召回,时间分层与冲突判据在它身上系统性失真,我方对它的原文和归档面没有处置权,所以下文「输入」一栏里的「全量」一律读作「本方活跃行」,契约见 [SHARE.md](SHARE.md)。
61
65
 
62
66
  ### 1. `conflict_resolution`(冲突消解)
@@ -113,6 +117,26 @@ flowchart TD
113
117
  - **输出**:新增关系边列表。
114
118
  - **验收**:关系推断符合领域常识;不引入循环依赖;图谱连通性提升。
115
119
 
120
+ ### 5. `affective_attribution`(情感归因,#619)
121
+ - **目标**:把「谁在什么场合因为什么而有情绪」写成图上的一条边,并且**不**让一次性的情绪升格成「他就是这种人」。
122
+ - **输入**:实体列表(`listEntities`,至少 2 个),扫描池口径与模式发现一致(`sleepPatternMinMemories`、剔除 document/summary/pattern、剔除 imported、剔暂停项);`sleepAffectiveTraitMinSessions`、`sleepAffectiveMaxRelationsPerRun`。
123
+ - **算法**:
124
+ 1. 开关 `sleepAffectiveEnabled !== true` → `skipped: disabled`;无 LLM 路由 → `skipped: no llm route`;实体不足 2 个 → `skipped: too few entities`。
125
+ 2. LLM 读实体名清单 + 记忆列表,每条候选输出 `person` / `target` / `emotion` / `attribution`(`occasion` 或 `trait`)/ `evidence`(记忆 id)/ `why`。
126
+ 3. **护栏在代码里,不在 prompt 里**:`person`/`target` 必须解析到已存在的实体(否则丢进 `dropped: unknown entity`);`evidence` 必须是本轮扫描池里的真实 id(否则 `no real evidence`);`emotion` 非空。
127
+ 4. **升特质要过 provenance**:模型说 `trait`,就用 `service.provenanceFor(memoryId)` 收集这批 evidence 覆盖的 `session_id`,去重后数量 `< sleepAffectiveTraitMinSessions` 的一律**降级为 `occasion`**(记 `downgradedFrom: "trait"`,不丢弃)。被降级的条数就是这张探针要抓的误归因数——静默丢掉它就等于把测到的东西藏起来。
128
+ 5. `occasion` 落边:先按**会话**判是不是同一个场合,再决定合并还是新写。`entity_relations` 没有 UNIQUE 约束,`saveRelation` 每次都是新插一条,而探针会拿同一批段反复回放——不判重就会把同一次场合叠成多条边、把 `sessionCount` 抬得比真实场合数高。但**判重不能按 evidence 有没有交集**:模型给 evidence 的顺序不稳定(`parseJsonArray` 也不排序),`[m1,m2]` 与 `[m2,m1]` 是同一场合的两种写法;同一会话里的两条不同记忆各说一句「他很焦虑」,证据天然不相交,可它们是同一个场合。所以键落在会话层:同一 `(person, target, emotion)` 且两边**会话集**有交集 ⇒ 同一场合。
129
+ - 命中 ⇒ **合并而不是丢弃**:把 evidence 并集、按并集重算的 `sessionCount` 写回既有边(`updateRelationMetadata`),`lastMergedRunId` 记下是哪一轮补的,并计入返回值 `merged`。只丢弃会让边上的场合数冻结在第一次写入那一刻,将来拿它算归因覆盖率会一直读到 1。
130
+ - 未命中且未超 `sleepAffectiveMaxRelationsPerRun` ⇒ `saveRelation({relation_type: "affective_context", source: "llm", memory_id: evidence[0], metadata: {emotion, attribution, why, evidence, sessionCount, model, runId}})`;超出的进 `dropped: relation cap`。合并发生在 cap 之前——合并既有边不新增行,不该占名额。
131
+ 6. `trait` 走**标准决策通道**——铸成 `type=pattern` 记忆,经 `validateDecisions`(`allowedActions: ["create"]`、`maxCreatePerRun` 取 `sleepMaxPatternPerRun`)与 `applyDecisions`(`mintSource: "sleep"`,带 #512 铸造台账)。不新增写路径,因此继承所有既有上限与审计。**标题必须含 `target`**:`applyCreate` 走 `service.saveWithDedupe`,按 `(type, title)` 找同题行;标题不含对象时,「对答辩长期焦虑」与「对面试长期焦虑」是同一条,第二条会被静默合并——不报错、不进 `failures`,而 `applied` 仍如实显示写了,等于一条真实结论被吞掉而返回值说它已落库。**pattern 走 `_overwrite` 原地刷新、不追加**:`applyCreate` 对 `type === "pattern"` 传 `_overwrite: true`,命中同题行时正文被整体替换(旧文进 `content_history`,`source: "overwrite"`)。不这样的话,重放同一批证据会把同一段结论一遍遍 `appendContent` 粘上去(实测 161 → 355 → 549 字,外加一串重复的 `[证据: …]` 尾巴),而 pattern 正是注入面消费的东西。只对 pattern 放开——其他类型被 `create` 撞上同题行时仍按合并语义走,整段覆盖既有行比追加更危险。
132
+ 7. `applyCreate` 命中同题行时 `saveWithDedupe` 返回 `action: "merged"`:它确实刷新了那一行,但**没有铸出新条目**。这种结局不计入 `applied`,单独计 `merged` 并返回 `refreshedTraits`;决定 `status` 时它既不是 `ok`(没有新铸)也不是 `noop`(确实动过库),单独报 `refreshed`。`deriveStatus` 把 `refreshed` 算作「有产出」,否则整轮会被记成 noop,审计行又变回「跑了、什么都没干」。
133
+ 8. 已存在的 `occasion` 边在同类情绪被确认为 `trait` 时**不改写、只标记**:边记录的是「这次场合观察到过」,这句话在升格后仍然为真,所以 `sessionCount` 保持原值;但会给它写上 `promotedToTrait: true` 与 `traitSessionCount`。不标的话,同一件事实在图上写着「一次性」、在 pattern 里写着「长期」,读归因覆盖率的人拿到两份互相矛盾的记录,而且不会自愈——边不参与 trait 判定(trait 只看当轮 evidence)。
134
+ - **输出**:`{status, scanned, relations, merged, mergedList, edgeWrites, applied, refreshedTraits, failures, conflicts, traits, downgraded, dropped}`。`downgraded` 与 `dropped` 必须都是可见数字。`edgeWrites`、`merged`、`refreshedTraits` 各自单独报,因为它们都不是 `applied` 决策:第一个是「新写了一条边」,第二个是「认出了同一场合、把证据补进既有边」,第三个是「trait 标题撞上既有 pattern、原地刷新了它」。混进 `applied` 会让两个数字的含义随相位漂移,也会让「一条真实结论被同题行吃掉」看起来像成功。但 **receipt 里三者都算**——`buildReceipt` 只回答「这一轮到底改动过库没有」,只累加 `applied` 会让「写了边的一轮」和「只做了合并的一轮」都读成 `:0`,也就是「跑了但什么都没做」,那正是探针绝不能看到的状态。receipt 保持八段不变(`parseReceipt` 与 `organize.js` 共用这条契约),明细在各相位的 `phases` JSON 里,不在 receipt 里。
135
+ - **每条返回路径都带全字段**:`aborted` 与 `failed` 两个分支同样有 `merged` 与 `mergedList`。少一个不会报错,消费方读到 `undefined` 会静默当作「没有合并」——审计面上最难查的就是这种。
136
+ - **中断与失败**:中途 `signal.aborted` ⇒ `status: "aborted"`(**即使一条都没写**——循环断在任意下标,没扫完就不能报 `noop` 说「池子是空的」);`validateDecisions` 拒绝 trait 决策 ⇒ `status: "failed"`,不是静默的 `noop`。
137
+ - **验收**:一次场合的情绪只能落 `affective_context` 边、不能铸 pattern;同一类型跨 N 个独立会话才允许铸 pattern;判为 `related_to` 的关系计数在本相位后不增长;同一批段重放不产生第二条相同边(而是合并进既有边);重放时把 evidence 数组换个顺序也不产生第二条边;同一会话里两条不同记忆说的是同一种情绪时合并成一条边、`sessionCount` 仍是 1;后面又攒到新会话时既有边被扩充、`sessionCount` 跟着涨而不是冻结;同一条记忆里的两种不同情绪必须是两条边;只做了合并的一轮,receipt 的写入计数不为 0;同一个人对两个不同对象各够 trait 阈值时铸出**两条** pattern(不许一条吞掉另一条);重放同一批证据三轮,pattern 正文长度与 `[证据]` 尾巴个数都不变;只做了原地刷新的那一轮报 `refreshed` 而不是 `ok` + `applied: 0`;`occasion` 边在升格 trait 后被标上 `promotedToTrait` 而 `sessionCount` 不变。
138
+ - **为什么不能接在 `relation_completion` 里**:那一相只扫**孤立**实体(`getRelations(e.id).length === 0`),且 `inferRelationType` 三种类型硬编码、末行 `return "related_to"` 兜底——把情感塞进去只会得到一批 `related_to`,图上看不出情感轴。
139
+
116
140
  ## 分层压缩策略表
117
141
  | 记忆层级 | 判定条件 (`last_accessed_at`) | 处理策略 | 存储形态 |
118
142
  |:---|:---|:---|:---|
@@ -158,7 +182,7 @@ flowchart TD
158
182
  2. **`minRefTimeMs` 防误降级**:调用 `demoteToSummary` 时必须传入快照时刻 `{minRefTimeMs: archiveCut}`。否则在 Sleep Run 执行期间被用户 `touch` 的记忆会因时间窗口漂移被错误降级。
159
183
  3. **证据强过滤**:Pattern Discovery 阶段 LLM 返回的 `evidence` 必须与 DB 真实 ID 列表求交集。未通过 `intersect` 校验的证据一律丢弃,严防幻觉伪造关联。
160
184
  4. **严格串行化**:所有 Sleep Run **必须**走 `service.enqueue`。禁止直接调用异步函数启动,确保与 `autoDream` 绝对不重叠,避免写入锁冲突。
161
- 5. **Fail-Safe 阶段隔离**:四阶段必须各自独立 `try/catch`。LLM 服务抖动或网络异常时,仅跳过当前阶段(如跳过 Pattern 发现),后续阶段(如 Demotion、Relation)必须继续执行并记录 Receipt。
185
+ 5. **Fail-Safe 阶段隔离**:各阶段必须各自独立 `try/catch`。LLM 服务抖动或网络异常时,仅跳过当前阶段(如跳过 Pattern 发现),后续阶段(如 Demotion、Relation、Affective)必须继续执行并记录 Receipt。
162
186
  6. **无 LLM 降级路由**:当 `resolveSleepRoute` 返回 `undefined`(如 Provider 未配置或模型不可用)时,系统应自动跳过所有依赖 LLM 的阶段,但 `archival_demotion`(纯规则)仍需照常执行,保障基础压缩能力不丢失。
163
187
 
164
188
  ## 测试清单
@@ -167,6 +191,7 @@ flowchart TD
167
191
  - [ ] **阶段隔离**:Mock LLM 500 错误,验证仅对应阶段跳过,Receipt 仍生成且 `run_type='sleep'`。
168
192
  - [ ] **降级策略**:构造不同 `last_accessed_at` 的记忆,验证 `minRefTimeMs` 防误降级逻辑及分层归档正确性。
169
193
  - [ ] **模式发现**:注入含虚假 ID 的 LLM 响应,验证 `intersect` 过滤生效且创建数 `<= sleepMaxPatternPerRun`。
194
+ - [ ] **情感归因(#619)**:单会话证据判 `trait` → 必须降级为 `occasion` 且 `downgraded` 计数 +1;跨 N 个独立会话才铸 pattern;`related_to` 计数在本相位后不增长;未知实体/伪造 evidence 进 `dropped` 而不是静默消失。
170
195
  - [ ] **迁移幂等**:重复执行迁移脚本,验证无报错、无重复列/数据。
171
196
  - [ ] **队列互斥**:并发触发 `autoDream` 与 Sleep,验证 `service.enqueue` 串行执行无重叠。
172
197
 
package/docs/STORAGE.md CHANGED
@@ -39,6 +39,8 @@ dsh-mneme reclaim --json # 原始 JSON,给脚本用
39
39
 
40
40
  `distill_records` 只在 `distillRecords.enabled=true` 时才会有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。**曾经开过再关的库有行**:关掉开关只是不再写、也不再经 HTTP 读出去,已留的行一直躺在库里,直到 `dsh-mneme reclaim`(不读这个键)或重新开启后的启动裁剪(见 [蒸馏原文留存](DISTILL.md))。
41
41
 
42
+ `dream_runs.input` 另有一个**写入期**瘦身口(#557):`dreamInputSummaryEnabled`(opt-in 默认关)让新 auto 轮的快照只落骨架(id/type/title/importance/updated_at,不含 content)。它与本入口共享同一条边界——organize 轮的重放载荷无论哪个口都不碰;receipt 的 `snapshot_hash` 照常在内存里对含 content 的快照计算,但瘦身轮的原文不在库里、离线重算不出 hash,#512 的「每一行都能回放一次巩固」对瘦身轮降级为骨架级。删行依旧不在任何口子里(拍板 3:裁列同意、删行不同意)。
43
+
42
44
  取消归档时(`memory_archive` 还原)服务端会重新排队嵌入,所以被回收的向量能拿回来。
43
45
 
44
46
  ## 代价与实测
@@ -82,6 +84,27 @@ VACUUM 是 O(库大小),且需要排他写锁:库越大越慢,期间的写
82
84
  - 想保留活跃面:工具带 `keep_evidence_active: true`(注册器参数是 `archiveEvidence: false`);
83
85
  - 重注册同一份文档(出新版)时,那些已归档的 evidence 行仍可为**它所属的那份文档**背书;换个文档拿归档 id 当证据,照旧按捏造拒绝。
84
86
 
87
+ ## 容量预警(#275 空间维度)
88
+
89
+ 回收入口是「人主动按」的;容量预警补的是另一半:**让人知道该按了**。库体积 / 归档区规模过线时,`GET /api/dsh-mneme/health` 的 `capacity` 块与面板「库体积」卡(库内一览组)同时上报——维护者 2026-10-21 的接手口径:保留期(`retentionDays`)给了时间维度,这块补空间维度。
90
+
91
+ 三道阈值,全部**观察口**:没有周期性消费者(`/health` 被读到才算一次),预警永不自动归档 / 删除(#275 拍板:真删由人显式确认)。
92
+
93
+ | 检查 | 键 | 默认 | 过线含义 |
94
+ |---|---|---|---|
95
+ | `db_size` | `capacityWarning.dbSizeWarnMb` | `256` | 磁盘足迹(主文件 + WAL + SHM)到线:VACUUM 的 O(库大小) 代价开始值得先看数字再动手 |
96
+ | `archived_rows` | `capacityWarning.archivedRowsWarn` | `5000` | 归档区行数到「数千条这个量级」的上沿 |
97
+ | `archived_ratio` | `capacityWarning.archivedRatioWarn` | `0.95` | 归档占比(归档 / 全部)到「归档区吞掉活跃区」——健康形态本身在 0.8~0.9,预警线刻意不放那里 |
98
+
99
+ 读法与边界:
100
+
101
+ - 判级在 `service.getCapacityHealth`,与镜像健康同款 fail-safe:读失败显式 `status:"unknown"`,绝不伪装 ok。每条检查回 `value / threshold`,报警要能对上数字;**阈值 0 = 显式关掉那条检查**(判级前先看启用条件,不靠比较碰巧不成立)。
102
+ - `:memory:` 库没有磁盘足迹:`file_bytes` 为 `null`,`db_size` 检查**缺席**而不是 ok——「量不到」和「零体积」是两回事。
103
+ - 答复只带数字,**不带库文件路径**(storageStats 里有 path,`/health` 是半张对外面,不外带本机布局)。
104
+ - 默认值锚定 #275 议题的形态区间,依据写在 config.js 的键注释里;三个键都走 feature_flags 白名单(面板可改,启动快照生效)。
105
+ - 增速指标(归档净增速率 + 可压掉行数)是 recall-stats `archive` 块的第五指标,见下一节——预警卡刻意不算速率,同一个数字长两处就会分叉。
106
+ - 过线之后先跑本入口(`dsh-mneme reclaim` / 维护卡);归档区同主题堆积是出口问题,归 #254 的写入准入与整理动作面,预警不碰内容。
107
+
85
108
  ## 第五指标:归档净增速率 + 可压掉行数
86
109
 
87
110
  面板「记忆复用」卡与 `GET /api/dsh-mneme/recall-stats` 的返回里多了一个 `archive` 块(#275 拍板:与既有指标同位):
@@ -478,7 +478,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
478
478
  const listFilters = { includeArchived, ...(occurredFrom ? { occurredFrom } : {}), ...(occurredTo ? { occurredTo } : {}) };
479
479
  const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source, ...listFilters }));
480
480
  // Total honors the same filters so pager math stays correct.
481
- sendJson(res, 200, { items, total: service.count(type, { minImportance, source, ...listFilters }) });
481
+ // #597:typeCounts 与宿主内 /list 同口径(同一份筛选、不带 type 的全库分布),
482
+ // 两张面只许剩鉴权与路径不同。
483
+ const countFilters = { minImportance, source, ...listFilters };
484
+ sendJson(res, 200, { items, total: service.count(type, countFilters), typeCounts: service.countByType(countFilters) });
482
485
  return;
483
486
  }
484
487