voyageai-cli 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ const os = require('os');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const pc = require('picocolors');
7
- const { getConfigValue } = require('../lib/config');
7
+ const { getConfigValue, setConfigValue, CONFIG_DIR, CONFIG_PATH } = require('../lib/config');
8
8
  const { getApiBase } = require('../lib/api');
9
9
 
10
10
  /**
@@ -240,15 +240,83 @@ async function checkConfig() {
240
240
  };
241
241
  }
242
242
 
243
+ // ── Fix functions (for --fix mode) ──
244
+
245
+ async function fixApiKey() {
246
+ const readline = require('readline');
247
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
248
+
249
+ return new Promise((resolve) => {
250
+ console.log(pc.cyan('\n Fixing: Voyage AI API Key'));
251
+ console.log(pc.dim(' Get a key at: https://dash.voyageai.com/api-keys\n'));
252
+
253
+ rl.question(' Enter your Voyage AI API key: ', (key) => {
254
+ rl.close();
255
+ const trimmed = (key || '').trim();
256
+ if (!trimmed) {
257
+ console.log(pc.yellow(' Skipped (no key entered)'));
258
+ resolve(false);
259
+ return;
260
+ }
261
+ try {
262
+ setConfigValue('apiKey', trimmed);
263
+ // Also set in env for the current session so the connection check passes
264
+ process.env.VOYAGE_API_KEY = trimmed;
265
+ console.log(pc.green(' ✓ API key saved to ~/.vai/config.json'));
266
+ resolve(true);
267
+ } catch (err) {
268
+ console.log(pc.red(` ✗ Failed to save: ${err.message}`));
269
+ resolve(false);
270
+ }
271
+ });
272
+ });
273
+ }
274
+
275
+ function fixConfigPermissions() {
276
+ try {
277
+ fs.chmodSync(CONFIG_PATH, 0o600);
278
+ console.log(pc.green(' ✓ Fixed ~/.vai/config.json permissions to 600'));
279
+ return true;
280
+ } catch (err) {
281
+ console.log(pc.red(` ✗ Failed to fix permissions: ${err.message}`));
282
+ return false;
283
+ }
284
+ }
285
+
286
+ function fixConfigDir() {
287
+ try {
288
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
289
+ console.log(pc.green(' ✓ Created ~/.vai/ directory'));
290
+ return true;
291
+ } catch (err) {
292
+ console.log(pc.red(` ✗ Failed to create directory: ${err.message}`));
293
+ return false;
294
+ }
295
+ }
296
+
297
+ async function fixPdfParse() {
298
+ const { execSync } = require('child_process');
299
+ console.log(pc.cyan('\n Installing pdf-parse...'));
300
+ try {
301
+ execSync('npm install pdf-parse', { stdio: 'pipe' });
302
+ console.log(pc.green(' ✓ pdf-parse installed'));
303
+ return true;
304
+ } catch (err) {
305
+ console.log(pc.red(` ✗ Failed to install: ${err.message}`));
306
+ return false;
307
+ }
308
+ }
309
+
243
310
  async function runDoctor(options = {}) {
244
- const { json, verbose } = options;
245
-
311
+ const { json, verbose, fix } = options;
312
+
246
313
  console.log(pc.bold('\n🩺 Voyage AI CLI Health Check\n'));
247
-
314
+
248
315
  const results = {};
249
316
  let hasError = false;
250
317
  let hasWarning = false;
251
-
318
+ const fixable = [];
319
+
252
320
  // Run all checks
253
321
  const checks = [
254
322
  { key: 'node', fn: checkNodeVersion },
@@ -258,12 +326,12 @@ async function runDoctor(options = {}) {
258
326
  { key: 'pdfParse', fn: checkPdfParse },
259
327
  { key: 'config', fn: checkConfig },
260
328
  ];
261
-
329
+
262
330
  for (const { key, fn } of checks) {
263
331
  const check = CHECKS[key];
264
332
  const result = await fn();
265
333
  results[key] = result;
266
-
334
+
267
335
  // Determine status icon
268
336
  let icon;
269
337
  if (result.ok === true) {
@@ -276,15 +344,23 @@ async function runDoctor(options = {}) {
276
344
  icon = warnMark();
277
345
  hasWarning = true;
278
346
  }
279
-
347
+
280
348
  // Print result
281
349
  console.log(` ${icon} ${pc.bold(check.name)}: ${result.message}`);
282
-
350
+
283
351
  if (result.hint && (verbose || result.ok === false)) {
284
352
  console.log(` ${pc.dim(result.hint)}`);
285
353
  }
354
+
355
+ // Track fixable issues
356
+ if (result.ok !== true) {
357
+ if (key === 'apiKey' && result.ok === false) fixable.push('apiKey');
358
+ if (key === 'pdfParse' && result.ok === null) fixable.push('pdfParse');
359
+ if (key === 'config' && result.message && result.message.includes('permissions')) fixable.push('configPerms');
360
+ if (key === 'config' && result.ok === null) fixable.push('configDir');
361
+ }
286
362
  }
287
-
363
+
288
364
  // Summary
289
365
  console.log('');
290
366
  if (hasError) {
@@ -294,7 +370,43 @@ async function runDoctor(options = {}) {
294
370
  } else {
295
371
  console.log(pc.green(' ✓ All checks passed. vai is ready to use!\n'));
296
372
  }
297
-
373
+
374
+ // --fix mode: attempt automatic repairs
375
+ if (fix && fixable.length > 0) {
376
+ console.log(pc.bold(' 🔧 Attempting fixes...\n'));
377
+ let fixed = 0;
378
+
379
+ for (const item of fixable) {
380
+ if (item === 'configDir') {
381
+ if (fixConfigDir()) fixed++;
382
+ }
383
+ if (item === 'apiKey') {
384
+ if (await fixApiKey()) fixed++;
385
+ }
386
+ if (item === 'configPerms') {
387
+ if (fixConfigPermissions()) fixed++;
388
+ }
389
+ if (item === 'pdfParse') {
390
+ if (await fixPdfParse()) fixed++;
391
+ }
392
+ }
393
+
394
+ console.log('');
395
+ if (fixed > 0) {
396
+ console.log(pc.green(` ✓ Fixed ${fixed} issue${fixed === 1 ? '' : 's'}. Run ${pc.bold('vai doctor')} again to verify.\n`));
397
+ } else {
398
+ console.log(pc.yellow(' No issues were fixed. See hints above for manual steps.\n'));
399
+ }
400
+ return 0;
401
+ } else if (fix && fixable.length === 0 && (hasError || hasWarning)) {
402
+ console.log(pc.dim(' No auto-fixable issues found. See hints above for manual steps.\n'));
403
+ }
404
+
405
+ // Suggest --fix if there are fixable issues and not in fix mode
406
+ if (!fix && fixable.length > 0) {
407
+ console.log(pc.dim(` Tip: run ${pc.bold('vai doctor --fix')} to attempt automatic repairs\n`));
408
+ }
409
+
298
410
  // Suggest next steps
299
411
  if (!hasError) {
300
412
  console.log(pc.dim(' Next steps:'));
@@ -302,11 +414,11 @@ async function runDoctor(options = {}) {
302
414
  console.log(pc.dim(' vai quickstart — Zero-to-search tutorial'));
303
415
  console.log(pc.dim(' vai explain — Learn key concepts\n'));
304
416
  }
305
-
417
+
306
418
  if (json) {
307
419
  console.log(JSON.stringify(results, null, 2));
308
420
  }
309
-
421
+
310
422
  return hasError ? 1 : 0;
311
423
  }
312
424
 
@@ -316,10 +428,41 @@ function register(program) {
316
428
  .description('Run health checks on your vai setup')
317
429
  .option('--json', 'Output results as JSON')
318
430
  .option('-v, --verbose', 'Show hints for all checks')
431
+ .option('-f, --fix', 'Attempt to fix issues automatically')
319
432
  .action(async (options) => {
320
433
  const exitCode = await runDoctor(options);
321
434
  if (exitCode !== 0) process.exit(exitCode);
322
435
  });
323
436
  }
324
437
 
325
- module.exports = { register, runDoctor };
438
+ /**
439
+ * Run all checks programmatically and return structured results.
440
+ * Used by the playground /api/doctor endpoint.
441
+ */
442
+ async function runChecks() {
443
+ const checks = [
444
+ { key: 'node', fn: checkNodeVersion },
445
+ { key: 'apiKey', fn: checkApiKey },
446
+ { key: 'apiConnection', fn: checkApiConnection },
447
+ { key: 'mongodb', fn: checkMongoDB },
448
+ { key: 'pdfParse', fn: checkPdfParse },
449
+ { key: 'config', fn: checkConfig },
450
+ ];
451
+
452
+ const results = {};
453
+ for (const { key, fn } of checks) {
454
+ const result = await fn();
455
+ // Strip picocolors ANSI codes from messages for JSON output
456
+ const clean = (s) => typeof s === 'string' ? s.replace(/\x1b\[[0-9;]*m/g, '') : s;
457
+ results[key] = {
458
+ name: CHECKS[key].name,
459
+ required: CHECKS[key].required,
460
+ ok: result.ok,
461
+ message: clean(result.message),
462
+ hint: result.hint ? clean(result.hint) : null,
463
+ };
464
+ }
465
+ return results;
466
+ }
467
+
468
+ module.exports = { register, runDoctor, runChecks };
@@ -277,6 +277,7 @@ function createPlaygroundServer() {
277
277
  db: proj.db || null,
278
278
  collection: proj.collection || null,
279
279
  chat: proj.chat || {},
280
+ mode: proj.chat?.mode || 'pipeline',
280
281
  }));
281
282
  return;
282
283
  }
@@ -308,6 +309,20 @@ function createPlaygroundServer() {
308
309
  return;
309
310
  }
310
311
 
312
+ // API: Doctor health checks
313
+ if (req.method === 'GET' && req.url === '/api/doctor') {
314
+ try {
315
+ const { runChecks } = require('./doctor');
316
+ const results = await runChecks();
317
+ res.writeHead(200, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify(results));
319
+ } catch (err) {
320
+ res.writeHead(500, { 'Content-Type': 'application/json' });
321
+ res.end(JSON.stringify({ error: err.message }));
322
+ }
323
+ return;
324
+ }
325
+
311
326
  // API: Settings origins — where each config value comes from
312
327
  if (req.method === 'GET' && req.url === '/api/settings/origins') {
313
328
  const { resolveLLMConfig } = require('../lib/llm');
@@ -337,6 +352,40 @@ function createPlaygroundServer() {
337
352
  return;
338
353
  }
339
354
 
355
+ // API: List built-in workflows
356
+ if (req.method === 'GET' && req.url === '/api/workflows') {
357
+ try {
358
+ const { listBuiltinWorkflows } = require('../lib/workflow');
359
+ const workflows = listBuiltinWorkflows();
360
+ res.writeHead(200, { 'Content-Type': 'application/json' });
361
+ res.end(JSON.stringify({ workflows }));
362
+ } catch (err) {
363
+ res.writeHead(500, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify({ error: err.message }));
365
+ }
366
+ return;
367
+ }
368
+
369
+ // API: Get a specific workflow by name
370
+ if (req.method === 'GET' && req.url?.startsWith('/api/workflows/')) {
371
+ const name = decodeURIComponent(req.url.replace('/api/workflows/', ''));
372
+ if (!name) {
373
+ res.writeHead(400, { 'Content-Type': 'application/json' });
374
+ res.end(JSON.stringify({ error: 'Workflow name is required' }));
375
+ return;
376
+ }
377
+ try {
378
+ const { loadWorkflow } = require('../lib/workflow');
379
+ const definition = loadWorkflow(name);
380
+ res.writeHead(200, { 'Content-Type': 'application/json' });
381
+ res.end(JSON.stringify({ definition }));
382
+ } catch (err) {
383
+ res.writeHead(404, { 'Content-Type': 'application/json' });
384
+ res.end(JSON.stringify({ error: err.message }));
385
+ }
386
+ return;
387
+ }
388
+
340
389
  // API: Save chat config (POST) — persists to .vai.json
341
390
  // Placed before generic POST handler so it doesn't require Voyage API key
342
391
  if (req.method === 'POST' && req.url === '/api/chat/config') {
@@ -364,6 +413,7 @@ function createPlaygroundServer() {
364
413
  if (parsed.maxDocs !== undefined) proj.chat.maxContextDocs = parsed.maxDocs;
365
414
  if (parsed.rerank !== undefined) proj.chat.rerank = parsed.rerank;
366
415
  if (parsed.systemPrompt !== undefined) proj.chat.systemPrompt = parsed.systemPrompt;
416
+ if (parsed.mode !== undefined) proj.chat.mode = parsed.mode;
367
417
 
368
418
  try {
369
419
  saveProject(proj, filePath || undefined);
@@ -401,20 +451,23 @@ function createPlaygroundServer() {
401
451
 
402
452
  // API: Chat message (streaming SSE)
403
453
  if (req.url === '/api/chat/message') {
404
- const { query, db, collection, provider, model, maxDocs, rerank, systemPrompt } = parsed;
454
+ const { query, db, collection, provider, model, maxDocs, rerank, systemPrompt, mode } = parsed;
455
+ const isAgent = mode === 'agent';
456
+
405
457
  if (!query) {
406
458
  res.writeHead(400, { 'Content-Type': 'application/json' });
407
459
  res.end(JSON.stringify({ error: 'query is required' }));
408
460
  return;
409
461
  }
410
- if (!db || !collection) {
462
+ // Pipeline mode requires db + collection; agent mode they're optional
463
+ if (!isAgent && (!db || !collection)) {
411
464
  res.writeHead(400, { 'Content-Type': 'application/json' });
412
- res.end(JSON.stringify({ error: 'db and collection are required' }));
465
+ res.end(JSON.stringify({ error: 'db and collection are required for pipeline mode' }));
413
466
  return;
414
467
  }
415
468
 
416
469
  const { createLLMProvider } = require('../lib/llm');
417
- const { chatTurn } = require('../lib/chat');
470
+ const { chatTurn, agentChatTurn } = require('../lib/chat');
418
471
  const { ChatHistory } = require('../lib/history');
419
472
 
420
473
  let llm;
@@ -447,21 +500,68 @@ function createPlaygroundServer() {
447
500
  });
448
501
 
449
502
  try {
450
- for await (const event of chatTurn({
451
- query, db, collection, llm, history,
452
- opts: {
453
- maxDocs: maxDocs || 5,
454
- rerank: rerank !== false,
455
- stream: true,
456
- systemPrompt,
457
- },
458
- })) {
459
- if (event.type === 'retrieval') {
460
- res.write(`event: retrieval\ndata: ${JSON.stringify(event.data)}\n\n`);
461
- } else if (event.type === 'chunk') {
462
- res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
463
- } else if (event.type === 'done') {
464
- res.write(`event: done\ndata: ${JSON.stringify(event.data)}\n\n`);
503
+ if (isAgent) {
504
+ // Agent mode: LLM decides which tools to call
505
+ for await (const event of agentChatTurn({
506
+ query, llm, history,
507
+ opts: { systemPrompt, db: db || undefined, collection: collection || undefined },
508
+ })) {
509
+ if (event.type === 'tool_call') {
510
+ const { name, args, timeMs, error, result } = event.data;
511
+ // Build a short human-readable summary of the tool result
512
+ let resultSummary = '';
513
+ if (!error && result) {
514
+ if (name === 'vai_query' || name === 'vai_search') {
515
+ const docs = result.results || result.documents || [];
516
+ resultSummary = `Found ${docs.length} result${docs.length !== 1 ? 's' : ''}`;
517
+ } else if (name === 'vai_collections') {
518
+ const colls = result.collections || [];
519
+ resultSummary = colls.length > 0
520
+ ? colls.map(c => `<code>${c.name || c}</code>`).slice(0, 5).join(', ')
521
+ : 'No collections found';
522
+ } else if (name === 'vai_models') {
523
+ const models = result.models || [];
524
+ resultSummary = `${models.length} model${models.length !== 1 ? 's' : ''} available`;
525
+ } else if (name === 'vai_embed') {
526
+ const dims = result.dimensions || result.embedding?.length || '?';
527
+ resultSummary = `${dims}-dim vector`;
528
+ } else if (name === 'vai_similarity') {
529
+ const score = result.similarity ?? result.score;
530
+ resultSummary = score !== undefined ? `Score: ${Number(score).toFixed(4)}` : '';
531
+ } else if (name === 'vai_rerank') {
532
+ const items = result.results || [];
533
+ resultSummary = `Reranked ${items.length} result${items.length !== 1 ? 's' : ''}`;
534
+ } else if (name === 'vai_estimate') {
535
+ resultSummary = result.recommendation || '';
536
+ } else if (name === 'vai_explain' || name === 'vai_topics') {
537
+ resultSummary = result.title || result.topic || '';
538
+ }
539
+ }
540
+ res.write(`event: tool_call\ndata: ${JSON.stringify({ name, args, timeMs, error, resultSummary })}\n\n`);
541
+ } else if (event.type === 'chunk') {
542
+ res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
543
+ } else if (event.type === 'done') {
544
+ res.write(`event: done\ndata: ${JSON.stringify(event.data)}\n\n`);
545
+ }
546
+ }
547
+ } else {
548
+ // Pipeline mode: fixed RAG retrieval
549
+ for await (const event of chatTurn({
550
+ query, db, collection, llm, history,
551
+ opts: {
552
+ maxDocs: maxDocs || 5,
553
+ rerank: rerank !== false,
554
+ stream: true,
555
+ systemPrompt,
556
+ },
557
+ })) {
558
+ if (event.type === 'retrieval') {
559
+ res.write(`event: retrieval\ndata: ${JSON.stringify(event.data)}\n\n`);
560
+ } else if (event.type === 'chunk') {
561
+ res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
562
+ } else if (event.type === 'done') {
563
+ res.write(`event: done\ndata: ${JSON.stringify(event.data)}\n\n`);
564
+ }
465
565
  }
466
566
  }
467
567
  } catch (err) {
@@ -622,6 +722,120 @@ function createPlaygroundServer() {
622
722
  }));
623
723
  return;
624
724
  }
725
+
726
+ // API: Validate a workflow definition
727
+ if (req.url === '/api/workflows/validate') {
728
+ const { validateWorkflow } = require('../lib/workflow');
729
+ const { definition } = parsed;
730
+ if (!definition) {
731
+ res.writeHead(400, { 'Content-Type': 'application/json' });
732
+ res.end(JSON.stringify({ error: 'definition is required' }));
733
+ return;
734
+ }
735
+ const errors = validateWorkflow(definition);
736
+ res.writeHead(200, { 'Content-Type': 'application/json' });
737
+ res.end(JSON.stringify({ valid: errors.length === 0, errors }));
738
+ return;
739
+ }
740
+
741
+ // API: Get execution plan (layers + dependency graph)
742
+ if (req.url === '/api/workflows/plan') {
743
+ const { buildExecutionPlan, buildDependencyGraph, validateWorkflow } = require('../lib/workflow');
744
+ const { definition } = parsed;
745
+ if (!definition || !definition.steps) {
746
+ res.writeHead(400, { 'Content-Type': 'application/json' });
747
+ res.end(JSON.stringify({ error: 'definition with steps is required' }));
748
+ return;
749
+ }
750
+ const errors = validateWorkflow(definition);
751
+ if (errors.length > 0) {
752
+ res.writeHead(400, { 'Content-Type': 'application/json' });
753
+ res.end(JSON.stringify({ error: 'Invalid workflow', errors }));
754
+ return;
755
+ }
756
+ const layers = buildExecutionPlan(definition.steps);
757
+ const graphMap = buildDependencyGraph(definition.steps);
758
+ // Convert Map<string, Set<string>> to plain object for JSON serialization
759
+ const graph = {};
760
+ for (const [stepId, deps] of graphMap) {
761
+ graph[stepId] = Array.from(deps);
762
+ }
763
+ res.writeHead(200, { 'Content-Type': 'application/json' });
764
+ res.end(JSON.stringify({ layers, graph }));
765
+ return;
766
+ }
767
+
768
+ // API: Execute a workflow (streaming SSE)
769
+ if (req.url === '/api/workflows/execute') {
770
+ const { executeWorkflow, validateWorkflow: validateWf } = require('../lib/workflow');
771
+ const { definition, inputs } = parsed;
772
+ if (!definition) {
773
+ res.writeHead(400, { 'Content-Type': 'application/json' });
774
+ res.end(JSON.stringify({ error: 'definition is required' }));
775
+ return;
776
+ }
777
+ const errors = validateWf(definition);
778
+ if (errors.length > 0) {
779
+ res.writeHead(400, { 'Content-Type': 'application/json' });
780
+ res.end(JSON.stringify({ error: 'Invalid workflow', errors }));
781
+ return;
782
+ }
783
+
784
+ // Stream execution events as SSE
785
+ res.writeHead(200, {
786
+ 'Content-Type': 'text/event-stream',
787
+ 'Cache-Control': 'no-cache',
788
+ 'Connection': 'keep-alive',
789
+ });
790
+
791
+ try {
792
+ const result = await executeWorkflow(definition, {
793
+ inputs: inputs || {},
794
+ onStepStart: (stepId, stepDef) => {
795
+ res.write(`event: step_start\ndata: ${JSON.stringify({
796
+ stepId,
797
+ name: stepDef.name || stepId,
798
+ tool: stepDef.tool,
799
+ })}\n\n`);
800
+ },
801
+ onStepComplete: (stepId, output, timeMs) => {
802
+ // Summarize output to avoid huge payloads
803
+ let summary = '';
804
+ if (output && typeof output === 'object') {
805
+ if (output.results) summary = `${output.results.length} results`;
806
+ else if (output.similarity !== undefined) summary = `similarity: ${Number(output.similarity).toFixed(4)}`;
807
+ else if (output.text) summary = output.text.slice(0, 100) + (output.text.length > 100 ? '...' : '');
808
+ else summary = JSON.stringify(output).slice(0, 200);
809
+ }
810
+ res.write(`event: step_complete\ndata: ${JSON.stringify({
811
+ stepId, timeMs, summary,
812
+ output: JSON.stringify(output).length < 5000 ? output : { _truncated: true, summary },
813
+ })}\n\n`);
814
+ },
815
+ onStepSkip: (stepId, reason) => {
816
+ res.write(`event: step_skip\ndata: ${JSON.stringify({ stepId, reason })}\n\n`);
817
+ },
818
+ onStepError: (stepId, error) => {
819
+ res.write(`event: step_error\ndata: ${JSON.stringify({
820
+ stepId,
821
+ error: error.message || String(error),
822
+ })}\n\n`);
823
+ },
824
+ });
825
+
826
+ res.write(`event: done\ndata: ${JSON.stringify({
827
+ output: result.output,
828
+ totalTimeMs: result.totalTimeMs,
829
+ layers: result.layers,
830
+ steps: result.steps,
831
+ })}\n\n`);
832
+ } catch (err) {
833
+ res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
834
+ }
835
+
836
+ res.end();
837
+ return;
838
+ }
625
839
  }
626
840
 
627
841
  // 404