@cleocode/core 2026.4.37 → 2026.4.38
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/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/task-hooks.js +11 -0
- package/dist/hooks/handlers/task-hooks.js.map +1 -1
- package/dist/index.js +644 -33
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +3 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -1
- package/dist/internal.js.map +1 -1
- package/dist/memory/decisions.d.ts.map +1 -1
- package/dist/memory/decisions.js +18 -0
- package/dist/memory/decisions.js.map +1 -1
- package/dist/memory/engine-compat.d.ts +17 -0
- package/dist/memory/engine-compat.d.ts.map +1 -1
- package/dist/memory/engine-compat.js +36 -0
- package/dist/memory/engine-compat.js.map +1 -1
- package/dist/memory/graph-memory-bridge.d.ts +158 -0
- package/dist/memory/graph-memory-bridge.d.ts.map +1 -0
- package/dist/memory/graph-memory-bridge.js +519 -0
- package/dist/memory/graph-memory-bridge.js.map +1 -0
- package/dist/memory/index.d.ts +1 -0
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +2 -0
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/learnings.d.ts.map +1 -1
- package/dist/memory/learnings.js +18 -0
- package/dist/memory/learnings.js.map +1 -1
- package/dist/memory/llm-extraction.js.map +1 -1
- package/dist/memory/patterns.d.ts.map +1 -1
- package/dist/memory/patterns.js +18 -0
- package/dist/memory/patterns.js.map +1 -1
- package/dist/memory/quality-feedback.d.ts +129 -0
- package/dist/memory/quality-feedback.d.ts.map +1 -0
- package/dist/memory/quality-feedback.js +449 -0
- package/dist/memory/quality-feedback.js.map +1 -0
- package/dist/memory/sleep-consolidation.d.ts +98 -0
- package/dist/memory/sleep-consolidation.d.ts.map +1 -0
- package/dist/memory/sleep-consolidation.js +706 -0
- package/dist/memory/sleep-consolidation.js.map +1 -0
- package/dist/memory/temporal-supersession.d.ts +155 -0
- package/dist/memory/temporal-supersession.d.ts.map +1 -0
- package/dist/memory/temporal-supersession.js +406 -0
- package/dist/memory/temporal-supersession.js.map +1 -0
- package/package.json +6 -6
- package/src/hooks/handlers/task-hooks.ts +11 -0
- package/src/internal.ts +12 -0
- package/src/memory/__tests__/graph-memory-bridge.test.ts +357 -0
- package/src/memory/__tests__/llm-extraction.test.ts +17 -0
- package/src/memory/__tests__/quality-feedback.test.ts +418 -0
- package/src/memory/__tests__/sleep-consolidation.test.ts +790 -0
- package/src/memory/__tests__/temporal-supersession.test.ts +534 -0
- package/src/memory/decisions.ts +24 -0
- package/src/memory/engine-compat.ts +37 -0
- package/src/memory/graph-memory-bridge.ts +751 -0
- package/src/memory/index.ts +2 -0
- package/src/memory/learnings.ts +24 -0
- package/src/memory/patterns.ts +24 -0
- package/src/memory/quality-feedback.ts +640 -0
- package/src/memory/sleep-consolidation.ts +932 -0
- package/src/memory/temporal-supersession.ts +568 -0
- package/src/store/__tests__/performance-safety.test.ts +4 -4
|
@@ -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
|
+
});
|