UNPKG

voyageai-cli

Version:

CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search

752 lines (651 loc) 24.6 kB
'use strict'; const path = require('path'); const fs = require('fs'); const pc = require('picocolors'); const { generateEmbeddings, apiRequest } = require('../lib/api'); const { getMongoCollection } = require('../lib/mongo'); const { loadProject, saveProject } = require('../lib/project'); const { DEFAULT_RERANK_MODEL } = require('../lib/catalog'); const { showCombinedCostSummary } = require('../lib/cost-display'); const ui = require('../lib/ui'); const { DEFAULT_CODE_MODEL, DEFAULT_DB, smartChunkCode, extractSymbols, findCodeFiles, resolveConfig, deriveCollectionName, } = require('../lib/code-search'); // ── Command registration ── /** * Register the code-search command group on a Commander program. * @param {import('commander').Command} program */ function registerCodeSearch(program) { const codeSearchCmd = program .command('code-search') .description('Semantic code search — index and search your codebase') .argument('[query]', 'Search query (omit for subcommands)') .option('-l, --limit <n>', 'Number of results', (v) => parseInt(v, 10), 10) .option('--no-rerank', 'Skip reranking') .option('--rerank-model <model>', 'Reranking model', DEFAULT_RERANK_MODEL) .option('-m, --model <model>', 'Embedding model') .option('--db <name>', 'MongoDB database name') .option('--collection <name>', 'Collection name') .option('--json', 'Machine-readable JSON output') .option('-q, --quiet', 'Suppress non-essential output') .action(async (query, opts) => { if (!query) { codeSearchCmd.outputHelp(); return; } await handleSearch(query, opts); }); // ── code-search init ── codeSearchCmd .command('init [path]') .description('Index a codebase for semantic code search') .option('-m, --model <model>', 'Embedding model', DEFAULT_CODE_MODEL) .option('--db <name>', 'MongoDB database name') .option('--collection <name>', 'Collection name') .option('--chunk-size <n>', 'Target chunk size in characters', (v) => parseInt(v, 10), 512) .option('--chunk-overlap <n>', 'Overlap between chunks', (v) => parseInt(v, 10), 50) .option('--max-files <n>', 'Maximum files to index', (v) => parseInt(v, 10), 5000) .option('--max-file-size <bytes>', 'Maximum file size in bytes', (v) => parseInt(v, 10), 100000) .option('--batch-size <n>', 'Embedding batch size', (v) => parseInt(v, 10), 20) .option('--json', 'Machine-readable JSON output') .option('-q, --quiet', 'Suppress non-essential output') .action(async (workspacePath, opts) => { await handleInit(workspacePath, opts); }); // ── code-search status ── codeSearchCmd .command('status') .description('Show index stats for the current codebase') .option('--db <name>', 'MongoDB database name') .option('--collection <name>', 'Collection name') .option('--json', 'Machine-readable JSON output') .action(async (opts) => { await handleStatus(opts); }); // ── code-search refresh ── codeSearchCmd .command('refresh [path]') .description('Re-index only changed files') .option('-m, --model <model>', 'Embedding model') .option('--db <name>', 'MongoDB database name') .option('--collection <name>', 'Collection name') .option('--chunk-size <n>', 'Target chunk size in characters', (v) => parseInt(v, 10), 512) .option('--chunk-overlap <n>', 'Overlap between chunks', (v) => parseInt(v, 10), 50) .option('--batch-size <n>', 'Embedding batch size', (v) => parseInt(v, 10), 20) .option('--json', 'Machine-readable JSON output') .option('-q, --quiet', 'Suppress non-essential output') .action(async (workspacePath, opts) => { await handleRefresh(workspacePath, opts); }); } // ── Handlers ── async function handleInit(workspacePath, opts) { const telemetry = require('../lib/telemetry'); telemetry.send('cli_code_search_init'); const resolvedPath = workspacePath ? path.resolve(workspacePath) : process.cwd(); const { db, collection: collName, model } = resolveConfig(opts, resolvedPath); const useSpinner = !opts.json && !opts.quiet; let spin; if (useSpinner) { spin = ui.spinner(`Scanning ${resolvedPath}...`); spin.start(); } const start = Date.now(); const files = await findCodeFiles(resolvedPath, { maxFiles: opts.maxFiles, maxFileSize: opts.maxFileSize, }); if (spin) spin.stop(); if (files.length === 0) { console.log(ui.warn(`No code files found in ${resolvedPath}`)); return; } if (!opts.quiet && !opts.json) { console.log(ui.info(`Found ${files.length} code files`)); } let client; try { const { client: c, collection } = await getMongoCollection(db, collName); client = c; // Clear existing data for this workspace await collection.deleteMany({ 'metadata.workspace': resolvedPath }); const stats = { filesIndexed: 0, chunksCreated: 0, errors: [] }; const batchSize = opts.batchSize || 20; // Process files and create chunks const allDocs = []; for (const filePath of files) { try { const content = await fs.promises.readFile(filePath, 'utf-8'); const relativePath = path.relative(resolvedPath, filePath); const ext = path.extname(filePath).toLowerCase(); const fileStats = await fs.promises.stat(filePath); const symbols = extractSymbols(content, filePath); const chunks = smartChunkCode(content, filePath, { chunkSize: opts.chunkSize, chunkOverlap: opts.chunkOverlap, }); for (const c of chunks) { allDocs.push({ text: c.text, metadata: { source: relativePath, filePath, workspace: resolvedPath, language: ext.slice(1), startLine: c.startLine, endLine: c.endLine, chunkType: c.type, symbols: symbols.filter(s => c.text.includes(s)), mtime: fileStats.mtimeMs, indexedAt: new Date().toISOString(), }, }); } stats.filesIndexed++; } catch (err) { stats.errors.push({ file: filePath, error: err.message }); } } stats.chunksCreated = allDocs.length; // Embed and insert in batches if (useSpinner) { spin = ui.spinner(`Embedding ${allDocs.length} chunks...`); spin.start(); } let totalTokens = 0; for (let i = 0; i < allDocs.length; i += batchSize) { const batch = allDocs.slice(i, i + batchSize); const texts = batch.map(d => d.text); const embedResult = await generateEmbeddings(texts, { model, inputType: 'document' }); totalTokens += embedResult.usage?.total_tokens || 0; const docsToInsert = batch.map((doc, idx) => ({ text: doc.text, embedding: embedResult.data[idx].embedding, metadata: doc.metadata, })); await collection.insertMany(docsToInsert); if (useSpinner && spin) { spin.stop(); spin = ui.spinner(`Embedding chunks... ${Math.min(i + batchSize, allDocs.length)}/${allDocs.length}`); spin.start(); } } if (spin) spin.stop(); // Create vector search index if (useSpinner) { spin = ui.spinner('Creating vector search index...'); spin.start(); } try { await collection.createSearchIndex({ name: 'code_search_index', type: 'vectorSearch', definition: { fields: [ { type: 'vector', path: 'embedding', numDimensions: 1024, similarity: 'cosine' }, { type: 'filter', path: 'metadata.language' }, { type: 'filter', path: 'metadata.workspace' }, ], }, }); } catch (err) { // Index may already exist if (!err.message?.includes('already exists')) { if (spin) spin.stop(); console.log(ui.warn(`Could not create search index: ${err.message}`)); } } if (spin) spin.stop(); // Save config to .vai.json const { config: proj, filePath: projPath } = loadProject(resolvedPath); proj.codeSearch = { db, collection: collName, model, lastIndexed: new Date().toISOString(), workspace: resolvedPath, }; try { saveProject(proj, projPath || path.join(resolvedPath, '.vai.json')); } catch { /* non-critical */ } const timeMs = Date.now() - start; if (opts.json) { console.log(JSON.stringify({ ...stats, db, collection: collName, model, totalTokens, timeMs, }, null, 2)); } else { console.log(''); console.log(pc.green('✓ Codebase indexed successfully!')); console.log(''); console.log(ui.label('Files indexed', `${stats.filesIndexed}/${files.length}`)); console.log(ui.label('Chunks created', String(stats.chunksCreated))); console.log(ui.label('Collection', `${db}.${collName}`)); console.log(ui.label('Model', model)); console.log(ui.label('Time', `${timeMs}ms`)); console.log(ui.label('Tokens', String(totalTokens))); if (stats.errors.length > 0) { console.log(''); console.log(pc.yellow(`⚠ ${stats.errors.length} file(s) had errors`)); for (const e of stats.errors.slice(0, 5)) { console.log(` ${pc.dim(e.file)}: ${e.error}`); } if (stats.errors.length > 5) { console.log(` ... and ${stats.errors.length - 5} more`); } } console.log(''); console.log(ui.dim('Search with: vai code-search "your query"')); console.log(ui.dim('Note: Vector search index may take a few minutes to become ready.')); showCombinedCostSummary([{ model, tokens: totalTokens, label: `embed (${model})` }], opts); } } finally { if (client) await client.close(); } } async function handleSearch(query, opts) { const telemetry = require('../lib/telemetry'); const { db, collection: collName, model } = resolveConfig(opts); const doRerank = opts.rerank !== false; const rerankModel = opts.rerankModel || DEFAULT_RERANK_MODEL; const limit = opts.limit || 10; const useSpinner = !opts.json && !opts.quiet; const done = telemetry.timer('cli_code_search_query', { model, rerank: doRerank }); let client; try { // Embed query let spin; if (useSpinner) { spin = ui.spinner('Embedding query...'); spin.start(); } const embedResult = await generateEmbeddings([query], { model, inputType: 'query' }); const queryVector = embedResult.data[0].embedding; const embedTokens = embedResult.usage?.total_tokens || 0; if (spin) spin.stop(); // Vector search if (useSpinner) { spin = ui.spinner(`Searching ${db}.${collName}...`); spin.start(); } const { client: c, collection } = await getMongoCollection(db, collName); client = c; // Check if collection has documents const docCount = await collection.estimatedDocumentCount(); if (docCount === 0) { if (spin) spin.stop(); console.log(ui.warn('No indexed code found. Run `vai code-search init` first.')); return; } const numCandidates = Math.min(limit * 15, 10000); const pipeline = [ { $vectorSearch: { index: 'code_search_index', path: 'embedding', queryVector, numCandidates, limit: doRerank ? limit * 3 : limit, }, }, { $addFields: { _vsScore: { $meta: 'vectorSearchScore' } } }, ]; let searchResults; try { searchResults = await collection.aggregate(pipeline).toArray(); } catch (err) { if (spin) spin.stop(); if (err.message?.includes('index') || err.codeName === 'InvalidPipelineOperator') { console.log(ui.warn('Vector search index not ready. Run `vai code-search init` and wait a few minutes.')); return; } throw err; } if (spin) spin.stop(); if (searchResults.length === 0) { if (opts.json) { console.log(JSON.stringify({ query, results: [] }, null, 2)); } else { console.log(ui.yellow('No results found.')); } return; } // Rerank let finalResults; let rerankTokens = 0; if (doRerank && searchResults.length > 1) { if (useSpinner) { spin = ui.spinner(`Reranking ${searchResults.length} results...`); spin.start(); } const documents = searchResults.map(d => d.text || ''); const rerankResult = await apiRequest('/rerank', { query, documents, model: rerankModel, top_k: limit, }); rerankTokens = rerankResult.usage?.total_tokens || 0; if (spin) spin.stop(); finalResults = (rerankResult.data || []).map(item => { const doc = searchResults[item.index]; return { ...doc, _vsScore: doc._vsScore, _rerankScore: item.relevance_score }; }); } else { finalResults = searchResults.slice(0, limit); } // Output if (opts.json) { const jsonResults = finalResults.map((r, i) => ({ rank: i + 1, source: r.metadata?.source, language: r.metadata?.language, startLine: r.metadata?.startLine, endLine: r.metadata?.endLine, symbols: r.metadata?.symbols, score: r._rerankScore || r._vsScore, vectorScore: r._vsScore, rerankScore: r._rerankScore, text: r.text, })); console.log(JSON.stringify({ query, model, rerankModel: doRerank ? rerankModel : null, db, collection: collName, tokens: { embed: embedTokens, rerank: rerankTokens }, results: jsonResults, }, null, 2)); done({ resultCount: finalResults.length }); return; } // Pretty print console.log(''); console.log(ui.label('Query', ui.cyan(`"${query}"`))); console.log(ui.label('Search', `${searchResults.length} candidates from ${ui.dim(`${db}.${collName}`)}`)); if (doRerank && searchResults.length > 1) { console.log(ui.label('Rerank', `Top ${finalResults.length} via ${ui.dim(rerankModel)}`)); } console.log(''); for (let i = 0; i < finalResults.length; i++) { const r = finalResults[i]; const meta = r.metadata || {}; const score = r._rerankScore || r._vsScore; const scoreStr = score != null ? ui.score(score) : ''; const vsStr = r._vsScore != null ? ui.dim(`vs:${r._vsScore.toFixed(3)}`) : ''; const rrStr = r._rerankScore != null ? ui.dim(`rr:${r._rerankScore.toFixed(3)}`) : ''; const scores = [vsStr, rrStr].filter(Boolean).join(' '); // File header const lineRange = meta.startLine ? pc.dim(`:${meta.startLine}-${meta.endLine}`) : ''; console.log(`${pc.bold(`#${i + 1}`)} ${pc.cyan(meta.source || 'unknown')}${lineRange} ${scoreStr} ${scores}`); // Symbols if (meta.symbols?.length > 0) { console.log(` ${pc.dim('symbols:')} ${meta.symbols.slice(0, 5).join(', ')}`); } // Code snippet const snippet = (r.text || '').substring(0, 300); const ellipsis = (r.text || '').length > 300 ? '...' : ''; const indented = snippet.split('\n').map(l => ' ' + l).join('\n'); console.log(pc.dim(indented + ellipsis)); console.log(''); } const totalTokens = embedTokens + rerankTokens; console.log(ui.dim(` Tokens: ${totalTokens} (embed: ${embedTokens}${rerankTokens ? `, rerank: ${rerankTokens}` : ''})`)); showCombinedCostSummary([ { model, tokens: embedTokens, label: `embed (${model})` }, ...(rerankTokens ? [{ model: rerankModel, tokens: rerankTokens, label: `rerank (${rerankModel})` }] : []), ], opts); done({ resultCount: finalResults.length }); } catch (err) { telemetry.send('cli_error', { command: 'code-search', errorType: err.constructor.name }); console.error(ui.error(err.message)); process.exit(1); } finally { if (client) await client.close(); } } async function handleStatus(opts) { const { db, collection: collName, model } = resolveConfig(opts); const useSpinner = !opts.json; let client; try { let spin; if (useSpinner) { spin = ui.spinner('Fetching index stats...'); spin.start(); } const { client: c, collection } = await getMongoCollection(db, collName); client = c; const totalChunks = await collection.estimatedDocumentCount(); if (totalChunks === 0) { if (spin) spin.stop(); console.log(ui.warn('No indexed code found. Run `vai code-search init` first.')); return; } // Get unique files and last indexed time const [fileStats] = await collection.aggregate([ { $group: { _id: null, uniqueFiles: { $addToSet: '$metadata.source' }, lastIndexed: { $max: '$metadata.indexedAt' }, languages: { $addToSet: '$metadata.language' }, }, }, ]).toArray(); // Get index info let indexes = []; try { indexes = await collection.listSearchIndexes().toArray(); } catch { /* might not have permissions */ } if (spin) spin.stop(); const stats = { db, collection: collName, model, totalChunks, filesIndexed: fileStats?.uniqueFiles?.length || 0, lastIndexed: fileStats?.lastIndexed || 'unknown', languages: fileStats?.languages || [], indexes: indexes.map(i => ({ name: i.name, status: i.status })), }; if (opts.json) { console.log(JSON.stringify(stats, null, 2)); return; } console.log(''); console.log(pc.bold('Code Search Index Status')); console.log(''); console.log(ui.label('Collection', `${db}.${collName}`)); console.log(ui.label('Model', model)); console.log(ui.label('Files indexed', String(stats.filesIndexed))); console.log(ui.label('Total chunks', String(stats.totalChunks))); console.log(ui.label('Languages', stats.languages.join(', ') || 'N/A')); console.log(ui.label('Last indexed', stats.lastIndexed)); if (indexes.length > 0) { console.log(''); for (const idx of indexes) { console.log(ui.label('Index', `${ui.bold(idx.name)} — ${ui.status(idx.status || 'unknown')}`)); } } console.log(''); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } finally { if (client) await client.close(); } } async function handleRefresh(workspacePath, opts) { const telemetry = require('../lib/telemetry'); telemetry.send('cli_code_search_refresh'); const resolvedPath = workspacePath ? path.resolve(workspacePath) : process.cwd(); const { db, collection: collName, model } = resolveConfig(opts, resolvedPath); const useSpinner = !opts.json && !opts.quiet; let client; try { let spin; if (useSpinner) { spin = ui.spinner('Checking for changed files...'); spin.start(); } const { client: c, collection } = await getMongoCollection(db, collName); client = c; // Get indexed file mtimes from MongoDB const indexedFiles = await collection.aggregate([ { $match: { 'metadata.workspace': resolvedPath } }, { $group: { _id: '$metadata.source', mtime: { $max: '$metadata.mtime' } } }, ]).toArray(); const indexedMap = new Map(indexedFiles.map(f => [f._id, f.mtime])); // Find current files const currentFiles = await findCodeFiles(resolvedPath, { maxFiles: opts.maxFiles || 5000, maxFileSize: opts.maxFileSize || 100000, }); // Determine changed/new files const changedFiles = []; const currentPaths = new Set(); for (const filePath of currentFiles) { const relativePath = path.relative(resolvedPath, filePath); currentPaths.add(relativePath); const stats = await fs.promises.stat(filePath); const indexedMtime = indexedMap.get(relativePath); if (!indexedMtime || stats.mtimeMs > indexedMtime) { changedFiles.push(filePath); } } // Find deleted files const deletedFiles = []; for (const [source] of indexedMap) { if (!currentPaths.has(source)) { deletedFiles.push(source); } } if (spin) spin.stop(); if (changedFiles.length === 0 && deletedFiles.length === 0) { if (opts.json) { console.log(JSON.stringify({ changed: 0, deleted: 0, message: 'Up to date' }, null, 2)); } else { console.log(ui.success('Index is up to date — no changes detected.')); } return; } if (!opts.quiet && !opts.json) { console.log(ui.info(`${changedFiles.length} changed/new, ${deletedFiles.length} deleted`)); } // Delete old chunks for changed & deleted files const filesToDelete = [ ...changedFiles.map(f => path.relative(resolvedPath, f)), ...deletedFiles, ]; if (filesToDelete.length > 0) { await collection.deleteMany({ 'metadata.workspace': resolvedPath, 'metadata.source': { $in: filesToDelete }, }); } // Re-index changed files const start = Date.now(); const batchSize = opts.batchSize || 20; const allDocs = []; let errors = []; for (const filePath of changedFiles) { try { const content = await fs.promises.readFile(filePath, 'utf-8'); const relativePath = path.relative(resolvedPath, filePath); const ext = path.extname(filePath).toLowerCase(); const fileStats = await fs.promises.stat(filePath); const symbols = extractSymbols(content, filePath); const chunks = smartChunkCode(content, filePath, { chunkSize: opts.chunkSize, chunkOverlap: opts.chunkOverlap, }); for (const ch of chunks) { allDocs.push({ text: ch.text, metadata: { source: relativePath, filePath, workspace: resolvedPath, language: ext.slice(1), startLine: ch.startLine, endLine: ch.endLine, chunkType: ch.type, symbols: symbols.filter(s => ch.text.includes(s)), mtime: fileStats.mtimeMs, indexedAt: new Date().toISOString(), }, }); } } catch (err) { errors.push({ file: filePath, error: err.message }); } } if (useSpinner && allDocs.length > 0) { spin = ui.spinner(`Embedding ${allDocs.length} chunks...`); spin.start(); } let totalTokens = 0; for (let i = 0; i < allDocs.length; i += batchSize) { const batch = allDocs.slice(i, i + batchSize); const texts = batch.map(d => d.text); const embedResult = await generateEmbeddings(texts, { model, inputType: 'document' }); totalTokens += embedResult.usage?.total_tokens || 0; const docsToInsert = batch.map((doc, idx) => ({ text: doc.text, embedding: embedResult.data[idx].embedding, metadata: doc.metadata, })); await collection.insertMany(docsToInsert); } if (spin) spin.stop(); // Update .vai.json const { config: proj, filePath: projPath } = loadProject(resolvedPath); if (proj.codeSearch) { proj.codeSearch.lastIndexed = new Date().toISOString(); try { saveProject(proj, projPath); } catch { /* non-critical */ } } const timeMs = Date.now() - start; if (opts.json) { console.log(JSON.stringify({ changed: changedFiles.length, deleted: deletedFiles.length, chunksCreated: allDocs.length, totalTokens, timeMs, errors, }, null, 2)); } else { console.log(''); console.log(pc.green('✓ Index refreshed!')); console.log(''); console.log(ui.label('Files updated', String(changedFiles.length))); console.log(ui.label('Files deleted', String(deletedFiles.length))); console.log(ui.label('Chunks created', String(allDocs.length))); console.log(ui.label('Time', `${timeMs}ms`)); console.log(ui.label('Tokens', String(totalTokens))); if (errors.length > 0) { console.log(''); console.log(pc.yellow(`⚠ ${errors.length} error(s)`)); } showCombinedCostSummary([{ model, tokens: totalTokens, label: `embed (${model})` }], opts); } } catch (err) { console.error(ui.error(err.message)); process.exit(1); } finally { if (client) await client.close(); } } module.exports = { registerCodeSearch };