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.
- package/docker-compose.qdrant.yml +15 -0
- package/package.json +2 -1
- package/src/embeddings.ts +3 -15
- package/src/host.ts +31 -0
- package/src/index.ts +548 -1
- package/src/providers/qdrant.ts +226 -0
- package/src/providers/sqlite-vec.ts +232 -0
- package/src/search.ts +10 -1
- package/src/server.ts +26 -1
- package/src/store.ts +68 -0
- package/src/types.ts +9 -0
- package/src/vector-store.ts +84 -0
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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();
|