@modusensus/dsh-mneme 0.8.3 → 0.8.5

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 (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +727 -636
  3. package/bin/cli.mjs +0 -0
  4. package/bin/dsh-mneme-mcp.mjs +510 -0
  5. package/cordis.patch.yml +21 -21
  6. package/lib/api-standalone.js +506 -316
  7. package/lib/api.js +1403 -1316
  8. package/lib/bootstrap.js +211 -0
  9. package/lib/client.js +4657 -4366
  10. package/lib/commands.js +95 -95
  11. package/lib/config.js +615 -510
  12. package/lib/document.js +221 -0
  13. package/lib/dream/clustering.js +118 -118
  14. package/lib/dream/decisions.js +658 -658
  15. package/lib/dream/narratives.js +44 -0
  16. package/lib/dream/sleep.js +702 -700
  17. package/lib/dream.js +147 -16
  18. package/lib/embedding.js +177 -177
  19. package/lib/entities/extractor.js +249 -249
  20. package/lib/guide.js +44 -0
  21. package/lib/heat.js +144 -136
  22. package/lib/hot-memory.js +46 -46
  23. package/lib/index.js +515 -515
  24. package/lib/inject.js +375 -295
  25. package/lib/lang.js +208 -179
  26. package/lib/local-embedder.js +320 -320
  27. package/lib/mirror.js +198 -198
  28. package/lib/organize.js +450 -0
  29. package/lib/quality-filter.js +126 -126
  30. package/lib/recall-stats.js +125 -0
  31. package/lib/reranker.js +269 -269
  32. package/lib/runtime/adopt.js +176 -176
  33. package/lib/runtime/closure.js +172 -172
  34. package/lib/runtime/download.js +247 -247
  35. package/lib/runtime/layout.js +265 -235
  36. package/lib/runtime/loader.js +252 -249
  37. package/lib/runtime/model-fetch.js +361 -361
  38. package/lib/runtime/provision.js +237 -237
  39. package/lib/runtime/tarball.js +130 -130
  40. package/lib/runtime/verify.js +204 -191
  41. package/lib/scope.js +118 -118
  42. package/lib/search/adaptive.js +60 -22
  43. package/lib/search/bm25.js +96 -96
  44. package/lib/service.js +2324 -2087
  45. package/lib/settings.js +459 -420
  46. package/lib/store.js +2549 -2367
  47. package/lib/summarize.js +820 -449
  48. package/lib/tools.js +702 -574
  49. package/lib/vector-index.js +110 -110
  50. package/lib/version-check.js +122 -122
  51. package/package.json +109 -108
  52. package/runtime-manifest.json +526 -596
  53. package/scripts/benchmark-embed.js +201 -201
  54. package/scripts/benchmark-recall.js +199 -199
  55. package/scripts/benchmark-rerank.js +166 -166
  56. package/scripts/build-runtime-manifest.mjs +224 -217
  57. package/scripts/check-sync.js +42 -42
  58. package/scripts/e2e-dsh.js +220 -220
  59. package/scripts/mneme-runtime.mjs +277 -277
  60. package/scripts/stress-dsh.js +255 -255
  61. package/scripts/sync-lib.js +47 -47
  62. package/scripts/sync-test-badge.mjs +67 -61
  63. package/src/api-standalone.js +506 -316
  64. package/src/api.js +1403 -1316
  65. package/src/bootstrap.js +211 -0
  66. package/src/commands.js +95 -95
  67. package/src/config.js +615 -510
  68. package/src/document.js +221 -0
  69. package/src/dream/clustering.js +118 -118
  70. package/src/dream/decisions.js +658 -658
  71. package/src/dream/narratives.js +44 -0
  72. package/src/dream/sleep.js +702 -700
  73. package/src/dream.js +147 -16
  74. package/src/embedding.js +177 -177
  75. package/src/entities/extractor.js +249 -249
  76. package/src/guide.js +44 -0
  77. package/src/heat.js +144 -136
  78. package/src/hot-memory.js +46 -46
  79. package/src/index.js +515 -515
  80. package/src/inject.js +375 -295
  81. package/src/lang.js +208 -179
  82. package/src/local-embedder.js +320 -320
  83. package/src/mirror.js +198 -198
  84. package/src/organize.js +450 -0
  85. package/src/quality-filter.js +126 -126
  86. package/src/recall-stats.js +125 -0
  87. package/src/reranker.js +269 -269
  88. package/src/runtime/adopt.js +176 -176
  89. package/src/runtime/closure.js +172 -172
  90. package/src/runtime/download.js +247 -247
  91. package/src/runtime/layout.js +265 -235
  92. package/src/runtime/loader.js +252 -249
  93. package/src/runtime/model-fetch.js +361 -361
  94. package/src/runtime/provision.js +237 -237
  95. package/src/runtime/tarball.js +130 -130
  96. package/src/runtime/verify.js +204 -191
  97. package/src/scope.js +118 -118
  98. package/src/search/adaptive.js +60 -22
  99. package/src/search/bm25.js +96 -96
  100. package/src/service.js +2324 -2087
  101. package/src/settings.js +459 -420
  102. package/src/store.js +2549 -2367
  103. package/src/summarize.js +820 -449
  104. package/src/tools.js +702 -574
  105. package/src/vector-index.js +110 -110
  106. package/src/version-check.js +122 -122
  107. package/test/adaptive-inject-budget.test.js +85 -0
  108. package/test/api-routes-gaps.test.js +218 -218
  109. package/test/api.test.js +1640 -1482
  110. package/test/audit.test.js +448 -448
  111. package/test/benchmark.test.js +94 -94
  112. package/test/bootstrap.test.js +220 -0
  113. package/test/client.test.js +860 -736
  114. package/test/clustering.test.js +100 -100
  115. package/test/commands.test.js +106 -106
  116. package/test/config.test.js +66 -66
  117. package/test/conflict-freeze.test.js +300 -300
  118. package/test/document.test.js +523 -0
  119. package/test/dream-archive-guardrail.test.js +116 -116
  120. package/test/dream-candidate-hybrid.test.js +281 -281
  121. package/test/dream-coverage-degrade.test.js +86 -86
  122. package/test/dream.test.js +1308 -1254
  123. package/test/embedding-ready.test.js +196 -196
  124. package/test/entities.test.js +547 -547
  125. package/test/entity-recall.test.js +179 -0
  126. package/test/epistemic.test.js +298 -298
  127. package/test/fnew-0112.test.js +311 -311
  128. package/test/fnew-03.test.js +422 -422
  129. package/test/graph-api.test.js +175 -175
  130. package/test/heat.test.js +175 -148
  131. package/test/helpers/dream-mock.js +82 -82
  132. package/test/helpers/peer-worker.mjs +25 -25
  133. package/test/helpers/runtime-source.js +60 -60
  134. package/test/helpers/tar-builder.js +80 -80
  135. package/test/hot-memory.test.js +145 -145
  136. package/test/inject-content-cap.test.js +91 -0
  137. package/test/inject-first-round-bm25.test.js +89 -89
  138. package/test/inject-heat.test.js +61 -0
  139. package/test/inject-pools.test.js +170 -0
  140. package/test/inject-recall.test.js +105 -0
  141. package/test/inject-rotation.test.js +161 -161
  142. package/test/inject.test.js +283 -249
  143. package/test/lang.test.js +87 -87
  144. package/test/lib-smoke.test.js +96 -96
  145. package/test/llm-audit.test.js +462 -309
  146. package/test/llm-message-source.test.js +177 -177
  147. package/test/local-embedder.test.js +249 -249
  148. package/test/mcp-stdio.test.js +385 -0
  149. package/test/mirror-dirty.test.js +424 -424
  150. package/test/mirror-edit-digest.test.js +187 -187
  151. package/test/mirror-generation.test.js +499 -499
  152. package/test/mirror.test.js +249 -249
  153. package/test/narrative-bars.test.js +162 -0
  154. package/test/organize.test.js +418 -0
  155. package/test/peer-blockers.test.js +193 -193
  156. package/test/policy-epoch.test.js +259 -259
  157. package/test/quality-filter.test.js +186 -186
  158. package/test/reasoning-effort.test.js +746 -746
  159. package/test/recall-evals.test.js +235 -235
  160. package/test/recall-layer.test.js +326 -326
  161. package/test/recall-stats.test.js +123 -0
  162. package/test/receipt-chain.test.js +451 -451
  163. package/test/reflection.test.js +226 -226
  164. package/test/reindex-backfill.test.js +62 -62
  165. package/test/reranker-single-logit.test.js +73 -73
  166. package/test/reranker.test.js +197 -197
  167. package/test/resident-status.test.js +83 -0
  168. package/test/runtime-adopt.test.js +206 -206
  169. package/test/runtime-cli.test.js +89 -89
  170. package/test/runtime-closure.test.js +193 -193
  171. package/test/runtime-download.test.js +302 -302
  172. package/test/runtime-layout.test.js +163 -163
  173. package/test/runtime-loader.test.js +255 -237
  174. package/test/runtime-manifest.test.js +159 -158
  175. package/test/runtime-model-fetch.test.js +348 -348
  176. package/test/runtime-provision.test.js +101 -101
  177. package/test/runtime-tarball.test.js +81 -81
  178. package/test/runtime-verify.test.js +279 -214
  179. package/test/scope-candidates.test.js +239 -239
  180. package/test/scope-explicit.test.js +452 -452
  181. package/test/scope-retrieval.test.js +197 -197
  182. package/test/scope-storage.test.js +255 -255
  183. package/test/scope-strict.test.js +231 -231
  184. package/test/search-fusion.test.js +90 -90
  185. package/test/semantic.test.js +124 -124
  186. package/test/service-search.test.js +174 -174
  187. package/test/service.test.js +405 -405
  188. package/test/settings.test.js +261 -261
  189. package/test/skip-invalid-restore.test.js +262 -262
  190. package/test/sleep-action-set.test.js +422 -422
  191. package/test/sleep-heat.test.js +126 -125
  192. package/test/sleep-scheduler-rearm.test.js +89 -89
  193. package/test/sleep.test.js +410 -373
  194. package/test/standalone-api.test.js +544 -359
  195. package/test/store-vector-cache.test.js +98 -98
  196. package/test/store.test.js +396 -383
  197. package/test/stress.test.js +209 -209
  198. package/test/summarize.test.js +1288 -575
  199. package/test/tools-dto-schema-matrix.test.js +325 -0
  200. package/test/tools-memory-get-schema.test.js +65 -65
  201. package/test/tools.test.js +374 -341
  202. package/test/updated-at-semantics.test.js +113 -113
  203. package/test/vector-index.test.js +221 -221
  204. package/test/version-check.test.js +152 -152
  205. package/test/webServer-optional.test.js +82 -82
@@ -0,0 +1,450 @@
1
+ // dsh-mneme/src/organize.js
2
+ // agent 主动整理接口(issue #231,口径基线 = #164 的「第三腿触发」):dryRun
3
+ // 出比对报告 → agent 判断 → apply 落库;筛除项进归档不删;调用全程留审计回执。
4
+ //
5
+ // 只做功能本体(维护者口径,issue #231):落在 service 层并带测试,**不进工具
6
+ // 列表、不加独立 opt-in 开关**——#249 到位时只差「注册工具 + 注入指引」一步。
7
+ //
8
+ // 独立成模块(AGENTS.md 尺寸约定,同 document.js / recall-stats.js 先例):
9
+ // service.js 只做依赖注入 + barrel 出口,调用方零改动。
10
+ //
11
+ // 四条硬规则,都是「宁可什么都不做」的形态:
12
+ // 1. dryRun 不写记忆表:它只读 + 写一行审计(dream_runs)。比对是报告,不是动作。
13
+ // 2. apply 必须引用一次真实 dryRun——run_id 且 run_type='organize',且不是 apply
14
+ // 自己的回执(靠 outcome.dry_run_id 区分)、也不是 failed 的报告,且**还没落地过**
15
+ // (报告行上的 outcome.applied_at,同一份报告只认一次,挡掉重放)。没有比对过的
16
+ // 候选一律不落库,堵死「跳过报告直接写」这条绕过路径。
17
+ // 3. apply 只重放 dryRun 校验过的那份规范化快照,且只认快照里的索引:被 dryRun
18
+ // 跳过的候选没有资格落库;scope 比对按**候选自己的** scope(candidate ?? payload),
19
+ // 不会出现「报告按 scope A 比对、落地却写成 scope B」的错位。
20
+ // 3b. archive 只认报告里命中过的 id(item.match.id,落在报告的 decisions 列):报告
21
+ // 是唯一的判断依据,整理接口不能变成第二条「按 id 改库」的通道。
22
+ // 4. 筛除 = 归档(store.setArchived),绝不物理删除:归档可恢复,删除不可逆。
23
+ //
24
+ // 宽容形态(仓库红线 4,同 issue #89)只管**输入**:单条非法候选/决策跳过 + 应用
25
+ // 合法子集 + run 记 degraded,不整单拒绝。基础设施错误(嵌入器抛错、存储异常)
26
+ // 相反——记 failed 并原样上抛,绝不静默降级成「没找到重复」。审计口径复用
27
+ // dream_runs 的 receipt 语义(buildReceipt / parseReceipt 同一格式),不新建审计面。
28
+
29
+ import { createHash, randomUUID } from "node:crypto";
30
+ import { scopeKeyOf } from "./scope.js";
31
+ import { cosineSimilarity } from "./dream/clustering.js";
32
+ import { buildReceipt } from "./dream.js";
33
+
34
+ // 与 document 的 C2 档(#230)和 findSessionDuplicate 的 vector 档同源:同一份
35
+ // 「近重复」语义只有一个阈值,不在第三个地方另发明一个。
36
+ const MIN_SIM = 0.92;
37
+ // 向量比对对象的上限(document.js 同口径:精确层全量、向量层有界)。精确层
38
+ // 不能截断——同标题漏检会让报告给出错误的 verdict。
39
+ const CANDIDATE_LIMIT = 200;
40
+ // 单次 dryRun 的候选硬上限:主场景是「长段工作收尾整理」,不是批量导入;不设界
41
+ // 的话一次调用会把整库拉进比对。
42
+ const MAX_ITEMS = 50;
43
+ // scope 标注只做搬运(判定在 store / service 侧),省得整理接口成为第二条绕过
44
+ // scope 治理的写入通道。载荷级给的值是候选级的缺省——比对按它判 scope,落地就必须
45
+ // 带上它,否则报告与写入落在两个 scope 里。
46
+ const SCOPE_KEYS = ["sensitivity", "agent_scope", "workspace_scope", "agent_scope_source", "workspace_scope_source"];
47
+ // 比对键必须与 saveWithDedupe 的去重键**同口径**:`src/service.js` 的 scopeMatches 只比
48
+ // sensitivity / agent_scope / workspace_scope 三列,来源两列不进键(它们是元数据,合并时
49
+ // 还会把被并入行的 auto 来源升级成 explicit)。比对键里多带来源,就会出现「dryRun 报 new、
50
+ // apply 却把它并进既有行」——报告与写入错位,正是本模块硬规则 3 要挡的那类不一致。
51
+ const MATCH_SCOPE_KEYS = ["sensitivity", "agent_scope", "workspace_scope"];
52
+
53
+ /**
54
+ * Build the organizer.
55
+ *
56
+ * Injected deps are service.js closure members (store / embedQuery / saveWithDedupe
57
+ * / transaction / finalize) so this module stays free of service-internal wiring;
58
+ * `saveWithDedupe` is the normal write path, so its own epilogue (mirror sync and
59
+ * notify) is never duplicated here. `finalize` is the post-commit re-embed hook
60
+ * (document.js 同款): transaction 提交后补重嵌入,见 service.js › transaction 注释。
61
+ *
62
+ * @returns {{ organize: (payload: object, opts?: object) => Promise<object> }}
63
+ * `organize({ mode: "dryRun"|"apply", ... }, opts)` — 一个入口带 mode 参数
64
+ * (维护者倾向的形态),内部两个具名函数各自可测。
65
+ */
66
+ export function createOrganizer({ store, embedQuery, saveWithDedupe, transaction, finalize }) {
67
+ const hashOf = (value) => createHash("sha256").update(JSON.stringify(value ?? null)).digest("hex");
68
+
69
+ /**
70
+ * 候选规范化。返回 `{ candidate }` 或 `{ error }`——错误在 dryRun 里进 skipped,
71
+ * 不抛整单(红线 4)。document 刻意在此拦下:它的唯一铸造口是 registerDocument
72
+ * (#230),service 的通用 save 路径也会拒绝,这里提前拦截只是为了报告不撒谎。
73
+ */
74
+ function normalizeCandidate(raw, defaults = {}) {
75
+ const type = String(raw?.type ?? "").trim();
76
+ const title = String(raw?.title ?? "").trim();
77
+ const content = String(raw?.content ?? "").trim();
78
+ if (!type || !title || !content) return { error: "type/title/content are required" };
79
+ if (type === "document") return { error: "document rows are minted only via registerDocument (#230)" };
80
+ const candidate = {
81
+ type,
82
+ title,
83
+ content,
84
+ importance: Number.isInteger(raw?.importance) ? Math.min(5, Math.max(1, raw.importance)) : 3,
85
+ tags: [...new Set((Array.isArray(raw?.tags) ? raw.tags : []).map((t) => String(t ?? "").trim()).filter(Boolean))],
86
+ source: String(raw?.source ?? "organize")
87
+ };
88
+ for (const key of SCOPE_KEYS) {
89
+ const value = raw?.[key] ?? defaults[key];
90
+ if (value !== undefined) candidate[key] = value;
91
+ }
92
+ return { candidate };
93
+ }
94
+
95
+ /** 标题归一(同 findSessionDuplicate 的 title 档):大小写与标点不构成「不同标题」。 */
96
+ const normalizeTitle = (s) => String(s ?? "").toLowerCase().replace(/[\s\p{P}]+/gu, "");
97
+
98
+ /**
99
+ * 向量档最佳命中。拿不到向量/嵌入器时返回 null(无信号 = 不判定,不是报错);
100
+ * 但**抛出的**错误不再吞掉——嵌入器故障下的空信号会被读成「全是新条目」,让
101
+ * agent 把重复条写进库,比整单失败更糟,交 dryRun 记 failed 后上抛。
102
+ */
103
+ async function bestNear(candidate, rows, vecs) {
104
+ if (!vecs?.size) return null;
105
+ const probe = await embedQuery([candidate.title, candidate.content].filter(Boolean).join("\n"));
106
+ if (!probe) return null;
107
+ let best = null;
108
+ for (const m of rows) {
109
+ const v = vecs.get(m.id);
110
+ if (!v) continue; // 无向量的既有行不参与比对
111
+ const sim = cosineSimilarity(probe, v);
112
+ if (sim >= MIN_SIM && (!best || sim > best.sim)) best = { memory: m, sim };
113
+ }
114
+ return best;
115
+ }
116
+
117
+ /**
118
+ * 比对报告。**不写记忆表**;只写一行 dream_runs 审计(run_type='organize')。
119
+ *
120
+ * @param {object} payload
121
+ * `{ candidates: Array<{type,title,content,importance?,tags?}>, agent_scope?,
122
+ * workspace_scope?, sensitivity? }`
123
+ * @returns {Promise<{run_id, created_at, status, counts, items, skipped}>}
124
+ * items[i] = `{ index, type, title, verdict: "exact"|"near"|"new", match }`
125
+ */
126
+ async function dryRun(payload = {}) {
127
+ const raws = Array.isArray(payload.candidates) ? payload.candidates : null;
128
+ if (!raws) throw new Error("organize.dryRun: candidates must be an array");
129
+ const bounded = raws.slice(0, MAX_ITEMS);
130
+ const defaults = Object.fromEntries(SCOPE_KEYS.filter((k) => payload?.[k] !== undefined).map((k) => [k, payload[k]]));
131
+
132
+ const skipped = [];
133
+ const accepted = [];
134
+ bounded.forEach((raw, index) => {
135
+ const { candidate, error } = normalizeCandidate(raw, defaults);
136
+ if (error) skipped.push({ index, error });
137
+ else accepted.push({ index, candidate });
138
+ });
139
+ // 审计存规范化快照(带原始索引),不存调用方原始输入:apply 要重放的就是这份
140
+ // ——原始输入里混着被跳过的候选,也没有随行的 payload 级 scope。
141
+ const snapshot = accepted.map(({ index, candidate }) => ({ index, ...candidate }));
142
+
143
+ // scope 同等才比对(跨 scope 永不互判——防泄漏,与 saveWithDedupe 的去重键
144
+ // 「type+title+scope」同口径)。**按候选自己的 scope 比,不按载荷级**:
145
+ // normalizeCandidate 允许逐条给 scope(raw ?? payload),若这里只看 payload,
146
+ // 一条 agent_scope:"agent-b" 的候选就会拿默认 scope 的行去比——同一个 scope 里
147
+ // 明明已有同标题行也报 "new",报告 scope 与写入 scope 就此错位(硬规则 3)。
148
+ // 行扫描与向量读取都按 (type, scope) 缓存:一批候选通常同类型同 scope,避免逐条全表扫。
149
+ // 比对键用 MATCH_SCOPE_KEYS(三列值),搬运用 SCOPE_KEYS(五列)——两件事,别混:
150
+ // 「怎么判同一行」跟 write 路径一致,「怎么把 scope 搬到候选上」才需要连来源一起带。
151
+ const scopeOf = (source) => MATCH_SCOPE_KEYS.map((k) => scopeKeyOf(source?.[k])).join("\u0000");
152
+ const scopeMatches = (m, source) => MATCH_SCOPE_KEYS.every((k) => scopeKeyOf(m[k]) === scopeKeyOf(source?.[k]));
153
+ const cacheKey = (candidate) => `${candidate.type}\u0000${scopeOf(candidate)}`;
154
+ const rowsByScope = new Map();
155
+ const rowsFor = (candidate) => {
156
+ const key = cacheKey(candidate);
157
+ if (!rowsByScope.has(key)) {
158
+ rowsByScope.set(
159
+ key,
160
+ store.list({ type: candidate.type, limit: null }).filter((m) => !m.archived && scopeMatches(m, candidate))
161
+ );
162
+ }
163
+ return rowsByScope.get(key);
164
+ };
165
+ const vecsByScope = new Map();
166
+ const vecsFor = (candidate, rows) => {
167
+ const key = cacheKey(candidate);
168
+ if (!vecsByScope.has(key)) {
169
+ vecsByScope.set(key, store.getEmbeddings(rows.slice(0, CANDIDATE_LIMIT).map((m) => m.id)));
170
+ }
171
+ return vecsByScope.get(key);
172
+ };
173
+
174
+ const runId = randomUUID();
175
+ const createdAt = new Date().toISOString();
176
+ // 哈希与 input 必须指向同一份东西:审计存的是规范化快照(apply 要重放它),
177
+ // 所以哈希也算快照——否则回执里的 snapshot_hash 描述的是调用方原始输入,
178
+ // 谁也校验不了。
179
+ const snapshotHash = hashOf(snapshot);
180
+ const record = (status, { error = null, decisions = items, skipped: skipList = skipped, applied = 0 } = {}) =>
181
+ store.saveDreamRun({
182
+ id: runId,
183
+ created_at: createdAt,
184
+ status,
185
+ error,
186
+ provider: null,
187
+ model: null,
188
+ snapshot_hash: snapshotHash,
189
+ input_count: snapshot.length,
190
+ input: snapshot,
191
+ decisions,
192
+ applied,
193
+ summary_stored: 0,
194
+ receipt: buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.length, applied, summaryStored: false }),
195
+ policy_epoch: 0,
196
+ run_type: "organize",
197
+ ...(skipList.length ? { skipped: skipList } : {})
198
+ });
199
+
200
+ const items = [];
201
+ try {
202
+ for (const { index, candidate } of accepted) {
203
+ const rows = rowsFor(candidate);
204
+ let item = { index, type: candidate.type, title: candidate.title, verdict: "new", match: null };
205
+ const exact = rows.find((m) => normalizeTitle(m.title) === normalizeTitle(candidate.title));
206
+ if (exact) {
207
+ item = { ...item, verdict: "exact", match: { id: exact.id, title: exact.title, sim: null } };
208
+ } else {
209
+ const near = await bestNear(candidate, rows, vecsFor(candidate, rows));
210
+ if (near) {
211
+ item = { ...item, verdict: "near", match: { id: near.memory.id, title: near.memory.title, sim: near.sim } };
212
+ }
213
+ }
214
+ items.push(item);
215
+ }
216
+ } catch (e) {
217
+ // 审计先留痕、再上抛:一份没有回执的失败报告,事后无法与「没调用过」区分。
218
+ record("failed", { error: String(e?.message ?? e) });
219
+ throw e;
220
+ }
221
+
222
+ const counts = {
223
+ total: items.length,
224
+ exact: items.filter((i) => i.verdict === "exact").length,
225
+ near: items.filter((i) => i.verdict === "near").length,
226
+ new: items.filter((i) => i.verdict === "new").length,
227
+ skipped: skipped.length,
228
+ truncated: raws.length - bounded.length
229
+ };
230
+ // 有被拒的候选 → degraded(如实标记),而不是 ok 掩盖过去;一条都没报 → noop。
231
+ const status = skipped.length ? "degraded" : items.length ? "ok" : "noop";
232
+ record(status);
233
+ return { run_id: runId, created_at: createdAt, status, counts, items, skipped };
234
+ }
235
+
236
+ /**
237
+ * 按 agent 的判断落库。decisions 逐条:
238
+ * `{ action: "save", index }` — 写候选(走 saveWithDedupe:同键自动合并)
239
+ * `{ action: "discard", index }` — 不写(只进回执,不删任何东西)
240
+ * `{ action: "archive", id }` — 库内被取代的行 → 归档(可恢复,不删)
241
+ *
242
+ * @param {object} payload `{ run_id, decisions }`
243
+ * @param {object} [opts] `{ hiddenIds }` — 调用者 scope 看不见的行按「不存在」
244
+ * 处理(#170 复核项 4:无存在性泄漏),与 document 的 hiddenEvidenceIds 同形:
245
+ * **可见性由可信调用方算好传入**(tools 层先例:register_document),不取 payload
246
+ * 里的字段——那是调用方可自选的内容,不是授权。
247
+ * @returns {{run_id, dry_run_id, status, saved, merged, discarded, archived, memory_ids, skipped, degraded}}
248
+ * `saved` 只数**新建**的行,合并进既有行的算 `merged`(两者之和 = 落地生效的条数);
249
+ * `memory_ids` 含合并命中的既有行 id(它们同样需要重嵌入)。
250
+ */
251
+ function apply(payload = {}, { hiddenIds = [] } = {}) {
252
+ const dryRunId = String(payload?.run_id ?? "").trim();
253
+ if (!dryRunId) {
254
+ throw new Error("organize.apply: run_id is required (apply must reference a dryRun report)");
255
+ }
256
+ const report = store.getDreamRun(dryRunId);
257
+ if (report?.run_type !== "organize") {
258
+ throw new Error(`organize.apply: unknown organize run "${dryRunId}"`);
259
+ }
260
+ // apply 回执与 dryRun 报告共用 run_type='organize':只有 outcome.dry_run_id 能
261
+ // 把它们分开。不查这一条,apply 的 run_id 可以被当成 dry_run_id,串成
262
+ // apply → apply 的审计链,报告那一环就此消失。
263
+ if (report.outcome?.dry_run_id) {
264
+ throw new Error(`organize.apply: "${dryRunId}" is an apply receipt, not a dryRun report`);
265
+ }
266
+ if (report.status === "failed") {
267
+ throw new Error(`organize.apply: dryRun "${dryRunId}" failed — re-run dryRun before applying`);
268
+ }
269
+ // 同一份报告只能落地一次。凭证写在**报告行**上(下面的 outcome.applied_at),
270
+ // 与数据同事务;只看 apply 回执挡不住「重放同一 run_id」——那会各记一份收据,
271
+ // 审计计数虚高,破坏「全留审计」的承诺。
272
+ if (report.outcome?.applied_at) {
273
+ throw new Error(`organize.apply: dryRun "${dryRunId}" was already applied at ${report.outcome.applied_at}`);
274
+ }
275
+ const decisions = Array.isArray(payload?.decisions) ? payload.decisions : null;
276
+ if (!decisions) throw new Error("organize.apply: decisions must be an array");
277
+
278
+ const snapshot = Array.isArray(report.input) ? report.input : [];
279
+ const byIndex = new Map(snapshot.map((entry) => [Number(entry?.index), entry]));
280
+ const hidden = new Set((Array.isArray(hiddenIds) ? hiddenIds : []).map((id) => String(id)));
281
+ // 报告认定过的「库内被取代行」:dryRun 的 items 落在报告的 decisions 列里,
282
+ // 只有那里命中过的 id 才有资格归档。不查这一条,apply 可以归档报告里根本
283
+ // 没出现过的行(评审实测:preference-only 的报告里归档了 project 行)——
284
+ // 整理接口就成了绕过报告的第二条改库通道。
285
+ const matchedIds = new Set(
286
+ (Array.isArray(report.decisions) ? report.decisions : [])
287
+ .map((d) => (d?.match?.id != null ? String(d.match.id) : null))
288
+ .filter(Boolean)
289
+ );
290
+ const skipped = [];
291
+ const byId = {};
292
+ const memoryIds = [];
293
+ const savedRows = [];
294
+ let saved = 0; // 真正新建的行
295
+ let merged = 0; // 合并进既有行(同键命中)——不是新建,不能算进 saved
296
+ let discarded = 0;
297
+ let archived = 0;
298
+
299
+ const runDecisions = () => {
300
+ decisions.forEach((decision, i) => {
301
+ const action = String(decision?.action ?? "").trim();
302
+ if (action === "save") {
303
+ const index = Number(decision?.index);
304
+ const entry = Number.isInteger(index) ? byIndex.get(index) : undefined;
305
+ if (!entry) {
306
+ // 索引不在快照里 = dryRun 从没校验过它(越界,或当时就被 normalize 拒了)。
307
+ skipped.push({ index: i, action, error: "candidate index was not accepted by this dryRun" });
308
+ return;
309
+ }
310
+ const { index: _originalIndex, ...candidate } = entry;
311
+ // 这里不吞异常:能走到这一步的候选是 dryRun 已经规范化过的合法输入,抛出
312
+ // 来的只能是基础设施错误 → 让它冒到事务外,整批回滚 + failed(红线 4 的
313
+ // 「单条跳过」只覆盖输入级错误,那一步在 normalizeCandidate)。
314
+ const result = saveWithDedupe(candidate);
315
+ if (result?.action === "created") saved++;
316
+ else merged++;
317
+ savedRows.push(result.memory);
318
+ memoryIds.push(result.memory.id);
319
+ byId[result.memory.id] = action;
320
+ return;
321
+ }
322
+ if (action === "discard") {
323
+ // 与 save 同一把尺:index 必须指向 dryRun 真的收下的候选。否则这是一条针对
324
+ // 不存在的候选的「裁决」,记进回执就是虚账(skipped 为空、可能还报 noop)。
325
+ const index = Number(decision?.index);
326
+ const entry = Number.isInteger(index) ? byIndex.get(index) : undefined;
327
+ if (!entry) {
328
+ skipped.push({ index: i, action, error: "candidate index was not accepted by this dryRun" });
329
+ return;
330
+ }
331
+ discarded++;
332
+ return;
333
+ }
334
+ if (action === "archive") {
335
+ const id = String(decision?.id ?? "").trim();
336
+ // 可见性先于「是否被报告命中」:调用方看不见的行一律按不存在处理(不泄漏
337
+ // 存在性,错误文案也维持「unknown, archived or out of scope」这一句)。
338
+ const row = !id || hidden.has(id) ? null : store.getById(id);
339
+ if (!row) {
340
+ skipped.push({ index: i, action, ids: id ? [id] : [], error: "unknown, archived or out of scope" });
341
+ return;
342
+ }
343
+ // 看得见、也存在,但报告从没把它标成命中行 → 拒绝:报告是唯一的判断依据。
344
+ if (!matchedIds.has(id)) {
345
+ skipped.push({ index: i, action, ids: [id], error: "id was not flagged as a match by this dryRun" });
346
+ return;
347
+ }
348
+ if (row.archived) {
349
+ byId[id] = "keep"; // 已归档 = 目标状态已达,不重复计数
350
+ return;
351
+ }
352
+ store.setArchived(id, true);
353
+ archived++;
354
+ byId[id] = action;
355
+ return;
356
+ }
357
+ skipped.push({ index: i, action, error: `unknown action "${action}"` });
358
+ });
359
+ };
360
+
361
+ const applyRunId = randomUUID();
362
+ const createdAt = new Date().toISOString();
363
+ const snapshotHash = report.snapshot_hash;
364
+ // 回执的 applied/byId 由调用方传入:失败路径必须报 0(事务已回滚,累积的
365
+ // saved/archived 是尝试值,写进回执就是撒谎)。
366
+ const record = (status, error, { byId: outcomeById = byId, applied = saved + merged + archived } = {}) => store.saveDreamRun({
367
+ id: applyRunId,
368
+ created_at: createdAt,
369
+ status,
370
+ error,
371
+ provider: null,
372
+ model: null,
373
+ snapshot_hash: snapshotHash,
374
+ input_count: snapshot.length,
375
+ input: snapshot,
376
+ decisions,
377
+ // dry_run_id 是这条回执与它所指报告的连接键:审计能还原「报告 → 判断 → 落地」
378
+ // 三步,而不用新建审计面。
379
+ outcome: { dry_run_id: dryRunId, byId: outcomeById },
380
+ applied,
381
+ summary_stored: 0,
382
+ receipt: buildReceipt({
383
+ runId: applyRunId,
384
+ status,
385
+ snapshotHash,
386
+ inputCount: snapshot.length,
387
+ applied,
388
+ summaryStored: false
389
+ }),
390
+ policy_epoch: 0,
391
+ run_type: "organize",
392
+ ...(skipped.length ? { skipped } : {})
393
+ });
394
+
395
+ let status;
396
+ try {
397
+ transaction(() => {
398
+ runDecisions();
399
+ // 给报告打「已落地」标记,与数据同事务:标记写不进去就整批回滚,不会出现
400
+ // 「库改了、报告却还能再落地一次」的窗口。
401
+ store.saveDreamRun({
402
+ ...report,
403
+ outcome: { ...(report.outcome ?? {}), applied_at: createdAt, applied_by: applyRunId }
404
+ });
405
+ // 只有 discard 的批次什么都没改 → noop(绝不虚报 ok)。
406
+ status = skipped.length ? "degraded" : saved + merged + archived > 0 ? "ok" : "noop";
407
+ // 成功回执与数据同事务:审计写不进去就一起回滚,不留「库改了却没有回执」
408
+ // 的窗口(回执是这条链上唯一的证据)。
409
+ record(status, null);
410
+ });
411
+ } catch (e) {
412
+ // 事务已回滚 → 失败回执单独写。审计本身失败不能顶掉原始错误(调用方要处理的
413
+ // 是原始错误那一件),但也别静默:挂到它身上,两件事都看得见。
414
+ try {
415
+ record("failed", String(e?.message ?? e), { byId: {}, applied: 0 });
416
+ } catch (auditError) {
417
+ e.audit_error = String(auditError?.message ?? auditError);
418
+ }
419
+ throw e;
420
+ }
421
+
422
+ // transaction 只负责镜像与通知,重嵌入留给调用方(service.js › transaction
423
+ // 注释);document.js 用同一个 finalize 先例。放在提交之后,事务里 scheduleEmbed
424
+ // 会被 txDepth 挡掉,不补这一步新写的记忆就没有向量。
425
+ if (savedRows.length && typeof finalize === "function") finalize(savedRows);
426
+
427
+ return {
428
+ run_id: applyRunId,
429
+ dry_run_id: dryRunId,
430
+ status,
431
+ saved,
432
+ merged,
433
+ discarded,
434
+ archived,
435
+ memory_ids: memoryIds,
436
+ skipped,
437
+ degraded: skipped.length > 0
438
+ };
439
+ }
440
+
441
+ /** 一个入口带 mode 参数(维护者口径);opts 是可信调用上下文,不取 payload 里的字段。 */
442
+ async function organize(payload = {}, opts = {}) {
443
+ const mode = String(payload?.mode ?? "dryRun").trim();
444
+ if (mode === "dryRun") return dryRun(payload);
445
+ if (mode === "apply") return apply(payload, opts);
446
+ throw new Error(`organize: unknown mode "${mode}" (dryRun | apply)`);
447
+ }
448
+
449
+ return { organize };
450
+ }