@modusensus/dsh-mneme 0.8.14 → 0.8.16

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 (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. package/test/write-admission.test.js +668 -668
package/lib/document.js CHANGED
@@ -1,303 +1,303 @@
1
- // dsh-mneme/src/document.js
2
- // document 型记忆注册(issue #230,口径基线 = #164 设计稿评审线):agent 产
3
- // 长文档的指针行铸造口。写入权分离——全文归 agent(管线对文件零读零写零改),
4
- // 管线只做四件事:注册校验(文件存在 + 路径合法 + evidence 求交)、摘要 +
5
- // doc_path 落库(库里只存这三样)、C2 vector 档比对去重、supersede 记账
6
- // (出新版 = 新摘要行 supersede 旧行,旧文件不删,content_history 可追溯)。
7
- //
8
- // 独立成模块(AGENTS.md 尺寸约定,同 recallStats 先例):service.js 已过
9
- // 2000 行参考线,这里只依赖注入 service 内部件,barrel 出口在 service.js,
10
- // 调用方零改动。防绕过三层兜底:service 两条业务守卫(saveWithDedupe /
11
- // updateMemory)+ 存储层唯一铸造口(store.saveDocument,通用 save/update/
12
- // CAS 整类拒绝 document)——注册是唯一铸造口。
13
-
14
- import { statSync } from "node:fs";
15
- import { homedir } from "node:os";
16
- import { isAbsolute, resolve, sep } from "node:path";
17
- import { scopeKeyOf } from "./scope.js";
18
- import { cosineSimilarity } from "./dream/clustering.js";
19
-
20
- // C2 vector 档阈值(#164 拍板 0.92,与 findSessionDuplicate 的 vector 档同源)。
21
- // 刻意不做 24h 时间窗:那个窗是会话去重的「近邻界」(findSessionDuplicate 注释),
22
- // 不是相似度语义本身;document 版本更新天然跨天/跨周,套窗会把一周后重注册的
23
- // 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
24
- const DOCUMENT_MIN_SIM = 0.92;
25
- const CANDIDATE_LIMIT = 200;
26
- const DOCUMENT_DIR_NAME = "documents";
27
-
28
- /**
29
- * 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
30
- * `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
31
- * memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
32
- * 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
33
- */
34
- export function resolveDocumentDir(memoryDir, configured = "") {
35
- const raw = String(configured ?? "").trim();
36
- if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
37
- if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
38
- return resolve(homedir(), raw.slice(2));
39
- }
40
- return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
41
- }
42
-
43
- /**
44
- * 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
45
- * 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
46
- * Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
47
- * `c:\A\Documents\x.md` 会被判成两类。
48
- */
49
- export function isManagedDocumentPath(dir, path) {
50
- if (!dir || !path) return false;
51
- const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
52
- const base = norm(resolve(dir));
53
- const target = norm(resolve(path));
54
- const prefix = base.endsWith(sep) ? base : base + sep;
55
- return target === base || target.startsWith(prefix);
56
- }
57
-
58
- // 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
59
- // 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
60
- // id + 旧行 content_history 里的旧摘要(source=superseded)。
61
- const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
62
-
63
- // 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
64
- // 行各有自己的生命周期,被归档会各自留下第二行——
65
- // document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
66
- // 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
67
- // 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
68
- // summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
69
- // 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
70
- // 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
71
- const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
72
-
73
- /**
74
- * Build the document registrar. Injected deps are service.js closure members
75
- * (store / embedQuery / transaction / finalize) so this module stays free of
76
- * service-internal wiring; `finalize` is the single write epilogue (mirror
77
- * sync + notify + re-embed) so callers never duplicate it.
78
- *
79
- * `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
80
- * 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
81
- * 方向反了会成环。
82
- *
83
- * @returns {(payload: object, opts?: object) => Promise<object>}
84
- * `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
85
- * evidence_dropped, evidence_archived, degraded }`
86
- * @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
87
- * 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
88
- */
89
- export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
90
- return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
91
- // opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
92
- // 指回配置键,agent 能准确转告用户去开。
93
- if (config?.documentMemoryEnabled !== true) {
94
- throw new Error("document memory is disabled (documentMemoryEnabled)");
95
- }
96
-
97
- // --- 1. 路径合法 + 文件存在 --------------------------------------------
98
- // 只认展开 ~ 后的绝对路径:相对路径依赖进程 cwd,注册时的 cwd 与读取时
99
- // 的 cwd 不保证一致,指针必须自带完整定位。校验存在 + 常规文件 + 非空——
100
- // 空文件/被截断成 0 字节是「文字结构意外破坏」的最早信号,注册时拦住
101
- // 而不是落一条死指针(回退路径:agent 修好文件后重注册,同 path/title
102
- // 自动 supersede 旧行)。
103
- const rawPath = String(payload?.path ?? "").trim();
104
- if (!rawPath) throw new Error("registerDocument: path is required");
105
- // 只展开 ~ / ~/(~user 形式拒收为普通相对路径走 isAbsolute 拦截——不猜测
106
- // 其他用户的 home,错展开比报错更糟)。
107
- const isTilde = rawPath === "~" || rawPath.startsWith("~/") || rawPath.startsWith("~\\");
108
- const absolute = isTilde ? resolve(homedir(), rawPath.slice(2)) : rawPath;
109
- if (!isAbsolute(absolute)) {
110
- throw new Error(`registerDocument: path must be absolute (got "${rawPath}")`);
111
- }
112
- // 所有绝对路径统一 resolve 归一化(. / .. / 重复分隔符)——归一化不做的话,
113
- // D:\a\.\b.md 与 D:\a\b.md 会被判成两个路径,同文件绕过 same-path
114
- // supersede,留下双活跃指针。realpathSync 刻意不用:符号链接合并会改写
115
- // agent 提交的路径字面量,指针的可读性比追符号链更重要。
116
- const expanded = resolve(absolute);
117
- let stat = null;
118
- try { stat = statSync(expanded); } catch { /* missing/unreadable → null */ }
119
- if (!stat?.isFile() || stat.size <= 0) {
120
- throw new Error(`registerDocument: document file not found or empty: ${expanded}`);
121
- }
122
-
123
- // --- 2. evidence 求交(sleep.js 扫描集求交同款语义)---------------------
124
- // 引用的原子条必须真实存在且未归档(归档行已被降级,不再为新鲜 document
125
- // 背书)。红线 4 宽容形态:合法子集落库 + evidence_degraded 系统标记;
126
- // 提供了 evidence 但全军覆没 = 捏造信号,整单拒绝,agent 修正后重试。
127
- const wanted = [...new Set(
128
- (Array.isArray(payload?.evidence) ? payload.evidence : [])
129
- .map((id) => String(id ?? "").trim())
130
- .filter(Boolean)
131
- )];
132
- // strictScope(tools 层传入,#170 复核项 4 同款):调用者 scope 看不见的
133
- // 行按「不存在」处理——不得为跨 scope id 建立引用(存在性泄漏),与
134
- // unknown/archived 同落 dropped,evidence_kept/dropped 计数保持诚实。
135
- const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
136
- const kept = [];
137
- const dropped = [];
138
- // 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
139
- // supersede 目标探测后的「认回」),不能一律按「不可用」处理。
140
- const archivedRefs = new Set();
141
- for (const id of wanted) {
142
- const row = hidden.has(id) ? null : store.getById(id);
143
- if (row?.archived) archivedRefs.add(id);
144
- (row && !row.archived ? kept : dropped).push(id);
145
- }
146
-
147
- const title = String(payload?.title ?? "").trim();
148
- if (!title) throw new Error("registerDocument: title is required");
149
- const summary = String(payload?.summary ?? "").trim();
150
- if (!summary) throw new Error("registerDocument: summary is required");
151
-
152
- // --- 3. supersede 目标探测(三层)---------------------------------------
153
- // 同 doc_path / 同标题 = agent 显式的「出新版」意图 → supersede;仅 vector
154
- // 近重复而路径标题都不同 = 疑似重复外档 → 拒绝并指路。C2×C1 硬线:比对
155
- // 不得越过矛盾裁决替 agent 二选一——改既有行还是归档后重注册,判断权留在
156
- // agent 手里。去重键带 scope(scopeKeyOf):跨作用域永不互判(防泄漏,与
157
- // saveWithDedupe 的 scopeMatches 同口径)。
158
- const scopeMatches = (m) =>
159
- scopeKeyOf(m.agent_scope) === scopeKeyOf(payload?.agent_scope) &&
160
- scopeKeyOf(m.workspace_scope) === scopeKeyOf(payload?.workspace_scope) &&
161
- scopeKeyOf(m.sensitivity) === scopeKeyOf(payload?.sensitivity);
162
- const samePath = (a, b) => {
163
- if (!a || !b) return false;
164
- // Windows 文件系统大小写不敏感:比较前归一,否则 C:\a.md 与 c:\A.md
165
- // 会被判成两个版本。
166
- const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
167
- return norm(a) === norm(b);
168
- };
169
- // 精确层(同路径/同标题)必须全量扫描、不允许窗口截断:>200 行活跃
170
- // document 时,窗口外的同路径旧版漏检会留下双活跃版本——supersede 判定
171
- // 是正确性要求,不是性能优化对象。vector 档比对保留 CANDIDATE_LIMIT
172
- // (getEmbeddings + 两两 cosine 才是有界对象)。
173
- const scopeMatched = store
174
- .list({ type: "document", limit: null })
175
- .filter((m) => scopeMatches(m));
176
- const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
177
- ?? scopeMatched.find((m) => m.title.trim() === title);
178
- // 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
179
- // archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
180
- // dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
181
- // **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
182
- // 背书;捏造与跨 scope 照旧拒绝。
183
- if (explicitTarget) {
184
- const own = new Set(explicitTarget.evidence ?? []);
185
- for (const id of archivedRefs) {
186
- if (!own.has(id)) continue;
187
- const at = dropped.indexOf(id);
188
- if (at >= 0) dropped.splice(at, 1);
189
- kept.push(id);
190
- }
191
- }
192
- if (wanted.length > 0 && kept.length === 0) {
193
- throw new Error(
194
- `registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
195
- );
196
- }
197
- if (!explicitTarget) {
198
- // vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
199
- // (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
200
- // (同两段拼接 embed)同构。
201
- const candidates = scopeMatched.slice(0, CANDIDATE_LIMIT);
202
- try {
203
- const vecs = store.getEmbeddings(candidates.map((m) => m.id));
204
- if (vecs.size) {
205
- const probe = await embedQuery([title, summary].filter(Boolean).join("\n"));
206
- if (probe) {
207
- let best = null;
208
- for (const m of candidates) {
209
- const v = vecs.get(m.id);
210
- if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
211
- const sim = cosineSimilarity(probe, v);
212
- if (sim >= DOCUMENT_MIN_SIM && (!best || sim > best.sim)) best = { m, sim };
213
- }
214
- if (best) {
215
- throw new Error(
216
- `registerDocument: summary is a near duplicate (C2 >= ${DOCUMENT_MIN_SIM}, sim ${best.sim.toFixed(3)}) ` +
217
- `of document row ${best.m.id} "${best.m.title}" — update that row via memory_update, or archive it ` +
218
- `before registering a separate document`
219
- );
220
- }
221
- }
222
- }
223
- } catch (e) {
224
- // 近重复拒绝是业务结果,原样上抛;embedder 故障吞掉回落(只做路径/标题层)。
225
- if (e instanceof Error && e.message.startsWith("registerDocument:")) throw e;
226
- }
227
- }
228
-
229
- // --- 4. 落库 + supersede 记账(同事务)----------------------------------
230
- // 新摘要行:库里只存 summary + doc_path + evidence(+ scope 标注),旧文件
231
- // 零触碰。supersede:旧行 content_history 存旧摘要(source=superseded)、
232
- // 正文追加指向新行的注记、归档(降可及性可复活,无物理删除)。
233
- const userTags = (Array.isArray(payload?.tags) ? payload.tags : [])
234
- .map((t) => String(t ?? "").trim())
235
- .filter(Boolean);
236
- const tags = [...new Set([...userTags, ...(dropped.length ? ["evidence_degraded"] : [])])];
237
- const result = transaction(() => {
238
- // saveDocument 是存储层唯一 document 铸造口(通用 save/update/CAS 拒绝
239
- // document——CodeRabbit 复核 #8/#882):registerDocument 传入的 doc_path
240
- // 已过归一化与文件校验。
241
- const created = store.saveDocument({
242
- type: "document",
243
- title,
244
- content: summary,
245
- tags,
246
- importance: Number.isInteger(payload?.importance)
247
- ? Math.min(5, Math.max(1, payload.importance))
248
- : 3,
249
- source: payload?.source ?? "tool",
250
- evidence: kept,
251
- doc_path: expanded,
252
- // sensitivity 进 supersede 匹配键(scopeKeyOf 第三维):不落库的话,
253
- // 首注册带 sensitivity、重注册同键会在匹配时判成两 scope,旧版漏检。
254
- ...(payload?.sensitivity !== undefined ? { sensitivity: payload.sensitivity } : {}),
255
- ...(payload?.agent_scope !== undefined
256
- ? { agent_scope: payload.agent_scope, agent_scope_source: payload.agent_scope_source }
257
- : {}),
258
- ...(payload?.workspace_scope !== undefined
259
- ? { workspace_scope: payload.workspace_scope, workspace_scope_source: payload.workspace_scope_source }
260
- : {})
261
- });
262
- let superseded;
263
- const loser = explicitTarget ? store.getById(explicitTarget.id) : null;
264
- if (loser && !loser.archived) {
265
- store.update(loser.id, {
266
- content: `${loser.content}${supersededByPointer(created.id)}`,
267
- content_history: pushContentHistory(loser, "superseded")
268
- });
269
- store.setArchived(loser.id, true);
270
- superseded = store.getById(loser.id);
271
- }
272
- // #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
273
- // 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
274
- // 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
275
- // 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
276
- // ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
277
- // (可恢复),opt-out 走 archiveEvidence。
278
- // pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
279
- let evidenceArchived = 0;
280
- if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
281
- for (const id of kept) {
282
- // 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
283
- const row = store.getById(id);
284
- if (!row || row.archived || row.forgotten) continue;
285
- if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
286
- store.setArchived(id, true);
287
- evidenceArchived += 1;
288
- }
289
- }
290
- return { created, superseded, evidenceArchived };
291
- });
292
- finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
293
- return {
294
- action: result.superseded ? "superseded" : "created",
295
- memory: result.created,
296
- ...(result.superseded ? { superseded: result.superseded } : {}),
297
- evidence_kept: kept.length,
298
- evidence_dropped: dropped.length,
299
- evidence_archived: result.evidenceArchived,
300
- degraded: dropped.length > 0
301
- };
302
- };
303
- }
1
+ // dsh-mneme/src/document.js
2
+ // document 型记忆注册(issue #230,口径基线 = #164 设计稿评审线):agent 产
3
+ // 长文档的指针行铸造口。写入权分离——全文归 agent(管线对文件零读零写零改),
4
+ // 管线只做四件事:注册校验(文件存在 + 路径合法 + evidence 求交)、摘要 +
5
+ // doc_path 落库(库里只存这三样)、C2 vector 档比对去重、supersede 记账
6
+ // (出新版 = 新摘要行 supersede 旧行,旧文件不删,content_history 可追溯)。
7
+ //
8
+ // 独立成模块(AGENTS.md 尺寸约定,同 recallStats 先例):service.js 已过
9
+ // 2000 行参考线,这里只依赖注入 service 内部件,barrel 出口在 service.js,
10
+ // 调用方零改动。防绕过三层兜底:service 两条业务守卫(saveWithDedupe /
11
+ // updateMemory)+ 存储层唯一铸造口(store.saveDocument,通用 save/update/
12
+ // CAS 整类拒绝 document)——注册是唯一铸造口。
13
+
14
+ import { statSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { isAbsolute, resolve, sep } from "node:path";
17
+ import { scopeKeyOf } from "./scope.js";
18
+ import { cosineSimilarity } from "./dream/clustering.js";
19
+
20
+ // C2 vector 档阈值(#164 拍板 0.92,与 findSessionDuplicate 的 vector 档同源)。
21
+ // 刻意不做 24h 时间窗:那个窗是会话去重的「近邻界」(findSessionDuplicate 注释),
22
+ // 不是相似度语义本身;document 版本更新天然跨天/跨周,套窗会把一周后重注册的
23
+ // 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
24
+ const DOCUMENT_MIN_SIM = 0.92;
25
+ const CANDIDATE_LIMIT = 200;
26
+ const DOCUMENT_DIR_NAME = "documents";
27
+
28
+ /**
29
+ * 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
30
+ * `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
31
+ * memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
32
+ * 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
33
+ */
34
+ export function resolveDocumentDir(memoryDir, configured = "") {
35
+ const raw = String(configured ?? "").trim();
36
+ if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
37
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
38
+ return resolve(homedir(), raw.slice(2));
39
+ }
40
+ return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
41
+ }
42
+
43
+ /**
44
+ * 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
45
+ * 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
46
+ * Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
47
+ * `c:\A\Documents\x.md` 会被判成两类。
48
+ */
49
+ export function isManagedDocumentPath(dir, path) {
50
+ if (!dir || !path) return false;
51
+ const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
52
+ const base = norm(resolve(dir));
53
+ const target = norm(resolve(path));
54
+ const prefix = base.endsWith(sep) ? base : base + sep;
55
+ return target === base || target.startsWith(prefix);
56
+ }
57
+
58
+ // 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
59
+ // 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
60
+ // id + 旧行 content_history 里的旧摘要(source=superseded)。
61
+ const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
62
+
63
+ // 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
64
+ // 行各有自己的生命周期,被归档会各自留下第二行——
65
+ // document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
66
+ // 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
67
+ // 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
68
+ // summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
69
+ // 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
70
+ // 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
71
+ const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
72
+
73
+ /**
74
+ * Build the document registrar. Injected deps are service.js closure members
75
+ * (store / embedQuery / transaction / finalize) so this module stays free of
76
+ * service-internal wiring; `finalize` is the single write epilogue (mirror
77
+ * sync + notify + re-embed) so callers never duplicate it.
78
+ *
79
+ * `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
80
+ * 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
81
+ * 方向反了会成环。
82
+ *
83
+ * @returns {(payload: object, opts?: object) => Promise<object>}
84
+ * `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
85
+ * evidence_dropped, evidence_archived, degraded }`
86
+ * @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
87
+ * 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
88
+ */
89
+ export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
90
+ return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
91
+ // opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
92
+ // 指回配置键,agent 能准确转告用户去开。
93
+ if (config?.documentMemoryEnabled !== true) {
94
+ throw new Error("document memory is disabled (documentMemoryEnabled)");
95
+ }
96
+
97
+ // --- 1. 路径合法 + 文件存在 --------------------------------------------
98
+ // 只认展开 ~ 后的绝对路径:相对路径依赖进程 cwd,注册时的 cwd 与读取时
99
+ // 的 cwd 不保证一致,指针必须自带完整定位。校验存在 + 常规文件 + 非空——
100
+ // 空文件/被截断成 0 字节是「文字结构意外破坏」的最早信号,注册时拦住
101
+ // 而不是落一条死指针(回退路径:agent 修好文件后重注册,同 path/title
102
+ // 自动 supersede 旧行)。
103
+ const rawPath = String(payload?.path ?? "").trim();
104
+ if (!rawPath) throw new Error("registerDocument: path is required");
105
+ // 只展开 ~ / ~/(~user 形式拒收为普通相对路径走 isAbsolute 拦截——不猜测
106
+ // 其他用户的 home,错展开比报错更糟)。
107
+ const isTilde = rawPath === "~" || rawPath.startsWith("~/") || rawPath.startsWith("~\\");
108
+ const absolute = isTilde ? resolve(homedir(), rawPath.slice(2)) : rawPath;
109
+ if (!isAbsolute(absolute)) {
110
+ throw new Error(`registerDocument: path must be absolute (got "${rawPath}")`);
111
+ }
112
+ // 所有绝对路径统一 resolve 归一化(. / .. / 重复分隔符)——归一化不做的话,
113
+ // D:\a\.\b.md 与 D:\a\b.md 会被判成两个路径,同文件绕过 same-path
114
+ // supersede,留下双活跃指针。realpathSync 刻意不用:符号链接合并会改写
115
+ // agent 提交的路径字面量,指针的可读性比追符号链更重要。
116
+ const expanded = resolve(absolute);
117
+ let stat = null;
118
+ try { stat = statSync(expanded); } catch { /* missing/unreadable → null */ }
119
+ if (!stat?.isFile() || stat.size <= 0) {
120
+ throw new Error(`registerDocument: document file not found or empty: ${expanded}`);
121
+ }
122
+
123
+ // --- 2. evidence 求交(sleep.js 扫描集求交同款语义)---------------------
124
+ // 引用的原子条必须真实存在且未归档(归档行已被降级,不再为新鲜 document
125
+ // 背书)。红线 4 宽容形态:合法子集落库 + evidence_degraded 系统标记;
126
+ // 提供了 evidence 但全军覆没 = 捏造信号,整单拒绝,agent 修正后重试。
127
+ const wanted = [...new Set(
128
+ (Array.isArray(payload?.evidence) ? payload.evidence : [])
129
+ .map((id) => String(id ?? "").trim())
130
+ .filter(Boolean)
131
+ )];
132
+ // strictScope(tools 层传入,#170 复核项 4 同款):调用者 scope 看不见的
133
+ // 行按「不存在」处理——不得为跨 scope id 建立引用(存在性泄漏),与
134
+ // unknown/archived 同落 dropped,evidence_kept/dropped 计数保持诚实。
135
+ const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
136
+ const kept = [];
137
+ const dropped = [];
138
+ // 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
139
+ // supersede 目标探测后的「认回」),不能一律按「不可用」处理。
140
+ const archivedRefs = new Set();
141
+ for (const id of wanted) {
142
+ const row = hidden.has(id) ? null : store.getById(id);
143
+ if (row?.archived) archivedRefs.add(id);
144
+ (row && !row.archived ? kept : dropped).push(id);
145
+ }
146
+
147
+ const title = String(payload?.title ?? "").trim();
148
+ if (!title) throw new Error("registerDocument: title is required");
149
+ const summary = String(payload?.summary ?? "").trim();
150
+ if (!summary) throw new Error("registerDocument: summary is required");
151
+
152
+ // --- 3. supersede 目标探测(三层)---------------------------------------
153
+ // 同 doc_path / 同标题 = agent 显式的「出新版」意图 → supersede;仅 vector
154
+ // 近重复而路径标题都不同 = 疑似重复外档 → 拒绝并指路。C2×C1 硬线:比对
155
+ // 不得越过矛盾裁决替 agent 二选一——改既有行还是归档后重注册,判断权留在
156
+ // agent 手里。去重键带 scope(scopeKeyOf):跨作用域永不互判(防泄漏,与
157
+ // saveWithDedupe 的 scopeMatches 同口径)。
158
+ const scopeMatches = (m) =>
159
+ scopeKeyOf(m.agent_scope) === scopeKeyOf(payload?.agent_scope) &&
160
+ scopeKeyOf(m.workspace_scope) === scopeKeyOf(payload?.workspace_scope) &&
161
+ scopeKeyOf(m.sensitivity) === scopeKeyOf(payload?.sensitivity);
162
+ const samePath = (a, b) => {
163
+ if (!a || !b) return false;
164
+ // Windows 文件系统大小写不敏感:比较前归一,否则 C:\a.md 与 c:\A.md
165
+ // 会被判成两个版本。
166
+ const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
167
+ return norm(a) === norm(b);
168
+ };
169
+ // 精确层(同路径/同标题)必须全量扫描、不允许窗口截断:>200 行活跃
170
+ // document 时,窗口外的同路径旧版漏检会留下双活跃版本——supersede 判定
171
+ // 是正确性要求,不是性能优化对象。vector 档比对保留 CANDIDATE_LIMIT
172
+ // (getEmbeddings + 两两 cosine 才是有界对象)。
173
+ const scopeMatched = store
174
+ .list({ type: "document", limit: null })
175
+ .filter((m) => scopeMatches(m));
176
+ const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
177
+ ?? scopeMatched.find((m) => m.title.trim() === title);
178
+ // 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
179
+ // archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
180
+ // dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
181
+ // **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
182
+ // 背书;捏造与跨 scope 照旧拒绝。
183
+ if (explicitTarget) {
184
+ const own = new Set(explicitTarget.evidence ?? []);
185
+ for (const id of archivedRefs) {
186
+ if (!own.has(id)) continue;
187
+ const at = dropped.indexOf(id);
188
+ if (at >= 0) dropped.splice(at, 1);
189
+ kept.push(id);
190
+ }
191
+ }
192
+ if (wanted.length > 0 && kept.length === 0) {
193
+ throw new Error(
194
+ `registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
195
+ );
196
+ }
197
+ if (!explicitTarget) {
198
+ // vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
199
+ // (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
200
+ // (同两段拼接 embed)同构。
201
+ const candidates = scopeMatched.slice(0, CANDIDATE_LIMIT);
202
+ try {
203
+ const vecs = store.getEmbeddings(candidates.map((m) => m.id));
204
+ if (vecs.size) {
205
+ const probe = await embedQuery([title, summary].filter(Boolean).join("\n"));
206
+ if (probe) {
207
+ let best = null;
208
+ for (const m of candidates) {
209
+ const v = vecs.get(m.id);
210
+ if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
211
+ const sim = cosineSimilarity(probe, v);
212
+ if (sim >= DOCUMENT_MIN_SIM && (!best || sim > best.sim)) best = { m, sim };
213
+ }
214
+ if (best) {
215
+ throw new Error(
216
+ `registerDocument: summary is a near duplicate (C2 >= ${DOCUMENT_MIN_SIM}, sim ${best.sim.toFixed(3)}) ` +
217
+ `of document row ${best.m.id} "${best.m.title}" — update that row via memory_update, or archive it ` +
218
+ `before registering a separate document`
219
+ );
220
+ }
221
+ }
222
+ }
223
+ } catch (e) {
224
+ // 近重复拒绝是业务结果,原样上抛;embedder 故障吞掉回落(只做路径/标题层)。
225
+ if (e instanceof Error && e.message.startsWith("registerDocument:")) throw e;
226
+ }
227
+ }
228
+
229
+ // --- 4. 落库 + supersede 记账(同事务)----------------------------------
230
+ // 新摘要行:库里只存 summary + doc_path + evidence(+ scope 标注),旧文件
231
+ // 零触碰。supersede:旧行 content_history 存旧摘要(source=superseded)、
232
+ // 正文追加指向新行的注记、归档(降可及性可复活,无物理删除)。
233
+ const userTags = (Array.isArray(payload?.tags) ? payload.tags : [])
234
+ .map((t) => String(t ?? "").trim())
235
+ .filter(Boolean);
236
+ const tags = [...new Set([...userTags, ...(dropped.length ? ["evidence_degraded"] : [])])];
237
+ const result = transaction(() => {
238
+ // saveDocument 是存储层唯一 document 铸造口(通用 save/update/CAS 拒绝
239
+ // document——CodeRabbit 复核 #8/#882):registerDocument 传入的 doc_path
240
+ // 已过归一化与文件校验。
241
+ const created = store.saveDocument({
242
+ type: "document",
243
+ title,
244
+ content: summary,
245
+ tags,
246
+ importance: Number.isInteger(payload?.importance)
247
+ ? Math.min(5, Math.max(1, payload.importance))
248
+ : 3,
249
+ source: payload?.source ?? "tool",
250
+ evidence: kept,
251
+ doc_path: expanded,
252
+ // sensitivity 进 supersede 匹配键(scopeKeyOf 第三维):不落库的话,
253
+ // 首注册带 sensitivity、重注册同键会在匹配时判成两 scope,旧版漏检。
254
+ ...(payload?.sensitivity !== undefined ? { sensitivity: payload.sensitivity } : {}),
255
+ ...(payload?.agent_scope !== undefined
256
+ ? { agent_scope: payload.agent_scope, agent_scope_source: payload.agent_scope_source }
257
+ : {}),
258
+ ...(payload?.workspace_scope !== undefined
259
+ ? { workspace_scope: payload.workspace_scope, workspace_scope_source: payload.workspace_scope_source }
260
+ : {})
261
+ });
262
+ let superseded;
263
+ const loser = explicitTarget ? store.getById(explicitTarget.id) : null;
264
+ if (loser && !loser.archived) {
265
+ store.update(loser.id, {
266
+ content: `${loser.content}${supersededByPointer(created.id)}`,
267
+ content_history: pushContentHistory(loser, "superseded")
268
+ });
269
+ store.setArchived(loser.id, true);
270
+ superseded = store.getById(loser.id);
271
+ }
272
+ // #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
273
+ // 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
274
+ // 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
275
+ // 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
276
+ // ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
277
+ // (可恢复),opt-out 走 archiveEvidence。
278
+ // pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
279
+ let evidenceArchived = 0;
280
+ if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
281
+ for (const id of kept) {
282
+ // 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
283
+ const row = store.getById(id);
284
+ if (!row || row.archived || row.forgotten) continue;
285
+ if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
286
+ store.setArchived(id, true);
287
+ evidenceArchived += 1;
288
+ }
289
+ }
290
+ return { created, superseded, evidenceArchived };
291
+ });
292
+ finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
293
+ return {
294
+ action: result.superseded ? "superseded" : "created",
295
+ memory: result.created,
296
+ ...(result.superseded ? { superseded: result.superseded } : {}),
297
+ evidence_kept: kept.length,
298
+ evidence_dropped: dropped.length,
299
+ evidence_archived: result.evidenceArchived,
300
+ degraded: dropped.length > 0
301
+ };
302
+ };
303
+ }