@yeaft/webchat-agent 0.1.541 → 0.1.543
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/package.json +1 -1
- package/unify/engine.js +39 -21
- package/unify/memory/dream-shard.js +722 -0
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { randomUUID } from 'crypto';
|
|
21
21
|
import { buildSystemPrompt } from './prompts.js';
|
|
22
22
|
import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
23
|
-
import {
|
|
23
|
+
import { recallR6, formatForInjection } from './memory/recall-r6.js';
|
|
24
24
|
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
25
25
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
26
26
|
import { runStopHooks } from './stop-hooks.js';
|
|
@@ -350,29 +350,34 @@ export class Engine {
|
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
352
|
* Perform memory recall for a given prompt.
|
|
353
|
+
* Uses recallR6 (R6 shard-based recall) when memoryShardStore is available,
|
|
354
|
+
* falling back to empty results if not.
|
|
353
355
|
*
|
|
354
356
|
* @param {string} prompt
|
|
355
|
-
* @returns {Promise<{ profile: string, entries: object[] }|null>}
|
|
357
|
+
* @returns {Promise<{ profile: string, entries: object[], formatted: string }|null>}
|
|
356
358
|
*/
|
|
357
359
|
async #recallMemory(prompt) {
|
|
358
|
-
|
|
360
|
+
const memory = { profile: '', entries: [], formatted: '' };
|
|
359
361
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
362
|
+
// Read user profile from legacy store if available
|
|
363
|
+
if (this.#memoryStore) {
|
|
364
|
+
memory.profile = this.#memoryStore.readProfile();
|
|
365
|
+
}
|
|
364
366
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
367
|
+
// R6 shard-based recall (preferred path)
|
|
368
|
+
if (this.#memoryShardStore) {
|
|
369
|
+
try {
|
|
370
|
+
const result = await recallR6({
|
|
371
|
+
prompt,
|
|
372
|
+
memoryShardStore: this.#memoryShardStore,
|
|
373
|
+
adapter: this.#adapter,
|
|
374
|
+
fastModel: this.#fastConfig?.model,
|
|
375
|
+
});
|
|
376
|
+
memory.entries = result.entries;
|
|
377
|
+
memory.formatted = formatForInjection(result.entries);
|
|
378
|
+
} catch {
|
|
379
|
+
// Recall failure is non-critical
|
|
380
|
+
}
|
|
376
381
|
}
|
|
377
382
|
|
|
378
383
|
return memory;
|
|
@@ -563,10 +568,13 @@ export class Engine {
|
|
|
563
568
|
async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat' }) {
|
|
564
569
|
|
|
565
570
|
// ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
|
|
566
|
-
//
|
|
567
|
-
//
|
|
571
|
+
// Two-layer recall:
|
|
572
|
+
// 1. Static memory index injection (buildMemoryInjection — always)
|
|
573
|
+
// 2. R6 shard-based recall (recallR6 — when memoryShardStore is wired)
|
|
574
|
+
// No per-turn fuzzy recall via old recall.js — LLM calls memory_load /
|
|
568
575
|
// memory_query on demand (memory_search still works as a deprecated alias).
|
|
569
576
|
let memoryInjection = '';
|
|
577
|
+
let recallEntryCount = 0;
|
|
570
578
|
if (this.#yeaftDir) {
|
|
571
579
|
try {
|
|
572
580
|
const entryCount = this.#memoryStore?.stats?.().entryCount ?? 0;
|
|
@@ -580,8 +588,18 @@ export class Engine {
|
|
|
580
588
|
// Injection failure is non-critical — fall back to empty.
|
|
581
589
|
}
|
|
582
590
|
}
|
|
591
|
+
|
|
592
|
+
// R6 recall: append shard-based recall results to memory injection
|
|
593
|
+
const recallResult = await this.#recallMemory(prompt);
|
|
594
|
+
if (recallResult && recallResult.formatted) {
|
|
595
|
+
memoryInjection = memoryInjection
|
|
596
|
+
? memoryInjection + '\n\n' + recallResult.formatted
|
|
597
|
+
: recallResult.formatted;
|
|
598
|
+
recallEntryCount = recallResult.entries.length;
|
|
599
|
+
}
|
|
600
|
+
|
|
583
601
|
if (memoryInjection) {
|
|
584
|
-
yield { type: 'recall', entryCount:
|
|
602
|
+
yield { type: 'recall', entryCount: recallEntryCount, cached: false };
|
|
585
603
|
}
|
|
586
604
|
|
|
587
605
|
const compactSummary = this.#getCompactSummary();
|
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-shard.js — task-334g Shard-based dream memory maintenance.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old entries-based dream scanner with shard-aware streaming:
|
|
5
|
+
* 1. Shard scanner: iterate shards → stream entries → build orient/merge/prune inputs
|
|
6
|
+
* 2. Compact job: rewrite shards with utilization < 50% to reclaim tombstones
|
|
7
|
+
* 3. Task-memory guard: dream NEVER writes to task-memory shards (avoids double-write)
|
|
8
|
+
*
|
|
9
|
+
* References:
|
|
10
|
+
* - R5 delta §Δ17.5: compact job spec
|
|
11
|
+
* - R5 delta §Δ16.4.3: "auto-dream 不写 task-memory"
|
|
12
|
+
* - 334f shard-store API: stageRecompression / commitRecompression / abortRecompression
|
|
13
|
+
* - schema.js: TASK_SHARDS, softCapFor
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { TASK_SHARDS, softCapFor } from './schema.js';
|
|
17
|
+
import { AUTHORED_BY } from './shard-store.js';
|
|
18
|
+
import { pickEffort } from '../effort.js';
|
|
19
|
+
|
|
20
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Utilization threshold below which a compact is triggered. */
|
|
23
|
+
const COMPACT_UTILIZATION_THRESHOLD = 0.5;
|
|
24
|
+
|
|
25
|
+
/** Maximum shards to compact in a single dream run (budget control). */
|
|
26
|
+
const MAX_COMPACTS_PER_DREAM = 4;
|
|
27
|
+
|
|
28
|
+
/** Maximum LLM calls for shard-based dream phases. */
|
|
29
|
+
const MAX_SHARD_DREAM_LLM_CALLS = 5;
|
|
30
|
+
|
|
31
|
+
/** Task-memory shard names — dream must never write to these. */
|
|
32
|
+
const TASK_SHARD_SET = new Set(TASK_SHARDS);
|
|
33
|
+
|
|
34
|
+
// ─── Task-Memory Guard ─────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true if the shard name belongs to task-memory.
|
|
38
|
+
* Dream must NOT write entries to these shards.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} shardName
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function isTaskMemoryShard(shardName) {
|
|
44
|
+
return TASK_SHARD_SET.has(shardName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Filter out task-memory shards from a list of shard names.
|
|
49
|
+
*
|
|
50
|
+
* @param {string[]} shardNames
|
|
51
|
+
* @returns {string[]}
|
|
52
|
+
*/
|
|
53
|
+
export function filterDreamableShards(shardNames) {
|
|
54
|
+
return shardNames.filter(s => !isTaskMemoryShard(s));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Shard Scanner ─────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Streaming scan of all VP-memory shards via the R6 shard store.
|
|
61
|
+
* Returns a structured summary suitable for dream Orient/Merge/Prune phases.
|
|
62
|
+
*
|
|
63
|
+
* This replaces the old `scanEntries(memoryStore)` which read individual files.
|
|
64
|
+
* Now we go through the shard store's query() API which reads from indexed
|
|
65
|
+
* shard files — much fewer file opens.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} shardStore — opened via openMemoryShardStore()
|
|
68
|
+
* @returns {ShardScanResult}
|
|
69
|
+
*/
|
|
70
|
+
export function scanShards(shardStore) {
|
|
71
|
+
const st = shardStore.stats();
|
|
72
|
+
const shardNames = Object.keys(st.shards);
|
|
73
|
+
const dreamableShards = filterDreamableShards(shardNames);
|
|
74
|
+
|
|
75
|
+
const result = {
|
|
76
|
+
shards: {},
|
|
77
|
+
totalEntries: 0,
|
|
78
|
+
totalBytes: 0,
|
|
79
|
+
supersededCount: 0,
|
|
80
|
+
byKind: {},
|
|
81
|
+
byTags: {},
|
|
82
|
+
needsCompaction: [],
|
|
83
|
+
entries: [], // thin entries for merge/prune analysis
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const shardName of dreamableShards) {
|
|
87
|
+
const bucket = st.shards[shardName];
|
|
88
|
+
if (!bucket) continue;
|
|
89
|
+
|
|
90
|
+
const cap = softCapFor(shardName);
|
|
91
|
+
const utilization = computeUtilization(bucket, cap);
|
|
92
|
+
|
|
93
|
+
result.shards[shardName] = {
|
|
94
|
+
entries: bucket.entries,
|
|
95
|
+
bytes: bucket.bytes,
|
|
96
|
+
softCap: cap,
|
|
97
|
+
utilization,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
result.totalEntries += bucket.entries;
|
|
101
|
+
result.totalBytes += bucket.bytes;
|
|
102
|
+
|
|
103
|
+
// Flag for compaction
|
|
104
|
+
if (utilization < COMPACT_UTILIZATION_THRESHOLD && bucket.entries > 0) {
|
|
105
|
+
result.needsCompaction.push(shardName);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Query all entries from dreamable shards to build thin index
|
|
110
|
+
for (const shardName of dreamableShards) {
|
|
111
|
+
const { results } = shardStore.query({ shard: shardName });
|
|
112
|
+
for (const rec of results) {
|
|
113
|
+
const thin = {
|
|
114
|
+
id: rec.id,
|
|
115
|
+
shard: rec.shard,
|
|
116
|
+
kind: rec.kind,
|
|
117
|
+
tags: rec.tags || [],
|
|
118
|
+
pinned: rec.pinned,
|
|
119
|
+
supersededBy: rec.supersededBy || null,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (thin.supersededBy) result.supersededCount++;
|
|
123
|
+
|
|
124
|
+
// Kind stats
|
|
125
|
+
const k = thin.kind || 'unknown';
|
|
126
|
+
result.byKind[k] = (result.byKind[k] || 0) + 1;
|
|
127
|
+
|
|
128
|
+
// Tag stats
|
|
129
|
+
for (const tag of thin.tags) {
|
|
130
|
+
result.byTags[tag] = (result.byTags[tag] || 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
result.entries.push(thin);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format a shard scan result as a human-readable summary string
|
|
142
|
+
* (used in Orient phase prompt).
|
|
143
|
+
*
|
|
144
|
+
* @param {ShardScanResult} scan
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function formatScanSummary(scan) {
|
|
148
|
+
const lines = [
|
|
149
|
+
`Total entries: ${scan.totalEntries} (${(scan.totalBytes / 1024).toFixed(1)} KiB)`,
|
|
150
|
+
`Superseded: ${scan.supersededCount}`,
|
|
151
|
+
'',
|
|
152
|
+
'### Shards',
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (const [name, info] of Object.entries(scan.shards)) {
|
|
156
|
+
const pct = (info.utilization * 100).toFixed(0);
|
|
157
|
+
const flag = info.utilization < COMPACT_UTILIZATION_THRESHOLD ? ' ⚠ needs compact' : '';
|
|
158
|
+
lines.push(`- **${name}**: ${info.entries} entries, ${(info.bytes / 1024).toFixed(1)} KiB, ${pct}% utilization${flag}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push('', '### By Kind');
|
|
162
|
+
for (const [kind, count] of Object.entries(scan.byKind)) {
|
|
163
|
+
lines.push(`- ${kind}: ${count}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (Object.keys(scan.byTags).length > 0) {
|
|
167
|
+
lines.push('', '### Top Tags');
|
|
168
|
+
const sorted = Object.entries(scan.byTags).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
169
|
+
for (const [tag, count] of sorted) {
|
|
170
|
+
lines.push(`- ${tag}: ${count}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (scan.needsCompaction.length > 0) {
|
|
175
|
+
lines.push('', `### Compaction needed: ${scan.needsCompaction.join(', ')}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Compact Job ───────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Run compaction on shards with low utilization.
|
|
185
|
+
*
|
|
186
|
+
* Compact rewrites a shard file to reclaim space from:
|
|
187
|
+
* - Superseded entries (entries with supersededBy set)
|
|
188
|
+
* - Tombstone gaps left by removed entries
|
|
189
|
+
*
|
|
190
|
+
* Uses the 334f stageRecompression/commitRecompression atomic handoff:
|
|
191
|
+
* 1. Read all live entries from the shard
|
|
192
|
+
* 2. Write them to a .compacting temp file via stageRecompression()
|
|
193
|
+
* 3. Atomically rename via commitRecompression()
|
|
194
|
+
* 4. On error, abort via abortRecompression()
|
|
195
|
+
*
|
|
196
|
+
* @param {{
|
|
197
|
+
* shardStore: object,
|
|
198
|
+
* shardNames?: string[],
|
|
199
|
+
* onCompact?: (shard: string, result: CompactResult) => void,
|
|
200
|
+
* }} params
|
|
201
|
+
* @returns {CompactJobResult}
|
|
202
|
+
*/
|
|
203
|
+
export function runCompactJob({ shardStore, shardNames, onCompact }) {
|
|
204
|
+
const st = shardStore.stats();
|
|
205
|
+
const allShards = Object.keys(st.shards);
|
|
206
|
+
const dreamableShards = filterDreamableShards(allShards);
|
|
207
|
+
|
|
208
|
+
// Determine which shards to compact
|
|
209
|
+
const candidates = shardNames
|
|
210
|
+
? shardNames.filter(s => dreamableShards.includes(s))
|
|
211
|
+
: dreamableShards.filter(s => {
|
|
212
|
+
const bucket = st.shards[s];
|
|
213
|
+
if (!bucket || bucket.entries === 0) return false;
|
|
214
|
+
const cap = softCapFor(s);
|
|
215
|
+
return computeUtilization(bucket, cap) < COMPACT_UTILIZATION_THRESHOLD;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const result = {
|
|
219
|
+
compacted: [],
|
|
220
|
+
skipped: [],
|
|
221
|
+
errors: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let compactCount = 0;
|
|
225
|
+
|
|
226
|
+
for (const shardName of candidates) {
|
|
227
|
+
if (compactCount >= MAX_COMPACTS_PER_DREAM) {
|
|
228
|
+
result.skipped.push(shardName);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const compactResult = compactShard(shardStore, shardName);
|
|
234
|
+
result.compacted.push({ shard: shardName, ...compactResult });
|
|
235
|
+
onCompact?.(shardName, compactResult);
|
|
236
|
+
compactCount++;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
result.errors.push({ shard: shardName, error: err.message });
|
|
239
|
+
// Ensure we abort any staged compaction
|
|
240
|
+
try { shardStore.abortRecompression(shardName); } catch { /* ignore */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Compact a single shard: filter out superseded entries, rebuild the shard file.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} shardStore
|
|
251
|
+
* @param {string} shardName
|
|
252
|
+
* @returns {CompactResult}
|
|
253
|
+
*/
|
|
254
|
+
function compactShard(shardStore, shardName) {
|
|
255
|
+
const { results } = shardStore.query({ shard: shardName });
|
|
256
|
+
|
|
257
|
+
// Partition: live (not superseded) vs superseded
|
|
258
|
+
const live = [];
|
|
259
|
+
const superseded = [];
|
|
260
|
+
|
|
261
|
+
for (const rec of results) {
|
|
262
|
+
if (rec.supersededBy) {
|
|
263
|
+
superseded.push(rec.id);
|
|
264
|
+
} else {
|
|
265
|
+
live.push(rec);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const beforeCount = results.length;
|
|
270
|
+
const afterCount = live.length;
|
|
271
|
+
const removedCount = superseded.length;
|
|
272
|
+
|
|
273
|
+
if (removedCount === 0) {
|
|
274
|
+
return { beforeCount, afterCount, removedCount, reclaimedBytes: 0 };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Rebuild shard body from live entries only
|
|
278
|
+
const bodyParts = [];
|
|
279
|
+
for (const rec of live) {
|
|
280
|
+
const full = shardStore.get(rec.id);
|
|
281
|
+
if (!full) continue;
|
|
282
|
+
// Re-serialize: the shard-store.js get() returns the parsed entry;
|
|
283
|
+
// we need to call put() to write back. But the atomic recompression
|
|
284
|
+
// approach is: build new body, stage, commit.
|
|
285
|
+
// The body from get() includes the full frontmatter+content serialisation.
|
|
286
|
+
bodyParts.push(full.body || '');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build the new shard body using the same delimiter format
|
|
290
|
+
const newBody = bodyParts.map((body, i) => {
|
|
291
|
+
const id = live[i].id;
|
|
292
|
+
return `\n<!--entry:${id}:START-->\n${body.replace(/\n+$/, '')}\n<!--entry:${id}:END-->\n`;
|
|
293
|
+
}).join('');
|
|
294
|
+
|
|
295
|
+
// Use atomic recompression handoff
|
|
296
|
+
const statsBefore = shardStore.stats();
|
|
297
|
+
const bytesBefore = statsBefore.shards[shardName]?.bytes || 0;
|
|
298
|
+
|
|
299
|
+
shardStore.stageRecompression(shardName, newBody);
|
|
300
|
+
shardStore.commitRecompression(shardName);
|
|
301
|
+
|
|
302
|
+
const statsAfter = shardStore.stats();
|
|
303
|
+
const bytesAfter = statsAfter.shards[shardName]?.bytes || 0;
|
|
304
|
+
const reclaimedBytes = Math.max(0, bytesBefore - bytesAfter);
|
|
305
|
+
|
|
306
|
+
return { beforeCount, afterCount, removedCount, reclaimedBytes };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Shard-Based Dream Pipeline ────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Run a shard-based dream cycle. This is the 334g replacement for the
|
|
313
|
+
* old `dream()` function's scan/merge/prune phases, using shard-store
|
|
314
|
+
* streaming instead of per-file entry scanning.
|
|
315
|
+
*
|
|
316
|
+
* Phases:
|
|
317
|
+
* 1. Scan — streaming scan of all VP-memory shards
|
|
318
|
+
* 2. Compact — rewrite low-utilization shards
|
|
319
|
+
* 3. Merge — LLM-driven merge of duplicate/superseded entries
|
|
320
|
+
* 4. Prune — LLM-driven removal of stale entries
|
|
321
|
+
*
|
|
322
|
+
* Task-memory guard: all phases skip TASK_SHARDS entirely.
|
|
323
|
+
*
|
|
324
|
+
* @param {{
|
|
325
|
+
* shardStore: object,
|
|
326
|
+
* adapter: object,
|
|
327
|
+
* config: object,
|
|
328
|
+
* onPhase?: (phase: string, data: any) => void,
|
|
329
|
+
* }} params
|
|
330
|
+
* @returns {Promise<ShardDreamResult>}
|
|
331
|
+
*/
|
|
332
|
+
export async function dreamShard({ shardStore, adapter, config, onPhase }) {
|
|
333
|
+
const result = {
|
|
334
|
+
scan: null,
|
|
335
|
+
compact: null,
|
|
336
|
+
merge: null,
|
|
337
|
+
prune: null,
|
|
338
|
+
entriesMerged: 0,
|
|
339
|
+
entriesPruned: 0,
|
|
340
|
+
bytesReclaimed: 0,
|
|
341
|
+
errors: [],
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
let llmCallsLeft = MAX_SHARD_DREAM_LLM_CALLS;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// ── Phase 1: Scan ──────────────────────────────────────
|
|
348
|
+
onPhase?.('scan', 'starting');
|
|
349
|
+
const scan = scanShards(shardStore);
|
|
350
|
+
result.scan = {
|
|
351
|
+
totalEntries: scan.totalEntries,
|
|
352
|
+
totalBytes: scan.totalBytes,
|
|
353
|
+
supersededCount: scan.supersededCount,
|
|
354
|
+
shardCount: Object.keys(scan.shards).length,
|
|
355
|
+
needsCompaction: scan.needsCompaction.slice(),
|
|
356
|
+
};
|
|
357
|
+
onPhase?.('scan', result.scan);
|
|
358
|
+
|
|
359
|
+
// ── Phase 2: Compact ───────────────────────────────────
|
|
360
|
+
onPhase?.('compact', 'starting');
|
|
361
|
+
const compactResult = runCompactJob({
|
|
362
|
+
shardStore,
|
|
363
|
+
shardNames: scan.needsCompaction,
|
|
364
|
+
onCompact: (shard, r) => onPhase?.('compact', { shard, ...r }),
|
|
365
|
+
});
|
|
366
|
+
result.compact = compactResult;
|
|
367
|
+
result.bytesReclaimed = compactResult.compacted.reduce(
|
|
368
|
+
(sum, c) => sum + (c.reclaimedBytes || 0), 0
|
|
369
|
+
);
|
|
370
|
+
if (compactResult.errors.length > 0) {
|
|
371
|
+
for (const e of compactResult.errors) {
|
|
372
|
+
result.errors.push(`compact(${e.shard}): ${e.error}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
onPhase?.('compact', compactResult);
|
|
376
|
+
|
|
377
|
+
// ── Phase 3: Merge (LLM) ──────────────────────────────
|
|
378
|
+
if (llmCallsLeft > 0 && scan.entries.length > 0) {
|
|
379
|
+
onPhase?.('merge', 'starting');
|
|
380
|
+
const mergeResult = await runMergePhase({
|
|
381
|
+
shardStore, scan, adapter, config,
|
|
382
|
+
});
|
|
383
|
+
result.merge = mergeResult;
|
|
384
|
+
result.entriesMerged = mergeResult.mergedCount;
|
|
385
|
+
llmCallsLeft -= mergeResult.llmCalls;
|
|
386
|
+
onPhase?.('merge', mergeResult);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Phase 4: Prune (LLM) ──────────────────────────────
|
|
390
|
+
if (llmCallsLeft > 0 && scan.entries.length > 0) {
|
|
391
|
+
onPhase?.('prune', 'starting');
|
|
392
|
+
const pruneResult = await runPrunePhase({
|
|
393
|
+
shardStore, scan, adapter, config,
|
|
394
|
+
});
|
|
395
|
+
result.prune = pruneResult;
|
|
396
|
+
result.entriesPruned = pruneResult.prunedCount;
|
|
397
|
+
llmCallsLeft -= pruneResult.llmCalls;
|
|
398
|
+
onPhase?.('prune', pruneResult);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
} catch (err) {
|
|
402
|
+
result.errors.push(err.message);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ─── Merge Phase ───────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* LLM-driven merge: find entries with high overlap and ask LLM to merge.
|
|
412
|
+
*
|
|
413
|
+
* @returns {Promise<{ mergedCount: number, llmCalls: number, merges: object[] }>}
|
|
414
|
+
*/
|
|
415
|
+
async function runMergePhase({ shardStore, scan, adapter, config }) {
|
|
416
|
+
const result = { mergedCount: 0, llmCalls: 0, merges: [] };
|
|
417
|
+
|
|
418
|
+
// Find candidate groups: entries in the same shard with same kind
|
|
419
|
+
const groups = groupByShardAndKind(scan.entries);
|
|
420
|
+
const candidates = [];
|
|
421
|
+
|
|
422
|
+
for (const [key, entries] of Object.entries(groups)) {
|
|
423
|
+
if (entries.length < 2) continue;
|
|
424
|
+
// Look for entries with overlapping tags
|
|
425
|
+
const tagOverlaps = findTagOverlaps(entries);
|
|
426
|
+
if (tagOverlaps.length > 0) {
|
|
427
|
+
candidates.push(...tagOverlaps);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (candidates.length === 0) return result;
|
|
432
|
+
|
|
433
|
+
// Load full bodies for the top candidates (max 5 pairs)
|
|
434
|
+
const toMerge = candidates.slice(0, 5);
|
|
435
|
+
const pairsWithBodies = [];
|
|
436
|
+
for (const pair of toMerge) {
|
|
437
|
+
const a = shardStore.get(pair[0]);
|
|
438
|
+
const b = shardStore.get(pair[1]);
|
|
439
|
+
if (a && b) {
|
|
440
|
+
pairsWithBodies.push({ a, b });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (pairsWithBodies.length === 0) return result;
|
|
445
|
+
|
|
446
|
+
// Single LLM call to merge all candidates
|
|
447
|
+
const prompt = buildShardMergePrompt(pairsWithBodies);
|
|
448
|
+
const llmResult = await shardDreamLlmCall(adapter, config,
|
|
449
|
+
'You are a memory maintenance assistant. Merge duplicate memory entries. Return JSON.',
|
|
450
|
+
prompt,
|
|
451
|
+
);
|
|
452
|
+
result.llmCalls = 1;
|
|
453
|
+
|
|
454
|
+
if (!llmResult?.merges || !Array.isArray(llmResult.merges)) return result;
|
|
455
|
+
|
|
456
|
+
for (const merge of llmResult.merges) {
|
|
457
|
+
if (!merge.mergedBody || !merge.keepId || !merge.removeId) continue;
|
|
458
|
+
try {
|
|
459
|
+
// Supersede: keep the winner entry with merged body
|
|
460
|
+
const keeper = shardStore.get(merge.keepId);
|
|
461
|
+
if (!keeper) continue;
|
|
462
|
+
|
|
463
|
+
shardStore.supersede({
|
|
464
|
+
newEntry: {
|
|
465
|
+
id: `${merge.keepId}-m${Date.now().toString(36)}`,
|
|
466
|
+
shard: keeper.shard,
|
|
467
|
+
kind: keeper.kind || keeper._meta?.kind || 'skill',
|
|
468
|
+
body: merge.mergedBody,
|
|
469
|
+
tags: keeper.tags || keeper._meta?.tags || [],
|
|
470
|
+
sourceRef: keeper.sourceRef || { msgIds: ['dream-merge'] },
|
|
471
|
+
authoredBy: AUTHORED_BY.DREAM,
|
|
472
|
+
},
|
|
473
|
+
oldIds: [merge.keepId, merge.removeId],
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
result.mergedCount++;
|
|
477
|
+
result.merges.push({ keepId: merge.keepId, removeId: merge.removeId });
|
|
478
|
+
} catch (err) {
|
|
479
|
+
// Skip failed merges silently
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Prune Phase ───────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* LLM-driven prune: find superseded/stale entries and remove.
|
|
490
|
+
*
|
|
491
|
+
* @returns {Promise<{ prunedCount: number, llmCalls: number, pruned: string[] }>}
|
|
492
|
+
*/
|
|
493
|
+
async function runPrunePhase({ shardStore, scan, adapter, config }) {
|
|
494
|
+
const result = { prunedCount: 0, llmCalls: 0, pruned: [] };
|
|
495
|
+
|
|
496
|
+
// Candidates: superseded entries + entries in over-soft-cap shards
|
|
497
|
+
const superseded = scan.entries.filter(e => e.supersededBy);
|
|
498
|
+
|
|
499
|
+
// Auto-prune superseded entries (no LLM needed)
|
|
500
|
+
for (const entry of superseded) {
|
|
501
|
+
try {
|
|
502
|
+
shardStore.remove(entry.id);
|
|
503
|
+
result.prunedCount++;
|
|
504
|
+
result.pruned.push(entry.id);
|
|
505
|
+
} catch { /* skip */ }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// For non-superseded entries, ask LLM which are stale
|
|
509
|
+
const st = shardStore.stats();
|
|
510
|
+
const overCapShards = [];
|
|
511
|
+
for (const [name, bucket] of Object.entries(st.shards)) {
|
|
512
|
+
if (isTaskMemoryShard(name)) continue;
|
|
513
|
+
const cap = softCapFor(name);
|
|
514
|
+
if (bucket.entries > cap.entries || bucket.bytes > cap.bytes) {
|
|
515
|
+
overCapShards.push(name);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (overCapShards.length === 0) return result;
|
|
520
|
+
|
|
521
|
+
// Load entries from over-cap shards for LLM analysis
|
|
522
|
+
const entriesForReview = [];
|
|
523
|
+
for (const shardName of overCapShards) {
|
|
524
|
+
const { results } = shardStore.query({ shard: shardName });
|
|
525
|
+
for (const rec of results) {
|
|
526
|
+
if (rec.pinned) continue; // never prune pinned
|
|
527
|
+
const full = shardStore.get(rec.id);
|
|
528
|
+
if (!full) continue;
|
|
529
|
+
entriesForReview.push({
|
|
530
|
+
id: rec.id,
|
|
531
|
+
shard: rec.shard,
|
|
532
|
+
kind: rec.kind,
|
|
533
|
+
tags: rec.tags,
|
|
534
|
+
body: (full.body || '').slice(0, 300),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (entriesForReview.length === 0) return result;
|
|
540
|
+
|
|
541
|
+
const prompt = buildShardPrunePrompt(entriesForReview, overCapShards);
|
|
542
|
+
const llmResult = await shardDreamLlmCall(adapter, config,
|
|
543
|
+
'You are a memory pruning assistant. Identify low-value entries to remove. Return JSON.',
|
|
544
|
+
prompt,
|
|
545
|
+
);
|
|
546
|
+
result.llmCalls = 1;
|
|
547
|
+
|
|
548
|
+
if (!llmResult?.toRemove || !Array.isArray(llmResult.toRemove)) return result;
|
|
549
|
+
|
|
550
|
+
for (const id of llmResult.toRemove) {
|
|
551
|
+
if (typeof id !== 'string') continue;
|
|
552
|
+
// Double-check it's not in a task shard
|
|
553
|
+
const entry = scan.entries.find(e => e.id === id);
|
|
554
|
+
if (entry && isTaskMemoryShard(entry.shard)) continue;
|
|
555
|
+
try {
|
|
556
|
+
shardStore.remove(id);
|
|
557
|
+
result.prunedCount++;
|
|
558
|
+
result.pruned.push(id);
|
|
559
|
+
} catch { /* skip */ }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
function computeUtilization(bucket, cap) {
|
|
568
|
+
if (!cap || !bucket) return 1;
|
|
569
|
+
const entryRatio = cap.entries > 0 ? bucket.entries / cap.entries : 0;
|
|
570
|
+
const byteRatio = cap.bytes > 0 ? bucket.bytes / cap.bytes : 0;
|
|
571
|
+
return Math.max(entryRatio, byteRatio);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function groupByShardAndKind(entries) {
|
|
575
|
+
const groups = {};
|
|
576
|
+
for (const e of entries) {
|
|
577
|
+
const key = `${e.shard}:${e.kind || 'unknown'}`;
|
|
578
|
+
if (!groups[key]) groups[key] = [];
|
|
579
|
+
groups[key].push(e);
|
|
580
|
+
}
|
|
581
|
+
return groups;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function findTagOverlaps(entries) {
|
|
585
|
+
const pairs = [];
|
|
586
|
+
for (let i = 0; i < entries.length; i++) {
|
|
587
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
588
|
+
const a = entries[i], b = entries[j];
|
|
589
|
+
if (!a.tags.length || !b.tags.length) continue;
|
|
590
|
+
const overlap = a.tags.filter(t => b.tags.includes(t));
|
|
591
|
+
if (overlap.length >= 1) {
|
|
592
|
+
pairs.push([a.id, b.id]);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return pairs;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildShardMergePrompt(pairs) {
|
|
600
|
+
const sections = pairs.map((p, i) => {
|
|
601
|
+
return `### Pair ${i + 1}
|
|
602
|
+
Entry A (id: ${p.a.id}, shard: ${p.a.shard}):
|
|
603
|
+
${(p.a.body || '').slice(0, 500)}
|
|
604
|
+
|
|
605
|
+
Entry B (id: ${p.b.id}, shard: ${p.b.shard}):
|
|
606
|
+
${(p.b.body || '').slice(0, 500)}`;
|
|
607
|
+
}).join('\n\n');
|
|
608
|
+
|
|
609
|
+
return `Review these potentially duplicate memory entry pairs and merge where appropriate.
|
|
610
|
+
|
|
611
|
+
${sections}
|
|
612
|
+
|
|
613
|
+
For each pair that should be merged, return:
|
|
614
|
+
- keepId: the id of the entry to keep (the "better" one)
|
|
615
|
+
- removeId: the id of the entry to remove
|
|
616
|
+
- mergedBody: the combined content (keep all unique information)
|
|
617
|
+
|
|
618
|
+
Return JSON:
|
|
619
|
+
{
|
|
620
|
+
"merges": [
|
|
621
|
+
{ "keepId": "...", "removeId": "...", "mergedBody": "..." }
|
|
622
|
+
]
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
If entries are NOT duplicates, return empty merges array. Return ONLY valid JSON.`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function buildShardPrunePrompt(entries, overCapShards) {
|
|
629
|
+
const entryLines = entries.map(e =>
|
|
630
|
+
`- id: ${e.id} | shard: ${e.shard} | kind: ${e.kind} | tags: [${(e.tags || []).join(', ')}]\n ${e.body}`
|
|
631
|
+
).join('\n');
|
|
632
|
+
|
|
633
|
+
return `The following shards are over their soft capacity: ${overCapShards.join(', ')}
|
|
634
|
+
|
|
635
|
+
Review these entries and identify the LEAST valuable ones to remove (target: reduce each shard to ~80% capacity).
|
|
636
|
+
|
|
637
|
+
${entryLines}
|
|
638
|
+
|
|
639
|
+
Criteria for removal:
|
|
640
|
+
- Stale or outdated information
|
|
641
|
+
- Very low specificity (too generic to be useful)
|
|
642
|
+
- Subsumed by other, better entries
|
|
643
|
+
- NOT pinned entries (those are protected)
|
|
644
|
+
|
|
645
|
+
Return JSON:
|
|
646
|
+
{
|
|
647
|
+
"toRemove": ["entry-id-1", "entry-id-2", ...],
|
|
648
|
+
"reasoning": "brief explanation"
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
Return ONLY valid JSON.`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function shardDreamLlmCall(adapter, config, system, prompt) {
|
|
655
|
+
try {
|
|
656
|
+
const result = await adapter.call({
|
|
657
|
+
model: config.model,
|
|
658
|
+
system,
|
|
659
|
+
messages: [{ role: 'user', content: prompt }],
|
|
660
|
+
maxTokens: 4096,
|
|
661
|
+
effort: pickEffort({ scenario: 'dream' }),
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const text = result.text.trim();
|
|
665
|
+
const jsonMatch = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
|
|
666
|
+
if (jsonMatch) {
|
|
667
|
+
return JSON.parse(jsonMatch[0]);
|
|
668
|
+
}
|
|
669
|
+
return null;
|
|
670
|
+
} catch {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ─── Types ─────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* @typedef {Object} ShardScanResult
|
|
679
|
+
* @property {Object<string, ShardInfo>} shards
|
|
680
|
+
* @property {number} totalEntries
|
|
681
|
+
* @property {number} totalBytes
|
|
682
|
+
* @property {number} supersededCount
|
|
683
|
+
* @property {Object<string, number>} byKind
|
|
684
|
+
* @property {Object<string, number>} byTags
|
|
685
|
+
* @property {string[]} needsCompaction
|
|
686
|
+
* @property {object[]} entries — thin entry records
|
|
687
|
+
*/
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* @typedef {Object} ShardInfo
|
|
691
|
+
* @property {number} entries
|
|
692
|
+
* @property {number} bytes
|
|
693
|
+
* @property {object} softCap
|
|
694
|
+
* @property {number} utilization
|
|
695
|
+
*/
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* @typedef {Object} CompactResult
|
|
699
|
+
* @property {number} beforeCount
|
|
700
|
+
* @property {number} afterCount
|
|
701
|
+
* @property {number} removedCount
|
|
702
|
+
* @property {number} reclaimedBytes
|
|
703
|
+
*/
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* @typedef {Object} CompactJobResult
|
|
707
|
+
* @property {Array<{shard: string} & CompactResult>} compacted
|
|
708
|
+
* @property {string[]} skipped
|
|
709
|
+
* @property {Array<{shard: string, error: string}>} errors
|
|
710
|
+
*/
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* @typedef {Object} ShardDreamResult
|
|
714
|
+
* @property {object} scan
|
|
715
|
+
* @property {CompactJobResult} compact
|
|
716
|
+
* @property {object} merge
|
|
717
|
+
* @property {object} prune
|
|
718
|
+
* @property {number} entriesMerged
|
|
719
|
+
* @property {number} entriesPruned
|
|
720
|
+
* @property {number} bytesReclaimed
|
|
721
|
+
* @property {string[]} errors
|
|
722
|
+
*/
|