nano-brain 2026.1.0

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 (79) hide show
  1. package/AGENTS_SNIPPET.md +36 -0
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +281 -0
  4. package/SKILL.md +153 -0
  5. package/bin/cli.js +18 -0
  6. package/index.html +929 -0
  7. package/nano-brain +4 -0
  8. package/opencode-mcp.json +9 -0
  9. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
  10. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
  11. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
  12. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
  13. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
  14. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
  15. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
  16. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
  17. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
  18. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
  19. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
  20. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
  21. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
  22. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
  23. package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
  24. package/openspec/changes/codebase-indexing/design.md +169 -0
  25. package/openspec/changes/codebase-indexing/proposal.md +30 -0
  26. package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
  27. package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
  28. package/openspec/changes/codebase-indexing/tasks.md +56 -0
  29. package/openspec/specs/mcp-integration-testing/spec.md +50 -0
  30. package/openspec/specs/mcp-server/spec.md +75 -0
  31. package/openspec/specs/search-pipeline/spec.md +29 -0
  32. package/openspec/specs/storage-limits/spec.md +94 -0
  33. package/openspec/specs/workspace-scoping/spec.md +70 -0
  34. package/package.json +34 -0
  35. package/site/build.js +66 -0
  36. package/site/partials/_api.html +83 -0
  37. package/site/partials/_compare.html +100 -0
  38. package/site/partials/_config.html +23 -0
  39. package/site/partials/_features.html +43 -0
  40. package/site/partials/_footer.html +6 -0
  41. package/site/partials/_hero.html +9 -0
  42. package/site/partials/_how-it-works.html +26 -0
  43. package/site/partials/_models.html +18 -0
  44. package/site/partials/_quick-start.html +15 -0
  45. package/site/partials/_stats.html +1 -0
  46. package/site/partials/_tech-stack.html +13 -0
  47. package/site/script.js +12 -0
  48. package/site/shell.html +44 -0
  49. package/site/styles.css +548 -0
  50. package/src/chunker.ts +427 -0
  51. package/src/codebase.ts +331 -0
  52. package/src/collections.ts +192 -0
  53. package/src/embeddings.ts +293 -0
  54. package/src/expansion.ts +79 -0
  55. package/src/harvester.ts +306 -0
  56. package/src/index.ts +503 -0
  57. package/src/reranker.ts +103 -0
  58. package/src/search.ts +294 -0
  59. package/src/server.ts +664 -0
  60. package/src/storage.ts +221 -0
  61. package/src/store.ts +623 -0
  62. package/src/types.ts +202 -0
  63. package/src/watcher.ts +384 -0
  64. package/test/chunker.test.ts +479 -0
  65. package/test/cli.test.ts +309 -0
  66. package/test/codebase-chunker.test.ts +446 -0
  67. package/test/codebase.test.ts +678 -0
  68. package/test/collections.test.ts +571 -0
  69. package/test/harvester.test.ts +636 -0
  70. package/test/integration.test.ts +150 -0
  71. package/test/llm.test.ts +322 -0
  72. package/test/search.test.ts +572 -0
  73. package/test/server.test.ts +541 -0
  74. package/test/storage.test.ts +302 -0
  75. package/test/store.test.ts +465 -0
  76. package/test/watcher.test.ts +656 -0
  77. package/test/workspace.test.ts +239 -0
  78. package/tsconfig.json +19 -0
  79. package/vitest.config.ts +16 -0
@@ -0,0 +1,572 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ rrfFuse,
4
+ applyTopRankBonus,
5
+ positionAwareBlend,
6
+ formatSnippet,
7
+ searchFTS,
8
+ searchVec,
9
+ hybridSearch,
10
+ } from '../src/search.js';
11
+ import type { SearchResult, Store } from '../src/types.js';
12
+
13
+ function createMockResult(id: string, score: number, snippet: string = 'test snippet'): SearchResult {
14
+ return {
15
+ id,
16
+ path: `path/${id}`,
17
+ collection: 'test',
18
+ title: `Title ${id}`,
19
+ snippet,
20
+ score,
21
+ startLine: 1,
22
+ endLine: 10,
23
+ docid: id.substring(0, 6),
24
+ };
25
+ }
26
+
27
+ function createMockStore(ftsResults: SearchResult[], vecResults: SearchResult[]): Store {
28
+ return {
29
+ searchFTS: vi.fn().mockReturnValue(ftsResults),
30
+ searchVec: vi.fn().mockReturnValue(vecResults),
31
+ getCachedResult: vi.fn().mockReturnValue(null),
32
+ setCachedResult: vi.fn(),
33
+ close: vi.fn(),
34
+ insertDocument: vi.fn(),
35
+ findDocument: vi.fn(),
36
+ getDocumentBody: vi.fn(),
37
+ deactivateDocument: vi.fn(),
38
+ bulkDeactivateExcept: vi.fn(),
39
+ insertContent: vi.fn(),
40
+ insertEmbedding: vi.fn(),
41
+ ensureVecTable: vi.fn(),
42
+ getIndexHealth: vi.fn(),
43
+ getHashesNeedingEmbedding: vi.fn(),
44
+ } as unknown as Store;
45
+ }
46
+
47
+ describe('Search Pipeline', () => {
48
+ describe('rrfFuse', () => {
49
+ it('should merge two result sets with RRF scores', () => {
50
+ const set1 = [
51
+ createMockResult('doc1', 10),
52
+ createMockResult('doc2', 8),
53
+ createMockResult('doc3', 6),
54
+ ];
55
+ const set2 = [
56
+ createMockResult('doc2', 9),
57
+ createMockResult('doc4', 7),
58
+ createMockResult('doc1', 5),
59
+ ];
60
+
61
+ const merged = rrfFuse([set1, set2], 60);
62
+
63
+ expect(merged.length).toBe(4);
64
+ expect(merged[0].id).toBe('doc2');
65
+ expect(merged.map(r => r.id)).toContain('doc1');
66
+ expect(merged.map(r => r.id)).toContain('doc3');
67
+ expect(merged.map(r => r.id)).toContain('doc4');
68
+ });
69
+
70
+ it('should apply weights correctly - original query gets 2x weight', () => {
71
+ const originalSet = [
72
+ createMockResult('doc1', 10),
73
+ createMockResult('doc2', 8),
74
+ ];
75
+ const expandedSet = [
76
+ createMockResult('doc3', 10),
77
+ createMockResult('doc2', 9),
78
+ ];
79
+
80
+ const merged = rrfFuse([originalSet, expandedSet], 60, [2, 1]);
81
+
82
+ expect(merged[0].id).toBe('doc2');
83
+ expect(merged[0].score).toBeGreaterThan(merged[1].score);
84
+ });
85
+
86
+ it('should handle different k values', () => {
87
+ const set1 = [createMockResult('doc1', 10)];
88
+
89
+ const mergedK60 = rrfFuse([set1], 60);
90
+ const mergedK30 = rrfFuse([set1], 30);
91
+
92
+ expect(mergedK30[0].score).toBeGreaterThan(mergedK60[0].score);
93
+ });
94
+
95
+ it('should deduplicate documents across sets', () => {
96
+ const set1 = [
97
+ createMockResult('doc1', 10),
98
+ createMockResult('doc2', 8),
99
+ ];
100
+ const set2 = [
101
+ createMockResult('doc1', 9),
102
+ createMockResult('doc2', 7),
103
+ ];
104
+
105
+ const merged = rrfFuse([set1, set2], 60);
106
+
107
+ expect(merged.length).toBe(2);
108
+ expect(merged[0].score).toBeGreaterThan(1 / 61);
109
+ });
110
+
111
+ it('should handle empty result sets', () => {
112
+ const merged = rrfFuse([[], []], 60);
113
+ expect(merged.length).toBe(0);
114
+ });
115
+ });
116
+
117
+ describe('applyTopRankBonus', () => {
118
+ it('should add +0.05 bonus to rank #1', () => {
119
+ const originalFts = [
120
+ createMockResult('doc1', 10),
121
+ createMockResult('doc2', 8),
122
+ ];
123
+ const rrfResults = [
124
+ createMockResult('doc2', 0.5),
125
+ createMockResult('doc1', 0.4),
126
+ ];
127
+
128
+ const boosted = applyTopRankBonus(rrfResults, originalFts);
129
+
130
+ const doc1 = boosted.find(r => r.id === 'doc1');
131
+ expect(doc1?.score).toBe(0.45);
132
+ });
133
+
134
+ it('should add +0.02 bonus to rank #2 and #3', () => {
135
+ const originalFts = [
136
+ createMockResult('doc1', 10),
137
+ createMockResult('doc2', 8),
138
+ createMockResult('doc3', 6),
139
+ createMockResult('doc4', 4),
140
+ ];
141
+ const rrfResults = [
142
+ createMockResult('doc4', 0.5),
143
+ createMockResult('doc3', 0.4),
144
+ createMockResult('doc2', 0.3),
145
+ createMockResult('doc1', 0.2),
146
+ ];
147
+
148
+ const boosted = applyTopRankBonus(rrfResults, originalFts);
149
+
150
+ const doc2 = boosted.find(r => r.id === 'doc2');
151
+ const doc3 = boosted.find(r => r.id === 'doc3');
152
+ expect(doc2?.score).toBeCloseTo(0.32, 5);
153
+ expect(doc3?.score).toBeCloseTo(0.42, 5);
154
+ });
155
+
156
+ it('should not add bonus to other ranks', () => {
157
+ const originalFts = [
158
+ createMockResult('doc1', 10),
159
+ createMockResult('doc2', 8),
160
+ createMockResult('doc3', 6),
161
+ createMockResult('doc4', 4),
162
+ ];
163
+ const rrfResults = [
164
+ createMockResult('doc4', 0.5),
165
+ ];
166
+
167
+ const boosted = applyTopRankBonus(rrfResults, originalFts);
168
+
169
+ const doc4 = boosted.find(r => r.id === 'doc4');
170
+ expect(doc4?.score).toBe(0.5);
171
+ });
172
+
173
+ it('should re-sort after applying bonuses', () => {
174
+ const originalFts = [
175
+ createMockResult('doc1', 10),
176
+ ];
177
+ const rrfResults = [
178
+ createMockResult('doc2', 0.5),
179
+ createMockResult('doc1', 0.48),
180
+ ];
181
+
182
+ const boosted = applyTopRankBonus(rrfResults, originalFts);
183
+
184
+ expect(boosted[0].id).toBe('doc1');
185
+ expect(boosted[0].score).toBe(0.53);
186
+ });
187
+ });
188
+
189
+ describe('positionAwareBlend', () => {
190
+ it('should use 75/25 split for top 3 positions', () => {
191
+ const rrfResults = [
192
+ createMockResult('doc1', 0.8),
193
+ createMockResult('doc2', 0.7),
194
+ createMockResult('doc3', 0.6),
195
+ ];
196
+ const rerankScores = new Map([
197
+ ['doc1', 0.4],
198
+ ['doc2', 0.5],
199
+ ['doc3', 0.6],
200
+ ]);
201
+
202
+ const blended = positionAwareBlend(rrfResults, rerankScores);
203
+
204
+ expect(blended[0].score).toBeCloseTo(0.75 * 0.8 + 0.25 * 0.4, 5);
205
+ expect(blended[1].score).toBeCloseTo(0.75 * 0.7 + 0.25 * 0.5, 5);
206
+ expect(blended[2].score).toBeCloseTo(0.75 * 0.6 + 0.25 * 0.6, 5);
207
+ });
208
+
209
+ it('should use 60/40 split for positions 4-10', () => {
210
+ const rrfResults = Array.from({ length: 10 }, (_, i) =>
211
+ createMockResult(`doc${i}`, 1 - i * 0.05)
212
+ );
213
+ const rerankScores = new Map(
214
+ rrfResults.map((r, i) => [r.id, 0.9 - i * 0.05])
215
+ );
216
+
217
+ const blended = positionAwareBlend(rrfResults, rerankScores);
218
+
219
+ const doc3 = blended.find(r => r.id === 'doc3');
220
+ const expectedScore = 0.60 * (1 - 3 * 0.05) + 0.40 * (0.9 - 3 * 0.05);
221
+ expect(doc3?.score).toBeCloseTo(expectedScore, 5);
222
+ });
223
+
224
+ it('should use 40/60 split for positions 11+', () => {
225
+ const rrfResults = Array.from({ length: 15 }, (_, i) =>
226
+ createMockResult(`doc${i}`, 1 - i * 0.03)
227
+ );
228
+ const rerankScores = new Map(
229
+ rrfResults.map((r, i) => [r.id, 0.9 - i * 0.03])
230
+ );
231
+
232
+ const blended = positionAwareBlend(rrfResults, rerankScores);
233
+
234
+ const doc10 = blended.find(r => r.id === 'doc10');
235
+ const expectedScore = 0.40 * (1 - 10 * 0.03) + 0.60 * (0.9 - 10 * 0.03);
236
+ expect(doc10?.score).toBeCloseTo(expectedScore, 5);
237
+ });
238
+
239
+ it('should use RRF score as-is when rerank score is missing', () => {
240
+ const rrfResults = [
241
+ createMockResult('doc1', 0.8),
242
+ createMockResult('doc2', 0.7),
243
+ ];
244
+ const rerankScores = new Map([
245
+ ['doc1', 0.5],
246
+ ]);
247
+
248
+ const blended = positionAwareBlend(rrfResults, rerankScores);
249
+
250
+ const doc2 = blended.find(r => r.id === 'doc2');
251
+ expect(doc2?.score).toBe(0.7);
252
+ });
253
+
254
+ it('should re-sort after blending', () => {
255
+ const rrfResults = [
256
+ createMockResult('doc1', 0.5),
257
+ createMockResult('doc2', 0.4),
258
+ ];
259
+ const rerankScores = new Map([
260
+ ['doc1', 0.3],
261
+ ['doc2', 0.9],
262
+ ]);
263
+
264
+ const blended = positionAwareBlend(rrfResults, rerankScores);
265
+
266
+ expect(blended[0].id).toBe('doc2');
267
+ });
268
+ });
269
+
270
+ describe('formatSnippet', () => {
271
+ it('should return text as-is if under max length', () => {
272
+ const text = 'Short text';
273
+ expect(formatSnippet(text, 100)).toBe('Short text');
274
+ });
275
+
276
+ it('should truncate at word boundary with ellipsis', () => {
277
+ const text = 'This is a long text that needs to be truncated at a word boundary';
278
+ const result = formatSnippet(text, 30);
279
+
280
+ expect(result.endsWith('...')).toBe(true);
281
+ expect(result.length).toBeLessThanOrEqual(33);
282
+ expect(result).not.toContain('boundar');
283
+ });
284
+
285
+ it('should truncate with ellipsis if no good word boundary', () => {
286
+ const text = 'a'.repeat(1000);
287
+ const result = formatSnippet(text, 700);
288
+
289
+ expect(result.endsWith('...')).toBe(true);
290
+ expect(result.length).toBe(703);
291
+ });
292
+
293
+ it('should use default max length of 700', () => {
294
+ const text = 'a'.repeat(1000);
295
+ const result = formatSnippet(text);
296
+
297
+ expect(result.length).toBe(703);
298
+ });
299
+ });
300
+
301
+ describe('searchFTS', () => {
302
+ it('should call store.searchFTS with correct parameters', () => {
303
+ const mockResults = [createMockResult('doc1', 10)];
304
+ const store = createMockStore(mockResults, []);
305
+
306
+ const results = searchFTS(store, 'test query', { limit: 5, collection: 'test-col' });
307
+
308
+ expect(store.searchFTS).toHaveBeenCalledWith('test query', 5, 'test-col');
309
+ expect(results).toEqual(mockResults);
310
+ });
311
+
312
+ it('should work without options', () => {
313
+ const mockResults = [createMockResult('doc1', 10)];
314
+ const store = createMockStore(mockResults, []);
315
+
316
+ const results = searchFTS(store, 'test query');
317
+
318
+ expect(store.searchFTS).toHaveBeenCalledWith('test query', undefined, undefined);
319
+ expect(results).toEqual(mockResults);
320
+ });
321
+ });
322
+
323
+ describe('searchVec', () => {
324
+ it('should call store.searchVec with correct parameters', () => {
325
+ const mockResults = [createMockResult('doc1', 0.9)];
326
+ const store = createMockStore([], mockResults);
327
+ const embedding = [0.1, 0.2, 0.3];
328
+
329
+ const results = searchVec(store, 'test query', embedding, { limit: 5, collection: 'test-col' });
330
+
331
+ expect(store.searchVec).toHaveBeenCalledWith('test query', embedding, 5, 'test-col');
332
+ expect(results).toEqual(mockResults);
333
+ });
334
+ });
335
+
336
+ describe('hybridSearch', () => {
337
+ it('should work with BM25 only (no embedder/reranker/expander)', async () => {
338
+ const mockFtsResults = [
339
+ createMockResult('doc1', 10),
340
+ createMockResult('doc2', 8),
341
+ ];
342
+ const store = createMockStore(mockFtsResults, []);
343
+
344
+ const results = await hybridSearch(
345
+ store,
346
+ { query: 'test query', limit: 10 },
347
+ {}
348
+ );
349
+
350
+ expect(results.length).toBeGreaterThan(0);
351
+ expect(store.searchFTS).toHaveBeenCalled();
352
+ });
353
+
354
+ it('should use expanded queries when expander is provided', async () => {
355
+ const mockFtsResults = [createMockResult('doc1', 10)];
356
+ const store = createMockStore(mockFtsResults, []);
357
+
358
+ const expander = {
359
+ expand: vi.fn().mockResolvedValue(['variant1', 'variant2']),
360
+ };
361
+
362
+ await hybridSearch(
363
+ store,
364
+ { query: 'test query', useExpansion: true },
365
+ { expander }
366
+ );
367
+
368
+ expect(expander.expand).toHaveBeenCalledWith('test query');
369
+ expect(store.searchFTS).toHaveBeenCalledTimes(3);
370
+ });
371
+
372
+ it('should cache expansion results', async () => {
373
+ const mockFtsResults = [createMockResult('doc1', 10)];
374
+ const store = createMockStore(mockFtsResults, []);
375
+
376
+ const expander = {
377
+ expand: vi.fn().mockResolvedValue(['variant1', 'variant2']),
378
+ };
379
+
380
+ await hybridSearch(
381
+ store,
382
+ { query: 'test query', useExpansion: true },
383
+ { expander }
384
+ );
385
+
386
+ expect(store.setCachedResult).toHaveBeenCalled();
387
+ const cacheCall = (store.setCachedResult as any).mock.calls[0];
388
+ expect(cacheCall[1]).toContain('variant1');
389
+ });
390
+
391
+ it('should use cached expansion results', async () => {
392
+ const mockFtsResults = [createMockResult('doc1', 10)];
393
+ const store = createMockStore(mockFtsResults, []);
394
+ (store.getCachedResult as any).mockReturnValue('["variant1","variant2"]');
395
+
396
+ const expander = {
397
+ expand: vi.fn().mockResolvedValue(['should-not-be-called']),
398
+ };
399
+
400
+ await hybridSearch(
401
+ store,
402
+ { query: 'test query', useExpansion: true },
403
+ { expander }
404
+ );
405
+
406
+ expect(expander.expand).not.toHaveBeenCalled();
407
+ expect(store.searchFTS).toHaveBeenCalledTimes(3);
408
+ });
409
+
410
+ it('should apply position-aware blending with reranker', async () => {
411
+ const mockFtsResults = [
412
+ createMockResult('doc1', 10, 'snippet1'),
413
+ createMockResult('doc2', 8, 'snippet2'),
414
+ ];
415
+ const store = createMockStore(mockFtsResults, []);
416
+
417
+ const reranker = {
418
+ rerank: vi.fn().mockResolvedValue({
419
+ results: [
420
+ { file: 'doc2', score: 0.9, index: 1 },
421
+ { file: 'doc1', score: 0.7, index: 0 },
422
+ ],
423
+ }),
424
+ };
425
+
426
+ const results = await hybridSearch(
427
+ store,
428
+ { query: 'test query', useReranking: true },
429
+ { reranker }
430
+ );
431
+
432
+ expect(reranker.rerank).toHaveBeenCalled();
433
+ expect(results.length).toBeGreaterThan(0);
434
+ });
435
+
436
+ it('should cache reranking results', async () => {
437
+ const mockFtsResults = [createMockResult('doc1', 10, 'snippet1')];
438
+ const store = createMockStore(mockFtsResults, []);
439
+
440
+ const reranker = {
441
+ rerank: vi.fn().mockResolvedValue({
442
+ results: [{ file: 'doc1', score: 0.9, index: 0 }],
443
+ }),
444
+ };
445
+
446
+ await hybridSearch(
447
+ store,
448
+ { query: 'test query', useReranking: true },
449
+ { reranker }
450
+ );
451
+
452
+ expect(store.setCachedResult).toHaveBeenCalled();
453
+ });
454
+
455
+ it('should filter by minScore', async () => {
456
+ const mockFtsResults = [
457
+ createMockResult('doc1', 10),
458
+ createMockResult('doc2', 8),
459
+ createMockResult('doc3', 6),
460
+ ];
461
+ const store = createMockStore(mockFtsResults, []);
462
+
463
+ const results = await hybridSearch(
464
+ store,
465
+ { query: 'test query', minScore: 0.015 },
466
+ {}
467
+ );
468
+
469
+ expect(results.every(r => r.score >= 0.015)).toBe(true);
470
+ });
471
+
472
+ it('should limit results to specified limit', async () => {
473
+ const mockFtsResults = Array.from({ length: 50 }, (_, i) =>
474
+ createMockResult(`doc${i}`, 50 - i)
475
+ );
476
+ const store = createMockStore(mockFtsResults, []);
477
+
478
+ const results = await hybridSearch(
479
+ store,
480
+ { query: 'test query', limit: 5 },
481
+ {}
482
+ );
483
+
484
+ expect(results.length).toBeLessThanOrEqual(5);
485
+ });
486
+
487
+ it('should format snippets in final results', async () => {
488
+ const longSnippet = 'a'.repeat(1000);
489
+ const mockFtsResults = [createMockResult('doc1', 10, longSnippet)];
490
+ const store = createMockStore(mockFtsResults, []);
491
+
492
+ const results = await hybridSearch(
493
+ store,
494
+ { query: 'test query' },
495
+ {}
496
+ );
497
+
498
+ expect(results[0].snippet.length).toBeLessThanOrEqual(703);
499
+ expect(results[0].snippet.endsWith('...')).toBe(true);
500
+ });
501
+
502
+ it('should use embedder for vector search', async () => {
503
+ const mockFtsResults = [createMockResult('doc1', 10)];
504
+ const mockVecResults = [createMockResult('doc2', 0.9)];
505
+ const store = createMockStore(mockFtsResults, mockVecResults);
506
+
507
+ const embedder = {
508
+ embed: vi.fn().mockResolvedValue({ embedding: [0.1, 0.2, 0.3] }),
509
+ };
510
+
511
+ await hybridSearch(
512
+ store,
513
+ { query: 'test query' },
514
+ { embedder }
515
+ );
516
+
517
+ expect(embedder.embed).toHaveBeenCalledWith('test query');
518
+ expect(store.searchVec).toHaveBeenCalled();
519
+ });
520
+
521
+ it('should handle embedder errors gracefully', async () => {
522
+ const mockFtsResults = [createMockResult('doc1', 10)];
523
+ const store = createMockStore(mockFtsResults, []);
524
+
525
+ const embedder = {
526
+ embed: vi.fn().mockRejectedValue(new Error('Embedding failed')),
527
+ };
528
+
529
+ const results = await hybridSearch(
530
+ store,
531
+ { query: 'test query' },
532
+ { embedder }
533
+ );
534
+
535
+ expect(results.length).toBeGreaterThan(0);
536
+ });
537
+
538
+ it('should handle expander errors gracefully', async () => {
539
+ const mockFtsResults = [createMockResult('doc1', 10)];
540
+ const store = createMockStore(mockFtsResults, []);
541
+
542
+ const expander = {
543
+ expand: vi.fn().mockRejectedValue(new Error('Expansion failed')),
544
+ };
545
+
546
+ const results = await hybridSearch(
547
+ store,
548
+ { query: 'test query', useExpansion: true },
549
+ { expander }
550
+ );
551
+
552
+ expect(results.length).toBeGreaterThan(0);
553
+ });
554
+
555
+ it('should handle reranker errors gracefully', async () => {
556
+ const mockFtsResults = [createMockResult('doc1', 10)];
557
+ const store = createMockStore(mockFtsResults, []);
558
+
559
+ const reranker = {
560
+ rerank: vi.fn().mockRejectedValue(new Error('Reranking failed')),
561
+ };
562
+
563
+ const results = await hybridSearch(
564
+ store,
565
+ { query: 'test query', useReranking: true },
566
+ { reranker }
567
+ );
568
+
569
+ expect(results.length).toBeGreaterThan(0);
570
+ });
571
+ });
572
+ });