@cleocode/core 2026.4.35 → 2026.4.37
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 +54928 -46853
- 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,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Reciprocal Rank Fusion (RRF) hybrid retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. RRF math — score = Σ 1/(k+rank), verified against known values
|
|
6
|
+
* 2. Deduplication — items in multiple lists accumulate correctly
|
|
7
|
+
* 3. Source tracking — ftsRank / vecRank exposed on results
|
|
8
|
+
* 4. Graceful degradation — single source list works
|
|
9
|
+
* 5. Integration — hybridSearch returns RRF-fused results over FTS-only
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import type { RrfHit } from '../brain-search.js';
|
|
14
|
+
import { RRF_K, reciprocalRankFusion } from '../brain-search.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Pure math tests — no DB required
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
describe('reciprocalRankFusion', () => {
|
|
21
|
+
describe('RRF_K constant', () => {
|
|
22
|
+
it('equals 60 (research-proven Cormack 2009 value)', () => {
|
|
23
|
+
expect(RRF_K).toBe(60);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('empty inputs', () => {
|
|
28
|
+
it('returns empty array for no sources', () => {
|
|
29
|
+
const result = reciprocalRankFusion([]);
|
|
30
|
+
expect(result).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty array for sources with empty hit lists', () => {
|
|
34
|
+
const result = reciprocalRankFusion([
|
|
35
|
+
{ source: 'fts', hits: [] },
|
|
36
|
+
{ source: 'vec', hits: [] },
|
|
37
|
+
]);
|
|
38
|
+
expect(result).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('single source list', () => {
|
|
43
|
+
it('scores first item as 1/(k+0)', () => {
|
|
44
|
+
const hits: RrfHit[] = [{ id: 'A', type: 'decision', title: 'A', text: 'a' }];
|
|
45
|
+
const result = reciprocalRankFusion([{ source: 'fts', hits }]);
|
|
46
|
+
|
|
47
|
+
expect(result).toHaveLength(1);
|
|
48
|
+
expect(result[0]!.rrfScore).toBeCloseTo(1 / (60 + 0), 10);
|
|
49
|
+
expect(result[0]!.id).toBe('A');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('scores items by rank: score decreases with rank', () => {
|
|
53
|
+
const hits: RrfHit[] = [
|
|
54
|
+
{ id: 'A', type: 'decision', title: 'A', text: 'a' },
|
|
55
|
+
{ id: 'B', type: 'pattern', title: 'B', text: 'b' },
|
|
56
|
+
{ id: 'C', type: 'learning', title: 'C', text: 'c' },
|
|
57
|
+
];
|
|
58
|
+
const result = reciprocalRankFusion([{ source: 'fts', hits }]);
|
|
59
|
+
|
|
60
|
+
expect(result).toHaveLength(3);
|
|
61
|
+
expect(result[0]!.rrfScore).toBeGreaterThan(result[1]!.rrfScore);
|
|
62
|
+
expect(result[1]!.rrfScore).toBeGreaterThan(result[2]!.rrfScore);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('verifies exact scores: rank0=1/60, rank1=1/61, rank2=1/62', () => {
|
|
66
|
+
const hits: RrfHit[] = [
|
|
67
|
+
{ id: 'A', type: 'observation', title: 'A', text: 'a' },
|
|
68
|
+
{ id: 'B', type: 'observation', title: 'B', text: 'b' },
|
|
69
|
+
{ id: 'C', type: 'observation', title: 'C', text: 'c' },
|
|
70
|
+
];
|
|
71
|
+
const result = reciprocalRankFusion([{ source: 'fts', hits }]);
|
|
72
|
+
|
|
73
|
+
expect(result[0]!.rrfScore).toBeCloseTo(1 / 60, 10);
|
|
74
|
+
expect(result[1]!.rrfScore).toBeCloseTo(1 / 61, 10);
|
|
75
|
+
expect(result[2]!.rrfScore).toBeCloseTo(1 / 62, 10);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('two source lists', () => {
|
|
80
|
+
it('accumulates scores for items in both lists', () => {
|
|
81
|
+
const ftsHits: RrfHit[] = [
|
|
82
|
+
{ id: 'A', type: 'decision', title: 'A', text: 'a' },
|
|
83
|
+
{ id: 'B', type: 'decision', title: 'B', text: 'b' },
|
|
84
|
+
];
|
|
85
|
+
const vecHits: RrfHit[] = [
|
|
86
|
+
{ id: 'B', type: 'decision', title: 'B', text: 'b' }, // B appears in both
|
|
87
|
+
{ id: 'C', type: 'decision', title: 'C', text: 'c' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const result = reciprocalRankFusion([
|
|
91
|
+
{ source: 'fts', hits: ftsHits },
|
|
92
|
+
{ source: 'vec', hits: vecHits },
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
// B is rank-1 in FTS and rank-0 in vec: 1/61 + 1/60
|
|
96
|
+
const bEntry = result.find((r) => r.id === 'B')!;
|
|
97
|
+
expect(bEntry).toBeDefined();
|
|
98
|
+
expect(bEntry.rrfScore).toBeCloseTo(1 / 61 + 1 / 60, 10);
|
|
99
|
+
|
|
100
|
+
// A is rank-0 in FTS only: 1/60
|
|
101
|
+
const aEntry = result.find((r) => r.id === 'A')!;
|
|
102
|
+
expect(aEntry.rrfScore).toBeCloseTo(1 / 60, 10);
|
|
103
|
+
|
|
104
|
+
// B score > A score (two sources > one even though A is rank-0 FTS)
|
|
105
|
+
expect(bEntry.rrfScore).toBeGreaterThan(aEntry.rrfScore);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('item in both lists at rank 0 beats item in one list at rank 0', () => {
|
|
109
|
+
const shared: RrfHit = { id: 'SHARED', type: 'decision', title: 'shared', text: 'shared' };
|
|
110
|
+
const unique: RrfHit = { id: 'UNIQUE', type: 'decision', title: 'unique', text: 'unique' };
|
|
111
|
+
|
|
112
|
+
const result = reciprocalRankFusion([
|
|
113
|
+
{ source: 'fts', hits: [shared, unique] },
|
|
114
|
+
{ source: 'vec', hits: [shared] },
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const sharedEntry = result.find((r) => r.id === 'SHARED')!;
|
|
118
|
+
const uniqueEntry = result.find((r) => r.id === 'UNIQUE')!;
|
|
119
|
+
|
|
120
|
+
// SHARED: 1/60 (fts rank 0) + 1/60 (vec rank 0) = 2/60
|
|
121
|
+
// UNIQUE: 1/61 (fts rank 1)
|
|
122
|
+
expect(sharedEntry.rrfScore).toBeCloseTo(2 / 60, 10);
|
|
123
|
+
expect(uniqueEntry.rrfScore).toBeCloseTo(1 / 61, 10);
|
|
124
|
+
expect(sharedEntry.rrfScore).toBeGreaterThan(uniqueEntry.rrfScore);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('source tracking', () => {
|
|
129
|
+
it('reports sources array for each result', () => {
|
|
130
|
+
const ftsHits: RrfHit[] = [{ id: 'A', type: 'decision', title: 'A', text: 'a' }];
|
|
131
|
+
const vecHits: RrfHit[] = [
|
|
132
|
+
{ id: 'A', type: 'decision', title: 'A', text: 'a' }, // shared
|
|
133
|
+
{ id: 'B', type: 'pattern', title: 'B', text: 'b' }, // vec-only
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const result = reciprocalRankFusion([
|
|
137
|
+
{ source: 'fts', hits: ftsHits },
|
|
138
|
+
{ source: 'vec', hits: vecHits },
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const aEntry = result.find((r) => r.id === 'A')!;
|
|
142
|
+
expect(aEntry.sources).toContain('fts');
|
|
143
|
+
expect(aEntry.sources).toContain('vec');
|
|
144
|
+
expect(aEntry.sources).toHaveLength(2);
|
|
145
|
+
|
|
146
|
+
const bEntry = result.find((r) => r.id === 'B')!;
|
|
147
|
+
expect(bEntry.sources).toEqual(['vec']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('exposes ftsRank and vecRank for transparency', () => {
|
|
151
|
+
const ftsHits: RrfHit[] = [
|
|
152
|
+
{ id: 'A', type: 'decision', title: 'A', text: 'a' },
|
|
153
|
+
{ id: 'B', type: 'pattern', title: 'B', text: 'b' },
|
|
154
|
+
];
|
|
155
|
+
const vecHits: RrfHit[] = [
|
|
156
|
+
{ id: 'B', type: 'pattern', title: 'B', text: 'b' },
|
|
157
|
+
{ id: 'A', type: 'decision', title: 'A', text: 'a' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const result = reciprocalRankFusion([
|
|
161
|
+
{ source: 'fts', hits: ftsHits },
|
|
162
|
+
{ source: 'vec', hits: vecHits },
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
const aEntry = result.find((r) => r.id === 'A')!;
|
|
166
|
+
expect(aEntry.ftsRank).toBe(0); // A is rank-0 in FTS
|
|
167
|
+
expect(aEntry.vecRank).toBe(1); // A is rank-1 in vec
|
|
168
|
+
|
|
169
|
+
const bEntry = result.find((r) => r.id === 'B')!;
|
|
170
|
+
expect(bEntry.ftsRank).toBe(1); // B is rank-1 in FTS
|
|
171
|
+
expect(bEntry.vecRank).toBe(0); // B is rank-0 in vec
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('ftsRank is undefined for vec-only items', () => {
|
|
175
|
+
const vecHits: RrfHit[] = [{ id: 'VEC_ONLY', type: 'observation', title: 'v', text: 'v' }];
|
|
176
|
+
const result = reciprocalRankFusion([{ source: 'vec', hits: vecHits }]);
|
|
177
|
+
expect(result[0]!.ftsRank).toBeUndefined();
|
|
178
|
+
expect(result[0]!.vecRank).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('custom k parameter', () => {
|
|
183
|
+
it('k=0 gives 1/(0+rank) = amplified top-rank signal', () => {
|
|
184
|
+
const hits: RrfHit[] = [{ id: 'A', type: 'decision', title: 'A', text: 'a' }];
|
|
185
|
+
const result = reciprocalRankFusion([{ source: 'fts', hits }], 0);
|
|
186
|
+
// rank=0, k=0 → 1/(0+0) = Infinity? Actually 1/0 = Infinity in JS
|
|
187
|
+
// More useful to test k=1
|
|
188
|
+
expect(result[0]!.rrfScore).toBe(Infinity);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('k=1 produces 1/1 = 1.0 for rank-0 item', () => {
|
|
192
|
+
const hits: RrfHit[] = [{ id: 'A', type: 'decision', title: 'A', text: 'a' }];
|
|
193
|
+
const result = reciprocalRankFusion([{ source: 'fts', hits }], 1);
|
|
194
|
+
expect(result[0]!.rrfScore).toBeCloseTo(1.0, 10);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('result ordering', () => {
|
|
199
|
+
it('results are sorted by rrfScore descending', () => {
|
|
200
|
+
const hits: RrfHit[] = [
|
|
201
|
+
{ id: 'rank0', type: 'decision', title: 'r0', text: 'r0' },
|
|
202
|
+
{ id: 'rank1', type: 'decision', title: 'r1', text: 'r1' },
|
|
203
|
+
{ id: 'rank2', type: 'decision', title: 'r2', text: 'r2' },
|
|
204
|
+
{ id: 'rank3', type: 'decision', title: 'r3', text: 'r3' },
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const result = reciprocalRankFusion([{ source: 'fts', hits }]);
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < result.length - 1; i++) {
|
|
210
|
+
expect(result[i]!.rrfScore).toBeGreaterThanOrEqual(result[i + 1]!.rrfScore);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('RRF fusion outperforms single-source ranking', () => {
|
|
216
|
+
it('item ranked 3rd in FTS and 1st in vec beats item ranked 1st in FTS only', () => {
|
|
217
|
+
// This is the key property of RRF: cross-source agreement elevates items
|
|
218
|
+
// above single-source champions.
|
|
219
|
+
const ftsHits: RrfHit[] = [
|
|
220
|
+
{ id: 'FTS_TOP', type: 'decision', title: 'fts top', text: 'fts top' },
|
|
221
|
+
{ id: 'OTHER1', type: 'decision', title: 'other1', text: 'other1' },
|
|
222
|
+
{ id: 'BOTH', type: 'decision', title: 'both', text: 'both' }, // rank 2 in FTS
|
|
223
|
+
];
|
|
224
|
+
const vecHits: RrfHit[] = [
|
|
225
|
+
{ id: 'BOTH', type: 'decision', title: 'both', text: 'both' }, // rank 0 in vec
|
|
226
|
+
{ id: 'VEC_TOP', type: 'decision', title: 'vec top', text: 'vec top' },
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const result = reciprocalRankFusion([
|
|
230
|
+
{ source: 'fts', hits: ftsHits },
|
|
231
|
+
{ source: 'vec', hits: vecHits },
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
// BOTH: 1/(60+2) + 1/(60+0) = 1/62 + 1/60 ≈ 0.02957
|
|
235
|
+
// FTS_TOP: 1/(60+0) = 1/60 ≈ 0.01667
|
|
236
|
+
const bothEntry = result.find((r) => r.id === 'BOTH')!;
|
|
237
|
+
const ftsTopEntry = result.find((r) => r.id === 'FTS_TOP')!;
|
|
238
|
+
|
|
239
|
+
expect(bothEntry.rrfScore).toBeGreaterThan(ftsTopEntry.rrfScore);
|
|
240
|
+
|
|
241
|
+
// Verify the math
|
|
242
|
+
expect(bothEntry.rrfScore).toBeCloseTo(1 / 62 + 1 / 60, 8);
|
|
243
|
+
expect(ftsTopEntry.rrfScore).toBeCloseTo(1 / 60, 10);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ============================================================================
|
|
249
|
+
// Integration: hybridSearch with real (in-memory) brain.db
|
|
250
|
+
// ============================================================================
|
|
251
|
+
|
|
252
|
+
describe('hybridSearch (RRF integration)', () => {
|
|
253
|
+
let tempDir: string;
|
|
254
|
+
|
|
255
|
+
// Dynamic imports used to avoid module-singleton issues across tests
|
|
256
|
+
async function setup() {
|
|
257
|
+
const { mkdir, mkdtemp } = await import('node:fs/promises');
|
|
258
|
+
const { tmpdir } = await import('node:os');
|
|
259
|
+
const { join } = await import('node:path');
|
|
260
|
+
tempDir = await mkdtemp(join(tmpdir(), 'cleo-rrf-'));
|
|
261
|
+
const cleoDir = join(tempDir, '.cleo');
|
|
262
|
+
await mkdir(cleoDir, { recursive: true });
|
|
263
|
+
process.env['CLEO_DIR'] = cleoDir;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function teardown() {
|
|
267
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
268
|
+
const { resetFts5Cache } = await import('../brain-search.js');
|
|
269
|
+
closeBrainDb();
|
|
270
|
+
resetFts5Cache();
|
|
271
|
+
delete process.env['CLEO_DIR'];
|
|
272
|
+
const { rm } = await import('node:fs/promises');
|
|
273
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
it('returns empty array for empty query', async () => {
|
|
277
|
+
await setup();
|
|
278
|
+
try {
|
|
279
|
+
const { hybridSearch } = await import('../brain-search.js');
|
|
280
|
+
const result = await hybridSearch('', tempDir);
|
|
281
|
+
expect(result).toEqual([]);
|
|
282
|
+
} finally {
|
|
283
|
+
await teardown();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('returns FTS results when vector unavailable (RRF graceful degradation)', async () => {
|
|
288
|
+
await setup();
|
|
289
|
+
try {
|
|
290
|
+
const { getBrainAccessor } = await import('../../store/brain-accessor.js');
|
|
291
|
+
const { hybridSearch, resetFts5Cache } = await import('../brain-search.js');
|
|
292
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
293
|
+
closeBrainDb();
|
|
294
|
+
resetFts5Cache();
|
|
295
|
+
|
|
296
|
+
const accessor = await getBrainAccessor(tempDir);
|
|
297
|
+
await accessor.addDecision({
|
|
298
|
+
id: 'D001',
|
|
299
|
+
type: 'architecture',
|
|
300
|
+
decision: 'Use SQLite for embedded storage',
|
|
301
|
+
rationale: 'Zero-dependency, serverless, reliable',
|
|
302
|
+
confidence: 'high',
|
|
303
|
+
});
|
|
304
|
+
await accessor.addDecision({
|
|
305
|
+
id: 'D002',
|
|
306
|
+
type: 'technical',
|
|
307
|
+
decision: 'Use JSON for config files',
|
|
308
|
+
rationale: 'Human readable format',
|
|
309
|
+
confidence: 'medium',
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// No embedding provider registered — vector path will yield empty results.
|
|
313
|
+
// RRF should still return FTS results correctly.
|
|
314
|
+
const results = await hybridSearch('SQLite embedded', tempDir, { limit: 10 });
|
|
315
|
+
|
|
316
|
+
expect(results.length).toBeGreaterThan(0);
|
|
317
|
+
// D001 mentions SQLite — should appear
|
|
318
|
+
const d001 = results.find((r) => r.id === 'D001');
|
|
319
|
+
expect(d001).toBeDefined();
|
|
320
|
+
// All results should carry sources array
|
|
321
|
+
for (const r of results) {
|
|
322
|
+
expect(r.sources).toBeInstanceOf(Array);
|
|
323
|
+
expect(r.sources.length).toBeGreaterThan(0);
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
await teardown();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('exposes rrfScore (via score field) and sources on all results', async () => {
|
|
331
|
+
await setup();
|
|
332
|
+
try {
|
|
333
|
+
const { getBrainAccessor } = await import('../../store/brain-accessor.js');
|
|
334
|
+
const { hybridSearch, resetFts5Cache } = await import('../brain-search.js');
|
|
335
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
336
|
+
closeBrainDb();
|
|
337
|
+
resetFts5Cache();
|
|
338
|
+
|
|
339
|
+
const accessor = await getBrainAccessor(tempDir);
|
|
340
|
+
await accessor.addPattern({
|
|
341
|
+
id: 'P001',
|
|
342
|
+
type: 'workflow',
|
|
343
|
+
pattern: 'Always validate input before processing',
|
|
344
|
+
context: 'API handlers and form validation',
|
|
345
|
+
frequency: 5,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const results = await hybridSearch('validate input', tempDir);
|
|
349
|
+
expect(results.length).toBeGreaterThan(0);
|
|
350
|
+
|
|
351
|
+
for (const r of results) {
|
|
352
|
+
expect(r.score).toBeGreaterThan(0);
|
|
353
|
+
expect(r.score).toBeLessThanOrEqual(1); // RRF max score for rank-0 with k=60 is 1/60 ≈ 0.0167
|
|
354
|
+
expect(r.sources).toBeInstanceOf(Array);
|
|
355
|
+
expect(r.id).toBeTruthy();
|
|
356
|
+
expect(r.type).toBeTruthy();
|
|
357
|
+
expect(r.title).toBeTruthy();
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
await teardown();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('respects limit option', async () => {
|
|
365
|
+
await setup();
|
|
366
|
+
try {
|
|
367
|
+
const { getBrainAccessor } = await import('../../store/brain-accessor.js');
|
|
368
|
+
const { hybridSearch, resetFts5Cache } = await import('../brain-search.js');
|
|
369
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
370
|
+
closeBrainDb();
|
|
371
|
+
resetFts5Cache();
|
|
372
|
+
|
|
373
|
+
const accessor = await getBrainAccessor(tempDir);
|
|
374
|
+
for (let i = 0; i < 8; i++) {
|
|
375
|
+
await accessor.addDecision({
|
|
376
|
+
id: `D${String(i + 1).padStart(3, '0')}`,
|
|
377
|
+
type: 'technical',
|
|
378
|
+
decision: `Performance optimization technique ${i}`,
|
|
379
|
+
rationale: `Benchmark results show improvement ${i}`,
|
|
380
|
+
confidence: 'medium',
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const results = await hybridSearch('performance optimization', tempDir, { limit: 3 });
|
|
385
|
+
expect(results.length).toBeLessThanOrEqual(3);
|
|
386
|
+
} finally {
|
|
387
|
+
await teardown();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('results are sorted by score descending', async () => {
|
|
392
|
+
await setup();
|
|
393
|
+
try {
|
|
394
|
+
const { getBrainAccessor } = await import('../../store/brain-accessor.js');
|
|
395
|
+
const { hybridSearch, resetFts5Cache } = await import('../brain-search.js');
|
|
396
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
397
|
+
closeBrainDb();
|
|
398
|
+
resetFts5Cache();
|
|
399
|
+
|
|
400
|
+
const accessor = await getBrainAccessor(tempDir);
|
|
401
|
+
await accessor.addDecision({
|
|
402
|
+
id: 'D001',
|
|
403
|
+
type: 'tech',
|
|
404
|
+
decision: 'caching strategy for performance',
|
|
405
|
+
rationale: 'perf',
|
|
406
|
+
confidence: 'high',
|
|
407
|
+
});
|
|
408
|
+
await accessor.addDecision({
|
|
409
|
+
id: 'D002',
|
|
410
|
+
type: 'tech',
|
|
411
|
+
decision: 'use performance profiling',
|
|
412
|
+
rationale: 'bottlenecks',
|
|
413
|
+
confidence: 'medium',
|
|
414
|
+
});
|
|
415
|
+
await accessor.addLearning({
|
|
416
|
+
id: 'L001',
|
|
417
|
+
insight: 'performance benchmarks guide decisions',
|
|
418
|
+
source: 'T100',
|
|
419
|
+
confidence: 0.9,
|
|
420
|
+
actionable: true,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const results = await hybridSearch('performance', tempDir, { limit: 10 });
|
|
424
|
+
for (let i = 0; i < results.length - 1; i++) {
|
|
425
|
+
expect(results[i]!.score).toBeGreaterThanOrEqual(results[i + 1]!.score);
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
await teardown();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|