@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.541",
3
+ "version": "0.1.543",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
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 { recall } from './memory/recall.js';
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
- if (!this.#memoryStore) return null;
360
+ const memory = { profile: '', entries: [], formatted: '' };
359
361
 
360
- const memory = { profile: '', entries: [] };
361
-
362
- // Read user profile
363
- memory.profile = this.#memoryStore.readProfile();
362
+ // Read user profile from legacy store if available
363
+ if (this.#memoryStore) {
364
+ memory.profile = this.#memoryStore.readProfile();
365
+ }
364
366
 
365
- // Recall relevant entries (uses fastModel for cheaper/faster side-queries)
366
- try {
367
- const result = await recall({
368
- prompt,
369
- adapter: this.#adapter,
370
- config: this.#fastConfig,
371
- memoryStore: this.#memoryStore,
372
- });
373
- memory.entries = result.entries;
374
- } catch {
375
- // Recall failure is non-critical
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
- // New layout: always inject Memory Index + user-preferences + project
567
- // header excerpt. No per-turn fuzzy recall LLM calls memory_load /
571
+ // Two-layer recall:
572
+ // 1. Static memory index injection (buildMemoryInjectionalways)
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: 0, cached: false };
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
+ */