@modusensus/dsh-mneme 0.8.13 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
package/src/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
+ }