nano-brain 2026.3.1 → 2026.3.3

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +300 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nano-brain",
3
- "version": "2026.3.1",
3
+ "version": "2026.3.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "nano-brain": "./bin/cli.js"
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { startServer } from './server.js';
2
2
  import { createStore, computeHash, indexDocument, extractProjectHashFromPath } from './store.js';
3
- import { loadCollectionConfig, addCollection, removeCollection, renameCollection, listCollections, getCollections, scanCollectionFiles, saveCollectionConfig } from './collections.js';
3
+ import { loadCollectionConfig, addCollection, removeCollection, renameCollection, listCollections, getCollections, scanCollectionFiles, saveCollectionConfig, getWorkspaceConfig } from './collections.js';
4
4
  import { harvestSessions } from './harvester.js';
5
5
  import { createEmbeddingProvider, detectOllamaUrl, checkOllamaHealth, checkOpenAIHealth } from './embeddings.js';
6
6
  import { hybridSearch, parseSearchConfig } from './search.js';
7
- import { indexCodebase, embedPendingCodebase } from './codebase.js';
7
+ import { indexCodebase, embedPendingCodebase, getCodebaseStats } from './codebase.js';
8
8
  import { findCycles } from './graph.js';
9
9
  import { handleBench } from './bench.js';
10
10
  import { resolveHostUrl } from './host.js';
@@ -12,6 +12,7 @@ import { QdrantVecStore } from './providers/qdrant.js';
12
12
  import { createVectorStore } from './vector-store.js';
13
13
  import type { SearchResult } from './types.js';
14
14
  import type { VectorPoint } from './vector-store.js';
15
+ import Database from 'better-sqlite3';
15
16
  import * as fs from 'fs';
16
17
  import * as path from 'path';
17
18
  import * as os from 'os';
@@ -102,6 +103,7 @@ nano-brain - Memory system with hybrid search
102
103
  --daemon Run as background daemon
103
104
  stop Stop running daemon
104
105
  status Show index health, embedding server status, and stats
106
+ --all Show status for all workspaces
105
107
  collection Manage collections
106
108
  add <name> <path> [--pattern=<glob>]
107
109
  remove <name>
@@ -164,6 +166,7 @@ nano-brain - Memory system with hybrid search
164
166
  --batch-size=<n> Vectors per batch (default: 500)
165
167
  --dry-run Show counts without migrating
166
168
  --activate Switch to Qdrant provider after migration
169
+ verify Compare SQLite vector counts against Qdrant
167
170
  activate Switch config to use Qdrant as vector provider
168
171
  cleanup Drop SQLite vector tables (requires Qdrant active with vectors)
169
172
  Logging Config (~/.nano-brain/config.yml):
@@ -328,69 +331,181 @@ async function handleCollection(globalOpts: GlobalOptions, commandArgs: string[]
328
331
  }
329
332
  }
330
333
 
331
- async function handleStatus(globalOpts: GlobalOptions): Promise<void> {
332
- log('cli', 'status command invoked');
333
- const store = createStore(globalOpts.dbPath);
334
- const config = loadCollectionConfig(globalOpts.configPath);
335
- const health = store.getIndexHealth();
336
- console.log('nano-brain Status');
337
- console.log('═══════════════════════════════════════════════════');
338
- console.log('');
339
- console.log('Index:');
340
- console.log(` Documents: ${health.documentCount}`);
341
- console.log(` Embedded: ${health.embeddedCount}`);
342
- console.log(` Pending embeddings: ${health.pendingEmbeddings}`);
343
- console.log(` Database size: ${(health.databaseSize / 1024 / 1024).toFixed(2)} MB`);
344
- console.log('');
345
-
346
- if (health.collections.length > 0) {
347
- console.log('Collections:');
348
- for (const coll of health.collections) {
349
- console.log(` ${coll.name}: ${coll.documentCount} documents`);
350
- }
351
- console.log('');
334
+ function extractWorkspaceName(dbFilename: string): string {
335
+ const base = path.basename(dbFilename, '.sqlite');
336
+ const parts = base.split('-');
337
+ if (parts.length > 1 && parts[parts.length - 1].length === 12) {
338
+ return parts.slice(0, -1).join('-');
352
339
  }
353
-
340
+ return base;
341
+ }
342
+
343
+ function formatBytes(bytes: number): string {
344
+ const mb = bytes / 1024 / 1024;
345
+ return `${mb.toFixed(1)} MB`;
346
+ }
347
+
348
+ async function printEmbeddingServerStatus(config: ReturnType<typeof loadCollectionConfig>): Promise<void> {
354
349
  const embeddingConfig = config?.embedding;
355
- const ollamaUrl = embeddingConfig?.url || detectOllamaUrl();
356
- const ollamaModel = embeddingConfig?.model || 'nomic-embed-text';
350
+ const url = embeddingConfig?.url || detectOllamaUrl();
351
+ const model = embeddingConfig?.model || 'nomic-embed-text';
357
352
  const provider = embeddingConfig?.provider || 'ollama';
358
-
353
+
359
354
  console.log('Embedding Server:');
360
355
  console.log(` Provider: ${provider}`);
361
- console.log(` URL: ${ollamaUrl}`);
362
- console.log(` Model: ${ollamaModel}`);
363
-
356
+ console.log(` URL: ${url}`);
357
+ console.log(` Model: ${model}`);
358
+
364
359
  if (provider === 'openai') {
365
- const openAiHealth = await checkOpenAIHealth(ollamaUrl, embeddingConfig?.apiKey || '', ollamaModel);
360
+ const openAiHealth = await checkOpenAIHealth(url, embeddingConfig?.apiKey || '', model);
366
361
  if (openAiHealth.reachable) {
367
362
  console.log(` Status: ✅ connected`);
368
- console.log(` Model: ✅ ${openAiHealth.model}`);
369
363
  } else {
370
364
  console.log(` Status: ❌ unreachable (${openAiHealth.error})`);
371
365
  }
372
366
  } else if (provider !== 'local') {
373
- const ollamaHealth = await checkOllamaHealth(ollamaUrl);
367
+ const ollamaHealth = await checkOllamaHealth(url);
374
368
  if (ollamaHealth.reachable) {
375
- const hasModel = ollamaHealth.models?.some(m => m.startsWith(ollamaModel));
376
369
  console.log(` Status: ✅ connected`);
377
- console.log(` Model: ${hasModel ? '✅ available' : '❌ not found — run: ollama pull ' + ollamaModel}`);
378
- if (ollamaHealth.models && ollamaHealth.models.length > 0) {
379
- console.log(` Available: ${ollamaHealth.models.join(', ')}`);
380
- }
381
370
  } else {
382
371
  console.log(` Status: ❌ unreachable (${ollamaHealth.error})`);
383
- console.log(` Fallback: local GGUF (node-llama-cpp)`);
384
372
  }
385
373
  } else {
386
374
  console.log(` Status: local GGUF mode`);
387
375
  }
376
+ }
377
+
378
+ async function handleStatus(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
379
+ log('cli', 'status command invoked');
380
+ const showAll = commandArgs.includes('--all');
381
+ const config = loadCollectionConfig(globalOpts.configPath);
382
+ const dataDir = path.dirname(globalOpts.dbPath);
383
+
384
+ if (showAll) {
385
+ let dbFiles: string[] = [];
386
+ try {
387
+ const files = fs.readdirSync(dataDir);
388
+ dbFiles = files.filter(f => f.endsWith('.sqlite')).map(f => path.join(dataDir, f));
389
+ } catch {
390
+ console.error(`Cannot read data directory: ${dataDir}`);
391
+ return;
392
+ }
393
+
394
+ if (dbFiles.length === 0) {
395
+ console.log('No workspaces found.');
396
+ return;
397
+ }
398
+
399
+ console.log('nano-brain Status — All Workspaces');
400
+ console.log('═══════════════════════════════════════════════════');
401
+ console.log('');
402
+
403
+ const header = ' Workspace Documents Embedded Pending DB Size';
404
+ const divider = ' ───────────────────── ───────── ──────── ─────── ───────';
405
+ console.log(header);
406
+ console.log(divider);
407
+
408
+ let totalDocs = 0;
409
+ let totalEmbedded = 0;
410
+ let totalPending = 0;
411
+ let totalSize = 0;
412
+
413
+ for (const dbFile of dbFiles) {
414
+ const workspaceName = extractWorkspaceName(dbFile);
415
+ let fileSize = 0;
416
+ try {
417
+ fileSize = fs.statSync(dbFile).size;
418
+ } catch { /* ignore */ }
419
+
420
+ let docs = 0;
421
+ let embedded = 0;
422
+ let pending = 0;
423
+ try {
424
+ const readDb = new Database(dbFile, { readonly: true });
425
+ try {
426
+ docs = (readDb.prepare('SELECT COUNT(*) as count FROM documents WHERE active = 1').get() as { count: number }).count;
427
+ embedded = (readDb.prepare('SELECT COUNT(*) as count FROM content_vectors').get() as { count: number }).count;
428
+ pending = docs - embedded;
429
+ if (pending < 0) pending = 0;
430
+ } catch {
431
+ }
432
+ readDb.close();
433
+ } catch { /* ignore */ }
434
+
435
+ totalDocs += docs;
436
+ totalEmbedded += embedded;
437
+ totalPending += pending;
438
+ totalSize += fileSize;
439
+
440
+ const name = workspaceName.padEnd(21);
441
+ const docsStr = docs.toLocaleString().padStart(9);
442
+ const embeddedStr = embedded.toLocaleString().padStart(8);
443
+ const pendingStr = pending.toLocaleString().padStart(7);
444
+ const sizeStr = formatBytes(fileSize).padStart(9);
445
+ console.log(` ${name} ${docsStr} ${embeddedStr} ${pendingStr} ${sizeStr}`);
446
+ }
447
+
448
+ console.log('');
449
+ console.log(` Total: ${dbFiles.length} workspaces, ${totalDocs.toLocaleString()} documents, ${totalPending.toLocaleString()} pending embeddings, ${formatBytes(totalSize)}`);
450
+ console.log('');
451
+
452
+ await printEmbeddingServerStatus(config);
453
+ return;
454
+ }
455
+
456
+ const workspaceRoot = process.cwd();
457
+ const resolvedDbPath = resolveDbPath(globalOpts.dbPath, workspaceRoot);
458
+ const workspaceName = extractWorkspaceName(resolvedDbPath);
459
+
460
+ let dbSize = 0;
461
+ try {
462
+ dbSize = fs.statSync(resolvedDbPath).size;
463
+ } catch { /* ignore */ }
464
+
465
+ const store = createStore(resolvedDbPath);
466
+ const health = store.getIndexHealth();
467
+
468
+ console.log(`nano-brain Status — ${workspaceName}`);
469
+ console.log('═══════════════════════════════════════════════════');
388
470
  console.log('');
389
-
471
+
472
+ console.log('Database:');
473
+ console.log(` Path: ${resolvedDbPath.replace(os.homedir(), '~')}`);
474
+ console.log(` Size: ${formatBytes(dbSize)} (on disk)`);
475
+ console.log('');
476
+
477
+ console.log('Index:');
478
+ console.log(` Documents: ${health.documentCount.toLocaleString()}`);
479
+ console.log(` Embedded: ${health.embeddedCount.toLocaleString()}`);
480
+ console.log(` Pending embeddings: ${health.pendingEmbeddings.toLocaleString()}`);
481
+ console.log('');
482
+
483
+ if (health.collections.length > 0) {
484
+ console.log('Collections:');
485
+ for (const coll of health.collections) {
486
+ console.log(` ${coll.name.padEnd(10)} ${coll.documentCount.toLocaleString()} documents`);
487
+ }
488
+ console.log('');
489
+ }
490
+
491
+ const wsConfig = getWorkspaceConfig(config, workspaceRoot);
492
+ const codebaseStats = getCodebaseStats(store, wsConfig?.codebase, workspaceRoot);
493
+ if (codebaseStats) {
494
+ console.log('Codebase:');
495
+ console.log(` Enabled: ${codebaseStats.enabled}`);
496
+ console.log(` Storage: ${formatBytes(codebaseStats.storageUsed)} / ${formatBytes(codebaseStats.maxSize)}`);
497
+ console.log(` Extensions: ${codebaseStats.extensions.join(', ') || 'auto-detect'}`);
498
+ console.log(` Excludes: ${codebaseStats.excludeCount} patterns`);
499
+ console.log('');
500
+ }
501
+
502
+ await printEmbeddingServerStatus(config);
503
+ console.log('');
504
+
390
505
  console.log('Models:');
391
506
  console.log(` Embedding: ${health.modelStatus.embedding}`);
392
507
  console.log(` Reranker: ${health.modelStatus.reranker}`);
393
- console.log(` Expander: ${health.modelStatus.expander}`);
508
+ console.log(` Expander: ${health.modelStatus.expander}`);
394
509
  store.close();
395
510
  }
396
511
 
@@ -1336,7 +1451,7 @@ async function handleQdrant(globalOpts: GlobalOptions, commandArgs: string[]): P
1336
1451
  const subcommand = commandArgs[0];
1337
1452
 
1338
1453
  if (!subcommand) {
1339
- console.error('Missing qdrant subcommand (up, down, status, migrate, activate, cleanup)');
1454
+ console.error('Missing qdrant subcommand (up, down, status, migrate, verify, activate, cleanup)');
1340
1455
  process.exit(1);
1341
1456
  }
1342
1457
 
@@ -1688,6 +1803,147 @@ async function handleQdrant(globalOpts: GlobalOptions, commandArgs: string[]): P
1688
1803
  break;
1689
1804
  }
1690
1805
 
1806
+ case 'verify': {
1807
+ const config = loadCollectionConfig(globalOpts.configPath);
1808
+ const vectorConfig = config?.vector;
1809
+ const qdrantUrl = vectorConfig?.url || 'http://localhost:6333';
1810
+ const resolvedUrl = resolveHostUrl(qdrantUrl);
1811
+
1812
+ try {
1813
+ const healthRes = await fetch(`${resolvedUrl}/healthz`);
1814
+ if (!healthRes.ok) {
1815
+ throw new Error(`HTTP ${healthRes.status}`);
1816
+ }
1817
+ } catch {
1818
+ console.error(`❌ Qdrant is not reachable at ${resolvedUrl}.`);
1819
+ console.error(' Run `npx nano-brain qdrant up` first.');
1820
+ console.error(' If running inside a container, Qdrant must be accessible at host.docker.internal:6333.');
1821
+ process.exit(1);
1822
+ }
1823
+
1824
+ const dataDir = DEFAULT_DB_DIR;
1825
+ if (!fs.existsSync(dataDir)) {
1826
+ console.log('No databases found in ' + dataDir);
1827
+ return;
1828
+ }
1829
+
1830
+ const sqliteFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.sqlite'));
1831
+ if (sqliteFiles.length === 0) {
1832
+ console.log('No SQLite databases found');
1833
+ return;
1834
+ }
1835
+
1836
+ console.log('Verifying migration...');
1837
+ console.log('═══════════════════════════════════════════════════');
1838
+
1839
+ const Database = (await import('better-sqlite3')).default;
1840
+ const sqliteVec = await import('sqlite-vec');
1841
+
1842
+ let totalVectors = 0;
1843
+ let dbCount = 0;
1844
+ const uniqueKeys = new Set<string>();
1845
+ let sawVectorTables = false;
1846
+
1847
+ for (const sqliteFile of sqliteFiles) {
1848
+ const dbPath = path.join(dataDir, sqliteFile);
1849
+ const db = new Database(dbPath);
1850
+
1851
+ try {
1852
+ sqliteVec.load(db);
1853
+ } catch {
1854
+ console.log(`[${sqliteFile}] sqlite-vec not available, skipping`);
1855
+ db.close();
1856
+ continue;
1857
+ }
1858
+
1859
+ let vectorCount = 0;
1860
+ try {
1861
+ const countStmt = db.prepare(`
1862
+ SELECT COUNT(*) as cnt FROM content_vectors cv
1863
+ JOIN vectors_vec vv ON cv.hash || ':' || cv.seq = vv.hash_seq
1864
+ `);
1865
+ const countRow = countStmt.get() as { cnt: number };
1866
+ vectorCount = countRow.cnt;
1867
+ } catch {
1868
+ console.log(`[${sqliteFile}] no vector tables, skipping`);
1869
+ db.close();
1870
+ continue;
1871
+ }
1872
+
1873
+ sawVectorTables = true;
1874
+
1875
+ if (vectorCount === 0) {
1876
+ console.log(`[${sqliteFile}] 0 vectors`);
1877
+ db.close();
1878
+ continue;
1879
+ }
1880
+
1881
+ const keyStmt = db.prepare(`
1882
+ SELECT DISTINCT cv.hash || ':' || cv.seq as key FROM content_vectors cv
1883
+ JOIN vectors_vec vv ON cv.hash || ':' || cv.seq = vv.hash_seq
1884
+ `);
1885
+ const rows = keyStmt.all() as Array<{ key: string }>;
1886
+ for (const row of rows) {
1887
+ uniqueKeys.add(row.key);
1888
+ }
1889
+
1890
+ console.log(`[${sqliteFile}] ${vectorCount.toLocaleString()} vectors in SQLite`);
1891
+ totalVectors += vectorCount;
1892
+ dbCount++;
1893
+ db.close();
1894
+ }
1895
+
1896
+ if (!sawVectorTables || totalVectors === 0) {
1897
+ let pointsCount = 0;
1898
+ try {
1899
+ const collectionRes = await fetch(`${resolvedUrl}/collections/nano-brain`);
1900
+ if (collectionRes.ok) {
1901
+ const collectionData = await collectionRes.json();
1902
+ const result = collectionData.result || collectionData;
1903
+ pointsCount = result.points_count ?? result.vectors_count ?? 0;
1904
+ }
1905
+ } catch {
1906
+ console.error('❌ Failed to check Qdrant collection');
1907
+ process.exit(1);
1908
+ }
1909
+
1910
+ console.log('SQLite: no vector data (already cleaned up)');
1911
+ console.log(`Qdrant: ${pointsCount.toLocaleString()} vectors`);
1912
+ console.log(`ℹ️ Cannot verify — SQLite vectors already cleaned. Qdrant has ${pointsCount.toLocaleString()} vectors.`);
1913
+ break;
1914
+ }
1915
+
1916
+ console.log('───────────────────────────────────────────────────');
1917
+ console.log(`SQLite total: ${totalVectors.toLocaleString()} vectors (across ${dbCount} databases)`);
1918
+
1919
+ let pointsCount = 0;
1920
+ try {
1921
+ const collectionRes = await fetch(`${resolvedUrl}/collections/nano-brain`);
1922
+ if (collectionRes.ok) {
1923
+ const collectionData = await collectionRes.json();
1924
+ const result = collectionData.result || collectionData;
1925
+ pointsCount = result.points_count ?? result.vectors_count ?? 0;
1926
+ }
1927
+ } catch {
1928
+ console.error('❌ Failed to check Qdrant collection');
1929
+ process.exit(1);
1930
+ }
1931
+
1932
+ const uniqueCount = uniqueKeys.size;
1933
+ console.log(`Qdrant total: ${pointsCount.toLocaleString()} unique vectors`);
1934
+ const difference = totalVectors - pointsCount;
1935
+ console.log(`Difference: ${difference.toLocaleString()} (expected — cross-workspace duplicates share the same hash:seq key)`);
1936
+ console.log('');
1937
+
1938
+ if (uniqueCount > pointsCount) {
1939
+ const missing = uniqueCount - pointsCount;
1940
+ console.log(`⚠️ Found ${missing.toLocaleString()} vectors in SQLite not present in Qdrant. Run \`npx nano-brain qdrant migrate\` to sync.`);
1941
+ } else {
1942
+ console.log('✅ Migration verified: Qdrant has all unique vectors');
1943
+ }
1944
+ break;
1945
+ }
1946
+
1691
1947
  case 'activate': {
1692
1948
  const config = loadCollectionConfig(globalOpts.configPath);
1693
1949
  const vectorConfig = config?.vector;
@@ -1841,7 +2097,7 @@ async function handleQdrant(globalOpts: GlobalOptions, commandArgs: string[]): P
1841
2097
 
1842
2098
  default:
1843
2099
  console.error(`Unknown qdrant subcommand: ${subcommand}`);
1844
- console.error('Available: up, down, status, migrate, activate, cleanup');
2100
+ console.error('Available: up, down, status, migrate, verify, activate, cleanup');
1845
2101
  process.exit(1);
1846
2102
  }
1847
2103
  }
@@ -1872,7 +2128,7 @@ async function main() {
1872
2128
  case 'collection':
1873
2129
  return handleCollection(globalOpts, commandArgs);
1874
2130
  case 'status':
1875
- return handleStatus(globalOpts);
2131
+ return handleStatus(globalOpts, commandArgs);
1876
2132
  case 'update':
1877
2133
  return handleUpdate(globalOpts);
1878
2134
  case 'embed':