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
package/src/index.ts ADDED
@@ -0,0 +1,503 @@
1
+ import { startServer } from './server.js';
2
+ import { createStore, computeHash, indexDocument } from './store.js';
3
+ import { loadCollectionConfig, addCollection, removeCollection, renameCollection, listCollections, getCollections, scanCollectionFiles } from './collections.js';
4
+ import { harvestSessions } from './harvester.js';
5
+ import { createEmbeddingProvider } from './embeddings.js';
6
+ import { hybridSearch } from './search.js';
7
+ import type { SearchResult } from './types.js';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+
12
+ const DEFAULT_DB_DIR = path.join(os.homedir(), '.cache', 'nano-brain');
13
+ const DEFAULT_CONFIG = path.join(os.homedir(), '.config', 'nano-brain', 'config.yml');
14
+ const DEFAULT_OUTPUT_DIR = path.join(os.homedir(), '.nano-brain', 'sessions');
15
+ const DEFAULT_MEMORY_DIR = path.join(os.homedir(), '.nano-brain', 'memory');
16
+
17
+ interface GlobalOptions {
18
+ dbPath: string;
19
+ configPath: string;
20
+ remaining: string[];
21
+ }
22
+
23
+ export function parseGlobalOptions(args: string[]): GlobalOptions {
24
+ let dbPath = path.join(DEFAULT_DB_DIR, 'default.sqlite');
25
+ let configPath = DEFAULT_CONFIG;
26
+ const remaining: string[] = [];
27
+
28
+ for (let i = 0; i < args.length; i++) {
29
+ const arg = args[i];
30
+
31
+ if (arg.startsWith('--db=')) {
32
+ dbPath = arg.substring(5);
33
+ } else if (arg === '--db' && i + 1 < args.length) {
34
+ dbPath = args[++i];
35
+ } else if (arg.startsWith('--config=')) {
36
+ configPath = arg.substring(9);
37
+ } else if (arg === '--config' && i + 1 < args.length) {
38
+ configPath = args[++i];
39
+ } else if (arg === '--help' || arg === '-h') {
40
+ showHelp();
41
+ process.exit(0);
42
+ } else if (arg === '--version' || arg === '-v') {
43
+ showVersion();
44
+ process.exit(0);
45
+ } else {
46
+ remaining.push(arg);
47
+ }
48
+ }
49
+
50
+ return { dbPath, configPath, remaining };
51
+ }
52
+
53
+ export function showHelp(): void {
54
+ console.log(`
55
+ nano-brain - Memory system with hybrid search
56
+
57
+ Usage:
58
+ nano-brain [global-options] <command> [command-options]
59
+
60
+ Global Options:
61
+ --db=<path> SQLite database path (default: ~/.cache/nano-brain/default.sqlite)
62
+ --config=<path> Config YAML path (default: ~/.config/nano-brain/config.yml)
63
+ --help, -h Show help
64
+ --version, -v Show version
65
+
66
+ Commands:
67
+ mcp Start MCP server (default command if no args)
68
+ --http Use HTTP transport instead of stdio
69
+ --port=<n> HTTP port (default: 8282)
70
+ --daemon Run as background daemon
71
+ stop Stop running daemon
72
+
73
+ collection Manage collections
74
+ add <name> <path> [--pattern=<glob>]
75
+ remove <name>
76
+ list
77
+ rename <old> <new>
78
+
79
+ status Show index health and stats
80
+ update Re-scan and reindex all collections
81
+ embed Generate embeddings for unembedded chunks
82
+ --force Re-embed all chunks
83
+
84
+ search <query> BM25 keyword search
85
+ -n <limit> Max results (default: 10)
86
+ -c <collection> Filter by collection
87
+ --json Output as JSON
88
+ --files Show file paths only
89
+
90
+ vsearch <query> Vector semantic search (same options as search)
91
+ query <query> Full hybrid search (same options as search)
92
+ --min-score=<n> Minimum score threshold
93
+
94
+ get <id> Get document by path or docid
95
+ --full Show full content
96
+ --from=<line> Start line
97
+ --lines=<n> Number of lines
98
+
99
+ harvest Manually trigger session harvesting
100
+ `);
101
+ }
102
+
103
+ export function showVersion(): void {
104
+ console.log('nano-brain v0.1.0');
105
+ }
106
+
107
+ export function formatSearchOutput(results: SearchResult[], format: 'text' | 'json' | 'files'): string {
108
+ if (format === 'json') {
109
+ return JSON.stringify(results, null, 2);
110
+ }
111
+
112
+ if (format === 'files') {
113
+ return results.map(r => r.path).join('\n');
114
+ }
115
+
116
+ const lines: string[] = [];
117
+ for (const result of results) {
118
+ lines.push(`[${result.docid}] ${result.collection}/${result.path}`);
119
+ lines.push(` Score: ${result.score.toFixed(4)} | ${result.title}`);
120
+ if (result.snippet) {
121
+ lines.push(` ${result.snippet}`);
122
+ }
123
+ lines.push('');
124
+ }
125
+ return lines.join('\n');
126
+ }
127
+
128
+ async function handleMcp(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
129
+ let useHttp = false;
130
+ let port = 8282;
131
+ let daemon = false;
132
+
133
+ for (const arg of commandArgs) {
134
+ if (arg === '--http') {
135
+ useHttp = true;
136
+ } else if (arg.startsWith('--port=')) {
137
+ port = parseInt(arg.substring(7), 10);
138
+ } else if (arg === '--daemon') {
139
+ daemon = true;
140
+ } else if (arg === 'stop') {
141
+ console.log('Daemon stop not implemented yet');
142
+ return;
143
+ }
144
+ }
145
+
146
+ await startServer({
147
+ dbPath: globalOpts.dbPath,
148
+ configPath: globalOpts.configPath,
149
+ });
150
+ }
151
+
152
+ async function handleCollection(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
153
+ const subcommand = commandArgs[0];
154
+
155
+ if (!subcommand) {
156
+ console.error('Missing collection subcommand (add, remove, list, rename)');
157
+ process.exit(1);
158
+ }
159
+
160
+ switch (subcommand) {
161
+ case 'add': {
162
+ const name = commandArgs[1];
163
+ const collectionPath = commandArgs[2];
164
+ let pattern = '**/*.md';
165
+
166
+ for (const arg of commandArgs.slice(3)) {
167
+ if (arg.startsWith('--pattern=')) {
168
+ pattern = arg.substring(10);
169
+ }
170
+ }
171
+
172
+ if (!name || !collectionPath) {
173
+ console.error('Usage: collection add <name> <path> [--pattern=<glob>]');
174
+ process.exit(1);
175
+ }
176
+
177
+ addCollection(globalOpts.configPath, name, collectionPath, pattern);
178
+ console.log(`✅ Added collection "${name}"`);
179
+ break;
180
+ }
181
+
182
+ case 'remove': {
183
+ const name = commandArgs[1];
184
+ if (!name) {
185
+ console.error('Usage: collection remove <name>');
186
+ process.exit(1);
187
+ }
188
+
189
+ removeCollection(globalOpts.configPath, name);
190
+ console.log(`✅ Removed collection "${name}"`);
191
+ break;
192
+ }
193
+
194
+ case 'list': {
195
+ const config = loadCollectionConfig(globalOpts.configPath);
196
+ if (!config) {
197
+ console.log('No collections configured');
198
+ return;
199
+ }
200
+
201
+ const names = listCollections(config);
202
+ if (names.length === 0) {
203
+ console.log('No collections configured');
204
+ } else {
205
+ console.log('Collections:');
206
+ for (const name of names) {
207
+ const coll = config.collections[name];
208
+ console.log(` ${name}: ${coll.path} (${coll.pattern || '**/*.md'})`);
209
+ }
210
+ }
211
+ break;
212
+ }
213
+
214
+ case 'rename': {
215
+ const oldName = commandArgs[1];
216
+ const newName = commandArgs[2];
217
+
218
+ if (!oldName || !newName) {
219
+ console.error('Usage: collection rename <old> <new>');
220
+ process.exit(1);
221
+ }
222
+
223
+ renameCollection(globalOpts.configPath, oldName, newName);
224
+ console.log(`✅ Renamed collection "${oldName}" to "${newName}"`);
225
+ break;
226
+ }
227
+
228
+ default:
229
+ console.error(`Unknown collection subcommand: ${subcommand}`);
230
+ process.exit(1);
231
+ }
232
+ }
233
+
234
+ async function handleStatus(globalOpts: GlobalOptions): Promise<void> {
235
+ const store = createStore(globalOpts.dbPath);
236
+ const health = store.getIndexHealth();
237
+
238
+ console.log('Index Health:');
239
+ console.log(` Documents: ${health.documentCount}`);
240
+ console.log(` Chunks: ${health.chunkCount}`);
241
+ console.log(` Pending embeddings: ${health.pendingEmbeddings}`);
242
+ console.log(` Database size: ${(health.databaseSize / 1024 / 1024).toFixed(2)} MB`);
243
+ console.log('');
244
+ console.log('Collections:');
245
+ for (const coll of health.collections) {
246
+ console.log(` ${coll.name}: ${coll.documentCount} documents`);
247
+ }
248
+ console.log('');
249
+ console.log('Model Status:');
250
+ console.log(` Embedding: ${health.modelStatus.embedding}`);
251
+ console.log(` Reranker: ${health.modelStatus.reranker}`);
252
+ console.log(` Expander: ${health.modelStatus.expander}`);
253
+
254
+ store.close();
255
+ }
256
+
257
+ async function handleUpdate(globalOpts: GlobalOptions): Promise<void> {
258
+ const store = createStore(globalOpts.dbPath);
259
+ const config = loadCollectionConfig(globalOpts.configPath);
260
+
261
+ if (!config) {
262
+ console.error('No config file found');
263
+ store.close();
264
+ process.exit(1);
265
+ }
266
+
267
+ const collections = getCollections(config);
268
+ let totalIndexed = 0;
269
+ let totalSkipped = 0;
270
+
271
+ for (const collection of collections) {
272
+ console.log(`Scanning collection: ${collection.name}`);
273
+ const files = await scanCollectionFiles(collection);
274
+
275
+ for (const file of files) {
276
+ const content = fs.readFileSync(file, 'utf-8');
277
+ const title = path.basename(file, path.extname(file));
278
+ const result = indexDocument(store, collection.name, file, content, title);
279
+
280
+ if (result.skipped) {
281
+ totalSkipped++;
282
+ } else {
283
+ totalIndexed++;
284
+ }
285
+ }
286
+ }
287
+
288
+ console.log(`✅ Indexed ${totalIndexed} documents, skipped ${totalSkipped}`);
289
+ store.close();
290
+ }
291
+
292
+ async function handleEmbed(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
293
+ let force = false;
294
+
295
+ for (const arg of commandArgs) {
296
+ if (arg === '--force') {
297
+ force = true;
298
+ }
299
+ }
300
+
301
+ const store = createStore(globalOpts.dbPath);
302
+ const hashes = store.getHashesNeedingEmbedding();
303
+
304
+ if (hashes.length === 0) {
305
+ console.log('No chunks need embedding');
306
+ store.close();
307
+ return;
308
+ }
309
+
310
+ console.log(`Found ${hashes.length} chunks needing embeddings`);
311
+ console.log('Loading embedding model...');
312
+
313
+ const provider = await createEmbeddingProvider();
314
+
315
+ if (!provider) {
316
+ console.error('Failed to load embedding provider');
317
+ store.close();
318
+ process.exit(1);
319
+ }
320
+
321
+ console.log('Generating embeddings...');
322
+
323
+ for (let i = 0; i < hashes.length; i++) {
324
+ const { hash, body } = hashes[i];
325
+ const result = await provider.embed(body);
326
+ store.insertEmbedding(hash, 0, 0, result.embedding, result.model);
327
+
328
+ if ((i + 1) % 10 === 0) {
329
+ console.log(` Progress: ${i + 1}/${hashes.length}`);
330
+ }
331
+ }
332
+
333
+ console.log(`✅ Generated ${hashes.length} embeddings`);
334
+
335
+ provider.dispose();
336
+ store.close();
337
+ }
338
+
339
+ async function handleSearch(
340
+ globalOpts: GlobalOptions,
341
+ commandArgs: string[],
342
+ mode: 'fts' | 'vec' | 'hybrid'
343
+ ): Promise<void> {
344
+ const query = commandArgs[0];
345
+
346
+ if (!query) {
347
+ console.error('Missing query argument');
348
+ process.exit(1);
349
+ }
350
+
351
+ let limit = 10;
352
+ let collection: string | undefined;
353
+ let format: 'text' | 'json' | 'files' = 'text';
354
+ let minScore = 0;
355
+
356
+ for (let i = 1; i < commandArgs.length; i++) {
357
+ const arg = commandArgs[i];
358
+
359
+ if (arg === '-n' && i + 1 < commandArgs.length) {
360
+ limit = parseInt(commandArgs[++i], 10);
361
+ } else if (arg === '-c' && i + 1 < commandArgs.length) {
362
+ collection = commandArgs[++i];
363
+ } else if (arg === '--json') {
364
+ format = 'json';
365
+ } else if (arg === '--files') {
366
+ format = 'files';
367
+ } else if (arg.startsWith('--min-score=')) {
368
+ minScore = parseFloat(arg.substring(12));
369
+ }
370
+ }
371
+
372
+ const store = createStore(globalOpts.dbPath);
373
+ let results: SearchResult[];
374
+
375
+ if (mode === 'fts') {
376
+ results = store.searchFTS(query, limit, collection);
377
+ } else if (mode === 'vec') {
378
+ const provider = await createEmbeddingProvider();
379
+ if (!provider) {
380
+ console.error('Vector search requires embedding model');
381
+ store.close();
382
+ process.exit(1);
383
+ }
384
+
385
+ const { embedding } = await provider.embed(query);
386
+ results = store.searchVec(query, embedding, limit, collection);
387
+ provider.dispose();
388
+ } else {
389
+ const provider = await createEmbeddingProvider();
390
+ results = await hybridSearch(
391
+ store,
392
+ { query, limit, collection, minScore },
393
+ { embedder: provider }
394
+ );
395
+ provider?.dispose();
396
+ }
397
+
398
+ console.log(formatSearchOutput(results, format));
399
+ store.close();
400
+ }
401
+
402
+ async function handleGet(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
403
+ const id = commandArgs[0];
404
+
405
+ if (!id) {
406
+ console.error('Missing document id or path');
407
+ process.exit(1);
408
+ }
409
+
410
+ let full = false;
411
+ let fromLine: number | undefined;
412
+ let maxLines: number | undefined;
413
+
414
+ for (let i = 1; i < commandArgs.length; i++) {
415
+ const arg = commandArgs[i];
416
+
417
+ if (arg === '--full') {
418
+ full = true;
419
+ } else if (arg.startsWith('--from=')) {
420
+ fromLine = parseInt(arg.substring(7), 10);
421
+ } else if (arg.startsWith('--lines=')) {
422
+ maxLines = parseInt(arg.substring(8), 10);
423
+ }
424
+ }
425
+
426
+ const store = createStore(globalOpts.dbPath);
427
+ const doc = store.findDocument(id);
428
+
429
+ if (!doc) {
430
+ console.error(`Document not found: ${id}`);
431
+ store.close();
432
+ process.exit(1);
433
+ }
434
+
435
+ console.log(`Document: ${doc.collection}/${doc.path}`);
436
+ console.log(`Title: ${doc.title}`);
437
+ console.log(`Docid: ${doc.hash.substring(0, 6)}`);
438
+ console.log('');
439
+
440
+ const body = store.getDocumentBody(doc.hash, fromLine, maxLines);
441
+ if (body) {
442
+ console.log(body);
443
+ }
444
+
445
+ store.close();
446
+ }
447
+
448
+ async function handleHarvest(globalOpts: GlobalOptions): Promise<void> {
449
+ const sessionDir = path.join(os.homedir(), '.opencode', 'storage');
450
+ const outputDir = DEFAULT_OUTPUT_DIR;
451
+
452
+ console.log('Harvesting sessions...');
453
+ const sessions = await harvestSessions({ sessionDir, outputDir });
454
+
455
+ console.log(`✅ Harvested ${sessions.length} sessions to ${outputDir}`);
456
+ }
457
+
458
+ async function main() {
459
+ const args = process.argv.slice(2);
460
+
461
+ const globalOpts = parseGlobalOptions(args);
462
+
463
+ const command = globalOpts.remaining[0] || 'mcp';
464
+ const commandArgs = globalOpts.remaining.slice(1);
465
+
466
+ switch (command) {
467
+ case 'mcp':
468
+ return handleMcp(globalOpts, commandArgs);
469
+ case 'collection':
470
+ return handleCollection(globalOpts, commandArgs);
471
+ case 'status':
472
+ return handleStatus(globalOpts);
473
+ case 'update':
474
+ return handleUpdate(globalOpts);
475
+ case 'embed':
476
+ return handleEmbed(globalOpts, commandArgs);
477
+ case 'search':
478
+ return handleSearch(globalOpts, commandArgs, 'fts');
479
+ case 'vsearch':
480
+ return handleSearch(globalOpts, commandArgs, 'vec');
481
+ case 'query':
482
+ return handleSearch(globalOpts, commandArgs, 'hybrid');
483
+ case 'get':
484
+ return handleGet(globalOpts, commandArgs);
485
+ case 'harvest':
486
+ return handleHarvest(globalOpts);
487
+ default:
488
+ console.error(`Unknown command: ${command}`);
489
+ showHelp();
490
+ process.exit(1);
491
+ }
492
+ }
493
+
494
+ const isMain = process.argv[1]?.endsWith('index.ts') ||
495
+ process.argv[1]?.endsWith('cli.js') ||
496
+ import.meta.url === `file://${process.argv[1]}`;
497
+
498
+ if (isMain) {
499
+ main().catch(err => {
500
+ console.error('Fatal error:', err);
501
+ process.exit(1);
502
+ });
503
+ }
@@ -0,0 +1,103 @@
1
+ import { getLlama } from 'node-llama-cpp';
2
+ import { cpus } from 'os';
3
+ import { resolveModelPath } from './embeddings.js';
4
+ import type { RerankResult, RerankDocument } from './types.js';
5
+
6
+ export interface Reranker {
7
+ rerank(query: string, documents: RerankDocument[]): Promise<RerankResult>;
8
+ dispose(): void;
9
+ }
10
+
11
+ export interface RerankerOptions {
12
+ modelPath?: string;
13
+ cacheDir?: string;
14
+ }
15
+
16
+ const DEFAULT_MODEL_URI = 'hf:gpustack/bge-reranker-v2-m3-GGUF/bge-reranker-v2-m3-Q4_K_M.gguf';
17
+ const MODEL_NAME = 'bge-reranker-v2-m3';
18
+ const CONTEXT_SIZE = 8192;
19
+
20
+ function sigmoid(x: number): number {
21
+ return 1 / (1 + Math.exp(-x));
22
+ }
23
+
24
+ class RerankerImpl implements Reranker {
25
+ private contexts: any[] = [];
26
+
27
+ constructor(
28
+ private model: any,
29
+ private parallelism: number
30
+ ) {}
31
+
32
+ async initialize(): Promise<void> {
33
+ for (let i = 0; i < this.parallelism; i++) {
34
+ const context = await this.model.createContext({
35
+ contextSize: CONTEXT_SIZE,
36
+ });
37
+ this.contexts.push(context);
38
+ }
39
+ }
40
+
41
+ async rerank(query: string, documents: RerankDocument[]): Promise<RerankResult> {
42
+ const scoredDocs: Array<{ file: string; score: number; index: number }> = [];
43
+
44
+ const batchSize = Math.min(4, this.parallelism);
45
+
46
+ for (let i = 0; i < documents.length; i += batchSize) {
47
+ const batch = documents.slice(i, i + batchSize);
48
+ const batchPromises = batch.map(async (doc, idx) => {
49
+ const contextIdx = idx % this.contexts.length;
50
+ const context = this.contexts[contextIdx];
51
+
52
+ const prompt = `Query: ${query}\nDocument: ${doc.text}`;
53
+
54
+ const result = await context.evaluate([prompt]);
55
+ const rawScore = result?.logits?.[0] || 0;
56
+ const normalizedScore = sigmoid(rawScore);
57
+
58
+ return {
59
+ file: doc.file,
60
+ score: normalizedScore,
61
+ index: doc.index,
62
+ };
63
+ });
64
+
65
+ const batchResults = await Promise.all(batchPromises);
66
+ scoredDocs.push(...batchResults);
67
+ }
68
+
69
+ scoredDocs.sort((a, b) => b.score - a.score);
70
+
71
+ return {
72
+ results: scoredDocs,
73
+ model: MODEL_NAME,
74
+ };
75
+ }
76
+
77
+ dispose(): void {
78
+ this.contexts = [];
79
+ }
80
+ }
81
+
82
+ export async function createReranker(
83
+ options?: RerankerOptions
84
+ ): Promise<Reranker | null> {
85
+ try {
86
+ const modelUri = options?.modelPath || DEFAULT_MODEL_URI;
87
+ const modelPath = await resolveModelPath(modelUri, options?.cacheDir);
88
+
89
+ const llama = await getLlama();
90
+ const model = await llama.loadModel({ modelPath });
91
+
92
+ const cpuCount = cpus().length;
93
+ const parallelism = Math.max(1, Math.min(4, Math.floor(cpuCount / 4)));
94
+
95
+ const reranker = new RerankerImpl(model, parallelism);
96
+ await reranker.initialize();
97
+
98
+ return reranker;
99
+ } catch (error) {
100
+ console.warn('Failed to load reranker model:', error instanceof Error ? error.message : String(error));
101
+ return null;
102
+ }
103
+ }