@modusensus/dsh-mneme 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dream.js CHANGED
@@ -1,209 +1,467 @@
1
- import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
- export { validateDecisions, applyDecisions };
3
-
4
- const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
5
-
6
- const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
7
- 请执行记忆巩固(consolidation):
8
- 1. 识别主题相近的条目 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
9
- 2. 识别重复/过时信息 → 输出 archive
10
- 3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser
11
- 4. 无问题的条目 → 输出 keep
12
-
13
- 规则:
14
- - 每条记忆至少出现在一个决策中
15
- - merge keepSource 必须是 ids 之一
16
- - 仅合并同类型条目(type 相同)
17
- - 不要编造 ids;只使用提供的 id
18
- - 重要性 1-5,合并后取最高
19
- - 只输出 JSON 数组,不要其他文字`;
20
-
21
- function totalChars(memories) {
22
- return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
23
- }
24
-
25
- /**
26
- * Consume an LLM stream and return the accumulated text. Direct text-delta
27
- * accumulation covers both the real protocol ({type:"text-delta", index, text})
28
- * and looser test doubles ({type:"text-delta", text}); a terminal error/abort
29
- * surfaces as undefined. The caller decides how to treat an empty result.
30
- */
31
- async function streamText(ctx, options) {
32
- let text = "";
33
- for await (const chunk of ctx.llm.stream(options)) {
34
- if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
35
- if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
36
- return undefined;
37
- }
38
- }
39
- return text;
40
- }
41
-
42
- /**
43
- * Resolve the LLM route: agent default model (deployment) first, plugin config
44
- * (dreamProvider/dreamModel) as fallback. Falls through to undefined when no
45
- * route exists runDream then fails safe. Fallback is logged so a silent
46
- * route switch is observable.
47
- */
48
- function resolveRoute(ctx, config, logger) {
49
- try {
50
- const sel = ctx.agentDefaultModel?.currentSelection?.();
51
- if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
52
- logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, falling back to config route");
53
- } catch (error) {
54
- logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed, falling back to config route: ${String(error)}`);
55
- }
56
- if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
57
- return undefined;
58
- }
59
-
60
- export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, logger }) {
61
- let pendingTimer = null;
62
- let running = false;
63
- let disposed = false;
64
- let baseline = { count: 0, chars: 0 };
65
- let inFlight = null;
66
-
67
- function shouldTrigger(service) {
68
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
69
- const count = memories.length;
70
- const chars = totalChars(memories);
71
- const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
72
- const overAbs = count >= thresholdCount || chars >= thresholdChars;
73
- return { trigger: overAbs && overBase, count, chars };
74
- }
75
-
76
- function maybeSchedule(service) {
77
- if (disposed || running || pendingTimer) return false;
78
- const { trigger, count, chars } = shouldTrigger(service);
79
- if (!trigger) return false;
80
- pendingTimer = setTimeout(() => {
81
- pendingTimer = null;
82
- running = true;
83
- // Defer the onRun invocation so a synchronous throw cannot escape the
84
- // timer callback (which would crash the process) and skip the teardown.
85
- // Errors are logged, never swallowed silently. inFlight lets dispose()
86
- // await the running consolidation before the caller closes the store.
87
- inFlight = Promise.resolve()
88
- .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
89
- .then((result) => {
90
- // Refresh the baseline only for a successful run (design §5.3: an
91
- // LLM failure must not move the baseline, so the next write can
92
- // immediately re-trigger a retry). A `{ok:false}` result or a throw
93
- // keeps the old baseline. A run that reports nothing is treated as
94
- // completed without failure (no-op hooks / minimal test doubles).
95
- if (result && result.ok) {
96
- try {
97
- baseline = shouldTrigger(service);
98
- } catch (error) {
99
- // Store closed mid-flight: keep the last known baseline.
100
- logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
101
- }
102
- }
103
- })
104
- .catch((error) => {
105
- logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
106
- // Failed runs do not refresh the baseline.
107
- })
108
- .finally(() => {
109
- running = false;
110
- inFlight = null;
111
- });
112
- }, delayMs);
113
- return true;
114
- }
115
-
116
- async function dispose() {
117
- disposed = true;
118
- if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
119
- // An in-flight run is left to complete naturally (its LLM calls are
120
- // already paid for and aborting would discard the work). Await it so the
121
- // caller can close the store only after every write has landed.
122
- if (inFlight) await inFlight.catch(() => {});
123
- }
124
-
125
- async function runDream(ctx, service, config) {
126
- const logger = ctx.logger;
127
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
128
- if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
129
- const snapshot = new Map(memories.map((m) => [m.id, m]));
130
- const route = resolveRoute(ctx, config, logger);
131
- if (!route) {
132
- logger?.warn?.("dsh-mneme dream: no llm route available");
133
- return { ok: false, error: "no llm route", summary: false };
134
- }
135
-
136
- const listText = [...snapshot.values()].map((m) =>
137
- `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`
138
- ).join("\n");
139
-
140
- let decisionText;
141
- try {
142
- decisionText = await streamText(ctx, {
143
- provider: route.provider,
144
- model: route.model,
145
- purpose: "compaction",
146
- maxTokens: config.dreamMaxTokens ?? 4096,
147
- messages: [
148
- { role: "system", content: [{ type: "text", text: CONSOLIDATION_PROMPT }] },
149
- { role: "user", content: [{ type: "text", text: listText }] }
150
- ]
151
- });
152
- } catch (error) {
153
- logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
154
- return { ok: false, error: "llm failed", summary: false };
155
- }
156
- if (decisionText === undefined) {
157
- logger?.warn?.("dsh-mneme dream: consolidation llm stream aborted or errored");
158
- return { ok: false, error: "llm failed", summary: false };
159
- }
160
-
161
- let decisions;
162
- try {
163
- const start = decisionText.indexOf("[");
164
- const end = decisionText.lastIndexOf("]");
165
- if (start === -1 || end <= start) {
166
- logger?.warn?.("dsh-mneme dream: no json array in llm output");
167
- return { ok: false, error: "no json array in llm output", summary: false };
168
- }
169
- decisions = JSON.parse(decisionText.slice(start, end + 1));
170
- } catch {
171
- logger?.warn?.("dsh-mneme dream: invalid decisions json");
172
- return { ok: false, error: "invalid decisions json", summary: false };
173
- }
174
- const { ok, errors } = validateDecisions(decisions, snapshot);
175
- if (!ok) {
176
- logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
177
- return { ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false };
178
- }
179
-
180
- const applied = applyDecisions(decisions, service, logger);
181
-
182
- // Summary generation (second LLM call). A throwing stream is reported as
183
- // a failed run; summary:false marks a run that produced no summary.
184
- let summaryText;
185
- try {
186
- summaryText = await streamText(ctx, {
187
- provider: route.provider,
188
- model: route.model,
189
- purpose: "compaction",
190
- maxTokens: config.dreamMaxTokens ?? 2048,
191
- messages: [
192
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
193
- { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
194
- ]
195
- });
196
- } catch (error) {
197
- logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
198
- return { ok: false, error: "llm failed", summary: false };
199
- }
200
- let summaryStored = false;
201
- if (summaryText !== undefined && summaryText.trim()) {
202
- service.saveWithDedupe({ type: "summary", title: "记忆库总览", content: summaryText.trim(), importance: 5, source: "dream" });
203
- summaryStored = true;
204
- }
205
- return { ok: true, applied, summary: summaryStored };
206
- }
207
-
208
- return { maybeSchedule, runDream, dispose };
209
- }
1
+ import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
+ import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ export { validateDecisions, applyDecisions };
5
+
6
+ const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
7
+
8
+ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
9
+ 请执行记忆巩固(consolidation):
10
+ 1. 识别主题相近的条目 → 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource
11
+ 2. 识别重复/过时信息 → 输出 archive
12
+ 3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser)
13
+ 4. 发现单条记忆中的信息已过时、错误或遗漏 → 输出 update(直接修正内容)
14
+ - update 的 ids 只能包含一个 id
15
+ - 必须提供修正后的 title 和/或 content
16
+ - 仅当内容确实需要修正时才使用,不要滥用
17
+ - 每次整理最多输出 2 个 update
18
+ - 24 小时内新建的记忆不可 update
19
+ 5. 无问题的条目 输出 keep
20
+
21
+ 规则:
22
+ - 每条记忆至少出现在一个决策中
23
+ - merge 的 keepSource 必须是 ids 之一
24
+ - 仅合并同类型条目(type 相同)
25
+ - 不要编造 ids;只使用提供的 id
26
+ - 重要性 1-5,合并后取最高
27
+ - update 只能改一条,且要有实际变化
28
+ - 只输出 JSON 数组,不要其他文字`;
29
+
30
+ function totalChars(memories) {
31
+ return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
32
+ }
33
+
34
+ // ---------------------------------------------------------------- audit
35
+
36
+ /**
37
+ * Canonical digest of the consolidation input snapshot. Built from stable
38
+ * fields sorted by id, so identical inputs always yield the same hash — the
39
+ * basis for replaying/verifying a recorded decision (receipt check).
40
+ */
41
+ export function hashSnapshot(memories) {
42
+ const canon = memories
43
+ .map((m) => [m.id, m.type, m.title, m.content, m.importance, m.updated_at])
44
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
45
+ .map((parts) => parts.map((p) => String(p ?? "")).join("\u0001"))
46
+ .join("\u0002");
47
+ return createHash("sha256").update(canon).digest("hex");
48
+ }
49
+
50
+ /**
51
+ * Compact machine-verifiable receipt for one autoDream run. Format:
52
+ * dsh-mneme:run:<runId>:<status>:<snapshotHash(12)>:<inputCount>:<applied>:<summaryFlag>
53
+ * Enough to correlate a run with its persisted audit row and to spot silent
54
+ * drift (same snapshot hash + same decisions must reproduce the same outcome).
55
+ */
56
+ export function buildReceipt({ runId, status, snapshotHash, inputCount, applied, summaryStored }) {
57
+ return `dsh-mneme:run:${runId}:${status}:${snapshotHash.slice(0, 12)}:${inputCount}:${applied}:${summaryStored ? 1 : 0}`;
58
+ }
59
+
60
+ /**
61
+ * Parse a receipt back into fields; returns undefined for malformed input.
62
+ */
63
+ export function parseReceipt(receipt) {
64
+ if (typeof receipt !== "string") return undefined;
65
+ const parts = receipt.split(":");
66
+ if (parts.length !== 8 || parts[0] !== "dsh-mneme" || parts[1] !== "run") return undefined;
67
+ const [, , runId, status, snapshotHash, inputCount, applied, summaryStored] = parts;
68
+ if (!runId || !/^(ok|failed)$/.test(status)) return undefined;
69
+ const count = Number(inputCount);
70
+ const appliedN = Number(applied);
71
+ if (!Number.isInteger(count) || !Number.isInteger(appliedN)) return undefined;
72
+ return { runId, status, snapshotHash, inputCount: count, applied: appliedN, summaryStored: summaryStored === "1" };
73
+ }
74
+
75
+ /**
76
+ * Derive the per-id disposition (keep / merge-keep / merge-archived /
77
+ * archived / conflict-winner / conflict-archived) from a validated decision
78
+ * list. Stored in the audit row so a run can be replayed without re-running
79
+ * the LLM.
80
+ */
81
+ export function buildOutcome(decisions) {
82
+ const byId = {};
83
+ for (const d of decisions ?? []) {
84
+ if (d.action === "keep") {
85
+ for (const id of d.ids) byId[id] = "keep";
86
+ } else if (d.action === "archive") {
87
+ for (const id of d.ids) byId[id] = "archived";
88
+ } else if (d.action === "merge") {
89
+ for (const id of d.ids) byId[id] = id === d.keepSource ? "merge-keep" : "merge-archived";
90
+ } else if (d.action === "conflict") {
91
+ byId[d.winner] = "conflict-winner";
92
+ byId[d.loser] = "conflict-archived";
93
+ } else if (d.action === "update") {
94
+ for (const id of d.ids) byId[id] = "updated";
95
+ }
96
+ }
97
+ return { byId };
98
+ }
99
+
100
+ /**
101
+ * Consume an LLM stream and return the accumulated text. Direct text-delta
102
+ * accumulation covers both the real protocol ({type:"text-delta", index, text})
103
+ * and looser test doubles ({type:"text-delta", text}); a terminal error/abort
104
+ * surfaces as undefined. The caller decides how to treat an empty result.
105
+ */
106
+ async function streamText(ctx, options) {
107
+ let text = "";
108
+ for await (const chunk of ctx.llm.stream(options)) {
109
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
110
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
111
+ return undefined;
112
+ }
113
+ }
114
+ return text;
115
+ }
116
+
117
+ /**
118
+ * Resolve the LLM route: agent default model (deployment) first, plugin config
119
+ * (dreamProvider/dreamModel) as fallback. Falls through to undefined when no
120
+ * route exists runDream then fails safe. Fallback is logged so a silent
121
+ * route switch is observable.
122
+ */
123
+ function resolveRoute(ctx, config, logger) {
124
+ try {
125
+ const sel = ctx.agentDefaultModel?.currentSelection?.();
126
+ if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
127
+ logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, falling back to config route");
128
+ } catch (error) {
129
+ logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed, falling back to config route: ${String(error)}`);
130
+ }
131
+ if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
132
+ return undefined;
133
+ }
134
+
135
+ // ------------------------------------------------------- semantic enhancement
136
+ // Best-effort: any failure here degrades to plain consolidation. The dream
137
+ // path must never be broken by an unavailable embedder/index.
138
+
139
+ /** Backfill + return vectors for every memory; null when impossible. */
140
+ async function collectVectors(memories, semantic) {
141
+ const { embedder, vectorIndex } = semantic;
142
+ if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return null;
143
+ const vectors = new Array(memories.length);
144
+ const missing = [];
145
+ for (let i = 0; i < memories.length; i++) {
146
+ const cached = vectorIndex.getEmbedding?.(memories[i].id);
147
+ if (cached) vectors[i] = cached;
148
+ else missing.push(i);
149
+ }
150
+ if (missing.length) {
151
+ const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
152
+ const rows = await embedder.embed(texts);
153
+ missing.forEach((mi, j) => {
154
+ if (rows[j]?.length) {
155
+ vectors[mi] = rows[j];
156
+ vectorIndex.saveEmbedding(memories[mi].id, rows[j]);
157
+ }
158
+ });
159
+ }
160
+ return vectors.some((v) => !v) ? null : vectors;
161
+ }
162
+
163
+ /**
164
+ * Rebuild the vector index after dream decisions so the store and the index
165
+ * stay in sync: merged-away/archived/conflict-loser rows lose their vectors,
166
+ * the merge keeper gets a fresh one.
167
+ */
168
+ async function maintainIndexAfterDream(decisions, service, semantic) {
169
+ const { embedder, vectorIndex } = semantic;
170
+ if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return;
171
+ const rebuild = new Map();
172
+ for (const d of decisions ?? []) {
173
+ if (d.action === "merge") {
174
+ for (const id of d.ids ?? []) {
175
+ if (id !== d.keepSource) vectorIndex.deleteEmbedding(id);
176
+ }
177
+ if (d.keepSource) {
178
+ const keeper = service.getById(d.keepSource);
179
+ if (keeper) rebuild.set(keeper.id, [keeper.title, keeper.content].filter(Boolean).join("\n"));
180
+ }
181
+ } else if (d.action === "archive" || d.action === "conflict") {
182
+ for (const id of d.ids ?? [d.loser]) vectorIndex.deleteEmbedding(id);
183
+ } else if (d.action === "update") {
184
+ const id = d.ids[0];
185
+ const mem = service.getById(id);
186
+ if (mem) {
187
+ vectorIndex.deleteEmbedding(id);
188
+ try {
189
+ const text = [mem.title, mem.content].filter(Boolean).join("\n");
190
+ const v = await embedder.embedSingle(text);
191
+ if (v?.length) vectorIndex.saveEmbedding(id, v);
192
+ } catch { /* best-effort */ }
193
+ }
194
+ }
195
+ }
196
+ for (const [id, text] of rebuild) {
197
+ try {
198
+ const v = await embedder.embedSingle(text);
199
+ if (v?.length) vectorIndex.saveEmbedding(id, v);
200
+ } catch { /* best-effort */ }
201
+ }
202
+ if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
203
+ }
204
+
205
+ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, logger, semantic = null }) {
206
+ let pendingTimer = null;
207
+ let running = false;
208
+ let disposed = false;
209
+ let baseline = { count: 0, chars: 0 };
210
+ let inFlight = null;
211
+
212
+ function shouldTrigger(service) {
213
+ const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
214
+ const count = memories.length;
215
+ const chars = totalChars(memories);
216
+ const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
217
+ const overAbs = count >= thresholdCount || chars >= thresholdChars;
218
+ return { trigger: overAbs && overBase, count, chars };
219
+ }
220
+
221
+ function maybeSchedule(service) {
222
+ if (disposed || running || pendingTimer) return false;
223
+ const { trigger, count, chars } = shouldTrigger(service);
224
+ if (!trigger) return false;
225
+ pendingTimer = setTimeout(() => {
226
+ pendingTimer = null;
227
+ running = true;
228
+ // Defer the onRun invocation so a synchronous throw cannot escape the
229
+ // timer callback (which would crash the process) and skip the teardown.
230
+ // Errors are logged, never swallowed silently. inFlight lets dispose()
231
+ // await the running consolidation before the caller closes the store.
232
+ inFlight = Promise.resolve()
233
+ .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
234
+ .then((result) => {
235
+ // Refresh the baseline only for a successful run (design §5.3: an
236
+ // LLM failure must not move the baseline, so the next write can
237
+ // immediately re-trigger a retry). A `{ok:false}` result or a throw
238
+ // keeps the old baseline. A run that reports nothing is treated as
239
+ // completed without failure (no-op hooks / minimal test doubles).
240
+ if (result && result.ok) {
241
+ try {
242
+ baseline = shouldTrigger(service);
243
+ } catch (error) {
244
+ // Store closed mid-flight: keep the last known baseline.
245
+ logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
246
+ }
247
+ }
248
+ })
249
+ .catch((error) => {
250
+ logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
251
+ // Failed runs do not refresh the baseline.
252
+ })
253
+ .finally(() => {
254
+ running = false;
255
+ inFlight = null;
256
+ });
257
+ }, delayMs);
258
+ return true;
259
+ }
260
+
261
+ async function dispose() {
262
+ disposed = true;
263
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
264
+ // An in-flight run is left to complete naturally (its LLM calls are
265
+ // already paid for and aborting would discard the work). Await it so the
266
+ // caller can close the store only after every write has landed.
267
+ if (inFlight) await inFlight.catch(() => {});
268
+ }
269
+
270
+ async function runDream(ctx, service, config) {
271
+ const logger = ctx.logger;
272
+ const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
273
+ if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
274
+ const snapshot = new Map(memories.map((m) => [m.id, m]));
275
+ const route = resolveRoute(ctx, config, logger);
276
+ const runId = randomUUID();
277
+ const snapshotHash = hashSnapshot([...snapshot.values()]);
278
+ // Every exit (success or failure) funnels through `finish`, which writes
279
+ // the audit row + receipt. A record failure is logged, never thrown —
280
+ // auditing must not break the consolidation path. Failed runs still
281
+ // capture their decisions/outcome when the LLM produced a validated list
282
+ // (e.g. summary step failed after consolidation), so the partial write is
283
+ // replayable too.
284
+ const finish = (result) => {
285
+ const status = result.ok ? "ok" : "failed";
286
+ const applied = result.applied ?? 0;
287
+ const summaryStored = result.summary ?? false;
288
+ const receipt = buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.size, applied, summaryStored });
289
+ try {
290
+ service.saveDreamRun({
291
+ id: runId,
292
+ status,
293
+ error: result.error,
294
+ provider: route?.provider,
295
+ model: route?.model,
296
+ snapshot_hash: snapshotHash,
297
+ input_count: snapshot.size,
298
+ // Full input snapshot (canonical fields) so the exact arbitration
299
+ // input can be rebuilt offline from the audit row alone — the
300
+ // digest + decisions + outcome triple makes silent errors locatable
301
+ // even after the store has moved on.
302
+ input: [...snapshot.values()].map((m) => ({
303
+ id: m.id,
304
+ type: m.type,
305
+ title: m.title,
306
+ content: m.content,
307
+ importance: m.importance,
308
+ updated_at: m.updated_at
309
+ })),
310
+ decisions: result.decisions,
311
+ outcome: result.outcome,
312
+ applied,
313
+ summary_stored: summaryStored,
314
+ receipt
315
+ });
316
+ } catch (error) {
317
+ logger?.warn?.(`dsh-mneme dream: failed to record audit run: ${String(error)}`);
318
+ }
319
+ return { ...result, runId, receipt, snapshotHash };
320
+ };
321
+ if (!route) {
322
+ logger?.warn?.("dsh-mneme dream: no llm route available");
323
+ return finish({ ok: false, error: "no llm route", summary: false });
324
+ }
325
+
326
+ let listText;
327
+ if (semantic?.embedder && semantic?.vectorIndex) {
328
+ try {
329
+ const vectors = await collectVectors(memories, semantic);
330
+ if (vectors) {
331
+ const k = Math.min(10, Math.max(1, Math.floor(Math.sqrt(memories.length / 2))));
332
+ const clusters = clusterMemories(memories, vectors, k);
333
+ const conflicts = findPotentialConflicts(memories, vectors, 0.85);
334
+ const conflictIds = new Set(conflicts.flatMap((c) => [c.a.id, c.b.id]));
335
+ const parts = [];
336
+ clusters.forEach((cluster, ci) => {
337
+ parts.push(`# 聚类 ${ci + 1}`);
338
+ for (const m of cluster) {
339
+ parts.push(
340
+ `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}` +
341
+ (conflictIds.has(m.id) ? " | [潜在冲突]" : "")
342
+ );
343
+ }
344
+ });
345
+ listText = parts.join("\n");
346
+ logger?.info?.(`[dsh-mneme] dream semantic pre-group: ${clusters.length} clusters, ${conflicts.length} conflict pairs`);
347
+ }
348
+ } catch (error) {
349
+ logger?.warn?.(`[dsh-mneme] dream semantic pre-group failed: ${String(error)}`);
350
+ }
351
+ }
352
+ if (!listText) {
353
+ listText = [...snapshot.values()].map((m) =>
354
+ `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`
355
+ ).join("\n");
356
+ }
357
+
358
+ let decisionText;
359
+ try {
360
+ decisionText = await streamText(ctx, {
361
+ provider: route.provider,
362
+ model: route.model,
363
+ purpose: "compaction",
364
+ maxTokens: config.dreamMaxTokens ?? 4096,
365
+ messages: [
366
+ { role: "system", content: [{ type: "text", text: CONSOLIDATION_PROMPT }] },
367
+ { role: "user", content: [{ type: "text", text: listText }] }
368
+ ]
369
+ });
370
+ } catch (error) {
371
+ logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
372
+ return finish({ ok: false, error: "llm failed", summary: false });
373
+ }
374
+ if (decisionText === undefined) {
375
+ logger?.warn?.("dsh-mneme dream: consolidation llm stream aborted or errored");
376
+ return finish({ ok: false, error: "llm failed", summary: false });
377
+ }
378
+
379
+ let decisions;
380
+ try {
381
+ const start = decisionText.indexOf("[");
382
+ const end = decisionText.lastIndexOf("]");
383
+ if (start === -1 || end <= start) {
384
+ logger?.warn?.("dsh-mneme dream: no json array in llm output");
385
+ return finish({ ok: false, error: "no json array in llm output", summary: false });
386
+ }
387
+ decisions = JSON.parse(decisionText.slice(start, end + 1));
388
+ } catch {
389
+ logger?.warn?.("dsh-mneme dream: invalid decisions json");
390
+ return finish({ ok: false, error: "invalid decisions json", summary: false });
391
+ }
392
+ const { ok, errors } = validateDecisions(decisions, snapshot, {
393
+ maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
394
+ minAgeHours: config.reflectionUpdateMinAgeHours
395
+ });
396
+ if (!ok) {
397
+ logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
398
+ return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
399
+ }
400
+
401
+ // Capture pre-update snapshots so the audit records what each update changed.
402
+ const updateSnapshots = {};
403
+ for (const d of decisions) {
404
+ if (d.action === "update") {
405
+ const mem = snapshot.get(d.ids[0]);
406
+ if (mem) updateSnapshots[d.ids[0]] = { title: mem.title, content: mem.content, importance: mem.importance };
407
+ }
408
+ }
409
+
410
+ const applied = applyDecisions(decisions, service, logger);
411
+ // Attach the pre-update snapshot to the audit copy of each update decision
412
+ // so the recorded row shows the before/after delta, not just the target.
413
+ const auditDecisions = decisions.map((d) =>
414
+ d.action === "update" && updateSnapshots[d.ids[0]]
415
+ ? { ...d, _before: updateSnapshots[d.ids[0]] }
416
+ : d
417
+ );
418
+ const outcome = buildOutcome(decisions);
419
+
420
+ // Keep the vector index consistent with the post-dream store state.
421
+ if (semantic?.embedder && semantic?.vectorIndex) {
422
+ try {
423
+ await maintainIndexAfterDream(decisions, service, semantic);
424
+ } catch (error) {
425
+ logger?.warn?.(`[dsh-mneme] dream index maintenance failed: ${String(error)}`);
426
+ }
427
+ }
428
+
429
+ // Summary generation (second LLM call). A throwing stream is reported as
430
+ // a failed run; summary:false marks a run that produced no summary.
431
+ let summaryText;
432
+ try {
433
+ summaryText = await streamText(ctx, {
434
+ provider: route.provider,
435
+ model: route.model,
436
+ purpose: "compaction",
437
+ maxTokens: config.dreamMaxTokens ?? 2048,
438
+ messages: [
439
+ { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
440
+ { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
441
+ ]
442
+ });
443
+ } catch (error) {
444
+ logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
445
+ return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, summary: false });
446
+ }
447
+ let summaryStored = false;
448
+ if (summaryText !== undefined && summaryText.trim()) {
449
+ service.saveWithDedupe({ type: "summary", title: "记忆库总览", content: summaryText.trim(), importance: 5, source: "dream" });
450
+ summaryStored = true;
451
+ // Re-embed the fresh summary so the index stays in sync with the store.
452
+ if (semantic?.embedder && semantic?.vectorIndex) {
453
+ try {
454
+ const summary = service.all().find((m) => m.type === "summary");
455
+ if (summary) {
456
+ const v = await semantic.embedder.embedSingle([summary.title, summary.content].filter(Boolean).join("\n"));
457
+ if (v?.length) semantic.vectorIndex.saveEmbedding(summary.id, v);
458
+ if (semantic.embedder.modelHash) semantic.vectorIndex.markModel?.(semantic.embedder.modelHash, semantic.embedder.dimension);
459
+ }
460
+ } catch { /* best-effort */ }
461
+ }
462
+ }
463
+ return finish({ ok: true, applied, decisions: auditDecisions, outcome, summary: summaryStored });
464
+ }
465
+
466
+ return { maybeSchedule, runDream, dispose };
467
+ }