@cleocode/core 2026.4.35 → 2026.4.36
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/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/hooks/handlers/conduit-hooks.d.ts +72 -0
- package/dist/hooks/handlers/conduit-hooks.d.ts.map +1 -0
- package/dist/hooks/handlers/conduit-hooks.js +229 -0
- package/dist/hooks/handlers/conduit-hooks.js.map +1 -0
- package/dist/hooks/handlers/index.d.ts +2 -0
- package/dist/hooks/handlers/index.d.ts.map +1 -1
- package/dist/hooks/handlers/index.js +3 -0
- package/dist/hooks/handlers/index.js.map +1 -1
- package/dist/hooks/handlers/session-hooks.d.ts +14 -0
- package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/session-hooks.js +33 -0
- package/dist/hooks/handlers/session-hooks.js.map +1 -1
- package/dist/hooks/handlers/task-hooks.d.ts +2 -0
- package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/task-hooks.js +14 -0
- package/dist/hooks/handlers/task-hooks.js.map +1 -1
- package/dist/index.js +54918 -46845
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/memory/anthropic-key-resolver.d.ts +35 -0
- package/dist/memory/anthropic-key-resolver.d.ts.map +1 -0
- package/dist/memory/anthropic-key-resolver.js +105 -0
- package/dist/memory/anthropic-key-resolver.js.map +1 -0
- package/dist/memory/auto-extract.d.ts +38 -42
- package/dist/memory/auto-extract.d.ts.map +1 -1
- package/dist/memory/auto-extract.js +38 -57
- package/dist/memory/auto-extract.js.map +1 -1
- package/dist/memory/brain-retrieval.d.ts +6 -0
- package/dist/memory/brain-retrieval.d.ts.map +1 -1
- package/dist/memory/brain-retrieval.js +145 -13
- package/dist/memory/brain-retrieval.js.map +1 -1
- package/dist/memory/brain-search.d.ts +82 -15
- package/dist/memory/brain-search.d.ts.map +1 -1
- package/dist/memory/brain-search.js +178 -93
- package/dist/memory/brain-search.js.map +1 -1
- package/dist/memory/engine-compat.d.ts +16 -1
- package/dist/memory/engine-compat.d.ts.map +1 -1
- package/dist/memory/engine-compat.js +0 -3
- package/dist/memory/engine-compat.js.map +1 -1
- package/dist/memory/learnings.d.ts.map +1 -1
- package/dist/memory/learnings.js +4 -3
- package/dist/memory/learnings.js.map +1 -1
- package/dist/memory/llm-extraction.d.ts +107 -0
- package/dist/memory/llm-extraction.d.ts.map +1 -0
- package/dist/memory/llm-extraction.js +425 -0
- package/dist/memory/llm-extraction.js.map +1 -0
- package/dist/memory/memory-bridge.js +23 -11
- package/dist/memory/memory-bridge.js.map +1 -1
- package/dist/memory/observer-reflector.d.ts +157 -0
- package/dist/memory/observer-reflector.d.ts.map +1 -0
- package/dist/memory/observer-reflector.js +626 -0
- package/dist/memory/observer-reflector.js.map +1 -0
- package/dist/store/brain-schema.d.ts +131 -0
- package/dist/store/brain-schema.d.ts.map +1 -1
- package/dist/store/brain-schema.js +30 -0
- package/dist/store/brain-schema.js.map +1 -1
- package/dist/store/brain-sqlite.js +41 -1
- package/dist/store/brain-sqlite.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +7 -8
- package/dist/tasks/complete.js.map +1 -1
- package/package.json +13 -12
- package/src/config.ts +7 -0
- package/src/hooks/handlers/__tests__/conduit-hooks.test.ts +356 -0
- package/src/hooks/handlers/conduit-hooks.ts +258 -0
- package/src/hooks/handlers/index.ts +7 -0
- package/src/hooks/handlers/session-hooks.ts +37 -0
- package/src/hooks/handlers/task-hooks.ts +14 -0
- package/src/internal.ts +8 -0
- package/src/memory/__tests__/auto-extract.test.ts +43 -114
- package/src/memory/__tests__/brain-automation.test.ts +16 -39
- package/src/memory/__tests__/brain-rrf.test.ts +431 -0
- package/src/memory/__tests__/llm-extraction.test.ts +342 -0
- package/src/memory/__tests__/observer-reflector.test.ts +475 -0
- package/src/memory/anthropic-key-resolver.ts +113 -0
- package/src/memory/auto-extract.ts +40 -72
- package/src/memory/brain-retrieval.ts +187 -18
- package/src/memory/brain-search.ts +196 -128
- package/src/memory/engine-compat.ts +16 -4
- package/src/memory/learnings.ts +4 -3
- package/src/memory/llm-extraction.ts +524 -0
- package/src/memory/memory-bridge.ts +29 -12
- package/src/memory/observer-reflector.ts +829 -0
- package/src/store/brain-schema.ts +44 -0
- package/src/tasks/complete.ts +7 -10
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for observer-reflector.ts — LLM-driven session compression.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - runObserver: gates (no API key, disabled, below threshold), happy path, LLM failure graceful degradation
|
|
6
|
+
* - runReflector: gates (no API key, disabled, insufficient observations), happy path, LLM failure graceful degradation
|
|
7
|
+
* - Markdown code fence stripping from LLM responses
|
|
8
|
+
* - Invalid entry skipping
|
|
9
|
+
* - Confidence clamping
|
|
10
|
+
*
|
|
11
|
+
* Uses mocked Anthropic fetch and mocked brain-sqlite / store functions.
|
|
12
|
+
*
|
|
13
|
+
* @task T554
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Hoisted mock factories — must use vi.hoisted() so variables are available
|
|
20
|
+
// inside vi.mock() factory functions before import resolution.
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
mockGetBrainDb,
|
|
25
|
+
mockGetBrainNativeDb,
|
|
26
|
+
mockStoreLearning,
|
|
27
|
+
mockStorePattern,
|
|
28
|
+
mockLoadConfig,
|
|
29
|
+
} = vi.hoisted(() => ({
|
|
30
|
+
mockGetBrainDb: vi.fn().mockResolvedValue({}),
|
|
31
|
+
mockGetBrainNativeDb: vi.fn(),
|
|
32
|
+
mockStoreLearning: vi.fn().mockResolvedValue({ id: 'L-test-001' }),
|
|
33
|
+
mockStorePattern: vi.fn().mockResolvedValue({ id: 'P-test-001' }),
|
|
34
|
+
mockLoadConfig: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('../../store/brain-sqlite.js', () => ({
|
|
38
|
+
getBrainDb: mockGetBrainDb,
|
|
39
|
+
getBrainNativeDb: mockGetBrainNativeDb,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('../learnings.js', () => ({
|
|
43
|
+
storeLearning: mockStoreLearning,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('../patterns.js', () => ({
|
|
47
|
+
storePattern: mockStorePattern,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('../graph-auto-populate.js', () => ({
|
|
51
|
+
addGraphEdge: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
upsertGraphNode: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock('../../config.js', () => ({
|
|
56
|
+
loadConfig: mockLoadConfig,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Import module under test (after all mocks)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
import { runObserver, runReflector } from '../observer-reflector.js';
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Helpers
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
const FAKE_API_KEY = 'sk-ant-test-key';
|
|
70
|
+
|
|
71
|
+
function setApiKey(key: string | undefined): void {
|
|
72
|
+
if (key === undefined) {
|
|
73
|
+
delete process.env['ANTHROPIC_API_KEY'];
|
|
74
|
+
} else {
|
|
75
|
+
process.env['ANTHROPIC_API_KEY'] = key;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type RawObs = {
|
|
80
|
+
id: string;
|
|
81
|
+
type: string;
|
|
82
|
+
title: string;
|
|
83
|
+
narrative: string;
|
|
84
|
+
created_at: string;
|
|
85
|
+
source_type: string | null;
|
|
86
|
+
source_session_id: string | null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Build a mock native SQLite DB with configurable .all() / .get() / .prepare() behavior. */
|
|
90
|
+
function buildMockNativeDb(options: {
|
|
91
|
+
observationCount?: number;
|
|
92
|
+
observations?: RawObs[];
|
|
93
|
+
insertSucceeds?: boolean;
|
|
94
|
+
}) {
|
|
95
|
+
const { observationCount = 0, observations = [], insertSucceeds = true } = options;
|
|
96
|
+
|
|
97
|
+
const mockRun = vi.fn().mockReturnValue({ changes: 1, lastInsertRowid: 0 });
|
|
98
|
+
const mockAll = vi.fn().mockReturnValue(observations);
|
|
99
|
+
const mockGet = vi.fn().mockReturnValue({ cnt: observationCount });
|
|
100
|
+
|
|
101
|
+
const stmtMock = {
|
|
102
|
+
run: mockRun,
|
|
103
|
+
all: mockAll,
|
|
104
|
+
get: mockGet,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (!insertSucceeds) {
|
|
108
|
+
mockRun.mockImplementation(() => {
|
|
109
|
+
throw new Error('DB write error');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
prepare: vi.fn().mockReturnValue(stmtMock),
|
|
115
|
+
_stmtMock: stmtMock,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makeObs(n: number, sessionId: string | null = null): RawObs[] {
|
|
120
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
121
|
+
id: `O-${String(i + 1).padStart(3, '0')}`,
|
|
122
|
+
type: 'change',
|
|
123
|
+
title: `Observation ${i + 1}`,
|
|
124
|
+
narrative: `Narrative for observation ${i + 1}`,
|
|
125
|
+
created_at: `2026-04-13 10:${String(i % 60).padStart(2, '0')}:00`,
|
|
126
|
+
source_type: 'agent',
|
|
127
|
+
source_session_id: sessionId,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function mockFetchOk(responseBody: string): ReturnType<typeof vi.spyOn> {
|
|
132
|
+
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
133
|
+
ok: true,
|
|
134
|
+
json: vi.fn().mockResolvedValue({
|
|
135
|
+
content: [{ type: 'text', text: responseBody }],
|
|
136
|
+
stop_reason: 'end_turn',
|
|
137
|
+
}),
|
|
138
|
+
} as unknown as Response);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Tests: runObserver
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
describe('runObserver', () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
vi.clearAllMocks();
|
|
148
|
+
mockLoadConfig.mockResolvedValue({
|
|
149
|
+
brain: { observer: { enabled: true, threshold: 10 } },
|
|
150
|
+
});
|
|
151
|
+
setApiKey(undefined);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
setApiKey(undefined);
|
|
156
|
+
vi.restoreAllMocks();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns empty result when ANTHROPIC_API_KEY is not set', async () => {
|
|
160
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observationCount: 20 }));
|
|
161
|
+
const result = await runObserver('/tmp/project');
|
|
162
|
+
|
|
163
|
+
expect(result.ran).toBe(false);
|
|
164
|
+
expect(result.stored).toBe(0);
|
|
165
|
+
expect(result.notes).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns empty result when observer is disabled in config', async () => {
|
|
169
|
+
setApiKey(FAKE_API_KEY);
|
|
170
|
+
mockLoadConfig.mockResolvedValue({
|
|
171
|
+
brain: { observer: { enabled: false, threshold: 10 } },
|
|
172
|
+
});
|
|
173
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observationCount: 20 }));
|
|
174
|
+
|
|
175
|
+
const result = await runObserver('/tmp/project');
|
|
176
|
+
expect(result.ran).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns empty result when observation count is below threshold', async () => {
|
|
180
|
+
setApiKey(FAKE_API_KEY);
|
|
181
|
+
// count = 5, threshold = 10 → should not run
|
|
182
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
183
|
+
buildMockNativeDb({ observationCount: 5, observations: makeObs(5) }),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const result = await runObserver('/tmp/project', 'ses_test');
|
|
187
|
+
expect(result.ran).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns empty result when fetch fails (graceful degradation)', async () => {
|
|
191
|
+
setApiKey(FAKE_API_KEY);
|
|
192
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
193
|
+
buildMockNativeDb({ observationCount: 12, observations: makeObs(12, 'ses_abc') }),
|
|
194
|
+
);
|
|
195
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
|
196
|
+
|
|
197
|
+
const result = await runObserver('/tmp/project', 'ses_abc');
|
|
198
|
+
expect(result.ran).toBe(false);
|
|
199
|
+
expect(result.stored).toBe(0);
|
|
200
|
+
|
|
201
|
+
fetchSpy.mockRestore();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('stores observer notes when LLM returns valid JSON', async () => {
|
|
205
|
+
setApiKey(FAKE_API_KEY);
|
|
206
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
207
|
+
buildMockNativeDb({ observationCount: 12, observations: makeObs(12, 'ses_abc') }),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const notes = [
|
|
211
|
+
{
|
|
212
|
+
date: '2026-04-13',
|
|
213
|
+
priority: 1,
|
|
214
|
+
observation:
|
|
215
|
+
'Architectural decision: use SQLite for brain.db because WAL mode prevents corruption',
|
|
216
|
+
source_ids: ['O-001', 'O-002', 'O-003'],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
date: '2026-04-13',
|
|
220
|
+
priority: 3,
|
|
221
|
+
observation: 'Fixed: task-hooks.ts missing observer call — added setImmediate wrapper',
|
|
222
|
+
source_ids: ['O-004', 'O-005'],
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const fetchSpy = mockFetchOk(JSON.stringify(notes));
|
|
227
|
+
const result = await runObserver('/tmp/project', 'ses_abc');
|
|
228
|
+
|
|
229
|
+
expect(result.ran).toBe(true);
|
|
230
|
+
expect(result.stored).toBe(2);
|
|
231
|
+
expect(result.notes).toHaveLength(2);
|
|
232
|
+
expect(result.compressedIds).toContain('O-001');
|
|
233
|
+
expect(result.compressedIds).toContain('O-004');
|
|
234
|
+
|
|
235
|
+
fetchSpy.mockRestore();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('returns empty result when LLM response is malformed JSON', async () => {
|
|
239
|
+
setApiKey(FAKE_API_KEY);
|
|
240
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
241
|
+
buildMockNativeDb({ observationCount: 12, observations: makeObs(12) }),
|
|
242
|
+
);
|
|
243
|
+
const fetchSpy = mockFetchOk('not json at all {broken');
|
|
244
|
+
|
|
245
|
+
const result = await runObserver('/tmp/project');
|
|
246
|
+
expect(result.ran).toBe(false);
|
|
247
|
+
expect(result.stored).toBe(0);
|
|
248
|
+
|
|
249
|
+
fetchSpy.mockRestore();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('strips markdown code fences from LLM response', async () => {
|
|
253
|
+
setApiKey(FAKE_API_KEY);
|
|
254
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
255
|
+
buildMockNativeDb({ observationCount: 12, observations: makeObs(12) }),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const noteWithFence =
|
|
259
|
+
'```json\n[{"date":"2026-04-13","priority":2,"observation":"Test note","source_ids":["O-001"]}]\n```';
|
|
260
|
+
const fetchSpy = mockFetchOk(noteWithFence);
|
|
261
|
+
|
|
262
|
+
const result = await runObserver('/tmp/project');
|
|
263
|
+
expect(result.ran).toBe(true);
|
|
264
|
+
expect(result.stored).toBe(1);
|
|
265
|
+
expect(result.notes[0]?.observation).toBe('Test note');
|
|
266
|
+
|
|
267
|
+
fetchSpy.mockRestore();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('handles Anthropic API HTTP error gracefully', async () => {
|
|
271
|
+
setApiKey(FAKE_API_KEY);
|
|
272
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
273
|
+
buildMockNativeDb({ observationCount: 15, observations: makeObs(15) }),
|
|
274
|
+
);
|
|
275
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
276
|
+
ok: false,
|
|
277
|
+
status: 401,
|
|
278
|
+
text: vi.fn().mockResolvedValue('Unauthorized'),
|
|
279
|
+
} as unknown as Response);
|
|
280
|
+
|
|
281
|
+
const result = await runObserver('/tmp/project');
|
|
282
|
+
expect(result.ran).toBe(false);
|
|
283
|
+
|
|
284
|
+
fetchSpy.mockRestore();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// Tests: runReflector
|
|
290
|
+
// ============================================================================
|
|
291
|
+
|
|
292
|
+
describe('runReflector', () => {
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
vi.clearAllMocks();
|
|
295
|
+
mockLoadConfig.mockResolvedValue({
|
|
296
|
+
brain: { reflector: { enabled: true } },
|
|
297
|
+
});
|
|
298
|
+
setApiKey(undefined);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterEach(() => {
|
|
302
|
+
setApiKey(undefined);
|
|
303
|
+
vi.restoreAllMocks();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('returns empty result when ANTHROPIC_API_KEY is not set', async () => {
|
|
307
|
+
const result = await runReflector('/tmp/project', 'ses_test');
|
|
308
|
+
expect(result.ran).toBe(false);
|
|
309
|
+
expect(result.patternsStored).toBe(0);
|
|
310
|
+
expect(result.learningsStored).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('returns empty result when reflector is disabled in config', async () => {
|
|
314
|
+
setApiKey(FAKE_API_KEY);
|
|
315
|
+
mockLoadConfig.mockResolvedValue({
|
|
316
|
+
brain: { reflector: { enabled: false } },
|
|
317
|
+
});
|
|
318
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observations: [] }));
|
|
319
|
+
|
|
320
|
+
const result = await runReflector('/tmp/project', 'ses_test');
|
|
321
|
+
expect(result.ran).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('returns empty result when fewer than 3 observations exist', async () => {
|
|
325
|
+
setApiKey(FAKE_API_KEY);
|
|
326
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
327
|
+
buildMockNativeDb({ observations: makeObs(2, 'ses_test') }),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const result = await runReflector('/tmp/project', 'ses_test');
|
|
331
|
+
expect(result.ran).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('stores patterns and learnings from valid LLM response', async () => {
|
|
335
|
+
setApiKey(FAKE_API_KEY);
|
|
336
|
+
mockGetBrainNativeDb.mockReturnValue(
|
|
337
|
+
buildMockNativeDb({ observations: makeObs(8, 'ses_test') }),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const reflectorOutput = {
|
|
341
|
+
patterns: [
|
|
342
|
+
{
|
|
343
|
+
pattern: 'When implementing hooks, always use setImmediate for async operations',
|
|
344
|
+
context: 'task-hooks.ts',
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
pattern: 'Observer threshold of 10 prevents excessive LLM calls per session',
|
|
348
|
+
context: 'observer config',
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
learnings: [
|
|
352
|
+
{ insight: 'Observer/reflector adds 3-6x compression without data loss', confidence: 0.85 },
|
|
353
|
+
{ insight: 'SQLite INSERT OR IGNORE prevents observer infinite loops', confidence: 0.9 },
|
|
354
|
+
],
|
|
355
|
+
superseded: ['O-001', 'O-002'],
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const fetchSpy = mockFetchOk(JSON.stringify(reflectorOutput));
|
|
359
|
+
const result = await runReflector('/tmp/project', 'ses_test');
|
|
360
|
+
|
|
361
|
+
expect(result.ran).toBe(true);
|
|
362
|
+
expect(result.patternsStored).toBe(2);
|
|
363
|
+
expect(result.learningsStored).toBe(2);
|
|
364
|
+
expect(result.supersededIds).toContain('O-001');
|
|
365
|
+
expect(result.supersededIds).toContain('O-002');
|
|
366
|
+
|
|
367
|
+
expect(mockStorePattern).toHaveBeenCalledTimes(2);
|
|
368
|
+
expect(mockStorePattern).toHaveBeenCalledWith(
|
|
369
|
+
'/tmp/project',
|
|
370
|
+
expect.objectContaining({ source: 'reflector-synthesized' }),
|
|
371
|
+
);
|
|
372
|
+
expect(mockStoreLearning).toHaveBeenCalledTimes(2);
|
|
373
|
+
expect(mockStoreLearning).toHaveBeenCalledWith(
|
|
374
|
+
'/tmp/project',
|
|
375
|
+
expect.objectContaining({ source: 'reflector-synthesized', confidence: 0.85 }),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
fetchSpy.mockRestore();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('clamps confidence to [0.1, 1.0] range', async () => {
|
|
382
|
+
setApiKey(FAKE_API_KEY);
|
|
383
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observations: makeObs(5) }));
|
|
384
|
+
|
|
385
|
+
const reflectorOutput = {
|
|
386
|
+
patterns: [],
|
|
387
|
+
learnings: [
|
|
388
|
+
{ insight: 'Something useful', confidence: -5.0 }, // clamp → 0.1
|
|
389
|
+
{ insight: 'Very certain fact', confidence: 99.0 }, // clamp → 1.0
|
|
390
|
+
],
|
|
391
|
+
superseded: [],
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const fetchSpy = mockFetchOk(JSON.stringify(reflectorOutput));
|
|
395
|
+
await runReflector('/tmp/project');
|
|
396
|
+
|
|
397
|
+
expect(mockStoreLearning).toHaveBeenCalledWith(
|
|
398
|
+
'/tmp/project',
|
|
399
|
+
expect.objectContaining({ confidence: 0.1 }),
|
|
400
|
+
);
|
|
401
|
+
expect(mockStoreLearning).toHaveBeenCalledWith(
|
|
402
|
+
'/tmp/project',
|
|
403
|
+
expect.objectContaining({ confidence: 1.0 }),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
fetchSpy.mockRestore();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('handles fetch failure gracefully', async () => {
|
|
410
|
+
setApiKey(FAKE_API_KEY);
|
|
411
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observations: makeObs(5) }));
|
|
412
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('timeout'));
|
|
413
|
+
|
|
414
|
+
const result = await runReflector('/tmp/project');
|
|
415
|
+
expect(result.ran).toBe(false);
|
|
416
|
+
expect(result.patternsStored).toBe(0);
|
|
417
|
+
expect(result.learningsStored).toBe(0);
|
|
418
|
+
|
|
419
|
+
fetchSpy.mockRestore();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('handles malformed LLM response gracefully', async () => {
|
|
423
|
+
setApiKey(FAKE_API_KEY);
|
|
424
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observations: makeObs(5) }));
|
|
425
|
+
const fetchSpy = mockFetchOk('{ this is not valid json ]');
|
|
426
|
+
|
|
427
|
+
const result = await runReflector('/tmp/project');
|
|
428
|
+
expect(result.ran).toBe(false);
|
|
429
|
+
|
|
430
|
+
fetchSpy.mockRestore();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('skips invalid pattern/learning entries without crashing', async () => {
|
|
434
|
+
setApiKey(FAKE_API_KEY);
|
|
435
|
+
mockGetBrainNativeDb.mockReturnValue(buildMockNativeDb({ observations: makeObs(5) }));
|
|
436
|
+
|
|
437
|
+
const reflectorOutput = {
|
|
438
|
+
patterns: [
|
|
439
|
+
{ pattern: '', context: 'empty — skip' },
|
|
440
|
+
{ pattern: 123, context: 'non-string — skip' },
|
|
441
|
+
{ pattern: 'Valid pattern here', context: 'good' },
|
|
442
|
+
],
|
|
443
|
+
learnings: [
|
|
444
|
+
{ insight: null, confidence: 0.8 }, // null → skip
|
|
445
|
+
{ insight: 'Valid learning', confidence: 0.75 },
|
|
446
|
+
],
|
|
447
|
+
superseded: [],
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const fetchSpy = mockFetchOk(JSON.stringify(reflectorOutput));
|
|
451
|
+
const result = await runReflector('/tmp/project');
|
|
452
|
+
|
|
453
|
+
expect(result.ran).toBe(true);
|
|
454
|
+
expect(result.patternsStored).toBe(1); // only the valid one
|
|
455
|
+
expect(result.learningsStored).toBe(1); // only the valid one
|
|
456
|
+
|
|
457
|
+
fetchSpy.mockRestore();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ============================================================================
|
|
462
|
+
// Tests: hook integration — verify exported handler functions exist
|
|
463
|
+
// ============================================================================
|
|
464
|
+
|
|
465
|
+
describe('hook wiring', () => {
|
|
466
|
+
it('session-hooks exports handleSessionEndReflector', async () => {
|
|
467
|
+
const mod = await import('../../hooks/handlers/session-hooks.js');
|
|
468
|
+
expect(typeof mod.handleSessionEndReflector).toBe('function');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('task-hooks exports handleToolComplete with observer wiring', async () => {
|
|
472
|
+
const mod = await import('../../hooks/handlers/task-hooks.js');
|
|
473
|
+
expect(typeof mod.handleToolComplete).toBe('function');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve Anthropic API key from multiple sources.
|
|
3
|
+
*
|
|
4
|
+
* Resolution priority (first match wins):
|
|
5
|
+
* 1. `ANTHROPIC_API_KEY` environment variable (explicit per-process config)
|
|
6
|
+
* 2. CLEO global config at `~/.local/share/cleo/anthropic-key` (user-stored key)
|
|
7
|
+
* 3. Claude Code OAuth token at `~/.claude/.credentials.json` (auto-discover
|
|
8
|
+
* from the user's existing Claude Code login — no manual config needed)
|
|
9
|
+
*
|
|
10
|
+
* To store a key explicitly: `cleo config set brain.anthropicApiKey <key>`
|
|
11
|
+
* or write directly to `~/.local/share/cleo/anthropic-key`.
|
|
12
|
+
*
|
|
13
|
+
* Returns null when no key is available.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** Cached key to avoid repeated filesystem reads within the same process. */
|
|
21
|
+
let cachedKey: string | null | undefined;
|
|
22
|
+
|
|
23
|
+
/** Global CLEO data directory (XDG_DATA_HOME/cleo). */
|
|
24
|
+
function globalCleoDir(): string {
|
|
25
|
+
const xdg = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
26
|
+
return join(xdg, 'cleo');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the Anthropic API key. Result is cached for the process lifetime.
|
|
31
|
+
*
|
|
32
|
+
* @returns The API key/token string, or null if unavailable.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveAnthropicApiKey(): string | null {
|
|
35
|
+
if (cachedKey !== undefined) return cachedKey;
|
|
36
|
+
|
|
37
|
+
// 1. Explicit env var (highest priority — per-process override)
|
|
38
|
+
const envKey = process.env.ANTHROPIC_API_KEY;
|
|
39
|
+
if (envKey?.trim()) {
|
|
40
|
+
cachedKey = envKey;
|
|
41
|
+
return cachedKey;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. CLEO global stored key (user explicitly set via CLI)
|
|
45
|
+
try {
|
|
46
|
+
const keyFile = join(globalCleoDir(), 'anthropic-key');
|
|
47
|
+
if (existsSync(keyFile)) {
|
|
48
|
+
const stored = readFileSync(keyFile, 'utf-8').trim();
|
|
49
|
+
if (stored) {
|
|
50
|
+
cachedKey = stored;
|
|
51
|
+
return cachedKey;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Not available — continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. Auto-discover from Claude Code credentials (zero config)
|
|
59
|
+
try {
|
|
60
|
+
const credPath = join(homedir(), '.claude', '.credentials.json');
|
|
61
|
+
if (!existsSync(credPath)) {
|
|
62
|
+
cachedKey = null;
|
|
63
|
+
return cachedKey;
|
|
64
|
+
}
|
|
65
|
+
const raw = readFileSync(credPath, 'utf-8');
|
|
66
|
+
const creds = JSON.parse(raw) as {
|
|
67
|
+
claudeAiOauth?: { accessToken?: string; expiresAt?: number };
|
|
68
|
+
};
|
|
69
|
+
const token = creds.claudeAiOauth?.accessToken;
|
|
70
|
+
if (token?.trim()) {
|
|
71
|
+
// Skip expired tokens
|
|
72
|
+
const expiresAt = creds.claudeAiOauth?.expiresAt;
|
|
73
|
+
if (expiresAt && Date.now() > expiresAt) {
|
|
74
|
+
cachedKey = null;
|
|
75
|
+
return cachedKey;
|
|
76
|
+
}
|
|
77
|
+
cachedKey = token;
|
|
78
|
+
return cachedKey;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Credentials file missing or unreadable — not an error
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
cachedKey = null;
|
|
85
|
+
return cachedKey;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Store an Anthropic API key in the CLEO global config directory.
|
|
90
|
+
*
|
|
91
|
+
* Writes to `~/.local/share/cleo/anthropic-key` with 0600 permissions.
|
|
92
|
+
* This is the backup path for users who want to set a key explicitly
|
|
93
|
+
* without using environment variables.
|
|
94
|
+
*
|
|
95
|
+
* @param apiKey - The API key to store.
|
|
96
|
+
*/
|
|
97
|
+
export function storeAnthropicApiKey(apiKey: string): void {
|
|
98
|
+
const dir = globalCleoDir();
|
|
99
|
+
if (!existsSync(dir)) {
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
const keyFile = join(dir, 'anthropic-key');
|
|
103
|
+
writeFileSync(keyFile, apiKey.trim(), { mode: 0o600 });
|
|
104
|
+
// Invalidate cache so next resolveAnthropicApiKey picks up the new key
|
|
105
|
+
cachedKey = undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Clear the cached key (useful for testing or token refresh scenarios).
|
|
110
|
+
*/
|
|
111
|
+
export function clearAnthropicKeyCache(): void {
|
|
112
|
+
cachedKey = undefined;
|
|
113
|
+
}
|