UNPKG

voyageai-cli

Version:

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

1,383 lines (1,241 loc) 51.8 kB
'use strict'; const readline = require('readline'); const { createLLMProvider, resolveLLMConfig } = require('../lib/llm'); const { ChatHistory } = require('../lib/history'); const { chatTurn, agentChatTurn } = require('../lib/chat'); const { TurnOrchestrator } = require('../lib/turn-orchestrator'); const { loadProject } = require('../lib/project'); const { getMongoCollection } = require('../lib/mongo'); const { setConfigValue } = require('../lib/config'); const { runWizard } = require('../lib/wizard'); const { createCLIRenderer } = require('../lib/wizard-cli'); const { chatSetupSteps } = require('../lib/wizard-steps-chat'); const ui = require('../lib/ui'); const chatUI = require('../lib/chat-ui'); const pc = require('picocolors'); const fs = require('fs'); const { moments } = require('../lib/robot-moments'); const { ChatSessionStats } = require('../lib/chat-session-stats'); const { LABELS } = require('../lib/turn-state'); /** * Register the chat command. * @param {import('commander').Command} program */ function registerChat(program) { program .command('chat') .description('RAG-powered conversational interface — chat with your knowledge base') .option('--db <name>', 'MongoDB database name') .option('--collection <name>', 'Collection with embedded documents') .option('--session <id>', 'Resume a previous chat session') .option('--llm-provider <name>', 'LLM provider: anthropic, openai, ollama') .option('--llm-model <name>', 'Specific LLM model to use') .option('--llm-api-key <key>', 'LLM API key') .option('--llm-base-url <url>', 'LLM API base URL (Ollama)') .option('--mode <mode>', 'Chat mode: pipeline (fixed RAG) or agent (tool-calling)', 'pipeline') .option('--max-context-docs <n>', 'Max retrieved documents for context', (v) => parseInt(v, 10), 5) .option('--max-turns <n>', 'Max conversation turns before truncation', (v) => parseInt(v, 10), 20) .option('--no-history', 'Disable MongoDB persistence (in-memory only)') .option('--no-rerank', 'Skip reranking step') .option('--local', 'Use local nano embeddings instead of Voyage API') .option('--embedding-model <name>', 'Embedding model: voyage-4-nano, voyage-4-lite, voyage-4, voyage-4-large') .option('--no-stream', 'Wait for complete response instead of streaming') .option('--system-prompt <text>', 'Override the system prompt') .option('--text-field <name>', 'Document text field name', 'text') .option('--filter <json>', 'MongoDB pre-filter for vector search') .option('--memory-strategy <name>', 'Memory strategy: sliding_window, summarization, hierarchical', 'sliding_window') .option('--estimate', 'Show estimated per-turn cost breakdown and exit') .option('--replay <id>', 'Replay a stored session for debugging') .option('--list', 'List recent chat sessions and exit') .option('--all', 'Include archived sessions in --list output') .option('--json', 'Output JSON per turn (for scripting)') .option('-q, --quiet', 'Suppress decorative output') .action(async (opts) => { try { await runChat(opts); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); } async function runChat(opts) { // Show startup spinner immediately so the user sees activity const startupAnim = moments.isInteractive({ json: opts.json, plain: opts.quiet }) ? moments.startWaving('Starting vai chat') : null; const { config: proj } = loadProject(); const chatConf = proj.chat || {}; const db = opts.db || proj.db; const collection = opts.collection || proj.collection; // --list: show recent sessions and exit if (opts.list) { if (startupAnim) startupAnim.stop(); const { SessionStore, SESSION_STATES } = require('../lib/session-store'); const store = new SessionStore({ db: db || 'vai' }); try { await store._connect(); if (store.isFallbackMode) { console.error(ui.error('MongoDB unavailable. Cannot list sessions.')); process.exit(1); } const filter = opts.all ? {} : { lifecycleState: { $ne: SESSION_STATES.ARCHIVED } }; const sessions = await store._sessionsCol .find(filter) .sort({ updatedAt: -1 }) .limit(20) .toArray(); if (sessions.length === 0) { console.log(pc.dim(' No sessions found.')); } else { console.log(''); console.log(pc.bold(' Recent Sessions')); console.log(''); for (const s of sessions) { const id = (s._id || '').toString().slice(0, 8); const status = s.lifecycleState || 'unknown'; const date = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : 'unknown'; const model = s.model || ''; const statusColor = status === 'archived' ? pc.dim(status) : pc.green(status); console.log(` ${pc.bold(id)} ${statusColor} ${pc.dim(date)} ${model}`); } console.log(''); console.log(pc.dim(' Resume with: vai chat --session <id>')); } await store.close(); } catch (err) { console.error(ui.error(`Failed to list sessions: ${err.message}`)); } return; } // --replay: replay a stored session and exit if (opts.replay) { if (startupAnim) startupAnim.stop(); await replaySession(opts, db); return; } const maxDocs = opts.maxContextDocs || chatConf.maxContextDocs || 5; const maxTurns = opts.maxTurns || chatConf.maxConversationTurns || 20; const textField = opts.textField || 'text'; // Resolve embedding model: explicit flag > --local shorthand > project config > default let embeddingModel = opts.embeddingModel || chatConf.embeddingModel || null; if (opts.local && !opts.embeddingModel) embeddingModel = 'voyage-4-nano'; const isLocalEmbed = embeddingModel === 'voyage-4-nano'; // Update isLocal to reflect embedding model choice (for existing isLocal checks) const isLocal = isLocalEmbed || opts.local || false; const doRerank = isLocalEmbed ? false : (opts.rerank !== false); // Validate embedding model name if explicitly provided const validEmbedModels = ['voyage-4-nano', 'voyage-4-lite', 'voyage-4', 'voyage-4-large']; if (embeddingModel && !validEmbedModels.includes(embeddingModel)) { if (startupAnim) startupAnim.stop(); console.error(ui.error(`Unknown embedding model: ${embeddingModel}`)); console.error(` Valid models: ${validEmbedModels.join(', ')}`); process.exit(1); } const doStream = opts.stream !== false; const systemPrompt = opts.systemPrompt || chatConf.systemPrompt; // Resolve mode const mode = opts.mode || chatConf.mode || 'pipeline'; const isAgent = mode === 'agent'; // Validate DB + collection (required for pipeline, recommended for agent) if (!isAgent && (!db || !collection)) { if (startupAnim) startupAnim.stop(); console.error(ui.error('Database and collection required for pipeline mode.')); console.error(''); console.error(' Use --db and --collection, or configure .vai.json:'); console.error(' vai init'); console.error(''); console.error(' Or use --mode agent to let the LLM discover collections.'); console.error(''); process.exit(1); } // Nano prerequisite check (before LLM config) if (isLocalEmbed) { const { checkVenv, checkModel } = require('../nano/nano-health'); const venv = checkVenv(); const model = checkModel(); if (!venv.ok || !model.ok) { if (startupAnim) startupAnim.stop(); console.error(''); console.error(pc.red(' voyage-4-nano is not set up.')); console.error(` Run ${pc.cyan('vai nano setup')} to install voyage-4-nano`); console.error(''); process.exit(1); } } // Resolve LLM config — run interactive setup if missing let llmConfig = resolveLLMConfig(opts); if (!llmConfig.provider) { if (startupAnim) startupAnim.stop(); if (opts.json) { // Non-interactive mode — can't run wizard console.error(JSON.stringify({ error: 'No LLM provider configured. Run vai chat interactively to set up.' })); process.exit(1); } const { answers, cancelled } = await runWizard({ steps: chatSetupSteps, config: llmConfig, renderer: createCLIRenderer({ title: 'vai chat — LLM Setup', doneMessage: 'Configuration saved. Starting chat...', }), }); if (cancelled) { process.exit(0); } // Persist to ~/.vai/config.json if (answers.provider) setConfigValue('llmProvider', answers.provider); if (answers.apiKey) setConfigValue('llmApiKey', answers.apiKey); if (answers.model) setConfigValue('llmModel', answers.model); if (answers.ollamaBaseUrl && answers.ollamaBaseUrl !== 'http://localhost:11434') { setConfigValue('llmBaseUrl', answers.ollamaBaseUrl); } // Re-resolve with new config llmConfig = resolveLLMConfig(opts); } // --estimate: show per-turn cost breakdown and exit if (opts.estimate) { if (startupAnim) startupAnim.stop(); const { estimateChatCost, formatChatCostBreakdown } = require('../lib/cost'); const breakdown = estimateChatCost({ query: 'How does authentication work?', // sample question contextDocs: maxDocs, embeddingModel: proj.model || 'voyage-4-large', rerankModel: doRerank ? 'rerank-2.5' : null, llmProvider: llmConfig.provider, llmModel: llmConfig.model, historyTurns: 0, }); if (opts.json) { console.log(JSON.stringify(breakdown, null, 2)); } else { console.log(''); console.log(formatChatCostBreakdown(breakdown)); console.log(''); } return; } const llm = createLLMProvider(opts); // Check tool support for agent mode if (isAgent && !llm.supportsTools) { if (!opts.quiet && !opts.json) { console.log(ui.warn(`LLM provider "${llm.name}" does not support tool calling. Falling back to pipeline mode.`)); } // Fall through to pipeline mode return runChat({ ...opts, mode: 'pipeline' }); } // Preflight: verify the RAG pipeline is ready (pipeline mode only) if (!isAgent && !opts.json) { const { runPreflight, formatPreflight, waitForIndex } = require('../lib/preflight'); const { checks, ready } = await runPreflight({ db, collection, field: proj.field || 'embedding', llmConfig, textField, local: isLocal, }); if (startupAnim) startupAnim.stop(); console.log(''); console.log(formatPreflight(checks)); console.log(''); if (!ready) { // Check if the only blocker is an index that's building const indexCheck = checks.find(c => c.id === 'vectorIndex'); const otherFailures = checks.filter(c => !c.ok && c.id !== 'vectorIndex'); if (indexCheck?.building && otherFailures.length === 0) { // Wait for it with a spinner const p = require('@clack/prompts'); const spinner = p.spinner(); spinner.start(`Index '${indexCheck.indexName}' is building — waiting for it to be ready...`); const result = await waitForIndex({ db, collection, indexName: indexCheck.indexName, timeoutMs: 300000, // 5 minutes }); if (result.ready) { const secs = Math.round(result.elapsed / 1000); spinner.stop(`Index ready (${secs}s)`); } else { spinner.stop(`Index not ready after ${Math.round(result.elapsed / 1000)}s (status: ${result.status})`); console.log(''); console.log(pc.dim(' The index may need more time. Try again in a few minutes.')); console.log(''); process.exit(1); } } else if (otherFailures.length > 0) { process.exit(1); } } } // Initialize session store and history let historyMongo = null; let sessionStore = null; let sessionId = opts.session || undefined; let fallbackWarningShown = false; if (opts.history !== false) { const { SessionStore } = require('../lib/session-store'); sessionStore = new SessionStore({ db: db || 'vai' }); if (opts.session) { // Resume existing session const existingSession = await sessionStore.getSession(opts.session); if (existingSession) { // Reactivate if paused if (existingSession.lifecycleState === 'paused') { await sessionStore.transitionLifecycle(opts.session, 'active'); } // Load turns const turns = await sessionStore.getLatestTurns(opts.session, maxTurns * 2); if (!opts.quiet && !opts.json) { console.log(pc.dim(` Resumed session ${opts.session.slice(0, 8)} (${turns.length} turns loaded)`)); } } else if (!opts.quiet && !opts.json) { console.log(ui.warn(`Session ${opts.session} not found. Starting new conversation.`)); sessionId = undefined; } } if (!sessionId) { // Create new session const newSession = await sessionStore.createSession({ model: llmConfig.model || 'unknown', provider: llmConfig.provider || 'unknown', mode: isAgent ? 'agent' : 'pipeline', }); sessionId = newSession._id; } // Show one-time warning if store fell back to in-memory if (sessionStore.isFallbackMode && !fallbackWarningShown && !opts.quiet && !opts.json) { console.log(pc.dim(' MongoDB unavailable \u2014 session will not be persisted')); fallbackWarningShown = true; } // Legacy mongo path not needed when store is available historyMongo = null; } const history = new ChatHistory({ sessionId, maxTurns, store: sessionStore, mongo: historyMongo, }); // Load existing session turns if resuming if (opts.session && sessionStore) { await history.load(); } // Initialize cross-session recall for resumed sessions let summaryStoreInstance = null; let crossSessionRecall = null; if (opts.session && sessionStore && !sessionStore.isFallbackMode) { try { const { SessionSummaryStore } = require('../lib/session-summary-store'); const { CrossSessionRecall } = require('../lib/cross-session-recall'); const { generateEmbeddings: embedForRecall } = require('../lib/api'); summaryStoreInstance = new SessionSummaryStore({ db: db || 'vai' }); await summaryStoreInstance._connect(); crossSessionRecall = new CrossSessionRecall({ summaryStore: summaryStoreInstance, embedFn: embedForRecall, }); } catch { // Cross-session recall init failure is non-fatal } } // Telemetry: track session const telemetry = require('../lib/telemetry'); const chatStartTime = Date.now(); let turnCount = 0; // Session-level token and cost accumulator const sessionStats = new ChatSessionStats({ embeddingModel: embeddingModel || 'default', llmProvider: llmConfig.provider, llmModel: llmConfig.model, }); // Create TurnOrchestrator for state-machine-driven turns const orchestrator = new TurnOrchestrator({ sessionId: history.sessionId, mode: isAgent ? 'agent' : 'pipeline', }); // Create MemoryManager for dynamic memory strategy selection const { createFullMemoryManager } = require('../lib/memory-strategy'); const memoryManager = createFullMemoryManager({ defaultStrategy: opts.memoryStrategy || chatConf.memoryStrategy || 'sliding_window', }); // Attach cross-session recall to memory manager if available if (crossSessionRecall) { memoryManager.setOpts({ recall: crossSessionRecall, currentSessionId: sessionId }); } // Track whether a turn is currently in progress (for SIGINT handling) let turnInProgress = false; // Track tool calls from last agent response (for /tools and /export-workflow) let lastToolCalls = []; // Stop startup spinner (covers agent mode and any remaining init) if (startupAnim) startupAnim.stop(); // Print header if (!opts.quiet && !opts.json) { console.log(chatUI.renderHeader({ version: getVersion(), provider: llmConfig.provider, model: llmConfig.model, mode: isAgent ? 'agent' : 'pipeline', db, collection, sessionId: history.sessionId, interactive: moments.isInteractive({ json: opts.json, plain: opts.quiet }), embeddingModel: embeddingModel || null, isLocalEmbed, })); } // Start REPL const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: (!opts.json && !opts.quiet) ? chatUI.renderUserPrompt() : '> ', terminal: !opts.json, }); rl.prompt(); rl.on('line', async (line) => { const input = line.trim(); if (!input) { rl.prompt(); return; } // Handle slash commands if (input.startsWith('/')) { const handled = await handleSlashCommand(input, { history, opts, db, collection, llm, rl, historyMongo, isAgent, lastToolCalls, sessionStore, sessionId, memoryManager, }); if (handled === 'quit') { await cleanup(historyMongo, sessionStore, summaryStoreInstance); process.exit(0); } rl.prompt(); return; } // Execute chat turn turnCount++; turnInProgress = true; try { if (isAgent) { lastToolCalls = await handleAgentTurn(input, { llm, history, opts, db, collection, systemPrompt, chatConf, sessionStats, orchestrator, memoryManager, }); } else { await handlePipelineTurn(input, { db, collection, llm, history, opts, maxDocs, doRerank, doStream, systemPrompt, textField, chatConf, isLocal, embeddingModel, isLocalEmbed, sessionStats, orchestrator, memoryManager, }); } } catch (err) { if (moments.isInteractive({ json: opts.json, plain: opts.quiet })) { moments.error(err.message); } else { console.error(''); console.error(ui.error(err.message)); console.error(''); } } finally { turnInProgress = false; } rl.prompt(); }); function sendChatTelemetry() { telemetry.send('cli_chat', { provider: llmConfig.provider, rerankModel: doRerank ? 'rerank-2.5' : undefined, llmModel: llmConfig.model, embeddingModel: embeddingModel || proj.model || undefined, local: isLocalEmbed, turnCount, durationMs: Date.now() - chatStartTime, }); } rl.on('close', async () => { sendChatTelemetry(); await cleanup(historyMongo, sessionStore, summaryStoreInstance); process.exit(0); }); // Handle Ctrl+C gracefully rl.on('SIGINT', async () => { if (turnInProgress) { // Interrupt the current turn instead of exiting orchestrator.interrupt(); } else { sendChatTelemetry(); console.log(''); await cleanup(historyMongo, sessionStore, summaryStoreInstance); process.exit(0); } }); } /** * Handle a single pipeline mode turn. */ async function handlePipelineTurn(input, ctx) { const { db, collection, llm, history, opts, maxDocs, doRerank, doStream, systemPrompt, textField, chatConf, isLocal, embeddingModel, isLocalEmbed, sessionStats, orchestrator, memoryManager } = ctx; // Build embedding options based on model selection let localOpts = {}; if (isLocalEmbed) { const { generateLocalEmbeddings } = require('../nano/nano-local'); localOpts = { embedFn: generateLocalEmbeddings, model: 'voyage-4-nano', dimensions: 1024 }; } else if (embeddingModel) { localOpts = { model: embeddingModel }; } const turnNum = Math.floor(history.turns.length / 2) + 1; const memoryStrategy = opts.memoryStrategy || chatConf.memoryStrategy || undefined; // Update MemoryManager with per-turn context (LLM for summarization, query for recall) if (memoryManager) { memoryManager.setOpts({ llm, query: input }); } if (opts.json) { // JSON mode — collect everything then output let fullResponse = ''; let sources = []; let metadata = {}; // Track state transitions for --json diagnostics const stateLog = []; orchestrator.on('stateChange', ({ from, to, timestamp }) => { stateLog.push({ from, to, timestamp }); }); for await (const event of orchestrator.executePipelineTurn({ generatorFn: (args) => chatTurn({ query: input, db, collection, llm, history, opts: { maxDocs, rerank: doRerank, stream: false, systemPrompt, textField, filter: opts.filter, ...localOpts, memoryManager, memoryStrategy } }), })) { if (event.type === 'chunk') fullResponse += event.data; if (event.type === 'done') { sources = event.data.sources; metadata = event.data.metadata; } if (event.type === 'error') { console.error(JSON.stringify({ error: event.data.message })); } } // Clean up listener to prevent leaks orchestrator.getStateMachine().removeAllListeners('stateChange'); sessionStats.recordTurn({ tokens: metadata.tokens }); const diagnostics = { stateTransitions: stateLog, memoryStrategy: memoryManager ? memoryManager._defaultStrategy : 'sliding_window', turnIndex: orchestrator.turnIndex, }; console.log(JSON.stringify({ sessionId: history.sessionId, turn: turnNum, query: input, response: fullResponse, sources, metadata, diagnostics, latency: { retrievalMs: metadata.retrievalTimeMs || null, generationMs: metadata.generationTimeMs || null, totalMs: metadata.totalTimeMs || null, }, sessionStats: sessionStats.getTotals(), })); } else { // Interactive mode — stream output with markdown rendering const showAnimations = moments.isInteractive({ json: opts.json, plain: opts.quiet }); let retrievalShown = false; let activeSpinner = null; let streamRenderer = null; // State-driven spinners: subscribe to state changes from the orchestrator if (showAnimations) { orchestrator.on('stateChange', ({ to }) => { const label = LABELS[to]; if (!label || to === 'IDLE' || to === 'STREAMING') return; if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (to === 'EMBEDDING' || to === 'RETRIEVING' || to === 'RERANKING') { activeSpinner = moments.startSearching(label); } else if (to === 'GENERATING' || to === 'BUILDING_PROMPT' || to === 'VALIDATING' || to === 'PERSISTING' || to === 'TOOL_CALLING') { activeSpinner = moments.startThinking(label); } }); } try { for await (const event of orchestrator.executePipelineTurn({ generatorFn: (args) => chatTurn({ query: input, db, collection, llm, history, opts: { maxDocs, rerank: doRerank, stream: doStream, systemPrompt, textField, filter: opts.filter, ...localOpts, memoryManager, memoryStrategy } }), })) { if (event.type === 'interrupted') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (streamRenderer) { streamRenderer.flush(); streamRenderer = null; } if (event.data.partialResponse) { console.log(pc.dim(' [interrupted]')); } console.log(''); break; } if (event.type === 'error') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (showAnimations) { moments.error(event.data.message); } else { console.error(''); console.error(ui.error(event.data.message)); console.error(''); } break; } if (event.type === 'history') { const { turnCount } = event.data; if (!opts.quiet && turnCount > 0) { console.log(pc.dim(` [${turnCount} prior turn${turnCount > 1 ? 's' : ''} in context]`)); } } if (event.type === 'retrieval') { if (!opts.quiet && !retrievalShown) { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } const { docs, timeMs } = event.data; const rerankNote = isLocal ? ', reranking skipped' : ''; console.log(pc.dim(` [${docs.length} docs retrieved in ${timeMs}ms${rerankNote}]`)); console.log(''); retrievalShown = true; } } if (event.type === 'chunk') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } // Initialize streaming markdown renderer on first chunk if (!streamRenderer) { if (showAnimations) { console.log(chatUI.renderAssistantLabel()); } streamRenderer = chatUI.createStreamRenderer(); } streamRenderer.write(event.data); } if (event.type === 'done') { // Flush any remaining buffered markdown if (streamRenderer) { streamRenderer.flush(); streamRenderer = null; } else { console.log(''); } // Show sources as box-drawn cards const { sources } = event.data; if (sources.length > 0 && chatConf.showSources !== false) { console.log(chatUI.renderSources(sources)); } // Brief success pose after successful response with sources if (showAnimations && sources.length > 0) { moments.success(); } // Show per-message latency if (!opts.quiet) { const { metadata } = event.data; const latencyStr = chatUI.renderLatencyLine(metadata); if (latencyStr) console.log(latencyStr); } // Accumulate and show session stats sessionStats.recordTurn({ tokens: event.data.metadata?.tokens }); if (!opts.quiet) { console.log(sessionStats.formatSummary()); } if (showAnimations) { console.log(chatUI.renderTurnDivider()); console.log(''); } else { console.log(''); } } } } finally { if (activeSpinner) activeSpinner.stop(); if (streamRenderer) streamRenderer.flush(); orchestrator.getStateMachine().removeAllListeners('stateChange'); } } } /** * Handle a single agent mode turn. * @returns {Array} Tool calls from this turn (for /tools and /export-workflow) */ async function handleAgentTurn(input, ctx) { const { llm, history, opts, db, collection, systemPrompt, chatConf, sessionStats, orchestrator, memoryManager } = ctx; const showToolCalls = chatConf.showToolCalls !== undefined ? chatConf.showToolCalls : true; const toolCalls = []; const memoryStrategy = opts.memoryStrategy || chatConf.memoryStrategy || undefined; // Update MemoryManager with per-turn context if (memoryManager) { memoryManager.setOpts({ llm, query: input }); } if (opts.json) { // JSON mode — collect everything then output let fullResponse = ''; let metadata = {}; // Track state transitions for --json diagnostics const stateLog = []; orchestrator.on('stateChange', ({ from, to, timestamp }) => { stateLog.push({ from, to, timestamp }); }); for await (const event of orchestrator.executeAgentTurn({ generatorFn: (args) => agentChatTurn({ query: input, llm, history, opts: { systemPrompt, db, collection, memoryManager, memoryStrategy } }), })) { if (event.type === 'tool_call') { toolCalls.push(event.data); } if (event.type === 'chunk') fullResponse += event.data; if (event.type === 'done') { metadata = event.data.metadata; } if (event.type === 'error') { console.error(JSON.stringify({ error: event.data.message })); } } // Clean up listener to prevent leaks orchestrator.getStateMachine().removeAllListeners('stateChange'); sessionStats.recordTurn({ tokens: metadata.tokens }); const diagnostics = { stateTransitions: stateLog, memoryStrategy: memoryManager ? memoryManager._defaultStrategy : 'sliding_window', turnIndex: orchestrator.turnIndex, }; console.log(JSON.stringify({ sessionId: history.sessionId, query: input, response: fullResponse, toolCalls, metadata, diagnostics, latency: { retrievalMs: metadata.retrievalTimeMs || null, generationMs: metadata.generationTimeMs || null, totalMs: metadata.totalTimeMs || null, }, sessionStats: sessionStats.getTotals(), })); } else { // Interactive mode with markdown rendering const showAnimations = moments.isInteractive({ json: opts.json, plain: opts.quiet }); let activeSpinner = null; let hasShownToolCalls = false; let streamRenderer = null; // State-driven spinners: subscribe to state changes from the orchestrator if (showAnimations) { orchestrator.on('stateChange', ({ to }) => { const label = LABELS[to]; if (!label || to === 'IDLE' || to === 'STREAMING') return; if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (to === 'EMBEDDING' || to === 'RETRIEVING' || to === 'RERANKING') { activeSpinner = moments.startSearching(label); } else if (to === 'GENERATING' || to === 'BUILDING_PROMPT' || to === 'VALIDATING' || to === 'PERSISTING' || to === 'TOOL_CALLING') { activeSpinner = moments.startThinking(label); } }); } try { for await (const event of orchestrator.executeAgentTurn({ generatorFn: (args) => agentChatTurn({ query: input, llm, history, opts: { systemPrompt, db, collection, memoryManager, memoryStrategy } }), })) { if (event.type === 'interrupted') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (streamRenderer) { streamRenderer.flush(); streamRenderer = null; } if (event.data.partialResponse) { console.log(pc.dim(' [interrupted]')); } console.log(''); break; } if (event.type === 'error') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (showAnimations) { moments.error(event.data.message); } else { console.error(''); console.error(ui.error(event.data.message)); console.error(''); } break; } if (event.type === 'history') { const { turnCount } = event.data; if (!opts.quiet && turnCount > 0) { console.log(pc.dim(` [${turnCount} prior turn${turnCount > 1 ? 's' : ''} in context]`)); } } if (event.type === 'tool_call') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } toolCalls.push(event.data); hasShownToolCalls = true; if (showToolCalls) { console.log(chatUI.renderToolCall(event.data, showToolCalls)); } } if (event.type === 'chunk') { if (activeSpinner) { activeSpinner.stop(); activeSpinner = null; } if (hasShownToolCalls && !opts.quiet) { console.log(''); // Visual separator after tool calls hasShownToolCalls = false; } if (!streamRenderer) { if (showAnimations) { console.log(chatUI.renderAssistantLabel()); } streamRenderer = chatUI.createStreamRenderer(); } streamRenderer.write(event.data); } if (event.type === 'done') { if (streamRenderer) { streamRenderer.flush(); streamRenderer = null; } else { console.log(''); } // Show per-message latency if (!opts.quiet) { const { metadata } = event.data; const latencyStr = chatUI.renderLatencyLine(metadata); if (latencyStr) console.log(latencyStr); } // Accumulate and show session stats sessionStats.recordTurn({ tokens: event.data.metadata?.tokens }); if (!opts.quiet) { console.log(sessionStats.formatSummary()); } if (showAnimations) { console.log(chatUI.renderTurnDivider()); console.log(''); } else { console.log(''); } } } } finally { if (activeSpinner) activeSpinner.stop(); if (streamRenderer) streamRenderer.flush(); orchestrator.getStateMachine().removeAllListeners('stateChange'); } } return toolCalls; } /** * Handle slash commands within the REPL. * @returns {'quit'|true|false} - 'quit' to exit, true if handled, false if unknown */ async function handleSlashCommand(input, ctx) { const { history, opts, db, collection, llm, rl, isAgent, lastToolCalls, sessionStore, sessionId, memoryManager } = ctx; const parts = input.split(/\s+/); const cmd = parts[0].toLowerCase(); switch (cmd) { case '/quit': case '/exit': case '/q': return 'quit'; case '/help': console.log(''); console.log(pc.bold('Commands:')); console.log(' /sources Show sources from last response'); console.log(' /session Display current session ID'); console.log(' /history List recent chat sessions'); console.log(' /context Show retrieved context from last query'); console.log(' /clear Clear conversation history'); console.log(' /model Show or switch LLM model (/model <name>)'); console.log(' /sessions List recent sessions'); console.log(' /memory Show memory strategy and utilization'); console.log(' /archive Archive current session'); console.log(' /export [format] [file] Export conversation (markdown, json, pdf)'); if (isAgent) { console.log(' /tools Show tool calls from last response'); console.log(' /export-workflow Export last tool sequence as workflow'); } console.log(' /help Show this help'); console.log(' /quit Exit chat'); console.log(''); return true; case '/sources': { const sources = history.getLastSources(); if (!sources) { console.log(pc.dim(' No sources available yet.')); } else { console.log(chatUI.renderSources(sources, { showPreview: true })); } return true; } case '/session': console.log(` Session: ${history.sessionId}`); console.log(` Turns: ${Math.floor(history.turns.length / 2)}`); if (isAgent) { console.log(` Mode: agent (tool-calling)`); } else { console.log(` Mode: pipeline (fixed RAG)`); } return true; case '/context': { const lastCtx = history.getLastContext(); if (!lastCtx) { console.log(pc.dim(' No context available yet.')); } else { console.log(''); for (const doc of lastCtx) { console.log(pc.bold(` [${doc.source}]`)); const preview = (doc.text || '').substring(0, 300); console.log(` ${preview}${doc.text?.length > 300 ? '...' : ''}`); console.log(''); } } return true; } case '/history': { if (!ctx.historyMongo) { console.log(pc.dim(' History persistence is disabled (--no-history or no MongoDB).')); return true; } try { const { listSessions } = require('../lib/history'); const sessions = await listSessions(ctx.historyMongo.collection, 10); if (sessions.length === 0) { console.log(pc.dim(' No previous sessions found.')); } else { console.log(''); for (const s of sessions) { const active = s.sessionId === history.sessionId ? ui.green(' <- current') : ''; const date = s.lastActivity ? new Date(s.lastActivity).toLocaleString() : 'unknown'; const preview = (s.firstMessage || '').substring(0, 60); console.log(` ${pc.bold(s.sessionId.slice(0, 8))} ${pc.dim(date)} ${s.turnCount} turns${active}`); if (preview) console.log(` ${pc.dim(preview)}${s.firstMessage?.length > 60 ? '...' : ''}`); } console.log(''); console.log(pc.dim(' Resume with: vai chat --session <id>')); } } catch (err) { console.log(pc.dim(` Error listing sessions: ${err.message}`)); } return true; } case '/clear': history.clear(); console.log(pc.dim(' Conversation cleared.')); return true; case '/model': case '/models': { if (parts.length > 1) { llm.model = parts[1]; console.log(` Model switched to: ${parts[1]}`); } else { console.log(` Current model: ${pc.bold(llm.model)}`); console.log(` Provider: ${llm.name}`); try { const { listModels } = require('../lib/llm'); const models = await listModels(llm.name); if (models.length > 0) { console.log(''); console.log(` Available models:`); for (const m of models) { const current = m.id === llm.model ? ui.green(' <- current') : ''; let info = m.name || m.id; if (m.size) info += pc.dim(` (${m.size})`); if (m.parameterSize) info += pc.dim(` [${m.parameterSize}]`); if (m.context) info += pc.dim(` ctx:${m.context}`); console.log(` ${info}${current}`); } console.log(''); console.log(pc.dim(' Switch with: /model <name>')); } } catch { /* ignore */ } } return true; } case '/export': { const format = parts[1] || 'markdown'; const outFile = parts[2] || null; const validFormats = ['json', 'markdown', 'md', 'pdf']; if (!validFormats.includes(format)) { console.log(pc.dim(` Unknown format: ${format}. Use: ${validFormats.join(', ')}`)); return true; } try { const { exportArtifact } = require('../lib/export'); const chatData = history.exportJSON(); const effectiveFormat = format === 'md' ? 'markdown' : format; const result = await exportArtifact({ context: 'chat', format: effectiveFormat, data: chatData, options: {}, }); const isBinary = Buffer.isBuffer(result.content); const filename = outFile || result.suggestedFilename; if (isBinary) { fs.writeFileSync(filename, result.content); } else { fs.writeFileSync(filename, result.content, 'utf-8'); } console.log(ui.success(`Exported to ${filename}`)); } catch (err) { console.log(pc.red(` Export failed: ${err.message}`)); } return true; } case '/tools': { if (!isAgent) { console.log(pc.dim(' /tools is only available in agent mode (--mode agent).')); return true; } if (!lastToolCalls || lastToolCalls.length === 0) { console.log(pc.dim(' No tool calls from the last response.')); return true; } console.log(''); console.log(pc.bold(` Tool calls (${lastToolCalls.length}):`)); console.log(''); for (let i = 0; i < lastToolCalls.length; i++) { const tc = lastToolCalls[i]; const status = tc.error ? pc.red('FAILED') : ui.green('OK'); console.log(` ${i + 1}. ${pc.bold(tc.name)} [${status}] (${tc.timeMs}ms)`); // Show args const argKeys = Object.keys(tc.args || {}); if (argKeys.length > 0) { const argStr = argKeys.map(k => `${k}=${JSON.stringify(tc.args[k])}`).join(', '); const preview = argStr.substring(0, 120); console.log(pc.dim(` Args: ${preview}${argStr.length > 120 ? '...' : ''}`)); } // Show result summary if (tc.error) { console.log(pc.dim(` Error: ${tc.error}`)); } else if (tc.result) { const resultStr = JSON.stringify(tc.result); const preview = resultStr.substring(0, 120); console.log(pc.dim(` Result: ${preview}${resultStr.length > 120 ? '...' : ''}`)); } console.log(''); } return true; } case '/export-workflow': { if (!isAgent) { console.log(pc.dim(' /export-workflow is only available in agent mode (--mode agent).')); return true; } if (!lastToolCalls || lastToolCalls.length === 0) { console.log(pc.dim(' No tool calls to export. Ask a question first.')); return true; } const workflow = { name: `agent-workflow-${Date.now()}`, description: 'Workflow exported from vai chat agent session', version: '1.0.0', steps: lastToolCalls.map((tc, i) => ({ id: `step_${i + 1}`, tool: tc.name, args: tc.args, description: `Step ${i + 1}: ${tc.name}`, })), metadata: { exportedAt: new Date().toISOString(), sessionId: history.sessionId, llmProvider: llm.name, llmModel: llm.model, }, }; const filename = `agent-workflow-${history.sessionId.slice(0, 8)}.vai-workflow.json`; fs.writeFileSync(filename, JSON.stringify(workflow, null, 2) + '\n'); console.log(ui.success(`Exported ${lastToolCalls.length} tool calls to ${filename}`)); return true; } case '/archive': { if (!sessionStore) { console.log(pc.dim(' Session archiving not available (no MongoDB connection).')); return true; } if (sessionStore.isFallbackMode) { console.log(pc.dim(' Session archiving not available (no MongoDB connection).')); return true; } try { await sessionStore.transitionLifecycle(sessionId, 'archived'); console.log(pc.dim(' Session archived.')); // Generate session summary for cross-session recall try { const { summarizeTurns } = require('../lib/memory-summarizer'); const { SessionSummaryStore } = require('../lib/session-summary-store'); const { generateEmbeddings } = require('../lib/api'); const allTurns = history.getMessages(); if (allTurns.length >= 2) { const summary = await summarizeTurns(allTurns, llm); if (summary) { const summaryStore = new SessionSummaryStore({ db: db || 'vai' }); try { const embedResult = await generateEmbeddings([summary], { model: 'voyage-4-large', inputType: 'document', }); const embedding = embedResult.data[0].embedding; await summaryStore.storeSummary({ sessionId, summary, embedding }); if (!opts.quiet && !opts.json) { console.log(pc.dim(' Session summary saved for cross-session recall.')); } } finally { await summaryStore.close(); } } } } catch { // Summary generation is non-fatal — session is already archived } } catch (err) { console.log(pc.dim(` Archive failed: ${err.message}`)); } return true; } case '/sessions': { if (!sessionStore || sessionStore.isFallbackMode) { console.log(pc.dim(' Session listing not available (no MongoDB connection).')); return true; } try { await sessionStore._connect(); const { SESSION_STATES } = require('../lib/session-store'); const sessions = await sessionStore._sessionsCol .find({ lifecycleState: { $ne: SESSION_STATES.ARCHIVED } }) .sort({ updatedAt: -1 }) .limit(10) .toArray(); if (sessions.length === 0) { console.log(pc.dim(' No sessions found.')); } else { console.log(''); for (const s of sessions) { const id = (s._id || '').toString().slice(0, 8); const active = s._id === sessionId ? ui.green(' <- current') : ''; const date = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : 'unknown'; console.log(` ${pc.bold(id)} ${pc.dim(date)} ${s.model || ''}${active}`); } console.log(''); console.log(pc.dim(' Resume with: vai chat --session <id>')); } } catch (err) { console.log(pc.dim(` Error listing sessions: ${err.message}`)); } return true; } case '/memory': { // Active strategy const strategyName = memoryManager ? memoryManager._defaultStrategy : 'sliding_window'; const availableStrategies = memoryManager ? memoryManager.getStrategyNames() : ['sliding_window']; console.log(''); console.log(pc.bold(' Memory Status')); console.log(''); console.log(` Strategy: ${pc.cyan(strategyName)}`); console.log(` Available: ${availableStrategies.join(', ')}`); // Token budget breakdown const { MemoryBudget } = require('../lib/memory-budget'); const budget = new MemoryBudget(); const historyBudget = budget.estimateSlotTokens({ systemPrompt: opts.systemPrompt || '', contextDocs: [], currentMessage: '', }); const breakdown = budget.getBreakdown(); if (breakdown) { console.log(''); console.log(pc.bold(' Token Budget')); console.log(` Model limit: ${breakdown.modelLimit.toLocaleString()}`); console.log(` Reserved: ${breakdown.reservedResponse.toLocaleString()} (response)`); console.log(` System: ${breakdown.systemPrompt.toLocaleString()}`); console.log(` History: ${pc.cyan(breakdown.historyBudget.toLocaleString())} available`); } // Current utilization const allTurns = history.getMessages(); const turnCount = Math.floor(allTurns.length / 2); const { estimateTokens } = require('../lib/turn-state'); const usedTokens = allTurns.reduce((sum, t) => sum + estimateTokens(t.content || ''), 0); const utilization = historyBudget > 0 ? Math.min(100, Math.round((usedTokens / historyBudget) * 100)) : 0; console.log(''); console.log(pc.bold(' Utilization')); console.log(` Turns: ${turnCount}`); console.log(` Tokens used: ${usedTokens.toLocaleString()} / ${historyBudget.toLocaleString()}`); console.log(` Utilization: ${utilization}%`); // Visual bar const barWidth = 30; const filled = Math.round((utilization / 100) * barWidth); const bar = pc.green('\u2588'.repeat(filled)) + pc.dim('\u2591'.repeat(barWidth - filled)); console.log(` [${bar}]`); console.log(''); return true; } default: console.log(pc.dim(` Unknown command: ${cmd}. Type /help for available commands.`)); return true; } } async function cleanup(mongo, store, summaryStore) { if (mongo?.client) { try { await mongo.client.close(); } catch { /* ignore */ } } if (store) { try { await store.close(); } catch { /* ignore */ } } if (summaryStore) { try { await summaryStore.close(); } catch { /* ignore */ } } } function getVersion() { try { return require('../lib/banner').getVersion(); } catch { return '0.0.0'; } } /** * Resolve embedding model configuration from CLI opts and project config. * Exported for testing. */ function resolveEmbeddingConfig(opts, chatConf = {}) { let embeddingModel = opts.embeddingModel || chatConf.embeddingModel || null; if (opts.local && !opts.embeddingModel) embeddingModel = 'voyage-4-nano'; const isLocalEmbed = embeddingModel === 'voyage-4-nano'; const doRerank = isLocalEmbed ? false : (opts.rerank !== false); return { embeddingModel, isLocalEmbed, doRerank }; } /** * Replay a stored session's turns for debugging. * @param {object} opts - CLI options (replay, json, quiet) * @param {string} db - Database name */ async function replaySession(opts, db) { const { SessionStore } = require('../lib/session-store'); const store = new SessionStore({ db: db || 'vai' }); try { const session = await store.getSession(opts.replay); if (!session) { console.error(ui.error(`Session not found: ${opts.replay}`)); console.error(pc.dim(' Tip: Run vai chat --list to see available sessions')); process.exit(1); } const turns = await store.getTurns(opts.replay); if (turns.length === 0) { console.log(pc.dim(' No turns found for this session.')); return; } if (opts.json) { // JSON output: session + per-turn diagnostics const output = { sessionId: opts.replay, model: session.model || null, provider: session.provider || null, mode: session.mode || 'pipeline', lifecycleState: session.lifecycleState || 'unknown', createdAt: session.createdAt, turnCount: turns.length, turns: turns.map(t => ({ turnIndex: t.turnIndex, request: t.request || { role: t.role, content: t.content }, response: t.response || null, tokens: t.tokens || null, timing: t.timing || null, createdAt: t.createdAt, diagnostics: { tokens: t.tokens || null, timing: t.timing || null, }, })), }; console.log(JSON.stringify(output, null, 2)); } else { // Interactive formatted output if (!opts.quiet) { console.log(''); console.log(pc.bold(` Session Replay: ${opts.replay.slice(0, 8)}...`));