@modusensus/dsh-mneme 0.8.3 → 0.8.4

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 (193) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +718 -636
  3. package/bin/cli.mjs +0 -0
  4. package/bin/dsh-mneme-mcp.mjs +502 -0
  5. package/cordis.patch.yml +21 -21
  6. package/lib/api-standalone.js +495 -316
  7. package/lib/api.js +1382 -1316
  8. package/lib/bootstrap.js +211 -0
  9. package/lib/client.js +4475 -4366
  10. package/lib/commands.js +95 -95
  11. package/lib/config.js +533 -510
  12. package/lib/dream/clustering.js +118 -118
  13. package/lib/dream/decisions.js +658 -658
  14. package/lib/dream/narratives.js +44 -0
  15. package/lib/dream/sleep.js +700 -700
  16. package/lib/dream.js +1319 -1216
  17. package/lib/embedding.js +177 -177
  18. package/lib/entities/extractor.js +249 -249
  19. package/lib/heat.js +142 -136
  20. package/lib/hot-memory.js +46 -46
  21. package/lib/index.js +515 -515
  22. package/lib/inject.js +299 -295
  23. package/lib/lang.js +202 -179
  24. package/lib/local-embedder.js +320 -320
  25. package/lib/mirror.js +198 -198
  26. package/lib/quality-filter.js +126 -126
  27. package/lib/recall-stats.js +103 -0
  28. package/lib/reranker.js +269 -269
  29. package/lib/runtime/adopt.js +176 -176
  30. package/lib/runtime/closure.js +172 -172
  31. package/lib/runtime/download.js +247 -247
  32. package/lib/runtime/layout.js +235 -235
  33. package/lib/runtime/loader.js +249 -249
  34. package/lib/runtime/model-fetch.js +361 -361
  35. package/lib/runtime/provision.js +237 -237
  36. package/lib/runtime/tarball.js +130 -130
  37. package/lib/runtime/verify.js +191 -191
  38. package/lib/scope.js +118 -118
  39. package/lib/search/adaptive.js +22 -22
  40. package/lib/search/bm25.js +96 -96
  41. package/lib/service.js +2171 -2087
  42. package/lib/settings.js +430 -420
  43. package/lib/store.js +2448 -2367
  44. package/lib/summarize.js +575 -449
  45. package/lib/tools.js +574 -574
  46. package/lib/vector-index.js +110 -110
  47. package/lib/version-check.js +122 -122
  48. package/package.json +109 -108
  49. package/runtime-manifest.json +596 -596
  50. package/scripts/benchmark-embed.js +201 -201
  51. package/scripts/benchmark-recall.js +199 -199
  52. package/scripts/benchmark-rerank.js +166 -166
  53. package/scripts/build-runtime-manifest.mjs +217 -217
  54. package/scripts/check-sync.js +42 -42
  55. package/scripts/e2e-dsh.js +220 -220
  56. package/scripts/mneme-runtime.mjs +277 -277
  57. package/scripts/stress-dsh.js +255 -255
  58. package/scripts/sync-lib.js +47 -47
  59. package/scripts/sync-test-badge.mjs +61 -61
  60. package/src/api-standalone.js +495 -316
  61. package/src/api.js +1382 -1316
  62. package/src/bootstrap.js +211 -0
  63. package/src/commands.js +95 -95
  64. package/src/config.js +533 -510
  65. package/src/dream/clustering.js +118 -118
  66. package/src/dream/decisions.js +658 -658
  67. package/src/dream/narratives.js +44 -0
  68. package/src/dream/sleep.js +700 -700
  69. package/src/dream.js +1319 -1216
  70. package/src/embedding.js +177 -177
  71. package/src/entities/extractor.js +249 -249
  72. package/src/heat.js +142 -136
  73. package/src/hot-memory.js +46 -46
  74. package/src/index.js +515 -515
  75. package/src/inject.js +299 -295
  76. package/src/lang.js +202 -179
  77. package/src/local-embedder.js +320 -320
  78. package/src/mirror.js +198 -198
  79. package/src/quality-filter.js +126 -126
  80. package/src/recall-stats.js +103 -0
  81. package/src/reranker.js +269 -269
  82. package/src/runtime/adopt.js +176 -176
  83. package/src/runtime/closure.js +172 -172
  84. package/src/runtime/download.js +247 -247
  85. package/src/runtime/layout.js +235 -235
  86. package/src/runtime/loader.js +249 -249
  87. package/src/runtime/model-fetch.js +361 -361
  88. package/src/runtime/provision.js +237 -237
  89. package/src/runtime/tarball.js +130 -130
  90. package/src/runtime/verify.js +191 -191
  91. package/src/scope.js +118 -118
  92. package/src/search/adaptive.js +22 -22
  93. package/src/search/bm25.js +96 -96
  94. package/src/service.js +2171 -2087
  95. package/src/settings.js +430 -420
  96. package/src/store.js +2448 -2367
  97. package/src/summarize.js +575 -449
  98. package/src/tools.js +574 -574
  99. package/src/vector-index.js +110 -110
  100. package/src/version-check.js +122 -122
  101. package/test/api-routes-gaps.test.js +218 -218
  102. package/test/api.test.js +1578 -1482
  103. package/test/audit.test.js +448 -448
  104. package/test/benchmark.test.js +94 -94
  105. package/test/bootstrap.test.js +220 -0
  106. package/test/client.test.js +799 -736
  107. package/test/clustering.test.js +100 -100
  108. package/test/commands.test.js +106 -106
  109. package/test/config.test.js +66 -66
  110. package/test/conflict-freeze.test.js +300 -300
  111. package/test/dream-archive-guardrail.test.js +116 -116
  112. package/test/dream-candidate-hybrid.test.js +281 -281
  113. package/test/dream-coverage-degrade.test.js +86 -86
  114. package/test/dream.test.js +1257 -1254
  115. package/test/embedding-ready.test.js +196 -196
  116. package/test/entities.test.js +547 -547
  117. package/test/entity-recall.test.js +179 -0
  118. package/test/epistemic.test.js +298 -298
  119. package/test/fnew-0112.test.js +311 -311
  120. package/test/fnew-03.test.js +422 -422
  121. package/test/graph-api.test.js +175 -175
  122. package/test/heat.test.js +175 -148
  123. package/test/helpers/dream-mock.js +82 -82
  124. package/test/helpers/peer-worker.mjs +25 -25
  125. package/test/helpers/runtime-source.js +60 -60
  126. package/test/helpers/tar-builder.js +80 -80
  127. package/test/hot-memory.test.js +145 -145
  128. package/test/inject-content-cap.test.js +89 -0
  129. package/test/inject-first-round-bm25.test.js +89 -89
  130. package/test/inject-heat.test.js +61 -0
  131. package/test/inject-rotation.test.js +161 -161
  132. package/test/inject.test.js +249 -249
  133. package/test/lang.test.js +87 -87
  134. package/test/lib-smoke.test.js +96 -96
  135. package/test/llm-audit.test.js +309 -309
  136. package/test/llm-message-source.test.js +177 -177
  137. package/test/local-embedder.test.js +249 -249
  138. package/test/mcp-stdio.test.js +385 -0
  139. package/test/mirror-dirty.test.js +424 -424
  140. package/test/mirror-edit-digest.test.js +187 -187
  141. package/test/mirror-generation.test.js +499 -499
  142. package/test/mirror.test.js +249 -249
  143. package/test/narrative-bars.test.js +162 -0
  144. package/test/peer-blockers.test.js +193 -193
  145. package/test/policy-epoch.test.js +259 -259
  146. package/test/quality-filter.test.js +186 -186
  147. package/test/reasoning-effort.test.js +746 -746
  148. package/test/recall-evals.test.js +235 -235
  149. package/test/recall-layer.test.js +326 -326
  150. package/test/recall-stats.test.js +117 -0
  151. package/test/receipt-chain.test.js +451 -451
  152. package/test/reflection.test.js +226 -226
  153. package/test/reindex-backfill.test.js +62 -62
  154. package/test/reranker-single-logit.test.js +73 -73
  155. package/test/reranker.test.js +197 -197
  156. package/test/resident-status.test.js +83 -0
  157. package/test/runtime-adopt.test.js +206 -206
  158. package/test/runtime-cli.test.js +89 -89
  159. package/test/runtime-closure.test.js +193 -193
  160. package/test/runtime-download.test.js +302 -302
  161. package/test/runtime-layout.test.js +163 -163
  162. package/test/runtime-loader.test.js +237 -237
  163. package/test/runtime-manifest.test.js +158 -158
  164. package/test/runtime-model-fetch.test.js +348 -348
  165. package/test/runtime-provision.test.js +101 -101
  166. package/test/runtime-tarball.test.js +81 -81
  167. package/test/runtime-verify.test.js +214 -214
  168. package/test/scope-candidates.test.js +239 -239
  169. package/test/scope-explicit.test.js +452 -452
  170. package/test/scope-retrieval.test.js +197 -197
  171. package/test/scope-storage.test.js +255 -255
  172. package/test/scope-strict.test.js +231 -231
  173. package/test/search-fusion.test.js +90 -90
  174. package/test/semantic.test.js +124 -124
  175. package/test/service-search.test.js +174 -174
  176. package/test/service.test.js +405 -405
  177. package/test/settings.test.js +261 -261
  178. package/test/skip-invalid-restore.test.js +262 -262
  179. package/test/sleep-action-set.test.js +422 -422
  180. package/test/sleep-heat.test.js +126 -125
  181. package/test/sleep-scheduler-rearm.test.js +89 -89
  182. package/test/sleep.test.js +373 -373
  183. package/test/standalone-api.test.js +544 -359
  184. package/test/store-vector-cache.test.js +98 -98
  185. package/test/store.test.js +383 -383
  186. package/test/stress.test.js +209 -209
  187. package/test/summarize.test.js +884 -575
  188. package/test/tools-memory-get-schema.test.js +65 -65
  189. package/test/tools.test.js +341 -341
  190. package/test/updated-at-semantics.test.js +113 -113
  191. package/test/vector-index.test.js +221 -221
  192. package/test/version-check.test.js +152 -152
  193. package/test/webServer-optional.test.js +82 -82
package/src/dream.js CHANGED
@@ -1,1216 +1,1319 @@
1
- import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
- import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
3
- import { scopeKeyOf } from "./scope.js";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { STR, langOf } from "./lang.js";
6
- export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
7
-
8
-
9
- // Extract the first JSON array from LLM output, tolerating markdown fences,
10
- // leading/trailing prose, and common wrapper noise. Returns an array or null.
11
- function extractJsonArray(text) {
12
- if (typeof text !== "string" || text.trim().length === 0) return null;
13
-
14
- // 1. Strip markdown code fences (```json ... ``` or ``` ... ```).
15
- let cleaned = text.replace(/```(?:json)?\s*([\s\S]*?)```/gi, "$1");
16
- cleaned = cleaned.trim();
17
-
18
- // 2. Find the first '[' and the matching last ']' that yields valid JSON.
19
- const start = cleaned.indexOf("[");
20
- if (start === -1) return null;
21
- for (let end = cleaned.lastIndexOf("]"); end > start; end = cleaned.lastIndexOf("]", end - 1)) {
22
- const candidate = cleaned.slice(start, end + 1);
23
- try {
24
- return JSON.parse(candidate);
25
- } catch {
26
- // Light repair: remove trailing commas before ] or }.
27
- try {
28
- const repaired = candidate.replace(/,(\s*[}\]])/g, "$1");
29
- return JSON.parse(repaired);
30
- } catch {
31
- // keep searching backwards
32
- }
33
- }
34
- }
35
-
36
- // 3. Fallback: a broader regex extraction.
37
- try {
38
- const match = cleaned.match(/\[[\s\S]*\]/);
39
- if (match) return JSON.parse(match[0]);
40
- } catch {
41
- // fall through
42
- }
43
- return null;
44
- }
45
- function totalChars(memories) {
46
- return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
47
- }
48
-
49
- // ---------------------------------------------------------------- audit
50
-
51
- /**
52
- * Canonical digest of the consolidation input snapshot. Built from stable
53
- * fields sorted by id, so identical inputs always yield the same hash — the
54
- * basis for replaying/verifying a recorded decision (receipt check).
55
- */
56
- export function hashSnapshot(memories) {
57
- const canon = memories
58
- .map((m) => [m.id, m.type, m.title, m.content, m.importance, m.updated_at])
59
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
60
- .map((parts) => parts.map((p) => String(p ?? "")).join("\u0001"))
61
- .join("\u0002");
62
- return createHash("sha256").update(canon).digest("hex");
63
- }
64
-
65
- /**
66
- * Compact machine-verifiable receipt for one autoDream run. Format:
67
- * dsh-mneme:run:<runId>:<status>:<snapshotHash(12)>:<inputCount>:<applied>:<summaryFlag>
68
- * Enough to correlate a run with its persisted audit row and to spot silent
69
- * drift (same snapshot hash + same decisions must reproduce the same outcome).
70
- */
71
- export function buildReceipt({ runId, status, snapshotHash, inputCount, applied, summaryStored }) {
72
- return `dsh-mneme:run:${runId}:${status}:${snapshotHash.slice(0, 12)}:${inputCount}:${applied}:${summaryStored ? 1 : 0}`;
73
- }
74
-
75
- /**
76
- * Parse a receipt back into fields; returns undefined for malformed input.
77
- */
78
- export function parseReceipt(receipt) {
79
- if (typeof receipt !== "string") return undefined;
80
- const parts = receipt.split(":");
81
- if (parts.length !== 8 || parts[0] !== "dsh-mneme" || parts[1] !== "run") return undefined;
82
- const [, , runId, status, snapshotHash, inputCount, applied, summaryStored] = parts;
83
- // reconcile = decisions validated but one or more did not commit (CAS
84
- // conflict / transaction rollback) — the store diverges from the decision
85
- // list and the run must be reconciled, never reported as a fake ok.
86
- if (!runId || !/^(ok|noop|degraded|reconcile|failed)$/.test(status)) return undefined;
87
- const count = Number(inputCount);
88
- const appliedN = Number(applied);
89
- if (!Number.isInteger(count) || !Number.isInteger(appliedN)) return undefined;
90
- return { runId, status, snapshotHash, inputCount: count, applied: appliedN, summaryStored: summaryStored === "1" };
91
- }
92
-
93
- /**
94
- * Derive the per-id disposition (keep / merge-keep / merge-archived /
95
- * archived / conflict-winner / conflict-archived) from a validated decision
96
- * list. Stored in the audit row so a run can be replayed without re-running
97
- * the LLM.
98
- */
99
- export function buildOutcome(decisions) {
100
- const byId = {};
101
- for (const d of decisions ?? []) {
102
- if (d.action === "keep") {
103
- for (const id of d.ids) byId[id] = "keep";
104
- } else if (d.action === "archive") {
105
- for (const id of d.ids) byId[id] = "archived";
106
- } else if (d.action === "merge") {
107
- for (const id of d.ids) byId[id] = id === d.keepSource ? "merge-keep" : "merge-archived";
108
- } else if (d.action === "conflict") {
109
- byId[d.winner] = "conflict-winner";
110
- byId[d.loser] = "conflict-archived";
111
- } else if (d.action === "update") {
112
- for (const id of d.ids) byId[id] = "updated";
113
- } else if (d.action === "supersede") {
114
- // Issue #126 review(Copilot):新动作此前在 outcome.byId 里没有 disposition,
115
- // 审计行产了 receipt 却看不出这些目标被如何处置。
116
- byId[d.winner] = "supersede-winner";
117
- byId[d.loser] = "superseded-archived";
118
- } else if (d.action === "differentiate") {
119
- for (const id of d.ids ?? []) byId[id] = "differentiated";
120
- }
121
- }
122
- return { byId };
123
- }
124
-
125
- /**
126
- * Content-addressed digest of the memories a verdict was decided against
127
- * (id + title + content + importance), sorted by id so identical inputs always
128
- * hash the same. This is the per-record "判定依据" fingerprint: a receipt whose
129
- * digest cannot be reproduced from the involved memories is a bare claim, and a
130
- * digest match with a divergent outcome pinpoints drift to the exact record.
131
- */
132
- export function hashDecisionInput(memories) {
133
- const canon = (memories ?? [])
134
- .map((m) => [m.id, m.title, m.content, m.importance])
135
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
136
- .map((p) => p.map((x) => String(x ?? "")).join(""))
137
- .join("");
138
- return createHash("sha256").update(canon).digest("hex");
139
- }
140
-
141
- /**
142
- * Build the per-record receipts for a run's actually-committed mutable verdicts
143
- * (merge/conflict/update) — one row per verdict in the receipt_chain. Inputs
144
- * are drawn from the run snapshot (what the LLM actually arbitrated against),
145
- * and the idempotency counters count_before → count_after come from the
146
- * committed sub-step, so replaying the same decision must reproduce the same
147
- * numbers. verdict starts "live"; a later policy_epoch upgrade will batch-mark
148
- * older verdicts "historical" (a receipt_chain rewrite driven by the store's
149
- * getLatestPolicyEpoch — out of scope for this pass), while "revoked" is
150
- * reserved for verdicts later overturned by an explicit human decision.
151
- */
152
- function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
153
- const at = (id) => snapshot?.get?.(id);
154
- const receipts = [];
155
- for (const c of committed ?? []) {
156
- const base = {
157
- run_id: runId,
158
- verdict: "live",
159
- count_before: c.count_before,
160
- count_after: c.count_after,
161
- policy_epoch: policyEpoch,
162
- created_at: new Date().toISOString()
163
- };
164
- if (c.action === "merge") {
165
- receipts.push({
166
- ...base,
167
- receipt_id: randomUUID(),
168
- record_id: c.keepSource,
169
- kind: "merge",
170
- input_digest: hashDecisionInput((c.ids ?? []).map(at).filter(Boolean)),
171
- keep_source: c.keepSource,
172
- sources: c.ids
173
- });
174
- } else if (c.action === "conflict") {
175
- receipts.push({
176
- ...base,
177
- receipt_id: randomUUID(),
178
- record_id: c.winner,
179
- kind: "conflict",
180
- input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
181
- winner_id: c.winner,
182
- loser_id: c.loser
183
- });
184
- } else if (c.action === "update") {
185
- receipts.push({
186
- ...base,
187
- receipt_id: randomUUID(),
188
- record_id: c.ids[0],
189
- kind: "update",
190
- input_digest: hashDecisionInput([at(c.ids[0])].filter(Boolean))
191
- });
192
- } else if (c.action === "supersede") {
193
- // Issue #126:演进型裁决——record 记被取代的一方。winner 正文未被改动,
194
- // 不需要自己的 receipt;loser 归档并追加"已被取代"注记。
195
- receipts.push({
196
- ...base,
197
- receipt_id: randomUUID(),
198
- record_id: c.loser,
199
- kind: "supersede",
200
- input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
201
- winner_id: c.winner,
202
- loser_id: c.loser
203
- });
204
- } else if (c.action === "differentiate") {
205
- // Issue #126:互补型裁决——两条都保留,各记一条 receipt。数量没有变化,
206
- // count_before/after 固定 1/1(该记录只是被追加了差异注记)。
207
- for (const id of c.ids ?? []) {
208
- receipts.push({
209
- ...base,
210
- receipt_id: randomUUID(),
211
- record_id: id,
212
- kind: "differentiate",
213
- input_digest: hashDecisionInput([at(id)].filter(Boolean)),
214
- count_before: 1,
215
- count_after: 1
216
- });
217
- }
218
- }
219
- }
220
- return receipts;
221
- }
222
-
223
- /**
224
- * Consume an LLM stream and return the accumulated text. Direct text-delta
225
- * accumulation covers both the real protocol ({type:"text-delta", index, text})
226
- * and looser test doubles ({type:"text-delta", text}); a terminal error/abort
227
- * surfaces as undefined. The caller decides how to treat an empty result.
228
- * `onUsage` (optional, Bug8) receives any usage chunk for token accounting.
229
- */
230
- async function streamText(ctx, options, onUsage, onStreamError) {
231
- let text = "";
232
- for await (const chunk of ctx.llm.stream(options)) {
233
- if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
234
- if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk);
235
- if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
236
- // dsh-llm rc.1 turns adapter-stage failures (unknown provider route,
237
- // UNSUPPORTED_REASONING_EFFORT from resolveCallWithInfo, …) into a
238
- // terminal finish chunk instead of a throw — the cause rides in
239
- // chunk.reason.failure {message, code}. Surface it, never swallow it.
240
- if (typeof onStreamError === "function") {
241
- try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
242
- }
243
- return undefined;
244
- }
245
- }
246
- return text;
247
- }
248
-
249
- /** One-line human-readable cause from a finish-chunk failure reason. */
250
- function describeStreamFailure(reason) {
251
- const failure = reason?.failure ?? reason ?? {};
252
- const code = failure.code ? String(failure.code) : "";
253
- const message = String(failure.message ?? failure.error ?? "");
254
- if (code && message) return message.includes(code) ? message : `${code}: ${message}`;
255
- return code || message;
256
- }
257
-
258
- /**
259
- * Bug8: wrap a background LLM call so its token/time/status are recorded in the
260
- * llm_audit_logs table. Best-effort bookkeeping: a failure to WRITE the audit
261
- * row is swallowed (never blocks the LLM call), while a failure of the call
262
- * itself is captured as status='error' and re-thrown so the caller keeps its
263
- * existing error path. `spec` carries the static metadata (trigger_source,
264
- * operation_type, model_id, related_memory_ids); `body(reportUsage)` performs
265
- * the actual stream consumption and is handed a usage reporter for the chunks.
266
- */
267
- async function runAuditedLlm(ctx, service, config, spec, body) {
268
- const audit = config?.llmAudit;
269
- if (audit?.enabled === false || typeof service?.saveLlmAudit !== "function") return body(() => {});
270
- const startedAt = Date.now();
271
- const timestamp = new Date(startedAt).toISOString();
272
- let inputTokens = 0;
273
- let outputTokens = 0;
274
- let status = "success";
275
- let errorMessage = null;
276
- let result;
277
- try {
278
- result = await body((usage) => {
279
- if (!usage) return;
280
- const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
281
- const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
282
- if (Number.isFinite(i)) inputTokens = i;
283
- if (Number.isFinite(o)) outputTokens = o;
284
- });
285
- if (result === undefined) {
286
- // stream aborted/errored: the caller treats undefined as a failed run;
287
- // record it as error here so the audit shows the truth. spec.streamError
288
- // (a getter) lets the caller attach the finish-chunk cause so the audit
289
- // row names it instead of a bare "aborted".
290
- status = "error";
291
- const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
292
- errorMessage = errorMessage ?? (streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored");
293
- } else if (typeof spec.auditError === "function") {
294
- // A stream that returned text but yields nothing usable is still a
295
- // failed call — record it as error, not the default success, so the
296
- // audit no longer contradicts a failed run (dream "no json array").
297
- const message = spec.auditError(result);
298
- if (message) {
299
- status = "error";
300
- errorMessage = message;
301
- }
302
- }
303
- return result;
304
- } catch (error) {
305
- status = "error";
306
- errorMessage = String(error?.message ?? error);
307
- throw error;
308
- } finally {
309
- try {
310
- service.saveLlmAudit({
311
- timestamp,
312
- trigger_source: spec.triggerSource,
313
- operation_type: spec.operationType,
314
- model_id: spec.modelId,
315
- input_tokens: inputTokens,
316
- output_tokens: outputTokens,
317
- total_tokens: inputTokens + outputTokens,
318
- cost_usd: 0,
319
- duration_ms: Date.now() - startedAt,
320
- status,
321
- error_message: errorMessage,
322
- related_memory_ids: spec.relatedMemoryIds ?? []
323
- });
324
- } catch (auditError) {
325
- ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
326
- }
327
- }
328
- }
329
-
330
- /**
331
- * Reasoning-effort rejection fallback (v0.8.1): a configured dreamReasoningEffort
332
- * / sleepReasoningEffort may be rejected by the provider (volcano-engine returns
333
- * UNSUPPORTED_REASONING_EFFORT for values it does not accept — "off" is known
334
- * rejected there). When that happens, retry once WITHOUT the reasoning field
335
- * instead of hard-failing the run, so effort config is safe to experiment with:
336
- * accepted → reasoning capped; rejected → provider default (old behavior),
337
- * logged so the rejection is observable.
338
- */
339
- async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
340
- if (!effort || effort === "none") return attempt();
341
- try {
342
- const result = await attempt();
343
- if (result === undefined) {
344
- // dsh-llm rc.1 streams a provider effort-rejection as a terminal error
345
- // finish chunk (adapterStream catches everything, never throws) — match
346
- // on the chunk's failure reason here or the retry below is dead code
347
- // for the stream path.
348
- const reason = String(getStreamError?.() ?? "");
349
- if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
350
- ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
351
- return fallback();
352
- }
353
- }
354
- return result;
355
- } catch (error) {
356
- const message = String(error?.message ?? error);
357
- // matches both "reasoning effort" (natural language) and the bare
358
- // "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
359
- if (!/reasoning[\s_]*effort/i.test(message)) throw error;
360
- ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
361
- return fallback();
362
- }
363
- }
364
-
365
- /**
366
- * Resolve the reasoning effort to actually send for a dream/sleep route.
367
- *
368
- * Reasoning-effort config that the provider does not accept trips the harness's
369
- * UNSUPPORTED_REASONING_EFFORT, and the defaultEffort trap makes retrying
370
- * "without the field" useless: the harness substitutes `reasoning.defaultEffort`,
371
- * which may itself be unsupported (DSH Desktop volcano-engine adapter declares
372
- * defaultEffort=low that its model rejects). So instead of blind retries, ask
373
- * the harness for the model's declared capability and pick a value that is
374
- * actually accepted — or omit the field entirely when the model declares no
375
- * reasoning capability at all.
376
- *
377
- * Unset vs explicit 'none' (Issue #135 建议 4/5): these are different intents.
378
- * Explicit 'none' = the user asked to omit the field (provider default
379
- * applies). Unset is the out-of-the-box state — dream/sleep calls must produce
380
- * JSON, and omission lets the harness substitute the model's defaultEffort
381
- * (thinking-type models often default to high), which drains the token budget
382
- * and returns an empty body. So unset resolves to the LOWEST effort the model
383
- * declares instead of omitting; when the capability query is unavailable the
384
- * field stays omitted (fail-safe, retry guard unchanged).
385
- *
386
- * @returns a supported effort id, or null when no effort should be sent, or the
387
- * configured value unchanged when the capability query is unavailable.
388
- */
389
-
390
- // 常识档位排序(越靠前推理开销越低);未知档位排最后(宁可交给显式配置)。
391
- const EFFORT_RANK = { minimal: 0, off: 0, low: 1, medium: 2, high: 3, max: 4 };
392
-
393
- function lowestSupportedEffort(reasoning) {
394
- const ids = (reasoning?.efforts ?? []).map((e) => e?.id).filter(Boolean);
395
- if (ids.length === 0) return null;
396
- return [...ids].sort((a, b) => (EFFORT_RANK[a] ?? 99) - (EFFORT_RANK[b] ?? 99))[0];
397
- }
398
-
399
- async function resolveDreamEffort(ctx, route, configuredEffort, logger) {
400
- // 显式 'none':用户要求省略字段(服务商自带默认生效),照旧。
401
- if (configuredEffort === "none") return null;
402
- if (!configuredEffort) {
403
- // 未配置(开箱默认):取模型声明的最低档,避免 defaultEffort 顶上(建议 4/5)。
404
- if (typeof ctx?.llm?.resolveModelInfo !== "function") return null;
405
- try {
406
- const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
407
- const reasoning = info?.reasoning;
408
- if (!reasoning) return null; // 无推理声明:省略即安全(无 defaultEffort 可顶上)
409
- const lowest = lowestSupportedEffort(reasoning);
410
- if (!lowest) return null;
411
- logger?.info?.(`dsh-mneme dream: no reasoningEffort configured for ${route.provider}:${route.model}; using lowest supported "${lowest}" instead of the model default "${reasoning.defaultEffort ?? "n/a"}"`);
412
- return lowest;
413
- } catch (error) {
414
- // 能力查询失败:省略字段(fail-safe),withEffortFallback 照旧兜底。
415
- logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); omitting reasoningEffort`);
416
- return null;
417
- }
418
- }
419
- // Capability query unavailable (older harness / minimal mocks): forward the
420
- // configured value as before — absence of the API proves nothing about the
421
- // model, and withEffortFallback still guards against rejection.
422
- if (typeof ctx?.llm?.resolveModelInfo !== "function") return configuredEffort;
423
- try {
424
- const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
425
- const reasoning = info?.reasoning;
426
- if (!reasoning) {
427
- // Model declares no reasoning capability: the harness rejects ANY
428
- // explicit effort for such a model, and omitting the field is safe
429
- // (no reasoning capability → no defaultEffort substitution).
430
- logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} declares no reasoning capability; ignoring configured effort "${configuredEffort}"`);
431
- return null;
432
- }
433
- const supported = reasoning.efforts?.map((effort) => effort.id) ?? [];
434
- if (supported.includes(configuredEffort)) return configuredEffort;
435
- // Configured effort unsupported → pick defaultEffort if it is supported,
436
- // else the first declared effort, so the run never trips
437
- // UNSUPPORTED_REASONING_EFFORT (nor the defaultEffort trap: we always
438
- // pass an explicit value, so the harness never falls back to a poison
439
- // default).
440
- const picked = reasoning.defaultEffort && supported.includes(reasoning.defaultEffort)
441
- ? reasoning.defaultEffort
442
- : supported[0];
443
- if (picked) {
444
- logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} does not support effort "${configuredEffort}" (supported: ${supported.join(", ")}); using "${picked}"`);
445
- return picked;
446
- }
447
- return null;
448
- } catch (error) {
449
- // Capability query failed — forward the configured value; withEffortFallback
450
- // still retries on rejection as before.
451
- logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); forwarding effort as configured`);
452
- return configuredEffort;
453
- }
454
- }
455
-
456
- /**
457
- * Resolve the LLM route (Issue #25): an explicit plugin config
458
- * (dreamProvider/dreamModel) is the user's declared override and wins; the
459
- * agent default model (deployment) is only a fallback when no config route is
460
- * set. In a standard DSH install agentDefaultModel always resolves, so without
461
- * this ordering the config route would be dead code and dreamProvider/dreamModel
462
- * could never take effect (v0.7.11 regressed this; README §config documents
463
- * config-first). Falls through to undefined when no route exists — runDream
464
- * then fails safe. A config→default switch is logged so it is observable.
465
- */
466
- function resolveRoute(ctx, config, logger) {
467
- if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
468
- try {
469
- const sel = ctx.agentDefaultModel?.currentSelection?.();
470
- if (sel?.provider && sel?.model) {
471
- logger?.info?.("dsh-mneme dream: no dreamProvider/dreamModel config, falling back to agent default");
472
- return { provider: sel.provider, model: sel.model };
473
- }
474
- logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, no config route either");
475
- } catch (error) {
476
- logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed: ${String(error)}`);
477
- }
478
- return undefined;
479
- }
480
-
481
- // ------------------------------------------------------- semantic enhancement
482
- // Best-effort: any failure here degrades to plain consolidation. The dream
483
- // path must never be broken by an unavailable embedder/index.
484
-
485
- /** Backfill + return vectors for every memory; null when impossible. */
486
- async function collectVectors(memories, semantic) {
487
- const { embedder, vectorIndex } = semantic;
488
- if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return null;
489
- const vectors = new Array(memories.length);
490
- const missing = [];
491
- for (let i = 0; i < memories.length; i++) {
492
- const cached = vectorIndex.getEmbedding?.(memories[i].id);
493
- if (cached) vectors[i] = cached;
494
- else missing.push(i);
495
- }
496
- if (missing.length) {
497
- const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
498
- const rows = await embedder.embed(texts);
499
- missing.forEach((mi, j) => {
500
- if (rows[j]?.length) {
501
- vectors[mi] = rows[j];
502
- vectorIndex.saveEmbedding(memories[mi].id, rows[j]);
503
- }
504
- });
505
- }
506
- return vectors.some((v) => !v) ? null : vectors;
507
- }
508
-
509
- /**
510
- * Issue #125:把高相似对聚成**连通分量组**——一个簇 = 一个候选单元。直接按对展开
511
- * 会把同一簇拆成互相抢占的多个决策(校验器有 one-claim 规则),成组才是正确粒度
512
- * (与 sleep 冲突阶段的贪心"每记忆每轮最多进一对"同理)。
513
- * 组内最小相似度用于组间排序:最该处理的簇先入选。
514
- */
515
- function similarityGroups(pairs) {
516
- const parent = new Map();
517
- const find = (x) => {
518
- let root = x;
519
- while (parent.get(root) !== root) root = parent.get(root);
520
- return root;
521
- };
522
- for (const p of pairs) {
523
- if (!parent.has(p.a.id)) parent.set(p.a.id, p.a.id);
524
- if (!parent.has(p.b.id)) parent.set(p.b.id, p.b.id);
525
- const ra = find(p.a.id);
526
- const rb = find(p.b.id);
527
- if (ra !== rb) parent.set(ra, rb);
528
- }
529
- const byRoot = new Map();
530
- for (const p of pairs) {
531
- for (const m of [p.a, p.b]) {
532
- const root = find(m.id);
533
- if (!byRoot.has(root)) byRoot.set(root, new Map());
534
- byRoot.get(root).set(m.id, m);
535
- }
536
- }
537
- return [...byRoot.values()].map((members) => {
538
- const list = [...members.values()];
539
- const ids = new Set(list.map((m) => m.id));
540
- let minSim = 1;
541
- for (const p of pairs) {
542
- if (ids.has(p.a.id) && ids.has(p.b.id)) minSim = Math.min(minSim, p.similarity);
543
- }
544
- return { members: list, minSim };
545
- });
546
- }
547
-
548
- /**
549
- * Issue #125:为候选构造收集向量。与 collectVectors 的差别是**允许部分缺失**——
550
- * 全量库只要有几条嵌入失败就整场返回 null,会让 hybrid 在真实库上几乎永远回落;
551
- * 拿不到向量的条目只是不参与比较(cosineSimilarity 对非数组返回 0,不会误判)。
552
- */
553
- async function collectCandidateVectors(memories, semantic, logger) {
554
- const { embedder, vectorIndex } = semantic;
555
- const vectors = new Array(memories.length);
556
- const missing = [];
557
- for (let i = 0; i < memories.length; i++) {
558
- const cached = vectorIndex.getEmbedding?.(memories[i].id);
559
- if (cached) vectors[i] = cached;
560
- else missing.push(i);
561
- }
562
- if (missing.length) {
563
- try {
564
- const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
565
- const rows = await embedder.embed(texts);
566
- missing.forEach((mi, j) => {
567
- if (rows[j]?.length) {
568
- vectors[mi] = rows[j];
569
- vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
570
- }
571
- });
572
- } catch (error) {
573
- logger?.warn?.(`[dsh-mneme] dream hybrid vector backfill failed: ${String(error)}`);
574
- }
575
- }
576
- return vectors;
577
- }
578
-
579
- /**
580
- * Issue #125:hybrid 候选成员,按"组内最小相似度"降序展平(高价值簇排前面,
581
- * 受上限截断时先入选)。同类型比较沿用 findPotentialConflicts;仅当
582
- * allowCrossTypeMerge 显式开启时再补跨类型高相似对。
583
- * 任何失败都返回空数组——调用方回落纯窗口,dream 路径绝不因向量层而失败。
584
- */
585
- async function hybridCandidateMembers(allMemories, config, semantic, logger) {
586
- try {
587
- const vectors = await collectCandidateVectors(allMemories, semantic, logger);
588
- const minSim = config.dreamCandidateMinSim ?? 0.85;
589
- const pairs = findPotentialConflicts(allMemories, vectors, minSim);
590
- if (config.allowCrossTypeMerge === true) {
591
- for (let i = 0; i < allMemories.length; i++) {
592
- for (let j = i + 1; j < allMemories.length; j++) {
593
- if (allMemories[i].type === allMemories[j].type) continue; // 同类型已由上面覆盖
594
- const sim = cosineSimilarity(vectors[i], vectors[j]);
595
- if (sim > minSim) pairs.push({ a: allMemories[i], b: allMemories[j], similarity: sim });
596
- }
597
- }
598
- }
599
- if (pairs.length === 0) return [];
600
- return similarityGroups(pairs)
601
- .sort((g1, g2) => g2.minSim - g1.minSim)
602
- .flatMap((g) => g.members);
603
- } catch (error) {
604
- logger?.warn?.(`[dsh-mneme] dream hybrid candidate expansion failed (falling back to window): ${String(error)}`);
605
- return [];
606
- }
607
- }
608
-
609
- /**
610
- * Rebuild the vector index after dream decisions so the store and the index
611
- * stay in sync: merged-away/archived/conflict-loser rows lose their vectors,
612
- * the merge keeper gets a fresh one.
613
- */
614
- export async function maintainIndexAfterDream(decisions, service, semantic) {
615
- const { embedder, vectorIndex } = semantic;
616
- if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return;
617
- const rebuild = new Map();
618
- for (const d of decisions ?? []) {
619
- if (d.action === "merge") {
620
- for (const id of d.ids ?? []) {
621
- if (id !== d.keepSource) vectorIndex.deleteEmbedding(id);
622
- }
623
- if (d.keepSource) {
624
- const keeper = service.getById(d.keepSource);
625
- if (keeper) rebuild.set(keeper.id, [keeper.title, keeper.content].filter(Boolean).join("\n"));
626
- }
627
- } else if (d.action === "archive" || d.action === "conflict") {
628
- for (const id of d.ids ?? [d.loser]) vectorIndex.deleteEmbedding(id);
629
- } else if (d.action === "supersede") {
630
- // Issue #126 review(Copilot):被取代的一方已归档 → 移除其向量(与 conflict 的
631
- // loser 同等处置)。winner 正文未变化,向量保持有效。
632
- vectorIndex.deleteEmbedding(d.loser);
633
- } else if (d.action === "differentiate") {
634
- // Issue #126 review(Copilot):双方正文都被追加了差异注记,而 update 在事务内
635
- // 会因 txDepth>0 跳过 scheduleEmbed —— 不在这里重嵌,注记就进不了 embedding,
636
- // 注释里承诺的"下一轮不再判成重复"就不成立。
637
- for (const id of d.ids ?? []) {
638
- const mem = service.getById(id);
639
- if (mem) rebuild.set(id, [mem.title, mem.content].filter(Boolean).join("\n"));
640
- }
641
- } else if (d.action === "update") {
642
- const id = d.ids[0];
643
- const mem = service.getById(id);
644
- if (mem) {
645
- vectorIndex.deleteEmbedding(id);
646
- try {
647
- const text = [mem.title, mem.content].filter(Boolean).join("\n");
648
- const v = await embedder.embedSingle(text);
649
- if (v?.length) vectorIndex.saveEmbedding(id, v);
650
- } catch { /* best-effort */ }
651
- }
652
- }
653
- }
654
- for (const [id, text] of rebuild) {
655
- try {
656
- const v = await embedder.embedSingle(text);
657
- if (v?.length) vectorIndex.saveEmbedding(id, v);
658
- } catch { /* best-effort */ }
659
- }
660
- if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
661
- }
662
-
663
- export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
664
- let pendingTimer = null;
665
- let running = false;
666
- let disposed = false;
667
- let baseline = { count: 0, chars: 0 };
668
- let inFlight = null;
669
- // Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
670
- // 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
671
- let lastRunAt = 0;
672
-
673
- function shouldTrigger(service) {
674
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
675
- const count = memories.length;
676
- const chars = totalChars(memories);
677
- const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
678
- const overAbs = count >= thresholdCount || chars >= thresholdChars;
679
- return { trigger: overAbs && overBase, count, chars };
680
- }
681
-
682
- function maybeSchedule(service) {
683
- if (disposed || running || pendingTimer) return false;
684
- // Issue #89(请求 2):最小触发间隔闸门。
685
- if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
686
- const { trigger, count, chars } = shouldTrigger(service);
687
- if (!trigger) return false;
688
- pendingTimer = setTimeout(() => {
689
- pendingTimer = null;
690
- running = true;
691
- lastRunAt = Date.now();
692
- // Defer the onRun invocation so a synchronous throw cannot escape the
693
- // timer callback (which would crash the process) and skip the teardown.
694
- // Errors are logged, never swallowed silently. inFlight lets dispose()
695
- // await the running consolidation before the caller closes the store.
696
- inFlight = Promise.resolve()
697
- .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
698
- .then((result) => {
699
- // Refresh the baseline only for a successful run (design §5.3: an
700
- // LLM failure must not move the baseline, so the next write can
701
- // immediately re-trigger a retry). A `{ok:false}` result or a throw
702
- // keeps the old baseline. A run that reports nothing is treated as
703
- // completed without failure (no-op hooks / minimal test doubles).
704
- if (result && result.ok) {
705
- try {
706
- baseline = shouldTrigger(service);
707
- } catch (error) {
708
- // Store closed mid-flight: keep the last known baseline.
709
- logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
710
- }
711
- }
712
- })
713
- .catch((error) => {
714
- logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
715
- // Failed runs do not refresh the baseline.
716
- })
717
- .finally(() => {
718
- running = false;
719
- inFlight = null;
720
- });
721
- }, delayMs);
722
- return true;
723
- }
724
-
725
- async function dispose() {
726
- disposed = true;
727
- if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
728
- // An in-flight run is left to complete naturally (its LLM calls are
729
- // already paid for and aborting would discard the work). Await it so the
730
- // caller can close the store only after every write has landed.
731
- if (inFlight) await inFlight.catch(() => {});
732
- }
733
-
734
- async function runDream(ctx, service, config) {
735
- const language = langOf(config);
736
- const logger = ctx.logger;
737
- let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
738
- if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
739
- // v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
740
- // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
741
- // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
742
- const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
743
- const windowMemories = [...memories]
744
- .sort((a, b) => {
745
- const ta = String(a.updated_at ?? "");
746
- const tb = String(b.updated_at ?? "");
747
- if (ta < tb) return 1;
748
- if (ta > tb) return -1;
749
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
750
- })
751
- .slice(0, Math.max(1, maxSize));
752
- // Issue #125:hybrid 档在窗口之外并入向量翻出的高相似组。候选**总量**仍由
753
- // maxSize(或显式 dreamCandidateMax)封顶、与库总量解耦——用"按相似度取候选"
754
- // 替代"调大窗口",输入成本才不随库增长。封顶截断时向量组优先于窗口尾部,
755
- // 否则"该合并的一对"仍会被时间窗挤掉。
756
- let selected = windowMemories;
757
- if ((config.dreamCandidateMode ?? "window") === "hybrid" && semantic?.embedder && semantic?.vectorIndex) {
758
- const extra = await hybridCandidateMembers(memories, config, semantic, logger);
759
- if (extra.length > 0) {
760
- const cap = Math.max(
761
- Math.max(1, maxSize),
762
- Number.isInteger(config.dreamCandidateMax) && config.dreamCandidateMax > 0 ? config.dreamCandidateMax : 0
763
- );
764
- const merged = [];
765
- const seen = new Set();
766
- for (const m of [...extra, ...windowMemories]) {
767
- if (seen.has(m.id)) continue;
768
- seen.add(m.id);
769
- merged.push(m);
770
- if (merged.length >= cap) break;
771
- }
772
- selected = merged;
773
- logger?.info?.(`[dsh-mneme] dream hybrid candidates: ${merged.length} (window ${windowMemories.length}, vector ${extra.length}, cap ${cap})`);
774
- }
775
- }
776
- memories = selected;
777
- const snapshot = new Map(memories.map((m) => [m.id, m]));
778
- const route = resolveRoute(ctx, config, logger);
779
- const runId = randomUUID();
780
- const snapshotHash = hashSnapshot([...snapshot.values()]);
781
- // Conflict freeze (opt-in): when enabled, conflict decisions are parked for
782
- // manual review instead of auto-adjudicated. Read once up front so the
783
- // prompt hint and the apply-split agree on the same gate.
784
- const freezeEnabled = config.conflictFreezeEnabled === true;
785
- // Every exit (success or failure) funnels through `finish`, which writes
786
- // the audit row + receipt. A record failure is logged, never thrown —
787
- // auditing must not break the consolidation path. Failed runs still
788
- // capture their decisions/outcome when the LLM produced a validated list
789
- // (e.g. summary step failed after consolidation), so the partial write is
790
- // replayable too.
791
- const finish = (result) => {
792
- // status is derived from what actually committed: ok only when the full
793
- // decision list landed (or a summary was refreshed); noop when nothing
794
- // changed; degraded when real changes landed without a summary;
795
- // reconcile when decisions were validated but some did not commit (CAS
796
- // conflict / rollback); failed on any LLM/validation error. No fake "ok"
797
- // for an empty or partial run.
798
- const status = result.status ?? (result.ok ? "ok" : "failed");
799
- const applied = result.applied ?? 0;
800
- const summaryStored = result.summary ?? false;
801
- const receipt = buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.size, applied, summaryStored });
802
- try {
803
- service.saveDreamRun({
804
- id: runId,
805
- status,
806
- error: result.error,
807
- provider: route?.provider,
808
- model: route?.model,
809
- snapshot_hash: snapshotHash,
810
- input_count: snapshot.size,
811
- // 裁决规则版本号:config.policyEpoch(默认 0)。规则升级后该行保留
812
- // 当时的 epoch,旧裁决据此降级为历史证据(store 层 getLatestPolicyEpoch
813
- // 只负责读取当前生效版本,写入由这里完成)。
814
- policy_epoch: config.policyEpoch ?? 0,
815
- // Full input snapshot (canonical fields) so the exact arbitration
816
- // input can be rebuilt offline from the audit row alone — the
817
- // digest + decisions + outcome triple makes silent errors locatable
818
- // even after the store has moved on.
819
- input: [...snapshot.values()].map((m) => ({
820
- id: m.id,
821
- type: m.type,
822
- title: m.title,
823
- content: m.content,
824
- importance: m.importance,
825
- updated_at: m.updated_at
826
- })),
827
- decisions: result.decisions,
828
- outcome: result.outcome,
829
- applied,
830
- summary_stored: summaryStored,
831
- receipt,
832
- // Issue #104:degraded(合法子集已应用)轮被跳过的决策明细。写成独立字段
833
- // 而非内联进 decisions——degraded 的 decisions 是合法子集,下游按「决策
834
- // 数组」消费,内联标记会污染其它读者(失败轮的 _validationFailed 是整单
835
- // 拒绝哨兵,语义与「部分应用」不同,也不复用)。
836
- skipped: result.skipped
837
- });
838
- } catch (error) {
839
- logger?.warn?.(`dsh-mneme dream: failed to record audit run: ${String(error)}`);
840
- }
841
- return { ...result, runId, receipt, snapshotHash };
842
- };
843
- if (!route) {
844
- logger?.warn?.("dsh-mneme dream: no llm route available");
845
- return finish({ ok: false, error: "no llm route", summary: false });
846
- }
847
-
848
- let listText;
849
- if (semantic?.embedder && semantic?.vectorIndex) {
850
- try {
851
- const vectors = await collectVectors(memories, semantic);
852
- if (vectors) {
853
- const k = Math.min(10, Math.max(1, Math.floor(Math.sqrt(memories.length / 2))));
854
- const clusters = clusterMemories(memories, vectors, k);
855
- const conflicts = findPotentialConflicts(memories, vectors, 0.85);
856
- const conflictIds = new Set(conflicts.flatMap((c) => [c.a.id, c.b.id]));
857
- const parts = [];
858
- clusters.forEach((cluster, ci) => {
859
- parts.push(STR.clusterHeader[language](ci + 1));
860
- for (const m of cluster) {
861
- parts.push(
862
- `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}` +
863
- (conflictIds.has(m.id) ? STR.conflictMark[language] : "")
864
- );
865
- }
866
- });
867
- listText = parts.join("\n");
868
- logger?.info?.(`[dsh-mneme] dream semantic pre-group: ${clusters.length} clusters, ${conflicts.length} conflict pairs`);
869
- }
870
- } catch (error) {
871
- logger?.warn?.(`[dsh-mneme] dream semantic pre-group failed: ${String(error)}`);
872
- }
873
- }
874
- if (!listText) {
875
- listText = [...snapshot.values()].map((m) =>
876
- `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`
877
- ).join("\n");
878
- }
879
-
880
- // Freeze-aware prompt: in freeze mode the conflict branch still outputs
881
- // winner/loser (validation requires them) but they are treated as tentative
882
- // candidates — the human makes the final call, not the model.
883
- const consolidationPrompt = freezeEnabled
884
- ? STR.prompts.consolidation[language] + STR.prompts.freezeSuffix[language]
885
- : STR.prompts.consolidation[language];
886
- let decisionText;
887
- // 加固(v0.8.1):配置的 reasoningEffort 被 provider 拒收时回退重试一次
888
- // (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
889
- // 在 auditError 检查器里、闭包交回主流程,避免二次解析;解析失败同时如实
890
- // 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
891
- const effort = await resolveDreamEffort(ctx, route, config.dreamReasoningEffort, logger);
892
- let decisions = null;
893
- let streamFailure = "";
894
- const runConsolidation = (withEffort) => {
895
- streamFailure = "";
896
- return runAuditedLlm(ctx, service, config, {
897
- triggerSource: "autoDream",
898
- operationType: "dream_consolidate",
899
- modelId: `${route.provider}:${route.model}`,
900
- relatedMemoryIds: [...snapshot.keys()],
901
- streamError: () => streamFailure,
902
- auditError: (text) => {
903
- decisions = extractJsonArray(text);
904
- return Array.isArray(decisions) ? null : "no json array in llm output";
905
- }
906
- }, (reportUsage) => streamText(ctx, {
907
- provider: route.provider,
908
- model: route.model,
909
- purpose: "compaction",
910
- maxTokens: config.dreamMaxTokens ?? 4096,
911
- ...(withEffort && effort ? { reasoningEffort: effort } : {}),
912
- messages: [
913
- { role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
914
- { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
915
- ]
916
- }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
917
- };
918
- try {
919
- // Bug8: the consolidation call is audited (tokens/time/status). A throw
920
- // re-propagates to the catch below; an aborted stream returns undefined
921
- // and is treated as a failed run after the check below.
922
- decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false), () => streamFailure);
923
- } catch (error) {
924
- logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
925
- return finish({ ok: false, error: "llm failed", summary: false });
926
- }
927
- if (decisionText === undefined) {
928
- logger?.warn?.(`dsh-mneme dream: consolidation llm stream aborted or errored${streamFailure ? ` (${streamFailure})` : ""}`);
929
- return finish({ ok: false, error: "llm failed", summary: false });
930
- }
931
- if (!Array.isArray(decisions)) {
932
- const head = (decisionText ?? "").slice(0, 300).replace(/\s+/g, " ").trim();
933
- logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
934
- return finish({ ok: false, error: "no json array in llm output", summary: false });
935
- }
936
- const { ok, errors, skipped, resolvedShortIds, coverageShortfall } = validateDecisions(decisions, snapshot, {
937
- maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
938
- // Issue #104 方向 2:archive 批量上限(全局闸门,超限整单拒绝)。
939
- maxArchivePerRun: config.dreamMaxArchivePerRun,
940
- minAgeHours: config.reflectionUpdateMinAgeHours,
941
- // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
942
- // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
943
- dreamImplicitKeep: config.dreamImplicitKeep,
944
- dreamMinExplicitCoverage: config.dreamMinExplicitCoverage,
945
- // Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条
946
- // 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。
947
- skipInvalid: config.dreamSkipInvalid !== false,
948
- allowCrossTypeMerge: config.allowCrossTypeMerge === true
949
- });
950
- // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。
951
- // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info
952
- // 便于事后从日志侧观察该行为的分布。
953
- if (resolvedShortIds > 0) {
954
- logger?.info?.(`[dsh-mneme] dream: resolved ${resolvedShortIds} short id prefix(es) to full ids`);
955
- }
956
- if (!ok) {
957
- // Issue #135(观测缺口):失败轮的逐条校验明细此前既不落库也基本不可见——
958
- // skipped 的 warn 写在 return 之后(失败路径到不了),dream_runs.decisions
959
- // 落 NULL,上百轮失败零现场。这里把明细前置到日志,并把
960
- // { _validationFailed, errors, skipped } 随失败行持久化,事后可从审计行
961
- // 直接定位「模型输出了什么、为何逐条非法」。
962
- logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
963
- if (skipped.length > 0) {
964
- logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped: ${skipped.map((s) => `decision[${s.index}]: ${s.error}`).join("; ")}`);
965
- }
966
- return finish({
967
- ok: false,
968
- error: `invalid decisions: ${errors.length} errors`,
969
- summary: false,
970
- decisions: [{ _validationFailed: true, errors, skipped }]
971
- });
972
- }
973
- const skippedInvalid = skipped.length > 0;
974
- if (skippedInvalid) {
975
- logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped (run degrades): ${skipped.map((s) => s.error).join("; ")}`);
976
- }
977
- // Issue #104 方向 1:覆盖率不足降级为 degraded(不再整单拒绝)——合法子集
978
- // 照常应用、未提及条目隐式 keep,理由随 outcome.degradations 落库供离线定位。
979
- const coverageDegraded = typeof coverageShortfall === "string" && coverageShortfall.length > 0;
980
- if (coverageDegraded) {
981
- logger?.warn?.(`dsh-mneme dream: ${coverageShortfall} (run degrades: valid subset applied, unclaimed ids kept implicitly)`);
982
- }
983
-
984
- // Capture pre-update snapshots so the audit records what each update changed.
985
- const updateSnapshots = {};
986
- for (const d of decisions) {
987
- if (d.action === "update") {
988
- const mem = snapshot.get(d.ids[0]);
989
- if (mem) updateSnapshots[d.ids[0]] = { title: mem.title, content: mem.content, importance: mem.importance };
990
- }
991
- }
992
-
993
- // Conflict freeze (opt-in): when enabled, conflict decisions are not
994
- // auto-adjudicated — no winner kept, no loser archived. The pair is parked
995
- // in conflict_pending for human review instead. Best-effort: a store
996
- // failure here must never block the run (fail-safe — the memories are left
997
- // untouched and nothing is arbitrated). The cap (conflictFreezeMaxPending)
998
- // bounds the review queue; overflow is skipped with a warning.
999
- let frozenCount = 0;
1000
- const frozenIds = [];
1001
- // v0.8.1(issue #170 复核项 3):跨 scope 的 conflict 决策两种模式都一律
1002
- // 停车人工裁决、绝不自动归档——否则普通 dream 的 LLM 能归档别的 scope 的
1003
- // 唯一副本,sleep 路径(scope-candidate-queue)的保护在这里缺位。同 scope
1004
- // 的 conflict 决策照旧:freeze 停车 / 非 freeze 自动裁决。
1005
- const isCrossScopeDecision = (d) => {
1006
- const a = snapshot.get(d.winner);
1007
- const b = snapshot.get(d.loser);
1008
- return !!a && !!b && (
1009
- scopeKeyOf(a.agent_scope) !== scopeKeyOf(b.agent_scope)
1010
- || scopeKeyOf(a.workspace_scope) !== scopeKeyOf(b.workspace_scope)
1011
- );
1012
- };
1013
- const applyList = freezeEnabled
1014
- ? decisions.filter((d) => d.action !== "conflict")
1015
- : decisions.filter((d) => d.action !== "conflict" || !isCrossScopeDecision(d));
1016
- if (freezeEnabled) {
1017
- const conflictsToFreeze = decisions.filter((d) => d.action === "conflict");
1018
- if (conflictsToFreeze.length > 0) {
1019
- try {
1020
- const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
1021
- const pendingNow = service.countConflictPending();
1022
- const budget = Math.max(0, maxPending - pendingNow);
1023
- const toFreeze = conflictsToFreeze.slice(0, budget);
1024
- if (conflictsToFreeze.length > budget) {
1025
- logger?.warn?.(`dsh-mneme dream: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${conflictsToFreeze.length - budget} conflict(s)`);
1026
- }
1027
- for (const d of toFreeze) {
1028
- try {
1029
- // 返回 undefined = 该对已被人工裁决且两侧内容未变(issue #170 复核
1030
- // 项 4:内容变了才重新入队)——不算停车、不进 frozenIds。
1031
- const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
1032
- if (parked == null) continue;
1033
- frozenCount++;
1034
- frozenIds.push(d.winner, d.loser);
1035
- } catch (error) {
1036
- logger?.warn?.(`dsh-mneme dream: failed to freeze conflict ${d.winner}/${d.loser}: ${String(error)}`);
1037
- }
1038
- }
1039
- } catch (error) {
1040
- logger?.warn?.(`dsh-mneme dream: conflict freeze lookup failed: ${String(error)}`);
1041
- }
1042
- }
1043
- } else {
1044
- // 非 freeze:跨 scope conflict 停车(绝不进 applyDecisions)。带专属
1045
- // reason + 模型给出的裁决理由,队列里读起来是「归属裁决」而非重复项。
1046
- const crossScope = decisions.filter((d) => d.action === "conflict" && isCrossScopeDecision(d));
1047
- for (const d of crossScope) {
1048
- try {
1049
- const parked = service.saveConflictPending({
1050
- run_id: runId,
1051
- memory_a: d.winner,
1052
- memory_b: d.loser,
1053
- reason: STR.scopeConflictParkReason[language](d.reason)
1054
- });
1055
- if (parked != null) {
1056
- frozenCount++;
1057
- frozenIds.push(d.winner, d.loser);
1058
- }
1059
- } catch (error) {
1060
- logger?.warn?.(`dsh-mneme dream: failed to park cross-scope conflict ${d.winner}/${d.loser}: ${String(error)}`);
1061
- }
1062
- }
1063
- if (crossScope.length > 0) {
1064
- logger?.info?.(`[dsh-mneme] dream: parked ${crossScope.length} cross-scope conflict(s) for human ownership review`);
1065
- }
1066
- }
1067
-
1068
- // CAS-guarded, per-decision-transactional apply against the run snapshot:
1069
- // a target changed during the LLM call is skipped and reported as a
1070
- // conflict instead of being overwritten (item ①). Frozen conflicts are
1071
- // excluded from this list (they are parked, not applied).
1072
- const { applied, conflicts, failures, committed } = applyDecisions(applyList, service, logger, snapshot, config);
1073
- // Per-record receipt chain: one row per actually-committed merge/conflict/
1074
- // update verdict, stamped with the decision-basis digest + idempotency
1075
- // counters (count_before → count_after). Written here, before the run audit
1076
- // row, so the verdict trail always precedes the run trail it belongs to.
1077
- // Bookkeeping: a write failure is logged and swallowed — it must never
1078
- // block the consolidation flow.
1079
- try {
1080
- for (const r of buildRecordReceipts({ runId, committed, snapshot, policyEpoch: config.policyEpoch ?? 0 })) {
1081
- service.saveReceipt(r);
1082
- }
1083
- } catch (error) {
1084
- logger?.warn?.(`dsh-mneme dream: failed to write per-record receipt: ${String(error)}`);
1085
- }
1086
- // Attach the pre-update snapshot to the audit copy of each update decision
1087
- // so the recorded row shows the before/after delta, not just the target.
1088
- const auditDecisions = decisions.map((d) =>
1089
- d.action === "update" && updateSnapshots[d.ids[0]]
1090
- ? { ...d, _before: updateSnapshots[d.ids[0]] }
1091
- : d
1092
- );
1093
- // Outcome is derived from the ACTUALLY committed sub-steps, never from the
1094
- // raw LLM decision list — a merge whose archive step rolled back must not
1095
- // claim "merge-archived" (item ②). Conflicts/failures ride along so the
1096
- // audit row records why the run diverged.
1097
- const outcome = { ...buildOutcome(committed), conflicts, failures };
1098
- if (coverageDegraded) outcome.degradations = [coverageShortfall];
1099
- // Frozen conflicts were not adjudicated: mark both sides pending in the
1100
- // per-id outcome so the audit row shows they were parked, not skipped.
1101
- if (frozenIds.length) {
1102
- for (const id of frozenIds) outcome.byId[id] = "conflict-pending";
1103
- }
1104
- // Decisions validated but not fully committed → reconcile (not ok).
1105
- const partial = conflicts.length > 0 || failures.length > 0;
1106
- // No decision landed (all-keep, or every decision skipped as an idempotent
1107
- // replay) → nothing substantive changed. Distinct from a success: such a
1108
- // run must never be reported as ok, or the audit claims work that never
1109
- // happened and the scheduler refreshes the baseline on a false positive.
1110
- // Frozen conflicts are substantive output (parked for review), so a run
1111
- // that only froze conflicts is not a noop.
1112
- const noChange = frozenCount === 0 && applied === 0 && committed.every((c) => c.action === "keep");
1113
-
1114
- // Keep the vector index consistent with the post-dream store state.
1115
- if (semantic?.embedder && semantic?.vectorIndex) {
1116
- try {
1117
- await maintainIndexAfterDream(applyList, service, semantic);
1118
- } catch (error) {
1119
- logger?.warn?.(`[dsh-mneme] dream index maintenance failed: ${String(error)}`);
1120
- }
1121
- }
1122
-
1123
- // Summary generation (second LLM call). A throwing stream is reported as
1124
- // a failed run; summary:false marks a run that produced no summary.
1125
- let summaryText;
1126
- let summaryStreamFailure = "";
1127
- const runSummary = (withEffort) => {
1128
- summaryStreamFailure = "";
1129
- return runAuditedLlm(ctx, service, config, {
1130
- triggerSource: "autoDream",
1131
- operationType: "dream_summarize",
1132
- modelId: `${route.provider}:${route.model}`,
1133
- relatedMemoryIds: [],
1134
- streamError: () => summaryStreamFailure
1135
- }, (reportUsage) => streamText(ctx, {
1136
- provider: route.provider,
1137
- model: route.model,
1138
- purpose: "compaction",
1139
- maxTokens: config.dreamMaxTokens ?? 2048,
1140
- ...(withEffort && effort ? { reasoningEffort: effort } : {}),
1141
- messages: [
1142
- { role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
1143
- { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin", plugin: "dsh-mneme" } }
1144
- ]
1145
- }, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
1146
- };
1147
- try {
1148
- // Bug8: the summary call is audited too (operation dream_summarize).
1149
- summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false), () => summaryStreamFailure);
1150
- } catch (error) {
1151
- logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
1152
- return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, frozen: frozenCount, summary: false });
1153
- }
1154
- let summaryStored = false;
1155
- if (summaryText !== undefined && summaryText.trim()) {
1156
- // Bug5 carve-out: the library overview is regenerated every run, so it
1157
- // must REPLACE the previous overview (not append — that would grow the
1158
- // summary unboundedly). `_overwrite` still archives the old overview into
1159
- // content_history before replacing it.
1160
- service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: summaryText.trim(), importance: 5, source: "dream", _overwrite: true });
1161
- summaryStored = true;
1162
- // Re-embed the fresh summary so the index stays in sync with the store.
1163
- if (semantic?.embedder && semantic?.vectorIndex) {
1164
- try {
1165
- const summary = service.all().find((m) => m.type === "summary");
1166
- if (summary) {
1167
- const v = await semantic.embedder.embedSingle([summary.title, summary.content].filter(Boolean).join("\n"));
1168
- if (v?.length) semantic.vectorIndex.saveEmbedding(summary.id, v);
1169
- if (semantic.embedder.modelHash) semantic.vectorIndex.markModel?.(semantic.embedder.modelHash, semantic.embedder.dimension);
1170
- }
1171
- } catch { /* best-effort */ }
1172
- }
1173
- }
1174
- // Honest status assignment (never a fake ok):
1175
- // reconcile — some decisions validated but did not commit (CAS/rollback).
1176
- // noop — nothing changed and no summary persisted: truly an empty
1177
- // run. ok:false keeps the scheduler from moving the baseline.
1178
- // ok — either real changes landed, or a fresh summary was stored
1179
- // (all-keep + summary is a substantive summary refresh).
1180
- // degraded — real consolidation landed but the run did not produce its
1181
- // full output: the summary came back empty/missing, or
1182
- // skipInvalid dropped individually-invalid decisions
1183
- // (Issue #89 — marked, not faked), or the explicit-decision
1184
- // coverage fell below dreamMinExplicitCoverage (Issue #104
1185
- // 方向 1 — degrade, don't reject). The valid subset was
1186
- // absorbed (ok for the baseline).
1187
- let status;
1188
- let okResult;
1189
- if (partial) {
1190
- status = "reconcile";
1191
- okResult = false;
1192
- } else if (noChange) {
1193
- status = summaryStored ? "ok" : "noop";
1194
- okResult = summaryStored;
1195
- } else {
1196
- status = summaryStored && !skippedInvalid && !coverageDegraded ? "ok" : "degraded";
1197
- okResult = true;
1198
- }
1199
- return finish({
1200
- ok: okResult,
1201
- status,
1202
- applied,
1203
- decisions: auditDecisions,
1204
- outcome,
1205
- conflicts,
1206
- failures,
1207
- frozen: frozenCount,
1208
- summary: summaryStored,
1209
- // Issue #104:只有真发生跳过时才落库(degraded 也可能仅因 summary 为空),
1210
- // ok 轮留 NULL,避免「空数组」与「无此字段」两种假明细占据审计行。
1211
- skipped: skippedInvalid ? skipped : undefined
1212
- });
1213
- }
1214
-
1215
- return { maybeSchedule, runDream, dispose };
1216
- }
1
+ import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
+ import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
3
+ import { clusterByTag, intersectEvidence } from "./dream/narratives.js";
4
+ import { scopeKeyOf } from "./scope.js";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { STR, langOf } from "./lang.js";
7
+ export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
8
+
9
+
10
+ // Extract the first JSON array from LLM output, tolerating markdown fences,
11
+ // leading/trailing prose, and common wrapper noise. Returns an array or null.
12
+ function extractJsonArray(text) {
13
+ if (typeof text !== "string" || text.trim().length === 0) return null;
14
+
15
+ // 1. Strip markdown code fences (```json ... ``` or ``` ... ```).
16
+ let cleaned = text.replace(/```(?:json)?\s*([\s\S]*?)```/gi, "$1");
17
+ cleaned = cleaned.trim();
18
+
19
+ // 2. Find the first '[' and the matching last ']' that yields valid JSON.
20
+ const start = cleaned.indexOf("[");
21
+ if (start === -1) return null;
22
+ for (let end = cleaned.lastIndexOf("]"); end > start; end = cleaned.lastIndexOf("]", end - 1)) {
23
+ const candidate = cleaned.slice(start, end + 1);
24
+ try {
25
+ return JSON.parse(candidate);
26
+ } catch {
27
+ // Light repair: remove trailing commas before ] or }.
28
+ try {
29
+ const repaired = candidate.replace(/,(\s*[}\]])/g, "$1");
30
+ return JSON.parse(repaired);
31
+ } catch {
32
+ // keep searching backwards
33
+ }
34
+ }
35
+ }
36
+
37
+ // 3. Fallback: a broader regex extraction.
38
+ try {
39
+ const match = cleaned.match(/\[[\s\S]*\]/);
40
+ if (match) return JSON.parse(match[0]);
41
+ } catch {
42
+ // fall through
43
+ }
44
+ return null;
45
+ }
46
+ function totalChars(memories) {
47
+ return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
48
+ }
49
+
50
+ // ---------------------------------------------------------------- audit
51
+
52
+ /**
53
+ * Canonical digest of the consolidation input snapshot. Built from stable
54
+ * fields sorted by id, so identical inputs always yield the same hash — the
55
+ * basis for replaying/verifying a recorded decision (receipt check).
56
+ */
57
+ export function hashSnapshot(memories) {
58
+ const canon = memories
59
+ .map((m) => [m.id, m.type, m.title, m.content, m.importance, m.updated_at])
60
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
61
+ .map((parts) => parts.map((p) => String(p ?? "")).join("\u0001"))
62
+ .join("\u0002");
63
+ return createHash("sha256").update(canon).digest("hex");
64
+ }
65
+
66
+ /**
67
+ * Compact machine-verifiable receipt for one autoDream run. Format:
68
+ * dsh-mneme:run:<runId>:<status>:<snapshotHash(12)>:<inputCount>:<applied>:<summaryFlag>
69
+ * Enough to correlate a run with its persisted audit row and to spot silent
70
+ * drift (same snapshot hash + same decisions must reproduce the same outcome).
71
+ */
72
+ export function buildReceipt({ runId, status, snapshotHash, inputCount, applied, summaryStored }) {
73
+ return `dsh-mneme:run:${runId}:${status}:${snapshotHash.slice(0, 12)}:${inputCount}:${applied}:${summaryStored ? 1 : 0}`;
74
+ }
75
+
76
+ /**
77
+ * Parse a receipt back into fields; returns undefined for malformed input.
78
+ */
79
+ export function parseReceipt(receipt) {
80
+ if (typeof receipt !== "string") return undefined;
81
+ const parts = receipt.split(":");
82
+ if (parts.length !== 8 || parts[0] !== "dsh-mneme" || parts[1] !== "run") return undefined;
83
+ const [, , runId, status, snapshotHash, inputCount, applied, summaryStored] = parts;
84
+ // reconcile = decisions validated but one or more did not commit (CAS
85
+ // conflict / transaction rollback) — the store diverges from the decision
86
+ // list and the run must be reconciled, never reported as a fake ok.
87
+ if (!runId || !/^(ok|noop|degraded|reconcile|failed)$/.test(status)) return undefined;
88
+ const count = Number(inputCount);
89
+ const appliedN = Number(applied);
90
+ if (!Number.isInteger(count) || !Number.isInteger(appliedN)) return undefined;
91
+ return { runId, status, snapshotHash, inputCount: count, applied: appliedN, summaryStored: summaryStored === "1" };
92
+ }
93
+
94
+ /**
95
+ * Derive the per-id disposition (keep / merge-keep / merge-archived /
96
+ * archived / conflict-winner / conflict-archived) from a validated decision
97
+ * list. Stored in the audit row so a run can be replayed without re-running
98
+ * the LLM.
99
+ */
100
+ export function buildOutcome(decisions) {
101
+ const byId = {};
102
+ for (const d of decisions ?? []) {
103
+ if (d.action === "keep") {
104
+ for (const id of d.ids) byId[id] = "keep";
105
+ } else if (d.action === "archive") {
106
+ for (const id of d.ids) byId[id] = "archived";
107
+ } else if (d.action === "merge") {
108
+ for (const id of d.ids) byId[id] = id === d.keepSource ? "merge-keep" : "merge-archived";
109
+ } else if (d.action === "conflict") {
110
+ byId[d.winner] = "conflict-winner";
111
+ byId[d.loser] = "conflict-archived";
112
+ } else if (d.action === "update") {
113
+ for (const id of d.ids) byId[id] = "updated";
114
+ } else if (d.action === "supersede") {
115
+ // Issue #126 review(Copilot):新动作此前在 outcome.byId 里没有 disposition,
116
+ // 审计行产了 receipt 却看不出这些目标被如何处置。
117
+ byId[d.winner] = "supersede-winner";
118
+ byId[d.loser] = "superseded-archived";
119
+ } else if (d.action === "differentiate") {
120
+ for (const id of d.ids ?? []) byId[id] = "differentiated";
121
+ }
122
+ }
123
+ return { byId };
124
+ }
125
+
126
+ /**
127
+ * Content-addressed digest of the memories a verdict was decided against
128
+ * (id + title + content + importance), sorted by id so identical inputs always
129
+ * hash the same. This is the per-record "判定依据" fingerprint: a receipt whose
130
+ * digest cannot be reproduced from the involved memories is a bare claim, and a
131
+ * digest match with a divergent outcome pinpoints drift to the exact record.
132
+ */
133
+ export function hashDecisionInput(memories) {
134
+ const canon = (memories ?? [])
135
+ .map((m) => [m.id, m.title, m.content, m.importance])
136
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
137
+ .map((p) => p.map((x) => String(x ?? "")).join(""))
138
+ .join("");
139
+ return createHash("sha256").update(canon).digest("hex");
140
+ }
141
+
142
+ /**
143
+ * Build the per-record receipts for a run's actually-committed mutable verdicts
144
+ * (merge/conflict/update) — one row per verdict in the receipt_chain. Inputs
145
+ * are drawn from the run snapshot (what the LLM actually arbitrated against),
146
+ * and the idempotency counters count_before → count_after come from the
147
+ * committed sub-step, so replaying the same decision must reproduce the same
148
+ * numbers. verdict starts "live"; a later policy_epoch upgrade will batch-mark
149
+ * older verdicts "historical" (a receipt_chain rewrite driven by the store's
150
+ * getLatestPolicyEpoch — out of scope for this pass), while "revoked" is
151
+ * reserved for verdicts later overturned by an explicit human decision.
152
+ */
153
+ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
154
+ const at = (id) => snapshot?.get?.(id);
155
+ const receipts = [];
156
+ for (const c of committed ?? []) {
157
+ const base = {
158
+ run_id: runId,
159
+ verdict: "live",
160
+ count_before: c.count_before,
161
+ count_after: c.count_after,
162
+ policy_epoch: policyEpoch,
163
+ created_at: new Date().toISOString()
164
+ };
165
+ if (c.action === "merge") {
166
+ receipts.push({
167
+ ...base,
168
+ receipt_id: randomUUID(),
169
+ record_id: c.keepSource,
170
+ kind: "merge",
171
+ input_digest: hashDecisionInput((c.ids ?? []).map(at).filter(Boolean)),
172
+ keep_source: c.keepSource,
173
+ sources: c.ids
174
+ });
175
+ } else if (c.action === "conflict") {
176
+ receipts.push({
177
+ ...base,
178
+ receipt_id: randomUUID(),
179
+ record_id: c.winner,
180
+ kind: "conflict",
181
+ input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
182
+ winner_id: c.winner,
183
+ loser_id: c.loser
184
+ });
185
+ } else if (c.action === "update") {
186
+ receipts.push({
187
+ ...base,
188
+ receipt_id: randomUUID(),
189
+ record_id: c.ids[0],
190
+ kind: "update",
191
+ input_digest: hashDecisionInput([at(c.ids[0])].filter(Boolean))
192
+ });
193
+ } else if (c.action === "supersede") {
194
+ // Issue #126:演进型裁决——record 记被取代的一方。winner 正文未被改动,
195
+ // 不需要自己的 receipt;loser 归档并追加"已被取代"注记。
196
+ receipts.push({
197
+ ...base,
198
+ receipt_id: randomUUID(),
199
+ record_id: c.loser,
200
+ kind: "supersede",
201
+ input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
202
+ winner_id: c.winner,
203
+ loser_id: c.loser
204
+ });
205
+ } else if (c.action === "differentiate") {
206
+ // Issue #126:互补型裁决——两条都保留,各记一条 receipt。数量没有变化,
207
+ // count_before/after 固定 1/1(该记录只是被追加了差异注记)。
208
+ for (const id of c.ids ?? []) {
209
+ receipts.push({
210
+ ...base,
211
+ receipt_id: randomUUID(),
212
+ record_id: id,
213
+ kind: "differentiate",
214
+ input_digest: hashDecisionInput([at(id)].filter(Boolean)),
215
+ count_before: 1,
216
+ count_after: 1
217
+ });
218
+ }
219
+ }
220
+ }
221
+ return receipts;
222
+ }
223
+
224
+ /**
225
+ * Consume an LLM stream and return the accumulated text. Direct text-delta
226
+ * accumulation covers both the real protocol ({type:"text-delta", index, text})
227
+ * and looser test doubles ({type:"text-delta", text}); a terminal error/abort
228
+ * surfaces as undefined. The caller decides how to treat an empty result.
229
+ * `onUsage` (optional, Bug8) receives any usage chunk for token accounting.
230
+ */
231
+ async function streamText(ctx, options, onUsage, onStreamError) {
232
+ let text = "";
233
+ for await (const chunk of ctx.llm.stream(options)) {
234
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
235
+ if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk);
236
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
237
+ // dsh-llm rc.1 turns adapter-stage failures (unknown provider route,
238
+ // UNSUPPORTED_REASONING_EFFORT from resolveCallWithInfo, …) into a
239
+ // terminal finish chunk instead of a throw — the cause rides in
240
+ // chunk.reason.failure {message, code}. Surface it, never swallow it.
241
+ if (typeof onStreamError === "function") {
242
+ try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
243
+ }
244
+ return undefined;
245
+ }
246
+ }
247
+ return text;
248
+ }
249
+
250
+ /** One-line human-readable cause from a finish-chunk failure reason. */
251
+ function describeStreamFailure(reason) {
252
+ const failure = reason?.failure ?? reason ?? {};
253
+ const code = failure.code ? String(failure.code) : "";
254
+ const message = String(failure.message ?? failure.error ?? "");
255
+ if (code && message) return message.includes(code) ? message : `${code}: ${message}`;
256
+ return code || message;
257
+ }
258
+
259
+ /**
260
+ * Bug8: wrap a background LLM call so its token/time/status are recorded in the
261
+ * llm_audit_logs table. Best-effort bookkeeping: a failure to WRITE the audit
262
+ * row is swallowed (never blocks the LLM call), while a failure of the call
263
+ * itself is captured as status='error' and re-thrown so the caller keeps its
264
+ * existing error path. `spec` carries the static metadata (trigger_source,
265
+ * operation_type, model_id, related_memory_ids); `body(reportUsage)` performs
266
+ * the actual stream consumption and is handed a usage reporter for the chunks.
267
+ */
268
+ async function runAuditedLlm(ctx, service, config, spec, body) {
269
+ const audit = config?.llmAudit;
270
+ if (audit?.enabled === false || typeof service?.saveLlmAudit !== "function") return body(() => {});
271
+ const startedAt = Date.now();
272
+ const timestamp = new Date(startedAt).toISOString();
273
+ let inputTokens = 0;
274
+ let outputTokens = 0;
275
+ let status = "success";
276
+ let errorMessage = null;
277
+ let result;
278
+ try {
279
+ result = await body((usage) => {
280
+ if (!usage) return;
281
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
282
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
283
+ if (Number.isFinite(i)) inputTokens = i;
284
+ if (Number.isFinite(o)) outputTokens = o;
285
+ });
286
+ if (result === undefined) {
287
+ // stream aborted/errored: the caller treats undefined as a failed run;
288
+ // record it as error here so the audit shows the truth. spec.streamError
289
+ // (a getter) lets the caller attach the finish-chunk cause so the audit
290
+ // row names it instead of a bare "aborted".
291
+ status = "error";
292
+ const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
293
+ errorMessage = errorMessage ?? (streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored");
294
+ } else if (typeof spec.auditError === "function") {
295
+ // A stream that returned text but yields nothing usable is still a
296
+ // failed call — record it as error, not the default success, so the
297
+ // audit no longer contradicts a failed run (dream "no json array").
298
+ const message = spec.auditError(result);
299
+ if (message) {
300
+ status = "error";
301
+ errorMessage = message;
302
+ }
303
+ }
304
+ return result;
305
+ } catch (error) {
306
+ status = "error";
307
+ errorMessage = String(error?.message ?? error);
308
+ throw error;
309
+ } finally {
310
+ try {
311
+ service.saveLlmAudit({
312
+ timestamp,
313
+ trigger_source: spec.triggerSource,
314
+ operation_type: spec.operationType,
315
+ model_id: spec.modelId,
316
+ input_tokens: inputTokens,
317
+ output_tokens: outputTokens,
318
+ total_tokens: inputTokens + outputTokens,
319
+ cost_usd: 0,
320
+ duration_ms: Date.now() - startedAt,
321
+ status,
322
+ error_message: errorMessage,
323
+ related_memory_ids: spec.relatedMemoryIds ?? []
324
+ });
325
+ } catch (auditError) {
326
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Reasoning-effort rejection fallback (v0.8.1): a configured dreamReasoningEffort
333
+ * / sleepReasoningEffort may be rejected by the provider (volcano-engine returns
334
+ * UNSUPPORTED_REASONING_EFFORT for values it does not accept — "off" is known
335
+ * rejected there). When that happens, retry once WITHOUT the reasoning field
336
+ * instead of hard-failing the run, so effort config is safe to experiment with:
337
+ * accepted → reasoning capped; rejected → provider default (old behavior),
338
+ * logged so the rejection is observable.
339
+ */
340
+ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
341
+ if (!effort || effort === "none") return attempt();
342
+ try {
343
+ const result = await attempt();
344
+ if (result === undefined) {
345
+ // dsh-llm rc.1 streams a provider effort-rejection as a terminal error
346
+ // finish chunk (adapterStream catches everything, never throws) — match
347
+ // on the chunk's failure reason here or the retry below is dead code
348
+ // for the stream path.
349
+ const reason = String(getStreamError?.() ?? "");
350
+ if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
351
+ ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
352
+ return fallback();
353
+ }
354
+ }
355
+ return result;
356
+ } catch (error) {
357
+ const message = String(error?.message ?? error);
358
+ // matches both "reasoning effort" (natural language) and the bare
359
+ // "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
360
+ if (!/reasoning[\s_]*effort/i.test(message)) throw error;
361
+ ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
362
+ return fallback();
363
+ }
364
+ }
365
+
366
+ /** 向量索引同步(总览与叙述条共用):fail-safe,失败只 warn 不反噬主流程。 */
367
+ async function reEmbedMemory(semantic, memory, logger) {
368
+ if (!semantic?.embedder || !semantic?.vectorIndex || !memory) return;
369
+ try {
370
+ const v = await semantic.embedder.embedSingle([memory.title, memory.content].filter(Boolean).join("\n"));
371
+ if (v?.length) semantic.vectorIndex.saveEmbedding(memory.id, v);
372
+ if (semantic.embedder.modelHash) semantic.vectorIndex.markModel?.(semantic.embedder.modelHash, semantic.embedder.dimension);
373
+ } catch (error) {
374
+ logger?.warn?.(`dsh-mneme dream: re-embed failed: ${String(error)}`);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * 叙述条阶段(#164 对齐,opt-in dreamNarrativeEnabled):共享 tag 主题聚类 →
380
+ * 单次 LLM 调用按簇合成叙述 → 证据求交(防捏造,dream/narratives.js 纯函数)
381
+ * → saveWithDedupe(source="narrative", _overwrite) 落库。按需检索不常驻注入。
382
+ * 任何失败降级为 0 条,绝不反噬 dream 主流程。
383
+ */
384
+ async function generateNarratives({ ctx, service, config, route, language, effort, semantic, logger }) {
385
+ const inputs = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
386
+ const clusters = clusterByTag(inputs, { minCluster: config.dreamNarrativeMinCluster ?? 3 });
387
+ if (!clusters.length) return 0;
388
+
389
+ const listing = clusters
390
+ .map(({ tag, members }) =>
391
+ [`# ${tag}`, ...members.map((m) => `- id=${m.id} | title=${m.title} | ${m.content}`)].join("\n"))
392
+ .join("\n\n");
393
+ let streamFailure = "";
394
+ const runNarratives = (withEffort) => {
395
+ streamFailure = "";
396
+ return runAuditedLlm(ctx, service, config, {
397
+ triggerSource: "autoDream",
398
+ operationType: "dream_narrative",
399
+ modelId: `${route.provider}:${route.model}`,
400
+ relatedMemoryIds: clusters.flatMap((c) => c.members.map((m) => m.id)),
401
+ streamError: () => streamFailure
402
+ }, (reportUsage) => streamText(ctx, {
403
+ provider: route.provider,
404
+ model: route.model,
405
+ purpose: "compaction",
406
+ maxTokens: config.dreamMaxTokens ?? 2048,
407
+ ...(withEffort && effort ? { reasoningEffort: effort } : {}),
408
+ messages: [
409
+ { role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
410
+ { role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin", plugin: "dsh-mneme" } }
411
+ ]
412
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
413
+ };
414
+ const text = await withEffortFallback(ctx, effort, () => runNarratives(true), () => runNarratives(false), () => streamFailure);
415
+
416
+ const entries = extractJsonArray(text);
417
+ if (!Array.isArray(entries)) return 0;
418
+ const clusterByOfferedTag = new Map(clusters.map((c) => [c.tag, c]));
419
+ const now = new Date().toISOString();
420
+ let stored = 0;
421
+ for (const entry of entries) {
422
+ if (!entry || typeof entry !== "object") continue;
423
+ const cluster = clusterByOfferedTag.get(typeof entry.tag === "string" ? entry.tag : "");
424
+ const content = String(entry.content ?? "").trim();
425
+ if (!cluster || !content) continue; // 未提供的 tag / 空叙述 → 跳过
426
+ const evidenceIds = intersectEvidence(entry.evidence, cluster.members);
427
+ const { memory } = service.saveWithDedupe({
428
+ type: "summary",
429
+ title: STR.narrativeTitle[language](cluster.tag),
430
+ content,
431
+ importance: 3,
432
+ tags: [cluster.tag],
433
+ source: "narrative",
434
+ evidence: evidenceIds.map((id) => ({ memory_id: id, op: "support", at: now })),
435
+ _overwrite: true
436
+ });
437
+ if (!memory) continue;
438
+ stored++;
439
+ // 叙述条按需可检索:向量索引同步(与总览共用 reEmbedMemory,fail-safe)。
440
+ await reEmbedMemory(semantic, memory, logger);
441
+ }
442
+ return stored;
443
+ }
444
+
445
+ /**
446
+ * Resolve the reasoning effort to actually send for a dream/sleep route.
447
+ *
448
+ * Reasoning-effort config that the provider does not accept trips the harness's
449
+ * UNSUPPORTED_REASONING_EFFORT, and the defaultEffort trap makes retrying
450
+ * "without the field" useless: the harness substitutes `reasoning.defaultEffort`,
451
+ * which may itself be unsupported (DSH Desktop volcano-engine adapter declares
452
+ * defaultEffort=low that its model rejects). So instead of blind retries, ask
453
+ * the harness for the model's declared capability and pick a value that is
454
+ * actually accepted — or omit the field entirely when the model declares no
455
+ * reasoning capability at all.
456
+ *
457
+ * Unset vs explicit 'none' (Issue #135 建议 4/5): these are different intents.
458
+ * Explicit 'none' = the user asked to omit the field (provider default
459
+ * applies). Unset is the out-of-the-box state — dream/sleep calls must produce
460
+ * JSON, and omission lets the harness substitute the model's defaultEffort
461
+ * (thinking-type models often default to high), which drains the token budget
462
+ * and returns an empty body. So unset resolves to the LOWEST effort the model
463
+ * declares instead of omitting; when the capability query is unavailable the
464
+ * field stays omitted (fail-safe, retry guard unchanged).
465
+ *
466
+ * @returns a supported effort id, or null when no effort should be sent, or the
467
+ * configured value unchanged when the capability query is unavailable.
468
+ */
469
+
470
+ // 常识档位排序(越靠前推理开销越低);未知档位排最后(宁可交给显式配置)。
471
+ const EFFORT_RANK = { minimal: 0, off: 0, low: 1, medium: 2, high: 3, max: 4 };
472
+
473
+ function lowestSupportedEffort(reasoning) {
474
+ const ids = (reasoning?.efforts ?? []).map((e) => e?.id).filter(Boolean);
475
+ if (ids.length === 0) return null;
476
+ return [...ids].sort((a, b) => (EFFORT_RANK[a] ?? 99) - (EFFORT_RANK[b] ?? 99))[0];
477
+ }
478
+
479
+ async function resolveDreamEffort(ctx, route, configuredEffort, logger) {
480
+ // 显式 'none':用户要求省略字段(服务商自带默认生效),照旧。
481
+ if (configuredEffort === "none") return null;
482
+ if (!configuredEffort) {
483
+ // 未配置(开箱默认):取模型声明的最低档,避免 defaultEffort 顶上(建议 4/5)。
484
+ if (typeof ctx?.llm?.resolveModelInfo !== "function") return null;
485
+ try {
486
+ const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
487
+ const reasoning = info?.reasoning;
488
+ if (!reasoning) return null; // 无推理声明:省略即安全(无 defaultEffort 可顶上)
489
+ const lowest = lowestSupportedEffort(reasoning);
490
+ if (!lowest) return null;
491
+ logger?.info?.(`dsh-mneme dream: no reasoningEffort configured for ${route.provider}:${route.model}; using lowest supported "${lowest}" instead of the model default "${reasoning.defaultEffort ?? "n/a"}"`);
492
+ return lowest;
493
+ } catch (error) {
494
+ // 能力查询失败:省略字段(fail-safe),withEffortFallback 照旧兜底。
495
+ logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); omitting reasoningEffort`);
496
+ return null;
497
+ }
498
+ }
499
+ // Capability query unavailable (older harness / minimal mocks): forward the
500
+ // configured value as before — absence of the API proves nothing about the
501
+ // model, and withEffortFallback still guards against rejection.
502
+ if (typeof ctx?.llm?.resolveModelInfo !== "function") return configuredEffort;
503
+ try {
504
+ const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
505
+ const reasoning = info?.reasoning;
506
+ if (!reasoning) {
507
+ // Model declares no reasoning capability: the harness rejects ANY
508
+ // explicit effort for such a model, and omitting the field is safe
509
+ // (no reasoning capability → no defaultEffort substitution).
510
+ logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} declares no reasoning capability; ignoring configured effort "${configuredEffort}"`);
511
+ return null;
512
+ }
513
+ const supported = reasoning.efforts?.map((effort) => effort.id) ?? [];
514
+ if (supported.includes(configuredEffort)) return configuredEffort;
515
+ // Configured effort unsupported → pick defaultEffort if it is supported,
516
+ // else the first declared effort, so the run never trips
517
+ // UNSUPPORTED_REASONING_EFFORT (nor the defaultEffort trap: we always
518
+ // pass an explicit value, so the harness never falls back to a poison
519
+ // default).
520
+ const picked = reasoning.defaultEffort && supported.includes(reasoning.defaultEffort)
521
+ ? reasoning.defaultEffort
522
+ : supported[0];
523
+ if (picked) {
524
+ logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} does not support effort "${configuredEffort}" (supported: ${supported.join(", ")}); using "${picked}"`);
525
+ return picked;
526
+ }
527
+ return null;
528
+ } catch (error) {
529
+ // Capability query failed — forward the configured value; withEffortFallback
530
+ // still retries on rejection as before.
531
+ logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); forwarding effort as configured`);
532
+ return configuredEffort;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Resolve the LLM route (Issue #25): an explicit plugin config
538
+ * (dreamProvider/dreamModel) is the user's declared override and wins; the
539
+ * agent default model (deployment) is only a fallback when no config route is
540
+ * set. In a standard DSH install agentDefaultModel always resolves, so without
541
+ * this ordering the config route would be dead code and dreamProvider/dreamModel
542
+ * could never take effect (v0.7.11 regressed this; README §config documents
543
+ * config-first). Falls through to undefined when no route exists — runDream
544
+ * then fails safe. A config→default switch is logged so it is observable.
545
+ */
546
+ function resolveRoute(ctx, config, logger) {
547
+ if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
548
+ try {
549
+ const sel = ctx.agentDefaultModel?.currentSelection?.();
550
+ if (sel?.provider && sel?.model) {
551
+ logger?.info?.("dsh-mneme dream: no dreamProvider/dreamModel config, falling back to agent default");
552
+ return { provider: sel.provider, model: sel.model };
553
+ }
554
+ logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, no config route either");
555
+ } catch (error) {
556
+ logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed: ${String(error)}`);
557
+ }
558
+ return undefined;
559
+ }
560
+
561
+ // ------------------------------------------------------- semantic enhancement
562
+ // Best-effort: any failure here degrades to plain consolidation. The dream
563
+ // path must never be broken by an unavailable embedder/index.
564
+
565
+ /** Backfill + return vectors for every memory; null when impossible. */
566
+ async function collectVectors(memories, semantic) {
567
+ const { embedder, vectorIndex } = semantic;
568
+ if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return null;
569
+ const vectors = new Array(memories.length);
570
+ const missing = [];
571
+ for (let i = 0; i < memories.length; i++) {
572
+ const cached = vectorIndex.getEmbedding?.(memories[i].id);
573
+ if (cached) vectors[i] = cached;
574
+ else missing.push(i);
575
+ }
576
+ if (missing.length) {
577
+ const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
578
+ const rows = await embedder.embed(texts);
579
+ missing.forEach((mi, j) => {
580
+ if (rows[j]?.length) {
581
+ vectors[mi] = rows[j];
582
+ vectorIndex.saveEmbedding(memories[mi].id, rows[j]);
583
+ }
584
+ });
585
+ }
586
+ return vectors.some((v) => !v) ? null : vectors;
587
+ }
588
+
589
+ /**
590
+ * Issue #125:把高相似对聚成**连通分量组**——一个簇 = 一个候选单元。直接按对展开
591
+ * 会把同一簇拆成互相抢占的多个决策(校验器有 one-claim 规则),成组才是正确粒度
592
+ * (与 sleep 冲突阶段的贪心"每记忆每轮最多进一对"同理)。
593
+ * 组内最小相似度用于组间排序:最该处理的簇先入选。
594
+ */
595
+ function similarityGroups(pairs) {
596
+ const parent = new Map();
597
+ const find = (x) => {
598
+ let root = x;
599
+ while (parent.get(root) !== root) root = parent.get(root);
600
+ return root;
601
+ };
602
+ for (const p of pairs) {
603
+ if (!parent.has(p.a.id)) parent.set(p.a.id, p.a.id);
604
+ if (!parent.has(p.b.id)) parent.set(p.b.id, p.b.id);
605
+ const ra = find(p.a.id);
606
+ const rb = find(p.b.id);
607
+ if (ra !== rb) parent.set(ra, rb);
608
+ }
609
+ const byRoot = new Map();
610
+ for (const p of pairs) {
611
+ for (const m of [p.a, p.b]) {
612
+ const root = find(m.id);
613
+ if (!byRoot.has(root)) byRoot.set(root, new Map());
614
+ byRoot.get(root).set(m.id, m);
615
+ }
616
+ }
617
+ return [...byRoot.values()].map((members) => {
618
+ const list = [...members.values()];
619
+ const ids = new Set(list.map((m) => m.id));
620
+ let minSim = 1;
621
+ for (const p of pairs) {
622
+ if (ids.has(p.a.id) && ids.has(p.b.id)) minSim = Math.min(minSim, p.similarity);
623
+ }
624
+ return { members: list, minSim };
625
+ });
626
+ }
627
+
628
+ /**
629
+ * Issue #125:为候选构造收集向量。与 collectVectors 的差别是**允许部分缺失**——
630
+ * 全量库只要有几条嵌入失败就整场返回 null,会让 hybrid 在真实库上几乎永远回落;
631
+ * 拿不到向量的条目只是不参与比较(cosineSimilarity 对非数组返回 0,不会误判)。
632
+ */
633
+ async function collectCandidateVectors(memories, semantic, logger) {
634
+ const { embedder, vectorIndex } = semantic;
635
+ const vectors = new Array(memories.length);
636
+ const missing = [];
637
+ for (let i = 0; i < memories.length; i++) {
638
+ const cached = vectorIndex.getEmbedding?.(memories[i].id);
639
+ if (cached) vectors[i] = cached;
640
+ else missing.push(i);
641
+ }
642
+ if (missing.length) {
643
+ try {
644
+ const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
645
+ const rows = await embedder.embed(texts);
646
+ missing.forEach((mi, j) => {
647
+ if (rows[j]?.length) {
648
+ vectors[mi] = rows[j];
649
+ vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
650
+ }
651
+ });
652
+ } catch (error) {
653
+ logger?.warn?.(`[dsh-mneme] dream hybrid vector backfill failed: ${String(error)}`);
654
+ }
655
+ }
656
+ return vectors;
657
+ }
658
+
659
+ /**
660
+ * Issue #125:hybrid 候选成员,按"组内最小相似度"降序展平(高价值簇排前面,
661
+ * 受上限截断时先入选)。同类型比较沿用 findPotentialConflicts;仅当
662
+ * allowCrossTypeMerge 显式开启时再补跨类型高相似对。
663
+ * 任何失败都返回空数组——调用方回落纯窗口,dream 路径绝不因向量层而失败。
664
+ */
665
+ async function hybridCandidateMembers(allMemories, config, semantic, logger) {
666
+ try {
667
+ const vectors = await collectCandidateVectors(allMemories, semantic, logger);
668
+ const minSim = config.dreamCandidateMinSim ?? 0.85;
669
+ const pairs = findPotentialConflicts(allMemories, vectors, minSim);
670
+ if (config.allowCrossTypeMerge === true) {
671
+ for (let i = 0; i < allMemories.length; i++) {
672
+ for (let j = i + 1; j < allMemories.length; j++) {
673
+ if (allMemories[i].type === allMemories[j].type) continue; // 同类型已由上面覆盖
674
+ const sim = cosineSimilarity(vectors[i], vectors[j]);
675
+ if (sim > minSim) pairs.push({ a: allMemories[i], b: allMemories[j], similarity: sim });
676
+ }
677
+ }
678
+ }
679
+ if (pairs.length === 0) return [];
680
+ return similarityGroups(pairs)
681
+ .sort((g1, g2) => g2.minSim - g1.minSim)
682
+ .flatMap((g) => g.members);
683
+ } catch (error) {
684
+ logger?.warn?.(`[dsh-mneme] dream hybrid candidate expansion failed (falling back to window): ${String(error)}`);
685
+ return [];
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Rebuild the vector index after dream decisions so the store and the index
691
+ * stay in sync: merged-away/archived/conflict-loser rows lose their vectors,
692
+ * the merge keeper gets a fresh one.
693
+ */
694
+ export async function maintainIndexAfterDream(decisions, service, semantic) {
695
+ const { embedder, vectorIndex } = semantic;
696
+ if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return;
697
+ const rebuild = new Map();
698
+ for (const d of decisions ?? []) {
699
+ if (d.action === "merge") {
700
+ for (const id of d.ids ?? []) {
701
+ if (id !== d.keepSource) vectorIndex.deleteEmbedding(id);
702
+ }
703
+ if (d.keepSource) {
704
+ const keeper = service.getById(d.keepSource);
705
+ if (keeper) rebuild.set(keeper.id, [keeper.title, keeper.content].filter(Boolean).join("\n"));
706
+ }
707
+ } else if (d.action === "archive" || d.action === "conflict") {
708
+ for (const id of d.ids ?? [d.loser]) vectorIndex.deleteEmbedding(id);
709
+ } else if (d.action === "supersede") {
710
+ // Issue #126 review(Copilot):被取代的一方已归档 → 移除其向量(与 conflict 的
711
+ // loser 同等处置)。winner 正文未变化,向量保持有效。
712
+ vectorIndex.deleteEmbedding(d.loser);
713
+ } else if (d.action === "differentiate") {
714
+ // Issue #126 review(Copilot):双方正文都被追加了差异注记,而 update 在事务内
715
+ // 会因 txDepth>0 跳过 scheduleEmbed —— 不在这里重嵌,注记就进不了 embedding,
716
+ // 注释里承诺的"下一轮不再判成重复"就不成立。
717
+ for (const id of d.ids ?? []) {
718
+ const mem = service.getById(id);
719
+ if (mem) rebuild.set(id, [mem.title, mem.content].filter(Boolean).join("\n"));
720
+ }
721
+ } else if (d.action === "update") {
722
+ const id = d.ids[0];
723
+ const mem = service.getById(id);
724
+ if (mem) {
725
+ vectorIndex.deleteEmbedding(id);
726
+ try {
727
+ const text = [mem.title, mem.content].filter(Boolean).join("\n");
728
+ const v = await embedder.embedSingle(text);
729
+ if (v?.length) vectorIndex.saveEmbedding(id, v);
730
+ } catch { /* best-effort */ }
731
+ }
732
+ }
733
+ }
734
+ for (const [id, text] of rebuild) {
735
+ try {
736
+ const v = await embedder.embedSingle(text);
737
+ if (v?.length) vectorIndex.saveEmbedding(id, v);
738
+ } catch { /* best-effort */ }
739
+ }
740
+ if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
741
+ }
742
+
743
+ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
744
+ let pendingTimer = null;
745
+ let running = false;
746
+ let disposed = false;
747
+ let baseline = { count: 0, chars: 0 };
748
+ let inFlight = null;
749
+ // Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
750
+ // 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
751
+ let lastRunAt = 0;
752
+
753
+ function shouldTrigger(service) {
754
+ const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
755
+ const count = memories.length;
756
+ const chars = totalChars(memories);
757
+ const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
758
+ const overAbs = count >= thresholdCount || chars >= thresholdChars;
759
+ return { trigger: overAbs && overBase, count, chars };
760
+ }
761
+
762
+ function maybeSchedule(service) {
763
+ if (disposed || running || pendingTimer) return false;
764
+ // Issue #89(请求 2):最小触发间隔闸门。
765
+ if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
766
+ const { trigger, count, chars } = shouldTrigger(service);
767
+ if (!trigger) return false;
768
+ pendingTimer = setTimeout(() => {
769
+ pendingTimer = null;
770
+ running = true;
771
+ lastRunAt = Date.now();
772
+ // Defer the onRun invocation so a synchronous throw cannot escape the
773
+ // timer callback (which would crash the process) and skip the teardown.
774
+ // Errors are logged, never swallowed silently. inFlight lets dispose()
775
+ // await the running consolidation before the caller closes the store.
776
+ inFlight = Promise.resolve()
777
+ .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
778
+ .then((result) => {
779
+ // Refresh the baseline only for a successful run (design §5.3: an
780
+ // LLM failure must not move the baseline, so the next write can
781
+ // immediately re-trigger a retry). A `{ok:false}` result or a throw
782
+ // keeps the old baseline. A run that reports nothing is treated as
783
+ // completed without failure (no-op hooks / minimal test doubles).
784
+ if (result && result.ok) {
785
+ try {
786
+ baseline = shouldTrigger(service);
787
+ } catch (error) {
788
+ // Store closed mid-flight: keep the last known baseline.
789
+ logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
790
+ }
791
+ }
792
+ })
793
+ .catch((error) => {
794
+ logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
795
+ // Failed runs do not refresh the baseline.
796
+ })
797
+ .finally(() => {
798
+ running = false;
799
+ inFlight = null;
800
+ });
801
+ }, delayMs);
802
+ return true;
803
+ }
804
+
805
+ async function dispose() {
806
+ disposed = true;
807
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
808
+ // An in-flight run is left to complete naturally (its LLM calls are
809
+ // already paid for and aborting would discard the work). Await it so the
810
+ // caller can close the store only after every write has landed.
811
+ if (inFlight) await inFlight.catch(() => {});
812
+ }
813
+
814
+ async function runDream(ctx, service, config) {
815
+ const language = langOf(config);
816
+ const logger = ctx.logger;
817
+ let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
818
+ if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
819
+ // v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
820
+ // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
821
+ // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
822
+ const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
823
+ const windowMemories = [...memories]
824
+ .sort((a, b) => {
825
+ const ta = String(a.updated_at ?? "");
826
+ const tb = String(b.updated_at ?? "");
827
+ if (ta < tb) return 1;
828
+ if (ta > tb) return -1;
829
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
830
+ })
831
+ .slice(0, Math.max(1, maxSize));
832
+ // Issue #125:hybrid 档在窗口之外并入向量翻出的高相似组。候选**总量**仍由
833
+ // maxSize(或显式 dreamCandidateMax)封顶、与库总量解耦——用"按相似度取候选"
834
+ // 替代"调大窗口",输入成本才不随库增长。封顶截断时向量组优先于窗口尾部,
835
+ // 否则"该合并的一对"仍会被时间窗挤掉。
836
+ let selected = windowMemories;
837
+ if ((config.dreamCandidateMode ?? "window") === "hybrid" && semantic?.embedder && semantic?.vectorIndex) {
838
+ const extra = await hybridCandidateMembers(memories, config, semantic, logger);
839
+ if (extra.length > 0) {
840
+ const cap = Math.max(
841
+ Math.max(1, maxSize),
842
+ Number.isInteger(config.dreamCandidateMax) && config.dreamCandidateMax > 0 ? config.dreamCandidateMax : 0
843
+ );
844
+ const merged = [];
845
+ const seen = new Set();
846
+ for (const m of [...extra, ...windowMemories]) {
847
+ if (seen.has(m.id)) continue;
848
+ seen.add(m.id);
849
+ merged.push(m);
850
+ if (merged.length >= cap) break;
851
+ }
852
+ selected = merged;
853
+ logger?.info?.(`[dsh-mneme] dream hybrid candidates: ${merged.length} (window ${windowMemories.length}, vector ${extra.length}, cap ${cap})`);
854
+ }
855
+ }
856
+ memories = selected;
857
+ const snapshot = new Map(memories.map((m) => [m.id, m]));
858
+ const route = resolveRoute(ctx, config, logger);
859
+ const runId = randomUUID();
860
+ const snapshotHash = hashSnapshot([...snapshot.values()]);
861
+ // Conflict freeze (opt-in): when enabled, conflict decisions are parked for
862
+ // manual review instead of auto-adjudicated. Read once up front so the
863
+ // prompt hint and the apply-split agree on the same gate.
864
+ const freezeEnabled = config.conflictFreezeEnabled === true;
865
+ // Every exit (success or failure) funnels through `finish`, which writes
866
+ // the audit row + receipt. A record failure is logged, never thrown —
867
+ // auditing must not break the consolidation path. Failed runs still
868
+ // capture their decisions/outcome when the LLM produced a validated list
869
+ // (e.g. summary step failed after consolidation), so the partial write is
870
+ // replayable too.
871
+ const finish = (result) => {
872
+ // status is derived from what actually committed: ok only when the full
873
+ // decision list landed (or a summary was refreshed); noop when nothing
874
+ // changed; degraded when real changes landed without a summary;
875
+ // reconcile when decisions were validated but some did not commit (CAS
876
+ // conflict / rollback); failed on any LLM/validation error. No fake "ok"
877
+ // for an empty or partial run.
878
+ const status = result.status ?? (result.ok ? "ok" : "failed");
879
+ const applied = result.applied ?? 0;
880
+ const summaryStored = result.summary ?? false;
881
+ const receipt = buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.size, applied, summaryStored });
882
+ try {
883
+ service.saveDreamRun({
884
+ id: runId,
885
+ status,
886
+ error: result.error,
887
+ provider: route?.provider,
888
+ model: route?.model,
889
+ snapshot_hash: snapshotHash,
890
+ input_count: snapshot.size,
891
+ // 裁决规则版本号:config.policyEpoch(默认 0)。规则升级后该行保留
892
+ // 当时的 epoch,旧裁决据此降级为历史证据(store 层 getLatestPolicyEpoch
893
+ // 只负责读取当前生效版本,写入由这里完成)。
894
+ policy_epoch: config.policyEpoch ?? 0,
895
+ // Full input snapshot (canonical fields) so the exact arbitration
896
+ // input can be rebuilt offline from the audit row alone — the
897
+ // digest + decisions + outcome triple makes silent errors locatable
898
+ // even after the store has moved on.
899
+ input: [...snapshot.values()].map((m) => ({
900
+ id: m.id,
901
+ type: m.type,
902
+ title: m.title,
903
+ content: m.content,
904
+ importance: m.importance,
905
+ updated_at: m.updated_at
906
+ })),
907
+ decisions: result.decisions,
908
+ outcome: result.outcome,
909
+ applied,
910
+ summary_stored: summaryStored,
911
+ receipt,
912
+ // Issue #104:degraded(合法子集已应用)轮被跳过的决策明细。写成独立字段
913
+ // 而非内联进 decisions——degraded 的 decisions 是合法子集,下游按「决策
914
+ // 数组」消费,内联标记会污染其它读者(失败轮的 _validationFailed 是整单
915
+ // 拒绝哨兵,语义与「部分应用」不同,也不复用)。
916
+ skipped: result.skipped
917
+ });
918
+ } catch (error) {
919
+ logger?.warn?.(`dsh-mneme dream: failed to record audit run: ${String(error)}`);
920
+ }
921
+ return { ...result, runId, receipt, snapshotHash };
922
+ };
923
+ if (!route) {
924
+ logger?.warn?.("dsh-mneme dream: no llm route available");
925
+ return finish({ ok: false, error: "no llm route", summary: false });
926
+ }
927
+
928
+ let listText;
929
+ if (semantic?.embedder && semantic?.vectorIndex) {
930
+ try {
931
+ const vectors = await collectVectors(memories, semantic);
932
+ if (vectors) {
933
+ const k = Math.min(10, Math.max(1, Math.floor(Math.sqrt(memories.length / 2))));
934
+ const clusters = clusterMemories(memories, vectors, k);
935
+ const conflicts = findPotentialConflicts(memories, vectors, 0.85);
936
+ const conflictIds = new Set(conflicts.flatMap((c) => [c.a.id, c.b.id]));
937
+ const parts = [];
938
+ clusters.forEach((cluster, ci) => {
939
+ parts.push(STR.clusterHeader[language](ci + 1));
940
+ for (const m of cluster) {
941
+ parts.push(
942
+ `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}` +
943
+ (conflictIds.has(m.id) ? STR.conflictMark[language] : "")
944
+ );
945
+ }
946
+ });
947
+ listText = parts.join("\n");
948
+ logger?.info?.(`[dsh-mneme] dream semantic pre-group: ${clusters.length} clusters, ${conflicts.length} conflict pairs`);
949
+ }
950
+ } catch (error) {
951
+ logger?.warn?.(`[dsh-mneme] dream semantic pre-group failed: ${String(error)}`);
952
+ }
953
+ }
954
+ if (!listText) {
955
+ listText = [...snapshot.values()].map((m) =>
956
+ `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`
957
+ ).join("\n");
958
+ }
959
+
960
+ // Freeze-aware prompt: in freeze mode the conflict branch still outputs
961
+ // winner/loser (validation requires them) but they are treated as tentative
962
+ // candidates — the human makes the final call, not the model.
963
+ const consolidationPrompt = freezeEnabled
964
+ ? STR.prompts.consolidation[language] + STR.prompts.freezeSuffix[language]
965
+ : STR.prompts.consolidation[language];
966
+ let decisionText;
967
+ // 加固(v0.8.1):配置的 reasoningEffort 被 provider 拒收时回退重试一次
968
+ // (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
969
+ // 在 auditError 检查器里、闭包交回主流程,避免二次解析;解析失败同时如实
970
+ // 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
971
+ const effort = await resolveDreamEffort(ctx, route, config.dreamReasoningEffort, logger);
972
+ let decisions = null;
973
+ let streamFailure = "";
974
+ const runConsolidation = (withEffort) => {
975
+ streamFailure = "";
976
+ return runAuditedLlm(ctx, service, config, {
977
+ triggerSource: "autoDream",
978
+ operationType: "dream_consolidate",
979
+ modelId: `${route.provider}:${route.model}`,
980
+ relatedMemoryIds: [...snapshot.keys()],
981
+ streamError: () => streamFailure,
982
+ auditError: (text) => {
983
+ decisions = extractJsonArray(text);
984
+ return Array.isArray(decisions) ? null : "no json array in llm output";
985
+ }
986
+ }, (reportUsage) => streamText(ctx, {
987
+ provider: route.provider,
988
+ model: route.model,
989
+ purpose: "compaction",
990
+ maxTokens: config.dreamMaxTokens ?? 4096,
991
+ ...(withEffort && effort ? { reasoningEffort: effort } : {}),
992
+ messages: [
993
+ { role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
994
+ { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
995
+ ]
996
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
997
+ };
998
+ try {
999
+ // Bug8: the consolidation call is audited (tokens/time/status). A throw
1000
+ // re-propagates to the catch below; an aborted stream returns undefined
1001
+ // and is treated as a failed run after the check below.
1002
+ decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false), () => streamFailure);
1003
+ } catch (error) {
1004
+ logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
1005
+ return finish({ ok: false, error: "llm failed", summary: false });
1006
+ }
1007
+ if (decisionText === undefined) {
1008
+ logger?.warn?.(`dsh-mneme dream: consolidation llm stream aborted or errored${streamFailure ? ` (${streamFailure})` : ""}`);
1009
+ return finish({ ok: false, error: "llm failed", summary: false });
1010
+ }
1011
+ if (!Array.isArray(decisions)) {
1012
+ const head = (decisionText ?? "").slice(0, 300).replace(/\s+/g, " ").trim();
1013
+ logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
1014
+ return finish({ ok: false, error: "no json array in llm output", summary: false });
1015
+ }
1016
+ const { ok, errors, skipped, resolvedShortIds, coverageShortfall } = validateDecisions(decisions, snapshot, {
1017
+ maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
1018
+ // Issue #104 方向 2:archive 批量上限(全局闸门,超限整单拒绝)。
1019
+ maxArchivePerRun: config.dreamMaxArchivePerRun,
1020
+ minAgeHours: config.reflectionUpdateMinAgeHours,
1021
+ // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
1022
+ // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
1023
+ dreamImplicitKeep: config.dreamImplicitKeep,
1024
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage,
1025
+ // Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条
1026
+ // 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。
1027
+ skipInvalid: config.dreamSkipInvalid !== false,
1028
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true
1029
+ });
1030
+ // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。
1031
+ // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info
1032
+ // 便于事后从日志侧观察该行为的分布。
1033
+ if (resolvedShortIds > 0) {
1034
+ logger?.info?.(`[dsh-mneme] dream: resolved ${resolvedShortIds} short id prefix(es) to full ids`);
1035
+ }
1036
+ if (!ok) {
1037
+ // Issue #135(观测缺口):失败轮的逐条校验明细此前既不落库也基本不可见——
1038
+ // skipped 的 warn 写在 return 之后(失败路径到不了),dream_runs.decisions
1039
+ // 落 NULL,上百轮失败零现场。这里把明细前置到日志,并把
1040
+ // { _validationFailed, errors, skipped } 随失败行持久化,事后可从审计行
1041
+ // 直接定位「模型输出了什么、为何逐条非法」。
1042
+ logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
1043
+ if (skipped.length > 0) {
1044
+ logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped: ${skipped.map((s) => `decision[${s.index}]: ${s.error}`).join("; ")}`);
1045
+ }
1046
+ return finish({
1047
+ ok: false,
1048
+ error: `invalid decisions: ${errors.length} errors`,
1049
+ summary: false,
1050
+ decisions: [{ _validationFailed: true, errors, skipped }]
1051
+ });
1052
+ }
1053
+ const skippedInvalid = skipped.length > 0;
1054
+ if (skippedInvalid) {
1055
+ logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped (run degrades): ${skipped.map((s) => s.error).join("; ")}`);
1056
+ }
1057
+ // Issue #104 方向 1:覆盖率不足降级为 degraded(不再整单拒绝)——合法子集
1058
+ // 照常应用、未提及条目隐式 keep,理由随 outcome.degradations 落库供离线定位。
1059
+ const coverageDegraded = typeof coverageShortfall === "string" && coverageShortfall.length > 0;
1060
+ if (coverageDegraded) {
1061
+ logger?.warn?.(`dsh-mneme dream: ${coverageShortfall} (run degrades: valid subset applied, unclaimed ids kept implicitly)`);
1062
+ }
1063
+
1064
+ // Capture pre-update snapshots so the audit records what each update changed.
1065
+ const updateSnapshots = {};
1066
+ for (const d of decisions) {
1067
+ if (d.action === "update") {
1068
+ const mem = snapshot.get(d.ids[0]);
1069
+ if (mem) updateSnapshots[d.ids[0]] = { title: mem.title, content: mem.content, importance: mem.importance };
1070
+ }
1071
+ }
1072
+
1073
+ // Conflict freeze (opt-in): when enabled, conflict decisions are not
1074
+ // auto-adjudicated — no winner kept, no loser archived. The pair is parked
1075
+ // in conflict_pending for human review instead. Best-effort: a store
1076
+ // failure here must never block the run (fail-safe — the memories are left
1077
+ // untouched and nothing is arbitrated). The cap (conflictFreezeMaxPending)
1078
+ // bounds the review queue; overflow is skipped with a warning.
1079
+ let frozenCount = 0;
1080
+ const frozenIds = [];
1081
+ // v0.8.1(issue #170 复核项 3):跨 scope 的 conflict 决策两种模式都一律
1082
+ // 停车人工裁决、绝不自动归档——否则普通 dream 的 LLM 能归档别的 scope 的
1083
+ // 唯一副本,sleep 路径(scope-candidate-queue)的保护在这里缺位。同 scope
1084
+ // 的 conflict 决策照旧:freeze 停车 / 非 freeze 自动裁决。
1085
+ const isCrossScopeDecision = (d) => {
1086
+ const a = snapshot.get(d.winner);
1087
+ const b = snapshot.get(d.loser);
1088
+ return !!a && !!b && (
1089
+ scopeKeyOf(a.agent_scope) !== scopeKeyOf(b.agent_scope)
1090
+ || scopeKeyOf(a.workspace_scope) !== scopeKeyOf(b.workspace_scope)
1091
+ );
1092
+ };
1093
+ const applyList = freezeEnabled
1094
+ ? decisions.filter((d) => d.action !== "conflict")
1095
+ : decisions.filter((d) => d.action !== "conflict" || !isCrossScopeDecision(d));
1096
+ if (freezeEnabled) {
1097
+ const conflictsToFreeze = decisions.filter((d) => d.action === "conflict");
1098
+ if (conflictsToFreeze.length > 0) {
1099
+ try {
1100
+ const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
1101
+ const pendingNow = service.countConflictPending();
1102
+ const budget = Math.max(0, maxPending - pendingNow);
1103
+ const toFreeze = conflictsToFreeze.slice(0, budget);
1104
+ if (conflictsToFreeze.length > budget) {
1105
+ logger?.warn?.(`dsh-mneme dream: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${conflictsToFreeze.length - budget} conflict(s)`);
1106
+ }
1107
+ for (const d of toFreeze) {
1108
+ try {
1109
+ // 返回 undefined = 该对已被人工裁决且两侧内容未变(issue #170 复核
1110
+ // 项 4:内容变了才重新入队)——不算停车、不进 frozenIds。
1111
+ const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
1112
+ if (parked == null) continue;
1113
+ frozenCount++;
1114
+ frozenIds.push(d.winner, d.loser);
1115
+ } catch (error) {
1116
+ logger?.warn?.(`dsh-mneme dream: failed to freeze conflict ${d.winner}/${d.loser}: ${String(error)}`);
1117
+ }
1118
+ }
1119
+ } catch (error) {
1120
+ logger?.warn?.(`dsh-mneme dream: conflict freeze lookup failed: ${String(error)}`);
1121
+ }
1122
+ }
1123
+ } else {
1124
+ // 非 freeze:跨 scope conflict 停车(绝不进 applyDecisions)。带专属
1125
+ // reason + 模型给出的裁决理由,队列里读起来是「归属裁决」而非重复项。
1126
+ const crossScope = decisions.filter((d) => d.action === "conflict" && isCrossScopeDecision(d));
1127
+ for (const d of crossScope) {
1128
+ try {
1129
+ const parked = service.saveConflictPending({
1130
+ run_id: runId,
1131
+ memory_a: d.winner,
1132
+ memory_b: d.loser,
1133
+ reason: STR.scopeConflictParkReason[language](d.reason)
1134
+ });
1135
+ if (parked != null) {
1136
+ frozenCount++;
1137
+ frozenIds.push(d.winner, d.loser);
1138
+ }
1139
+ } catch (error) {
1140
+ logger?.warn?.(`dsh-mneme dream: failed to park cross-scope conflict ${d.winner}/${d.loser}: ${String(error)}`);
1141
+ }
1142
+ }
1143
+ if (crossScope.length > 0) {
1144
+ logger?.info?.(`[dsh-mneme] dream: parked ${crossScope.length} cross-scope conflict(s) for human ownership review`);
1145
+ }
1146
+ }
1147
+
1148
+ // CAS-guarded, per-decision-transactional apply against the run snapshot:
1149
+ // a target changed during the LLM call is skipped and reported as a
1150
+ // conflict instead of being overwritten (item ①). Frozen conflicts are
1151
+ // excluded from this list (they are parked, not applied).
1152
+ const { applied, conflicts, failures, committed } = applyDecisions(applyList, service, logger, snapshot, config);
1153
+ // Per-record receipt chain: one row per actually-committed merge/conflict/
1154
+ // update verdict, stamped with the decision-basis digest + idempotency
1155
+ // counters (count_before → count_after). Written here, before the run audit
1156
+ // row, so the verdict trail always precedes the run trail it belongs to.
1157
+ // Bookkeeping: a write failure is logged and swallowed — it must never
1158
+ // block the consolidation flow.
1159
+ try {
1160
+ for (const r of buildRecordReceipts({ runId, committed, snapshot, policyEpoch: config.policyEpoch ?? 0 })) {
1161
+ service.saveReceipt(r);
1162
+ }
1163
+ } catch (error) {
1164
+ logger?.warn?.(`dsh-mneme dream: failed to write per-record receipt: ${String(error)}`);
1165
+ }
1166
+ // Attach the pre-update snapshot to the audit copy of each update decision
1167
+ // so the recorded row shows the before/after delta, not just the target.
1168
+ const auditDecisions = decisions.map((d) =>
1169
+ d.action === "update" && updateSnapshots[d.ids[0]]
1170
+ ? { ...d, _before: updateSnapshots[d.ids[0]] }
1171
+ : d
1172
+ );
1173
+ // Outcome is derived from the ACTUALLY committed sub-steps, never from the
1174
+ // raw LLM decision list — a merge whose archive step rolled back must not
1175
+ // claim "merge-archived" (item ②). Conflicts/failures ride along so the
1176
+ // audit row records why the run diverged.
1177
+ const outcome = { ...buildOutcome(committed), conflicts, failures };
1178
+ if (coverageDegraded) outcome.degradations = [coverageShortfall];
1179
+ // Frozen conflicts were not adjudicated: mark both sides pending in the
1180
+ // per-id outcome so the audit row shows they were parked, not skipped.
1181
+ if (frozenIds.length) {
1182
+ for (const id of frozenIds) outcome.byId[id] = "conflict-pending";
1183
+ }
1184
+ // Decisions validated but not fully committed → reconcile (not ok).
1185
+ const partial = conflicts.length > 0 || failures.length > 0;
1186
+ // No decision landed (all-keep, or every decision skipped as an idempotent
1187
+ // replay) → nothing substantive changed. Distinct from a success: such a
1188
+ // run must never be reported as ok, or the audit claims work that never
1189
+ // happened and the scheduler refreshes the baseline on a false positive.
1190
+ // Frozen conflicts are substantive output (parked for review), so a run
1191
+ // that only froze conflicts is not a noop.
1192
+ const noChange = frozenCount === 0 && applied === 0 && committed.every((c) => c.action === "keep");
1193
+
1194
+ // Keep the vector index consistent with the post-dream store state.
1195
+ if (semantic?.embedder && semantic?.vectorIndex) {
1196
+ try {
1197
+ await maintainIndexAfterDream(applyList, service, semantic);
1198
+ } catch (error) {
1199
+ logger?.warn?.(`[dsh-mneme] dream index maintenance failed: ${String(error)}`);
1200
+ }
1201
+ }
1202
+
1203
+ // Summary generation (second LLM call). A throwing stream is reported as
1204
+ // a failed run; summary:false marks a run that produced no summary.
1205
+ let summaryText;
1206
+ let summaryStreamFailure = "";
1207
+ const runSummary = (withEffort) => {
1208
+ summaryStreamFailure = "";
1209
+ return runAuditedLlm(ctx, service, config, {
1210
+ triggerSource: "autoDream",
1211
+ operationType: "dream_summarize",
1212
+ modelId: `${route.provider}:${route.model}`,
1213
+ relatedMemoryIds: [],
1214
+ streamError: () => summaryStreamFailure
1215
+ }, (reportUsage) => streamText(ctx, {
1216
+ provider: route.provider,
1217
+ model: route.model,
1218
+ purpose: "compaction",
1219
+ maxTokens: config.dreamMaxTokens ?? 2048,
1220
+ ...(withEffort && effort ? { reasoningEffort: effort } : {}),
1221
+ messages: [
1222
+ { role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
1223
+ { role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin", plugin: "dsh-mneme" } }
1224
+ ]
1225
+ }, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
1226
+ };
1227
+ // Resident status bar (#164 alignment): the overview is THE one resident
1228
+ // narrative — summary tier 0, refreshed every run via _overwrite (supersede
1229
+ // v1, content_history traceable). Its 口径 (what snapshot produced it) is
1230
+ // stamped into the content so the standing answer is always auditable; the
1231
+ // formal evidence column lands with the narrative-bars batch.
1232
+ const summaryInputs = service.all().filter((m) => !m.archived && m.type !== "summary");
1233
+ const summaryScope = STR.summaryScope[language](
1234
+ summaryInputs.length,
1235
+ runId.slice(0, 8),
1236
+ new Date().toISOString().slice(0, 10)
1237
+ );
1238
+ try {
1239
+ // Bug8: the summary call is audited too (operation dream_summarize).
1240
+ summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false), () => summaryStreamFailure);
1241
+ } catch (error) {
1242
+ logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
1243
+ return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, frozen: frozenCount, summary: false });
1244
+ }
1245
+ let summaryStored = false;
1246
+ if (summaryText !== undefined && summaryText.trim()) {
1247
+ // Bug5 carve-out: the library overview is regenerated every run, so it
1248
+ // must REPLACE the previous overview (not append — that would grow the
1249
+ // summary unboundedly). `_overwrite` still archives the old overview into
1250
+ // content_history before replacing it. Content carries the snapshot-scope
1251
+ // footer computed above — the resident bar states its own 口径.
1252
+ service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: `${summaryText.trim()}${summaryScope}`, importance: 5, source: "dream", _overwrite: true });
1253
+ summaryStored = true;
1254
+ // Re-embed the fresh summary so the index stays in sync with the store.
1255
+ if (semantic?.embedder && semantic?.vectorIndex) {
1256
+ try {
1257
+ // source="dream" 限定:叙述条(source=narrative)同为 summary 类型,
1258
+ // 不加限定时 find 可能截胡,把总览的 re-embed 花在叙述条上。
1259
+ const summary = service.all().find((m) => m.type === "summary" && m.source === "dream");
1260
+ if (summary) await reEmbedMemory(semantic, summary, logger);
1261
+ } catch { /* best-effort */ }
1262
+ }
1263
+ }
1264
+ // Narrative bars (#164 alignment, opt-in dreamNarrativeEnabled): per-topic
1265
+ // on-demand narratives after consolidation + summary. Additive phase —
1266
+ // failures degrade to zero narratives, never break the run.
1267
+ let narrativesStored = 0;
1268
+ if (config.dreamNarrativeEnabled === true) {
1269
+ try {
1270
+ narrativesStored = await generateNarratives({ ctx, service, config, route, language, effort, semantic, logger });
1271
+ } catch (error) {
1272
+ logger?.warn?.(`dsh-mneme dream: narrative phase failed: ${String(error)}`);
1273
+ }
1274
+ }
1275
+
1276
+ // Honest status assignment (never a fake ok):
1277
+ // reconcile — some decisions validated but did not commit (CAS/rollback).
1278
+ // noop — nothing changed and no summary persisted: truly an empty
1279
+ // run. ok:false keeps the scheduler from moving the baseline.
1280
+ // ok — either real changes landed, or a fresh summary was stored
1281
+ // (all-keep + summary is a substantive summary refresh).
1282
+ // degraded — real consolidation landed but the run did not produce its
1283
+ // full output: the summary came back empty/missing, or
1284
+ // skipInvalid dropped individually-invalid decisions
1285
+ // (Issue #89 — marked, not faked), or the explicit-decision
1286
+ // coverage fell below dreamMinExplicitCoverage (Issue #104
1287
+ // 方向 1 — degrade, don't reject). The valid subset was
1288
+ // absorbed (ok for the baseline).
1289
+ let status;
1290
+ let okResult;
1291
+ if (partial) {
1292
+ status = "reconcile";
1293
+ okResult = false;
1294
+ } else if (noChange) {
1295
+ status = summaryStored ? "ok" : "noop";
1296
+ okResult = summaryStored;
1297
+ } else {
1298
+ status = summaryStored && !skippedInvalid && !coverageDegraded ? "ok" : "degraded";
1299
+ okResult = true;
1300
+ }
1301
+ return finish({
1302
+ ok: okResult,
1303
+ status,
1304
+ applied,
1305
+ decisions: auditDecisions,
1306
+ outcome,
1307
+ conflicts,
1308
+ failures,
1309
+ frozen: frozenCount,
1310
+ summary: summaryStored,
1311
+ narratives: narrativesStored,
1312
+ // Issue #104:只有真发生跳过时才落库(degraded 也可能仅因 summary 为空),
1313
+ // ok 轮留 NULL,避免「空数组」与「无此字段」两种假明细占据审计行。
1314
+ skipped: skippedInvalid ? skipped : undefined
1315
+ });
1316
+ }
1317
+
1318
+ return { maybeSchedule, runDream, dispose };
1319
+ }