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