@cleocode/core 2026.4.37 → 2026.4.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
  2. package/dist/hooks/handlers/task-hooks.js +11 -0
  3. package/dist/hooks/handlers/task-hooks.js.map +1 -1
  4. package/dist/index.js +1048 -33
  5. package/dist/index.js.map +4 -4
  6. package/dist/internal.d.ts +3 -1
  7. package/dist/internal.d.ts.map +1 -1
  8. package/dist/internal.js +3 -1
  9. package/dist/internal.js.map +1 -1
  10. package/dist/memory/brain-lifecycle.d.ts +2 -0
  11. package/dist/memory/brain-lifecycle.d.ts.map +1 -1
  12. package/dist/memory/decisions.d.ts.map +1 -1
  13. package/dist/memory/decisions.js +18 -0
  14. package/dist/memory/decisions.js.map +1 -1
  15. package/dist/memory/engine-compat.d.ts +17 -0
  16. package/dist/memory/engine-compat.d.ts.map +1 -1
  17. package/dist/memory/engine-compat.js +36 -0
  18. package/dist/memory/engine-compat.js.map +1 -1
  19. package/dist/memory/graph-memory-bridge.d.ts +158 -0
  20. package/dist/memory/graph-memory-bridge.d.ts.map +1 -0
  21. package/dist/memory/graph-memory-bridge.js +519 -0
  22. package/dist/memory/graph-memory-bridge.js.map +1 -0
  23. package/dist/memory/index.d.ts +1 -0
  24. package/dist/memory/index.d.ts.map +1 -1
  25. package/dist/memory/index.js +2 -0
  26. package/dist/memory/index.js.map +1 -1
  27. package/dist/memory/learnings.d.ts.map +1 -1
  28. package/dist/memory/learnings.js +18 -0
  29. package/dist/memory/learnings.js.map +1 -1
  30. package/dist/memory/llm-extraction.js.map +1 -1
  31. package/dist/memory/patterns.d.ts.map +1 -1
  32. package/dist/memory/patterns.js +18 -0
  33. package/dist/memory/patterns.js.map +1 -1
  34. package/dist/memory/quality-feedback.d.ts +129 -0
  35. package/dist/memory/quality-feedback.d.ts.map +1 -0
  36. package/dist/memory/quality-feedback.js +449 -0
  37. package/dist/memory/quality-feedback.js.map +1 -0
  38. package/dist/memory/sleep-consolidation.d.ts +98 -0
  39. package/dist/memory/sleep-consolidation.d.ts.map +1 -0
  40. package/dist/memory/sleep-consolidation.js +706 -0
  41. package/dist/memory/sleep-consolidation.js.map +1 -0
  42. package/dist/memory/temporal-supersession.d.ts +155 -0
  43. package/dist/memory/temporal-supersession.d.ts.map +1 -0
  44. package/dist/memory/temporal-supersession.js +406 -0
  45. package/dist/memory/temporal-supersession.js.map +1 -0
  46. package/dist/tasks/complete.d.ts.map +1 -1
  47. package/package.json +8 -8
  48. package/src/hooks/handlers/task-hooks.ts +11 -0
  49. package/src/internal.ts +12 -0
  50. package/src/memory/__tests__/graph-memory-bridge.test.ts +357 -0
  51. package/src/memory/__tests__/llm-extraction.test.ts +17 -0
  52. package/src/memory/__tests__/quality-feedback.test.ts +418 -0
  53. package/src/memory/__tests__/sleep-consolidation.test.ts +790 -0
  54. package/src/memory/__tests__/temporal-supersession.test.ts +534 -0
  55. package/src/memory/brain-lifecycle.ts +13 -0
  56. package/src/memory/decisions.ts +24 -0
  57. package/src/memory/engine-compat.ts +37 -0
  58. package/src/memory/graph-memory-bridge.ts +751 -0
  59. package/src/memory/index.ts +2 -0
  60. package/src/memory/learnings.ts +24 -0
  61. package/src/memory/patterns.ts +24 -0
  62. package/src/memory/quality-feedback.ts +640 -0
  63. package/src/memory/sleep-consolidation.ts +932 -0
  64. package/src/memory/temporal-supersession.ts +568 -0
  65. package/src/store/__tests__/performance-safety.test.ts +4 -4
  66. package/src/tasks/complete.ts +20 -0
@@ -0,0 +1,790 @@
1
+ /**
2
+ * Tests for sleep-consolidation.ts — LLM-driven background memory hygiene.
3
+ *
4
+ * Tests cover:
5
+ * - runSleepConsolidation: config gate (disabled), no-API-key graceful no-op
6
+ * - stepMergeDuplicates: structural merge fallback, LLM merge decision
7
+ * - stepPruneStale: no candidates, LLM preserve decision, structural prune
8
+ * - stepStrengthenPatterns: no candidates, LLM synthesis, pattern stored
9
+ * - stepGenerateInsights: too few observations, cluster + insight stored
10
+ * - All LLM call failures are caught and result in graceful degradation
11
+ *
12
+ * Uses mocked Anthropic fetch and mocked brain-sqlite / store functions.
13
+ *
14
+ * @task T555
15
+ * @epic T549
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
+
20
+ // ============================================================================
21
+ // Hoisted mock factories
22
+ // ============================================================================
23
+
24
+ const {
25
+ mockGetBrainDb,
26
+ mockGetBrainNativeDb,
27
+ mockStoreLearning,
28
+ mockStorePattern,
29
+ mockLoadConfig,
30
+ mockResolveKey,
31
+ } = vi.hoisted(() => ({
32
+ mockGetBrainDb: vi.fn().mockResolvedValue({}),
33
+ mockGetBrainNativeDb: vi.fn(),
34
+ mockStoreLearning: vi.fn().mockResolvedValue({ id: 'L-test-001' }),
35
+ mockStorePattern: vi.fn().mockResolvedValue({ id: 'P-test-001' }),
36
+ mockLoadConfig: vi.fn(),
37
+ mockResolveKey: vi.fn().mockReturnValue(null),
38
+ }));
39
+
40
+ vi.mock('../../store/brain-sqlite.js', () => ({
41
+ getBrainDb: mockGetBrainDb,
42
+ getBrainNativeDb: mockGetBrainNativeDb,
43
+ }));
44
+
45
+ vi.mock('../learnings.js', () => ({
46
+ storeLearning: mockStoreLearning,
47
+ }));
48
+
49
+ vi.mock('../patterns.js', () => ({
50
+ storePattern: mockStorePattern,
51
+ }));
52
+
53
+ vi.mock('../graph-auto-populate.js', () => ({
54
+ addGraphEdge: vi.fn().mockResolvedValue(undefined),
55
+ upsertGraphNode: vi.fn().mockResolvedValue(undefined),
56
+ }));
57
+
58
+ vi.mock('../../config.js', () => ({
59
+ loadConfig: mockLoadConfig,
60
+ }));
61
+
62
+ // Mock the key resolver so tests don't depend on filesystem state
63
+ // (~/.claude/.credentials.json, ~/.local/share/cleo/anthropic-key).
64
+ vi.mock('../anthropic-key-resolver.js', () => ({
65
+ resolveAnthropicApiKey: (...args: unknown[]) => mockResolveKey(...args),
66
+ clearAnthropicKeyCache: vi.fn(),
67
+ }));
68
+
69
+ // ============================================================================
70
+ // Import module under test (after all mocks)
71
+ // ============================================================================
72
+
73
+ import { runSleepConsolidation } from '../sleep-consolidation.js';
74
+
75
+ // ============================================================================
76
+ // Helpers
77
+ // ============================================================================
78
+
79
+ const FAKE_API_KEY = 'sk-ant-test-key';
80
+
81
+ function setApiKey(key: string | undefined): void {
82
+ if (key === undefined) {
83
+ delete process.env['ANTHROPIC_API_KEY'];
84
+ mockResolveKey.mockReturnValue(null);
85
+ } else {
86
+ process.env['ANTHROPIC_API_KEY'] = key;
87
+ mockResolveKey.mockReturnValue(key);
88
+ }
89
+ }
90
+
91
+ /** Build a Float32 embedding buffer with the given cosine direction (unit vector in first dim). */
92
+ function makeEmbedding(val: number): Buffer {
93
+ const buf = Buffer.alloc(16); // 4 floats
94
+ buf.writeFloatLE(val, 0);
95
+ buf.writeFloatLE(0, 4);
96
+ buf.writeFloatLE(0, 8);
97
+ buf.writeFloatLE(0, 12);
98
+ return buf;
99
+ }
100
+
101
+ type ObsRow = {
102
+ id: string;
103
+ title: string;
104
+ narrative: string;
105
+ quality_score: number;
106
+ citation_count: number;
107
+ memory_tier: string;
108
+ created_at: string;
109
+ embedding: Buffer | null;
110
+ };
111
+
112
+ type LearningRow = {
113
+ id: string;
114
+ insight: string;
115
+ confidence: number;
116
+ citation_count: number;
117
+ source: string | null;
118
+ memory_tier: string;
119
+ };
120
+
121
+ type PatternRow = {
122
+ id: string;
123
+ pattern: string;
124
+ context: string;
125
+ impact: string;
126
+ frequency: number;
127
+ memory_tier: string;
128
+ };
129
+
130
+ type TextRow = {
131
+ id: string;
132
+ text: string;
133
+ };
134
+
135
+ /** Build a mock native SQLite DB with per-table configurable behavior. */
136
+ function buildMockNativeDb(options: {
137
+ obsRows?: ObsRow[];
138
+ learningRows?: LearningRow[];
139
+ patternRows?: PatternRow[];
140
+ obsTextRows?: TextRow[];
141
+ insertSucceeds?: boolean;
142
+ }) {
143
+ const {
144
+ obsRows = [],
145
+ learningRows = [],
146
+ patternRows = [],
147
+ obsTextRows = [],
148
+ insertSucceeds = true,
149
+ } = options;
150
+
151
+ const mockRun = vi.fn().mockReturnValue({ changes: 1, lastInsertRowid: 0 });
152
+
153
+ if (!insertSucceeds) {
154
+ mockRun.mockImplementation(() => {
155
+ throw new Error('DB write error');
156
+ });
157
+ }
158
+
159
+ // The prepare() mock captures the SQL and returns a statement mock whose
160
+ // .all() returns rows routed by the SQL content. This correctly mirrors the
161
+ // prepare(sql).all(params) call pattern used in sleep-consolidation.ts.
162
+ const prepare = vi.fn().mockImplementation((sql: string) => {
163
+ const mockAll = vi.fn().mockImplementation((..._args: unknown[]) => {
164
+ if (sql.includes('brain_learnings')) return learningRows;
165
+ if (sql.includes('brain_patterns')) return patternRows;
166
+ if (sql.includes('brain_observations') && sql.includes("memory_tier = 'short'"))
167
+ return obsRows;
168
+ if (sql.includes('brain_observations')) return obsTextRows;
169
+ return [];
170
+ });
171
+ return { run: mockRun, all: mockAll, get: vi.fn().mockReturnValue({ cnt: 0 }) };
172
+ });
173
+
174
+ const stmtMock = { run: mockRun, all: vi.fn().mockReturnValue([]), get: vi.fn().mockReturnValue({ cnt: 0 }) };
175
+ return { prepare, _stmtMock: stmtMock };
176
+ }
177
+
178
+ function mockFetchOk(responseBody: string): ReturnType<typeof vi.spyOn> {
179
+ return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
180
+ ok: true,
181
+ json: vi.fn().mockResolvedValue({
182
+ content: [{ type: 'text', text: responseBody }],
183
+ stop_reason: 'end_turn',
184
+ }),
185
+ } as unknown as Response);
186
+ }
187
+
188
+ function mockFetchError(): ReturnType<typeof vi.spyOn> {
189
+ return vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
190
+ }
191
+
192
+ // ============================================================================
193
+ // Tests: runSleepConsolidation (top-level gates)
194
+ // ============================================================================
195
+
196
+ describe('runSleepConsolidation', () => {
197
+ beforeEach(() => {
198
+ vi.clearAllMocks();
199
+ mockLoadConfig.mockResolvedValue({ brain: { sleepConsolidation: { enabled: true } } });
200
+ setApiKey(undefined);
201
+ });
202
+
203
+ afterEach(() => {
204
+ setApiKey(undefined);
205
+ vi.restoreAllMocks();
206
+ });
207
+
208
+ it('returns ran=false when sleepConsolidation is disabled in config', async () => {
209
+ mockLoadConfig.mockResolvedValue({
210
+ brain: { sleepConsolidation: { enabled: false } },
211
+ });
212
+ mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({}));
213
+
214
+ const result = await runSleepConsolidation('/tmp/project');
215
+ expect(result.ran).toBe(false);
216
+ });
217
+
218
+ it('returns ran=true and all-zero counts when no API key and no DB entries', async () => {
219
+ mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({}));
220
+
221
+ const result = await runSleepConsolidation('/tmp/project');
222
+ expect(result.ran).toBe(true);
223
+ expect(result.mergeDuplicates.merged).toBe(0);
224
+ expect(result.pruneStale.pruned).toBe(0);
225
+ expect(result.strengthenPatterns.synthesized).toBe(0);
226
+ expect(result.generateInsights.insightsStored).toBe(0);
227
+ });
228
+
229
+ it('returns ran=true even when DB is unavailable (null nativeDb)', async () => {
230
+ mockGetBrainNativeDb.mockReturnValue(null);
231
+
232
+ const result = await runSleepConsolidation('/tmp/project');
233
+ expect(result.ran).toBe(true);
234
+ expect(result.mergeDuplicates.merged).toBe(0);
235
+ });
236
+
237
+ it('never throws even when all LLM calls fail', async () => {
238
+ setApiKey(FAKE_API_KEY);
239
+ mockFetchError();
240
+ mockGetBrainNativeDb.mockReturnValue(
241
+ buildMockNativeDb({
242
+ obsRows: [
243
+ {
244
+ id: 'O-001',
245
+ title: 'Test',
246
+ narrative: 'Some text',
247
+ quality_score: 0.3,
248
+ citation_count: 0,
249
+ memory_tier: 'short',
250
+ created_at: '2026-01-01 00:00:00',
251
+ embedding: makeEmbedding(1.0),
252
+ },
253
+ ],
254
+ }),
255
+ );
256
+
257
+ await expect(runSleepConsolidation('/tmp/project')).resolves.not.toThrow();
258
+ });
259
+
260
+ it('defaults to enabled=true when config load fails', async () => {
261
+ mockLoadConfig.mockRejectedValue(new Error('Config unavailable'));
262
+ mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({}));
263
+
264
+ const result = await runSleepConsolidation('/tmp/project');
265
+ expect(result.ran).toBe(true);
266
+ });
267
+ });
268
+
269
+ // ============================================================================
270
+ // Tests: Merge Duplicates (Step 1)
271
+ // ============================================================================
272
+
273
+ describe('runSleepConsolidation — merge duplicates', () => {
274
+ beforeEach(() => {
275
+ vi.clearAllMocks();
276
+ mockLoadConfig.mockResolvedValue({ brain: { sleepConsolidation: { enabled: true } } });
277
+ });
278
+
279
+ afterEach(() => {
280
+ setApiKey(undefined);
281
+ vi.restoreAllMocks();
282
+ });
283
+
284
+ it('merges two observations with high embedding similarity (structural fallback, no key)', async () => {
285
+ setApiKey(undefined);
286
+ const embedding = makeEmbedding(1.0); // identical embeddings → similarity 1.0
287
+
288
+ const mockDb = buildMockNativeDb({
289
+ obsRows: [
290
+ {
291
+ id: 'O-001',
292
+ title: 'First',
293
+ narrative: 'Same content',
294
+ quality_score: 0.8,
295
+ citation_count: 2,
296
+ memory_tier: 'short',
297
+ created_at: '2026-04-10 10:00:00',
298
+ embedding,
299
+ },
300
+ {
301
+ id: 'O-002',
302
+ title: 'Second',
303
+ narrative: 'Same content duplicate',
304
+ quality_score: 0.5,
305
+ citation_count: 1,
306
+ memory_tier: 'short',
307
+ created_at: '2026-04-10 11:00:00',
308
+ embedding,
309
+ },
310
+ ],
311
+ });
312
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
313
+
314
+ const result = await runSleepConsolidation('/tmp/project');
315
+ expect(result.mergeDuplicates.merged).toBeGreaterThanOrEqual(1);
316
+ // Should UPDATE the evicted entry's invalid_at
317
+ expect(mockDb._stmtMock.run).toHaveBeenCalled();
318
+ });
319
+
320
+ it('skips merge when LLM says merge=false', async () => {
321
+ setApiKey(FAKE_API_KEY);
322
+ const embedding = makeEmbedding(1.0);
323
+
324
+ const mockDb = buildMockNativeDb({
325
+ obsRows: [
326
+ {
327
+ id: 'O-001',
328
+ title: 'First',
329
+ narrative: 'Content A',
330
+ quality_score: 0.8,
331
+ citation_count: 1,
332
+ memory_tier: 'short',
333
+ created_at: '2026-04-10 10:00:00',
334
+ embedding,
335
+ },
336
+ {
337
+ id: 'O-002',
338
+ title: 'Second',
339
+ narrative: 'Content B',
340
+ quality_score: 0.7,
341
+ citation_count: 1,
342
+ memory_tier: 'short',
343
+ created_at: '2026-04-10 11:00:00',
344
+ embedding,
345
+ },
346
+ ],
347
+ });
348
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
349
+
350
+ // LLM says: do not merge
351
+ mockFetchOk(JSON.stringify([{ pair: 0, merge: false, keep: 'O-001' }]));
352
+
353
+ const result = await runSleepConsolidation('/tmp/project');
354
+ expect(result.mergeDuplicates.merged).toBe(0);
355
+ expect(result.mergeDuplicates.llmDecisions).toBe(0);
356
+ });
357
+
358
+ it('skips entries with no embeddings (none to merge)', async () => {
359
+ const mockDb = buildMockNativeDb({
360
+ obsRows: [], // no embeddings available
361
+ });
362
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
363
+
364
+ const result = await runSleepConsolidation('/tmp/project');
365
+ expect(result.mergeDuplicates.merged).toBe(0);
366
+ });
367
+ });
368
+
369
+ // ============================================================================
370
+ // Tests: Prune Stale (Step 2)
371
+ // ============================================================================
372
+
373
+ describe('runSleepConsolidation — prune stale', () => {
374
+ beforeEach(() => {
375
+ vi.clearAllMocks();
376
+ mockLoadConfig.mockResolvedValue({ brain: { sleepConsolidation: { enabled: true } } });
377
+ });
378
+
379
+ afterEach(() => {
380
+ setApiKey(undefined);
381
+ vi.restoreAllMocks();
382
+ });
383
+
384
+ it('prunes stale entries (structural path, no API key)', async () => {
385
+ setApiKey(undefined);
386
+ const staleDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)
387
+ .toISOString()
388
+ .replace('T', ' ')
389
+ .slice(0, 19);
390
+
391
+ const staleObs: ObsRow = {
392
+ id: 'O-stale-001',
393
+ title: 'Stale',
394
+ narrative: 'Old low quality',
395
+ quality_score: 0.2,
396
+ citation_count: 0,
397
+ memory_tier: 'short',
398
+ created_at: staleDate,
399
+ embedding: null,
400
+ };
401
+
402
+ // obsRows is returned for short-tier queries (prune step), obsTextRows for text queries
403
+ const mockDb = buildMockNativeDb({ obsRows: [staleObs], obsTextRows: [] });
404
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
405
+
406
+ const result = await runSleepConsolidation('/tmp/project');
407
+ expect(result.pruneStale.pruned).toBeGreaterThanOrEqual(1);
408
+ });
409
+
410
+ it('preserves entries the LLM marks as worth keeping', async () => {
411
+ setApiKey(FAKE_API_KEY);
412
+ const staleDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)
413
+ .toISOString()
414
+ .replace('T', ' ')
415
+ .slice(0, 19);
416
+
417
+ const obsRow: ObsRow = {
418
+ id: 'O-keep-001',
419
+ title: 'Unique decision',
420
+ narrative: 'Contains irreplaceable context',
421
+ quality_score: 0.3,
422
+ citation_count: 0,
423
+ memory_tier: 'short',
424
+ created_at: staleDate,
425
+ embedding: null,
426
+ };
427
+
428
+ const mockDb = buildMockNativeDb({ obsRows: [obsRow], obsTextRows: [] });
429
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
430
+
431
+ // LLM says: preserve this entry
432
+ mockFetchOk(JSON.stringify({ preserve: ['O-keep-001'] }));
433
+
434
+ const result = await runSleepConsolidation('/tmp/project');
435
+ // pruned=0 because the only candidate was preserved
436
+ expect(result.pruneStale.preserved).toBe(1);
437
+ expect(result.pruneStale.pruned).toBe(0);
438
+ });
439
+
440
+ it('handles LLM failure gracefully (falls back to prune all candidates)', async () => {
441
+ setApiKey(FAKE_API_KEY);
442
+ mockFetchError();
443
+
444
+ const staleDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)
445
+ .toISOString()
446
+ .replace('T', ' ')
447
+ .slice(0, 19);
448
+
449
+ const obsRow: ObsRow = {
450
+ id: 'O-fallback-001',
451
+ title: 'Low quality',
452
+ narrative: 'Should be pruned',
453
+ quality_score: 0.1,
454
+ citation_count: 0,
455
+ memory_tier: 'short',
456
+ created_at: staleDate,
457
+ embedding: null,
458
+ };
459
+
460
+ const mockDb = buildMockNativeDb({ obsRows: [obsRow], obsTextRows: [] });
461
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
462
+
463
+ const result = await runSleepConsolidation('/tmp/project');
464
+ expect(result.pruneStale.pruned).toBe(1);
465
+ expect(result.pruneStale.preserved).toBe(0);
466
+ });
467
+
468
+ it('returns zero counts when no stale candidates', async () => {
469
+ const mockDb = buildMockNativeDb({ obsRows: [], obsTextRows: [] });
470
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
471
+
472
+ const result = await runSleepConsolidation('/tmp/project');
473
+ expect(result.pruneStale.pruned).toBe(0);
474
+ expect(result.pruneStale.preserved).toBe(0);
475
+ });
476
+ });
477
+
478
+ // ============================================================================
479
+ // Tests: Strengthen Patterns (Step 3)
480
+ // ============================================================================
481
+
482
+ describe('runSleepConsolidation — strengthen patterns', () => {
483
+ beforeEach(() => {
484
+ vi.clearAllMocks();
485
+ mockLoadConfig.mockResolvedValue({ brain: { sleepConsolidation: { enabled: true } } });
486
+ setApiKey(FAKE_API_KEY);
487
+ });
488
+
489
+ afterEach(() => {
490
+ setApiKey(undefined);
491
+ vi.restoreAllMocks();
492
+ });
493
+
494
+ it('synthesizes high-citation learnings into a new pattern', async () => {
495
+ const learnings: LearningRow[] = [
496
+ {
497
+ id: 'L-001',
498
+ insight: 'Always run biome before committing',
499
+ confidence: 0.9,
500
+ citation_count: 5,
501
+ source: 'agent',
502
+ memory_tier: 'medium',
503
+ },
504
+ {
505
+ id: 'L-002',
506
+ insight: 'Run pnpm build after major changes',
507
+ confidence: 0.85,
508
+ citation_count: 4,
509
+ source: 'agent',
510
+ memory_tier: 'medium',
511
+ },
512
+ ];
513
+
514
+ const mockDb = buildMockNativeDb({ learningRows: learnings, patternRows: [] });
515
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
516
+
517
+ mockFetchOk(
518
+ JSON.stringify({
519
+ patterns: [
520
+ {
521
+ pattern: 'Run quality gates (biome + build) before every commit',
522
+ context: 'Derived from repeated learnings about pre-commit hygiene',
523
+ impact: 'high',
524
+ },
525
+ ],
526
+ }),
527
+ );
528
+
529
+ const result = await runSleepConsolidation('/tmp/project');
530
+ expect(result.strengthenPatterns.synthesized).toBe(2);
531
+ expect(result.strengthenPatterns.patternsGenerated).toBe(1);
532
+ expect(mockStorePattern).toHaveBeenCalledOnce();
533
+ expect(mockStorePattern).toHaveBeenCalledWith(
534
+ '/tmp/project',
535
+ expect.objectContaining({
536
+ type: 'optimization',
537
+ source: 'sleep-consolidation',
538
+ }),
539
+ );
540
+ });
541
+
542
+ it('returns zero synthesized when no high-citation learnings exist', async () => {
543
+ const mockDb = buildMockNativeDb({ learningRows: [], patternRows: [] });
544
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
545
+
546
+ const result = await runSleepConsolidation('/tmp/project');
547
+ expect(result.strengthenPatterns.synthesized).toBe(0);
548
+ expect(result.strengthenPatterns.patternsGenerated).toBe(0);
549
+ expect(mockStorePattern).not.toHaveBeenCalled();
550
+ });
551
+
552
+ it('handles LLM returning empty patterns array gracefully', async () => {
553
+ const learnings: LearningRow[] = [
554
+ {
555
+ id: 'L-001',
556
+ insight: 'Some insight',
557
+ confidence: 0.9,
558
+ citation_count: 3,
559
+ source: 'agent',
560
+ memory_tier: 'medium',
561
+ },
562
+ ];
563
+ const mockDb = buildMockNativeDb({ learningRows: learnings, patternRows: [] });
564
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
565
+
566
+ mockFetchOk(JSON.stringify({ patterns: [] }));
567
+
568
+ const result = await runSleepConsolidation('/tmp/project');
569
+ expect(result.strengthenPatterns.synthesized).toBe(1);
570
+ expect(result.strengthenPatterns.patternsGenerated).toBe(0);
571
+ expect(mockStorePattern).not.toHaveBeenCalled();
572
+ });
573
+
574
+ it('skips patterns with empty or missing pattern text', async () => {
575
+ const learnings: LearningRow[] = [
576
+ {
577
+ id: 'L-001',
578
+ insight: 'Some insight',
579
+ confidence: 0.9,
580
+ citation_count: 3,
581
+ source: 'agent',
582
+ memory_tier: 'medium',
583
+ },
584
+ ];
585
+ const mockDb = buildMockNativeDb({ learningRows: learnings, patternRows: [] });
586
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
587
+
588
+ mockFetchOk(JSON.stringify({ patterns: [{ pattern: '', context: 'No text', impact: 'low' }] }));
589
+
590
+ const result = await runSleepConsolidation('/tmp/project');
591
+ expect(result.strengthenPatterns.patternsGenerated).toBe(0);
592
+ });
593
+
594
+ it('degrades gracefully when no API key is set', async () => {
595
+ setApiKey(undefined);
596
+
597
+ const learnings: LearningRow[] = [
598
+ {
599
+ id: 'L-001',
600
+ insight: 'Some insight',
601
+ confidence: 0.9,
602
+ citation_count: 5,
603
+ source: 'agent',
604
+ memory_tier: 'medium',
605
+ },
606
+ ];
607
+ const mockDb = buildMockNativeDb({ learningRows: learnings, patternRows: [] });
608
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
609
+
610
+ const result = await runSleepConsolidation('/tmp/project');
611
+ // synthesized count is still reported (candidates found), but no patterns stored
612
+ expect(result.strengthenPatterns.synthesized).toBe(1);
613
+ expect(result.strengthenPatterns.patternsGenerated).toBe(0);
614
+ expect(mockStorePattern).not.toHaveBeenCalled();
615
+ });
616
+ });
617
+
618
+ // ============================================================================
619
+ // Tests: Generate Insights (Step 4)
620
+ // ============================================================================
621
+
622
+ describe('runSleepConsolidation — generate insights', () => {
623
+ beforeEach(() => {
624
+ vi.clearAllMocks();
625
+ mockLoadConfig.mockResolvedValue({ brain: { sleepConsolidation: { enabled: true } } });
626
+ setApiKey(FAKE_API_KEY);
627
+ });
628
+
629
+ afterEach(() => {
630
+ setApiKey(undefined);
631
+ vi.restoreAllMocks();
632
+ });
633
+
634
+ it('stores a cross-cutting insight from a valid cluster', async () => {
635
+ // Need >= 5 observations to trigger clustering
636
+ const obsTextRows: TextRow[] = Array.from({ length: 8 }, (_, i) => ({
637
+ id: `O-${String(i + 1).padStart(3, '0')}`,
638
+ // Intentionally share tokens "brain memory consolidation" so they cluster together
639
+ text: `brain memory consolidation step ${i} dedup quality short tier entry update`,
640
+ }));
641
+
642
+ const mockDb = buildMockNativeDb({ obsRows: [], obsTextRows });
643
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
644
+
645
+ mockFetchOk(
646
+ JSON.stringify({
647
+ insights: [
648
+ {
649
+ cluster: 0,
650
+ insight: 'Brain consolidation runs best when short-tier entries are deduplicated first',
651
+ confidence: 0.85,
652
+ },
653
+ ],
654
+ }),
655
+ );
656
+
657
+ const result = await runSleepConsolidation('/tmp/project');
658
+ expect(result.generateInsights.clustersProcessed).toBeGreaterThanOrEqual(1);
659
+ expect(result.generateInsights.insightsStored).toBe(1);
660
+ expect(mockStoreLearning).toHaveBeenCalledWith(
661
+ '/tmp/project',
662
+ expect.objectContaining({
663
+ source: 'sleep-consolidation',
664
+ actionable: true,
665
+ }),
666
+ );
667
+ });
668
+
669
+ it('returns zero when fewer than 5 observations available', async () => {
670
+ const obsTextRows: TextRow[] = [
671
+ { id: 'O-001', text: 'only three entries' },
672
+ { id: 'O-002', text: 'second entry' },
673
+ { id: 'O-003', text: 'third entry' },
674
+ ];
675
+
676
+ const mockDb = buildMockNativeDb({ obsRows: [], obsTextRows });
677
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
678
+
679
+ const result = await runSleepConsolidation('/tmp/project');
680
+ expect(result.generateInsights.clustersProcessed).toBe(0);
681
+ expect(result.generateInsights.insightsStored).toBe(0);
682
+ });
683
+
684
+ it('skips insights with confidence below 0.7', async () => {
685
+ const obsTextRows: TextRow[] = Array.from({ length: 8 }, (_, i) => ({
686
+ id: `O-${String(i + 1).padStart(3, '0')}`,
687
+ text: `brain memory consolidation step ${i} dedup quality short tier entry update`,
688
+ }));
689
+ const mockDb = buildMockNativeDb({ obsRows: [], obsTextRows });
690
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
691
+
692
+ mockFetchOk(
693
+ JSON.stringify({
694
+ insights: [{ cluster: 0, insight: 'Low confidence insight', confidence: 0.5 }],
695
+ }),
696
+ );
697
+
698
+ const result = await runSleepConsolidation('/tmp/project');
699
+ expect(result.generateInsights.insightsStored).toBe(0);
700
+ expect(mockStoreLearning).not.toHaveBeenCalled();
701
+ });
702
+
703
+ it('handles LLM failure gracefully', async () => {
704
+ mockFetchError();
705
+
706
+ const obsTextRows: TextRow[] = Array.from({ length: 8 }, (_, i) => ({
707
+ id: `O-${String(i + 1).padStart(3, '0')}`,
708
+ text: `brain memory consolidation step ${i} dedup quality short tier entry update`,
709
+ }));
710
+ const mockDb = buildMockNativeDb({ obsRows: [], obsTextRows });
711
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
712
+
713
+ const result = await runSleepConsolidation('/tmp/project');
714
+ expect(result.generateInsights.clustersProcessed).toBeGreaterThanOrEqual(0);
715
+ expect(result.generateInsights.insightsStored).toBe(0);
716
+ expect(mockStoreLearning).not.toHaveBeenCalled();
717
+ });
718
+ });
719
+
720
+ // ============================================================================
721
+ // Tests: JSON parse helper (via LLM response path)
722
+ // ============================================================================
723
+
724
+ describe('runSleepConsolidation — JSON response handling', () => {
725
+ beforeEach(() => {
726
+ vi.clearAllMocks();
727
+ mockLoadConfig.mockResolvedValue({ brain: { sleepConsolidation: { enabled: true } } });
728
+ setApiKey(FAKE_API_KEY);
729
+ });
730
+
731
+ afterEach(() => {
732
+ setApiKey(undefined);
733
+ vi.restoreAllMocks();
734
+ });
735
+
736
+ it('handles markdown-fenced JSON responses from LLM', async () => {
737
+ const learnings: LearningRow[] = [
738
+ {
739
+ id: 'L-001',
740
+ insight: 'Some insight',
741
+ confidence: 0.9,
742
+ citation_count: 3,
743
+ source: 'agent',
744
+ memory_tier: 'medium',
745
+ },
746
+ ];
747
+ const mockDb = buildMockNativeDb({ learningRows: learnings, patternRows: [] });
748
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
749
+
750
+ // Simulate LLM wrapping JSON in markdown code fences
751
+ const fencedJson =
752
+ '```json\n' +
753
+ JSON.stringify({
754
+ patterns: [
755
+ {
756
+ pattern: 'Fenced pattern test',
757
+ context: 'From markdown-wrapped response',
758
+ impact: 'medium',
759
+ },
760
+ ],
761
+ }) +
762
+ '\n```';
763
+
764
+ mockFetchOk(fencedJson);
765
+
766
+ const result = await runSleepConsolidation('/tmp/project');
767
+ expect(result.strengthenPatterns.patternsGenerated).toBe(1);
768
+ });
769
+
770
+ it('gracefully handles invalid (non-JSON) LLM responses', async () => {
771
+ const learnings: LearningRow[] = [
772
+ {
773
+ id: 'L-001',
774
+ insight: 'Some insight',
775
+ confidence: 0.9,
776
+ citation_count: 3,
777
+ source: 'agent',
778
+ memory_tier: 'medium',
779
+ },
780
+ ];
781
+ const mockDb = buildMockNativeDb({ learningRows: learnings, patternRows: [] });
782
+ mockGetBrainNativeDb.mockReturnValue(mockDb);
783
+
784
+ mockFetchOk('This is not JSON at all, just plain text.');
785
+
786
+ const result = await runSleepConsolidation('/tmp/project');
787
+ expect(result.strengthenPatterns.synthesized).toBe(1);
788
+ expect(result.strengthenPatterns.patternsGenerated).toBe(0);
789
+ });
790
+ });