voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
1,383 lines (1,241 loc) • 51.8 kB
JavaScript
'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)}...`));