nano-brain 2026.2.0 → 2026.3.1

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.
@@ -0,0 +1,15 @@
1
+ services:
2
+ qdrant:
3
+ image: qdrant/qdrant:latest
4
+ container_name: nano-brain-qdrant
5
+ restart: unless-stopped
6
+ ports:
7
+ - "6333:6333"
8
+ - "6334:6334"
9
+ volumes:
10
+ - nano-brain-qdrant-data:/qdrant/storage
11
+ environment:
12
+ - QDRANT__SERVICE__GRPC_PORT=6334
13
+ volumes:
14
+ nano-brain-qdrant-data:
15
+ driver: local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nano-brain",
3
- "version": "2026.2.0",
3
+ "version": "2026.3.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "nano-brain": "./bin/cli.js"
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@modelcontextprotocol/sdk": "^1.26.0",
20
+ "@qdrant/js-client-rest": "^1.17.0",
20
21
  "better-sqlite3": "^12.6.2",
21
22
  "chokidar": "^5.0.0",
22
23
  "fast-glob": "^3.3.3",
package/src/embeddings.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { getLlama } from 'node-llama-cpp';
2
- import { promises as fs, accessSync, readFileSync } from 'fs';
2
+ import { promises as fs } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { homedir, cpus } from 'os';
5
5
  import type { EmbeddingResult, EmbeddingConfig } from './types.js';
6
6
  import { log } from './logger.js';
7
+ import { resolveHostUrl } from './host.js';
7
8
 
8
9
  export interface EmbeddingProvider {
9
10
  embed(text: string): Promise<EmbeddingResult>;
@@ -113,20 +114,7 @@ function formatDocumentPrompt(title: string, content: string): string {
113
114
 
114
115
 
115
116
  export function detectOllamaUrl(): string {
116
- const isDocker = (() => {
117
- try {
118
- accessSync('/.dockerenv');
119
- return true;
120
- } catch {
121
- try {
122
- const cgroup = readFileSync('/proc/1/cgroup', 'utf-8');
123
- return cgroup.includes('docker') || cgroup.includes('containerd');
124
- } catch {
125
- return false;
126
- }
127
- }
128
- })();
129
- return isDocker ? 'http://host.docker.internal:11434' : 'http://localhost:11434';
117
+ return resolveHostUrl('http://localhost:11434');
130
118
  }
131
119
 
132
120
  export async function checkOllamaHealth(url: string): Promise<{ reachable: boolean; models?: string[]; error?: string }> {
package/src/host.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { accessSync, readFileSync } from 'fs';
2
+
3
+ let cachedIsInsideContainer: boolean | null = null;
4
+
5
+ export function isInsideContainer(): boolean {
6
+ if (cachedIsInsideContainer !== null) {
7
+ return cachedIsInsideContainer;
8
+ }
9
+
10
+ try {
11
+ accessSync('/.dockerenv');
12
+ cachedIsInsideContainer = true;
13
+ return true;
14
+ } catch {
15
+ try {
16
+ const cgroup = readFileSync('/proc/1/cgroup', 'utf-8');
17
+ cachedIsInsideContainer = cgroup.includes('docker') || cgroup.includes('containerd');
18
+ return cachedIsInsideContainer;
19
+ } catch {
20
+ cachedIsInsideContainer = false;
21
+ return false;
22
+ }
23
+ }
24
+ }
25
+
26
+ export function resolveHostUrl(url: string): string {
27
+ if (!isInsideContainer()) {
28
+ return url;
29
+ }
30
+ return url.replace(/\b(localhost|127\.0\.0\.1)\b/g, 'host.docker.internal');
31
+ }
package/src/index.ts CHANGED
@@ -7,11 +7,16 @@ import { hybridSearch, parseSearchConfig } from './search.js';
7
7
  import { indexCodebase, embedPendingCodebase } from './codebase.js';
8
8
  import { findCycles } from './graph.js';
9
9
  import { handleBench } from './bench.js';
10
+ import { resolveHostUrl } from './host.js';
11
+ import { QdrantVecStore } from './providers/qdrant.js';
12
+ import { createVectorStore } from './vector-store.js';
10
13
  import type { SearchResult } from './types.js';
14
+ import type { VectorPoint } from './vector-store.js';
11
15
  import * as fs from 'fs';
12
16
  import * as path from 'path';
13
17
  import * as os from 'os';
14
18
  import * as crypto from 'crypto';
19
+ import { execSync } from 'child_process';
15
20
  import { log, initLogger } from './logger.js';
16
21
 
17
22
  function resolveOpenCodeStorageDir(): string {
@@ -150,6 +155,17 @@ nano-brain - Memory system with hybrid search
150
155
  --json Output as JSON
151
156
  --save Save results as baseline
152
157
  --compare Compare with last saved baseline
158
+ qdrant Manage Qdrant vector store
159
+ up Start Qdrant via Docker, configure as vector provider
160
+ down Stop Qdrant, switch back to sqlite-vec
161
+ status Show Qdrant container and collection health
162
+ migrate Migrate vectors from SQLite to Qdrant
163
+ --workspace=<path> Migrate specific workspace only
164
+ --batch-size=<n> Vectors per batch (default: 500)
165
+ --dry-run Show counts without migrating
166
+ --activate Switch to Qdrant provider after migration
167
+ activate Switch config to use Qdrant as vector provider
168
+ cleanup Drop SQLite vector tables (requires Qdrant active with vectors)
153
169
  Logging Config (~/.nano-brain/config.yml):
154
170
  logging:
155
171
  enabled: true # enable file logging (or use NANO_BRAIN_LOG=1 env)
@@ -744,6 +760,10 @@ async function handleSearch(
744
760
  results = store.searchFTS(query, { limit, collection, projectHash, tags, since, until });
745
761
  } else if (mode === 'vec') {
746
762
  const searchConfig = loadCollectionConfig(globalOpts.configPath);
763
+ if (searchConfig?.vector?.provider === 'qdrant' && searchConfig.vector.url) {
764
+ const vs = createVectorStore(searchConfig.vector);
765
+ store.setVectorStore(vs);
766
+ }
747
767
  const provider = await createEmbeddingProvider({ embeddingConfig: searchConfig?.embedding });
748
768
  if (!provider) {
749
769
  console.error('Vector search requires embedding model');
@@ -752,10 +772,14 @@ async function handleSearch(
752
772
  }
753
773
 
754
774
  const { embedding } = await provider.embed(query);
755
- results = store.searchVec(query, embedding, { limit, collection, projectHash, tags, since, until });
775
+ results = await store.searchVecAsync(query, embedding, { limit, collection, projectHash, tags, since, until });
756
776
  provider.dispose();
757
777
  } else {
758
778
  const searchConfig = loadCollectionConfig(globalOpts.configPath);
779
+ if (searchConfig?.vector?.provider === 'qdrant' && searchConfig.vector.url) {
780
+ const vs = createVectorStore(searchConfig.vector);
781
+ store.setVectorStore(vs);
782
+ }
759
783
  const provider = await createEmbeddingProvider({ embeddingConfig: searchConfig?.embedding });
760
784
  results = await hybridSearch(
761
785
  store,
@@ -1301,6 +1325,527 @@ function printLastLines(filePath: string, n: number): void {
1301
1325
  }
1302
1326
  }
1303
1327
 
1328
+ interface VectorConfigSection {
1329
+ provider: 'sqlite-vec' | 'qdrant';
1330
+ url?: string;
1331
+ apiKey?: string;
1332
+ collection?: string;
1333
+ }
1334
+
1335
+ async function handleQdrant(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
1336
+ const subcommand = commandArgs[0];
1337
+
1338
+ if (!subcommand) {
1339
+ console.error('Missing qdrant subcommand (up, down, status, migrate, activate, cleanup)');
1340
+ process.exit(1);
1341
+ }
1342
+
1343
+ log('cli', 'qdrant subcommand=' + subcommand);
1344
+
1345
+ const composeSource = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'docker-compose.qdrant.yml');
1346
+ const composeTarget = path.join(NANO_BRAIN_HOME, 'docker-compose.qdrant.yml');
1347
+
1348
+ switch (subcommand) {
1349
+ case 'up': {
1350
+ if (!fs.existsSync(composeTarget)) {
1351
+ if (!fs.existsSync(composeSource)) {
1352
+ console.error('❌ docker-compose.qdrant.yml not found in package');
1353
+ process.exit(1);
1354
+ }
1355
+ fs.mkdirSync(path.dirname(composeTarget), { recursive: true });
1356
+ fs.copyFileSync(composeSource, composeTarget);
1357
+ }
1358
+
1359
+ console.log('Starting Qdrant...');
1360
+ try {
1361
+ execSync(`docker compose -f "${composeTarget}" up -d`, { stdio: 'inherit' });
1362
+ } catch {
1363
+ console.error('❌ Failed to start Qdrant. Is Docker running?');
1364
+ process.exit(1);
1365
+ }
1366
+
1367
+ const healthUrl = resolveHostUrl('http://localhost:6333/healthz');
1368
+ let healthy = false;
1369
+ for (let i = 0; i < 5; i++) {
1370
+ await new Promise(r => setTimeout(r, 2000));
1371
+ try {
1372
+ const res = await fetch(healthUrl);
1373
+ if (res.ok) {
1374
+ healthy = true;
1375
+ break;
1376
+ }
1377
+ } catch {
1378
+ }
1379
+ console.log(`Waiting for Qdrant... (${i + 1}/5)`);
1380
+ }
1381
+
1382
+ if (!healthy) {
1383
+ console.error('❌ Qdrant failed to start. Check: docker logs nano-brain-qdrant');
1384
+ process.exit(1);
1385
+ }
1386
+
1387
+ let config = loadCollectionConfig(globalOpts.configPath);
1388
+ if (!config) {
1389
+ config = { collections: {} };
1390
+ }
1391
+ const vectorConfig: VectorConfigSection = {
1392
+ provider: 'qdrant',
1393
+ url: 'http://localhost:6333',
1394
+ collection: 'nano-brain',
1395
+ };
1396
+ config.vector = vectorConfig;
1397
+ saveCollectionConfig(globalOpts.configPath, config);
1398
+
1399
+ console.log('✅ Qdrant is running. Dashboard: http://localhost:6333/dashboard');
1400
+ break;
1401
+ }
1402
+
1403
+ case 'down': {
1404
+ console.log('Stopping Qdrant...');
1405
+ try {
1406
+ execSync(`docker compose -f "${composeTarget}" down`, { stdio: 'inherit' });
1407
+ } catch {
1408
+ console.error('❌ Failed to stop Qdrant');
1409
+ process.exit(1);
1410
+ }
1411
+
1412
+ let config = loadCollectionConfig(globalOpts.configPath);
1413
+ if (config) {
1414
+ const vectorConfig: VectorConfigSection = { provider: 'sqlite-vec' };
1415
+ config.vector = vectorConfig;
1416
+ saveCollectionConfig(globalOpts.configPath, config);
1417
+ }
1418
+
1419
+ console.log('✅ Qdrant stopped. Vector provider switched to sqlite-vec. Data persists in Docker volume.');
1420
+ break;
1421
+ }
1422
+
1423
+ case 'status': {
1424
+ const config = loadCollectionConfig(globalOpts.configPath);
1425
+ const vectorConfig = config?.vector;
1426
+ const currentProvider = vectorConfig?.provider || 'sqlite-vec';
1427
+
1428
+ console.log('Qdrant Status');
1429
+ console.log('═══════════════════════════════════════════════════');
1430
+ if (currentProvider === 'qdrant') {
1431
+ console.log(`Active provider: qdrant ✓`);
1432
+ } else {
1433
+ console.log(`Active provider: sqlite-vec (default)`);
1434
+ }
1435
+ console.log('');
1436
+
1437
+ let containerStatus = 'unknown';
1438
+ try {
1439
+ const output = execSync(`docker compose -f "${composeTarget}" ps --format json`, { encoding: 'utf-8' });
1440
+ const lines = output.trim().split('\n').filter(l => l.trim());
1441
+ for (const line of lines) {
1442
+ try {
1443
+ const info = JSON.parse(line);
1444
+ if (info.Name === 'nano-brain-qdrant' || info.Service === 'qdrant') {
1445
+ containerStatus = info.State || info.Status || 'running';
1446
+ break;
1447
+ }
1448
+ } catch {
1449
+ }
1450
+ }
1451
+ } catch {
1452
+ containerStatus = 'not running';
1453
+ }
1454
+
1455
+ console.log(`Container: ${containerStatus}`);
1456
+
1457
+ const qdrantUrl = vectorConfig?.url || 'http://localhost:6333';
1458
+ const resolvedUrl = resolveHostUrl(qdrantUrl);
1459
+
1460
+ try {
1461
+ const healthRes = await fetch(`${resolvedUrl}/healthz`);
1462
+ if (!healthRes.ok) {
1463
+ throw new Error(`HTTP ${healthRes.status}`);
1464
+ }
1465
+ console.log(`Health: ✅ reachable at ${resolvedUrl}`);
1466
+
1467
+ try {
1468
+ const collectionRes = await fetch(`${resolvedUrl}/collections/nano-brain`);
1469
+ if (collectionRes.ok) {
1470
+ const collectionData = await collectionRes.json();
1471
+ const result = collectionData.result || collectionData;
1472
+ console.log(`Collection: nano-brain`);
1473
+ console.log(` Vectors: ${result.points_count ?? result.vectors_count ?? 'unknown'}`);
1474
+ console.log(` Dimensions: ${result.config?.params?.vectors?.size ?? 'unknown'}`);
1475
+ } else {
1476
+ console.log('Collection: nano-brain (not created yet)');
1477
+ }
1478
+ } catch {
1479
+ console.log('Collection: nano-brain (not created yet)');
1480
+ }
1481
+ } catch {
1482
+ console.log(`Health: ❌ Qdrant is not reachable at ${resolvedUrl}`);
1483
+ if (resolvedUrl !== qdrantUrl) {
1484
+ console.log(` (config URL ${qdrantUrl} resolved to ${resolvedUrl} inside container)`);
1485
+ }
1486
+ console.log(' Run `npx nano-brain qdrant up` to start.');
1487
+ }
1488
+ break;
1489
+ }
1490
+
1491
+ case 'migrate': {
1492
+ let workspaceFilter: string | undefined;
1493
+ let batchSize = 500;
1494
+ let dryRun = false;
1495
+ let activateAfter = false;
1496
+
1497
+ for (const arg of commandArgs.slice(1)) {
1498
+ if (arg.startsWith('--workspace=')) {
1499
+ workspaceFilter = arg.substring(12);
1500
+ } else if (arg.startsWith('--batch-size=')) {
1501
+ batchSize = parseInt(arg.substring(13), 10);
1502
+ } else if (arg === '--dry-run') {
1503
+ dryRun = true;
1504
+ } else if (arg === '--activate') {
1505
+ activateAfter = true;
1506
+ }
1507
+ }
1508
+
1509
+ const config = loadCollectionConfig(globalOpts.configPath);
1510
+ const vectorConfig = config?.vector;
1511
+ const qdrantUrl = vectorConfig?.url || 'http://localhost:6333';
1512
+ const resolvedUrl = resolveHostUrl(qdrantUrl);
1513
+
1514
+ try {
1515
+ const healthRes = await fetch(`${resolvedUrl}/healthz`);
1516
+ if (!healthRes.ok) {
1517
+ throw new Error(`HTTP ${healthRes.status}`);
1518
+ }
1519
+ } catch {
1520
+ console.error(`❌ Qdrant is not reachable at ${resolvedUrl}.`);
1521
+ console.error(' Run `npx nano-brain qdrant up` first.');
1522
+ console.error(' If running inside a container, Qdrant must be accessible at host.docker.internal:6333.');
1523
+ process.exit(1);
1524
+ }
1525
+
1526
+ const dataDir = DEFAULT_DB_DIR;
1527
+ if (!fs.existsSync(dataDir)) {
1528
+ console.log('No databases found in ' + dataDir);
1529
+ return;
1530
+ }
1531
+
1532
+ let sqliteFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.sqlite'));
1533
+ if (workspaceFilter) {
1534
+ sqliteFiles = sqliteFiles.filter(f => f.includes(workspaceFilter));
1535
+ }
1536
+
1537
+ if (sqliteFiles.length === 0) {
1538
+ console.log('No matching databases found');
1539
+ return;
1540
+ }
1541
+
1542
+ console.log(`Found ${sqliteFiles.length} database(s) to migrate`);
1543
+ if (dryRun) {
1544
+ console.log('(dry-run mode - no vectors will be written)');
1545
+ }
1546
+
1547
+ const startTime = Date.now();
1548
+ let totalVectors = 0;
1549
+ let dbCount = 0;
1550
+
1551
+ const Database = (await import('better-sqlite3')).default;
1552
+ const sqliteVec = await import('sqlite-vec');
1553
+
1554
+ for (const sqliteFile of sqliteFiles) {
1555
+ const dbPath = path.join(dataDir, sqliteFile);
1556
+ const db = new Database(dbPath);
1557
+
1558
+ try {
1559
+ sqliteVec.load(db);
1560
+ } catch {
1561
+ console.log(`[${sqliteFile}] sqlite-vec not available, skipping`);
1562
+ db.close();
1563
+ continue;
1564
+ }
1565
+
1566
+ let vectorCount = 0;
1567
+ try {
1568
+ const countStmt = db.prepare(`
1569
+ SELECT COUNT(*) as cnt FROM content_vectors cv
1570
+ JOIN vectors_vec vv ON cv.hash || ':' || cv.seq = vv.hash_seq
1571
+ `);
1572
+ const countRow = countStmt.get() as { cnt: number };
1573
+ vectorCount = countRow.cnt;
1574
+ } catch {
1575
+ console.log(`[${sqliteFile}] no vector tables, skipping`);
1576
+ db.close();
1577
+ continue;
1578
+ }
1579
+
1580
+ if (vectorCount === 0) {
1581
+ console.log(`[${sqliteFile}] 0 vectors, skipping`);
1582
+ db.close();
1583
+ continue;
1584
+ }
1585
+
1586
+ if (dryRun) {
1587
+ console.log(`[${sqliteFile}] ${vectorCount} vectors (dry-run)`);
1588
+ totalVectors += vectorCount;
1589
+ dbCount++;
1590
+ db.close();
1591
+ continue;
1592
+ }
1593
+
1594
+ const qdrantStore = new QdrantVecStore({
1595
+ url: resolvedUrl,
1596
+ collection: vectorConfig?.collection || 'nano-brain',
1597
+ });
1598
+
1599
+ const selectStmt = db.prepare(`
1600
+ SELECT cv.hash, cv.seq, cv.pos, cv.model, vv.embedding,
1601
+ MIN(d.collection) as collection, MIN(d.project_hash) as project_hash
1602
+ FROM content_vectors cv
1603
+ JOIN vectors_vec vv ON cv.hash || ':' || cv.seq = vv.hash_seq
1604
+ LEFT JOIN documents d ON cv.hash = d.hash AND d.active = 1
1605
+ GROUP BY cv.hash, cv.seq
1606
+ `);
1607
+
1608
+ const rows = selectStmt.all() as Array<{
1609
+ hash: string;
1610
+ seq: number;
1611
+ pos: number;
1612
+ model: string;
1613
+ project_hash: string | null;
1614
+ embedding: Buffer;
1615
+ collection: string | null;
1616
+ }>;
1617
+
1618
+ let migrated = 0;
1619
+ const batch: VectorPoint[] = [];
1620
+
1621
+ for (const row of rows) {
1622
+ const embeddingArray = Array.from(new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4));
1623
+
1624
+ const point: VectorPoint = {
1625
+ id: `${row.hash}:${row.seq}`,
1626
+ embedding: embeddingArray,
1627
+ metadata: {
1628
+ hash: row.hash,
1629
+ seq: row.seq,
1630
+ pos: row.pos,
1631
+ model: row.model,
1632
+ collection: row.collection || undefined,
1633
+ projectHash: row.project_hash || undefined,
1634
+ },
1635
+ };
1636
+
1637
+ batch.push(point);
1638
+
1639
+ if (batch.length >= batchSize) {
1640
+ await qdrantStore.batchUpsert(batch);
1641
+ migrated += batch.length;
1642
+ console.log(`[${sqliteFile}] ${migrated}/${vectorCount} vectors migrated...`);
1643
+ batch.length = 0;
1644
+ }
1645
+ }
1646
+
1647
+ if (batch.length > 0) {
1648
+ await qdrantStore.batchUpsert(batch);
1649
+ migrated += batch.length;
1650
+ }
1651
+
1652
+ console.log(`[${sqliteFile}] ${migrated}/${vectorCount} vectors migrated`);
1653
+ totalVectors += migrated;
1654
+ dbCount++;
1655
+
1656
+ await qdrantStore.close();
1657
+ db.close();
1658
+ }
1659
+
1660
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1661
+ if (dryRun) {
1662
+ console.log(`\n📊 Dry-run complete: ${totalVectors} vectors in ${dbCount} database(s)`);
1663
+ } else {
1664
+ console.log(`\n✅ Migrated ${totalVectors} vectors from ${dbCount} database(s) in ${elapsed}s`);
1665
+
1666
+ const currentProvider = config?.vector?.provider || 'sqlite-vec';
1667
+ if (currentProvider !== 'qdrant') {
1668
+ if (activateAfter) {
1669
+ let updatedConfig = loadCollectionConfig(globalOpts.configPath);
1670
+ if (!updatedConfig) {
1671
+ updatedConfig = { collections: {} };
1672
+ }
1673
+ const newVectorConfig: VectorConfigSection = {
1674
+ provider: 'qdrant',
1675
+ url: vectorConfig?.url || 'http://localhost:6333',
1676
+ collection: vectorConfig?.collection || 'nano-brain',
1677
+ };
1678
+ updatedConfig.vector = newVectorConfig;
1679
+ saveCollectionConfig(globalOpts.configPath, updatedConfig);
1680
+ console.log('\n✅ Switched to Qdrant provider');
1681
+ } else {
1682
+ console.log(`\nProvider is currently: ${currentProvider}`);
1683
+ console.log('To use Qdrant for searches, run: npx nano-brain qdrant activate');
1684
+ console.log('Or re-run with: npx nano-brain qdrant migrate --activate');
1685
+ }
1686
+ }
1687
+ }
1688
+ break;
1689
+ }
1690
+
1691
+ case 'activate': {
1692
+ const config = loadCollectionConfig(globalOpts.configPath);
1693
+ const vectorConfig = config?.vector;
1694
+ const qdrantUrl = vectorConfig?.url || 'http://localhost:6333';
1695
+ const resolvedUrl = resolveHostUrl(qdrantUrl);
1696
+
1697
+ try {
1698
+ const healthRes = await fetch(`${resolvedUrl}/healthz`);
1699
+ if (!healthRes.ok) {
1700
+ throw new Error(`HTTP ${healthRes.status}`);
1701
+ }
1702
+ } catch {
1703
+ console.error(`❌ Qdrant is not reachable at ${resolvedUrl}.`);
1704
+ console.error(' Run `npx nano-brain qdrant up` first.');
1705
+ process.exit(1);
1706
+ }
1707
+
1708
+ let updatedConfig = loadCollectionConfig(globalOpts.configPath);
1709
+ if (!updatedConfig) {
1710
+ updatedConfig = { collections: {} };
1711
+ }
1712
+ const newVectorConfig: VectorConfigSection = {
1713
+ provider: 'qdrant',
1714
+ url: qdrantUrl,
1715
+ collection: vectorConfig?.collection || 'nano-brain',
1716
+ };
1717
+ updatedConfig.vector = newVectorConfig;
1718
+ saveCollectionConfig(globalOpts.configPath, updatedConfig);
1719
+
1720
+ console.log('✅ Switched to Qdrant provider');
1721
+ console.log(` URL: ${qdrantUrl}`);
1722
+ console.log(` Collection: ${newVectorConfig.collection}`);
1723
+ break;
1724
+ }
1725
+
1726
+ case 'cleanup': {
1727
+ const config = loadCollectionConfig(globalOpts.configPath);
1728
+ const vectorConfig = config?.vector;
1729
+ const currentProvider = vectorConfig?.provider || 'sqlite-vec';
1730
+
1731
+ if (currentProvider !== 'qdrant') {
1732
+ console.error('❌ Cannot cleanup: provider is not set to qdrant');
1733
+ console.error(` Current provider: ${currentProvider}`);
1734
+ console.error(' Run `npx nano-brain qdrant activate` first.');
1735
+ process.exit(1);
1736
+ }
1737
+
1738
+ const qdrantUrl = vectorConfig?.url || 'http://localhost:6333';
1739
+ const resolvedUrl = resolveHostUrl(qdrantUrl);
1740
+
1741
+ try {
1742
+ const healthRes = await fetch(`${resolvedUrl}/healthz`);
1743
+ if (!healthRes.ok) {
1744
+ throw new Error(`HTTP ${healthRes.status}`);
1745
+ }
1746
+ } catch {
1747
+ console.error(`❌ Qdrant is not reachable at ${resolvedUrl}.`);
1748
+ console.error(' Cannot cleanup without verifying Qdrant has vectors.');
1749
+ process.exit(1);
1750
+ }
1751
+
1752
+ let pointsCount = 0;
1753
+ try {
1754
+ const collectionRes = await fetch(`${resolvedUrl}/collections/nano-brain`);
1755
+ if (collectionRes.ok) {
1756
+ const collectionData = await collectionRes.json();
1757
+ const result = collectionData.result || collectionData;
1758
+ pointsCount = result.points_count ?? result.vectors_count ?? 0;
1759
+ }
1760
+ } catch {
1761
+ console.error('❌ Failed to check Qdrant collection');
1762
+ process.exit(1);
1763
+ }
1764
+
1765
+ if (pointsCount === 0) {
1766
+ console.error('❌ Cannot cleanup: Qdrant collection has no vectors');
1767
+ console.error(' Run `npx nano-brain qdrant migrate` first to migrate vectors.');
1768
+ process.exit(1);
1769
+ }
1770
+
1771
+ console.log(`Qdrant has ${pointsCount} vectors. Proceeding with SQLite cleanup...`);
1772
+
1773
+ const dataDir = DEFAULT_DB_DIR;
1774
+ if (!fs.existsSync(dataDir)) {
1775
+ console.log('No databases found in ' + dataDir);
1776
+ return;
1777
+ }
1778
+
1779
+ const sqliteFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.sqlite'));
1780
+ if (sqliteFiles.length === 0) {
1781
+ console.log('No SQLite databases found');
1782
+ return;
1783
+ }
1784
+
1785
+ const Database = (await import('better-sqlite3')).default;
1786
+ const sqliteVec = await import('sqlite-vec');
1787
+
1788
+ let cleanedCount = 0;
1789
+ let totalSpaceSaved = 0;
1790
+
1791
+ for (const sqliteFile of sqliteFiles) {
1792
+ const dbPath = path.join(dataDir, sqliteFile);
1793
+ const statBefore = fs.statSync(dbPath);
1794
+ const db = new Database(dbPath);
1795
+
1796
+ try {
1797
+ sqliteVec.load(db);
1798
+ } catch {
1799
+ console.log(`[${sqliteFile}] sqlite-vec not available, skipping`);
1800
+ db.close();
1801
+ continue;
1802
+ }
1803
+
1804
+ let hasVectorTables = false;
1805
+ try {
1806
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('vectors_vec', 'content_vectors')").all() as Array<{ name: string }>;
1807
+ hasVectorTables = tables.length > 0;
1808
+ } catch {
1809
+ db.close();
1810
+ continue;
1811
+ }
1812
+
1813
+ if (!hasVectorTables) {
1814
+ console.log(`[${sqliteFile}] no vector tables, skipping`);
1815
+ db.close();
1816
+ continue;
1817
+ }
1818
+
1819
+ try {
1820
+ db.exec('DROP TABLE IF EXISTS vectors_vec');
1821
+ db.exec('DELETE FROM content_vectors');
1822
+ db.exec('VACUUM');
1823
+ cleanedCount++;
1824
+
1825
+ const statAfter = fs.statSync(dbPath);
1826
+ const spaceSaved = statBefore.size - statAfter.size;
1827
+ totalSpaceSaved += Math.max(0, spaceSaved);
1828
+
1829
+ console.log(`[${sqliteFile}] cleaned`);
1830
+ } catch (err) {
1831
+ console.error(`[${sqliteFile}] cleanup failed:`, err);
1832
+ }
1833
+
1834
+ db.close();
1835
+ }
1836
+
1837
+ const spaceMB = (totalSpaceSaved / (1024 * 1024)).toFixed(2);
1838
+ console.log(`\n✅ Cleaned ${cleanedCount} database(s), ~${spaceMB} MB freed`);
1839
+ break;
1840
+ }
1841
+
1842
+ default:
1843
+ console.error(`Unknown qdrant subcommand: ${subcommand}`);
1844
+ console.error('Available: up, down, status, migrate, activate, cleanup');
1845
+ process.exit(1);
1846
+ }
1847
+ }
1848
+
1304
1849
  async function main() {
1305
1850
  const args = process.argv.slice(2);
1306
1851
 
@@ -1360,6 +1905,8 @@ async function main() {
1360
1905
  return handleImpact(globalOpts, commandArgs);
1361
1906
  case 'logs':
1362
1907
  return handleLogs(commandArgs);
1908
+ case 'qdrant':
1909
+ return handleQdrant(globalOpts, commandArgs);
1363
1910
  default:
1364
1911
  console.error(`Unknown command: ${command}`);
1365
1912
  showHelp();