voyageai-cli 1.30.1 → 1.30.3

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.
Files changed (41) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +2 -0
  4. package/src/commands/about.js +3 -3
  5. package/src/commands/code-search.js +751 -0
  6. package/src/commands/doctor.js +1 -1
  7. package/src/commands/embed.js +121 -2
  8. package/src/commands/index-workspace.js +9 -5
  9. package/src/commands/playground.js +65 -4
  10. package/src/commands/quickstart.js +4 -4
  11. package/src/commands/workflow.js +132 -65
  12. package/src/lib/api.js +31 -0
  13. package/src/lib/catalog.js +4 -2
  14. package/src/lib/code-search.js +315 -0
  15. package/src/lib/codegen.js +1 -1
  16. package/src/lib/explanations.js +3 -3
  17. package/src/lib/github.js +226 -0
  18. package/src/lib/input.js +92 -1
  19. package/src/lib/template-engine.js +154 -20
  20. package/src/lib/workflow-builder.js +753 -0
  21. package/src/lib/workflow-formatters.js +454 -0
  22. package/src/lib/workflow-input-cache.js +111 -0
  23. package/src/lib/workflow-scaffold.js +1 -1
  24. package/src/lib/workflow.js +124 -8
  25. package/src/mcp/schemas/index.js +142 -0
  26. package/src/mcp/server.js +17 -4
  27. package/src/mcp/tools/authoring.js +662 -0
  28. package/src/mcp/tools/code-search.js +620 -0
  29. package/src/mcp/tools/embedding.js +72 -3
  30. package/src/mcp/tools/ingest.js +2 -5
  31. package/src/mcp/tools/retrieval.js +2 -15
  32. package/src/mcp/tools/workspace.js +1 -12
  33. package/src/mcp/utils.js +20 -0
  34. package/src/playground/help/workflow-nodes.js +127 -2
  35. package/src/playground/index.html +2013 -139
  36. package/src/workflows/code-review.json +110 -0
  37. package/src/workflows/cost-analysis.json +5 -0
  38. package/src/workflows/tests/code-review.fresh-index.test.json +83 -0
  39. package/src/workflows/tests/code-review.happy-path.test.json +121 -0
  40. package/src/workflows/tests/code-review.no-question.test.json +70 -0
  41. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +2 -2
@@ -121,7 +121,7 @@ async function checkApiConnection() {
121
121
  });
122
122
 
123
123
  // Send minimal request body
124
- req.write(JSON.stringify({ model: 'voyage-3-lite', input: ['test'] }));
124
+ req.write(JSON.stringify({ model: 'voyage-4-lite', input: ['test'] }));
125
125
  req.end();
126
126
  });
127
127
 
@@ -1,11 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  const { getDefaultModel } = require('../lib/catalog');
4
- const { generateEmbeddings } = require('../lib/api');
5
- const { resolveTextInput } = require('../lib/input');
4
+ const { generateEmbeddings, generateMultimodalEmbeddings } = require('../lib/api');
5
+ const { resolveTextInput, readMediaAsBase64, isImageFile, isVideoFile } = require('../lib/input');
6
6
  const ui = require('../lib/ui');
7
7
  const { showCostSummary } = require('../lib/cost-display');
8
8
 
9
+ const MULTIMODAL_MODEL = 'voyage-multimodal-3.5';
10
+
9
11
  /**
10
12
  * Register the embed command on a Commander program.
11
13
  * @param {import('commander').Command} program
@@ -18,6 +20,8 @@ function registerEmbed(program) {
18
20
  .option('-t, --input-type <type>', 'Input type: query or document')
19
21
  .option('-d, --dimensions <n>', 'Output dimensions', (v) => parseInt(v, 10))
20
22
  .option('-f, --file <path>', 'Read text from file')
23
+ .option('--image <path>', 'Embed an image file (uses voyage-multimodal-3.5)')
24
+ .option('--video <path>', 'Embed a video file (uses voyage-multimodal-3.5)')
21
25
  .option('--truncation', 'Enable truncation for long inputs')
22
26
  .option('--no-truncation', 'Disable truncation')
23
27
  .option('--output-dtype <type>', 'Output data type: float, int8, uint8, binary, ubinary', 'float')
@@ -28,6 +32,121 @@ function registerEmbed(program) {
28
32
  .action(async (text, opts) => {
29
33
  try {
30
34
  const telemetry = require('../lib/telemetry');
35
+ const isMultimodal = !!(opts.image || opts.video);
36
+
37
+ // Validate: --image/--video are incompatible with --file
38
+ if (isMultimodal && opts.file) {
39
+ console.error(ui.error('Cannot combine --image or --video with --file. Use --image/--video for multimodal, or --file for text.'));
40
+ process.exit(1);
41
+ }
42
+
43
+ // Multimodal path: --image and/or --video
44
+ if (isMultimodal) {
45
+ const model = opts.model === getDefaultModel() ? MULTIMODAL_MODEL : opts.model;
46
+ const useColor = !opts.json;
47
+ const useSpinner = useColor && !opts.quiet;
48
+
49
+ // Build content array
50
+ const contentItems = [];
51
+ const mediaMeta = [];
52
+
53
+ // Add text if provided
54
+ if (text) {
55
+ contentItems.push({ type: 'text', text });
56
+ }
57
+
58
+ // Add image
59
+ if (opts.image) {
60
+ if (!isImageFile(opts.image)) {
61
+ console.error(ui.error(`Not a supported image format: ${opts.image}`));
62
+ process.exit(1);
63
+ }
64
+ const media = readMediaAsBase64(opts.image);
65
+ contentItems.push({ type: 'image_base64', image_base64: media.base64DataUrl });
66
+ mediaMeta.push({ type: 'image', path: opts.image, mime: media.mimeType, size: media.sizeBytes });
67
+ }
68
+
69
+ // Add video
70
+ if (opts.video) {
71
+ if (!isVideoFile(opts.video)) {
72
+ console.error(ui.error(`Not a supported video format: ${opts.video}`));
73
+ process.exit(1);
74
+ }
75
+ const media = readMediaAsBase64(opts.video);
76
+ contentItems.push({ type: 'video_base64', video_base64: media.base64DataUrl });
77
+ mediaMeta.push({ type: 'video', path: opts.video, mime: media.mimeType, size: media.sizeBytes });
78
+ }
79
+
80
+ if (contentItems.length === 0) {
81
+ console.error(ui.error('No content provided. Pass text, --image, or --video.'));
82
+ process.exit(1);
83
+ }
84
+
85
+ const done = telemetry.timer('cli_embed', {
86
+ model,
87
+ multimodal: true,
88
+ hasText: !!text,
89
+ hasImage: !!opts.image,
90
+ hasVideo: !!opts.video,
91
+ });
92
+
93
+ let spin;
94
+ if (useSpinner) {
95
+ spin = ui.spinner('Generating multimodal embeddings...');
96
+ spin.start();
97
+ }
98
+
99
+ const mmOpts = { model };
100
+ if (opts.inputType) mmOpts.inputType = opts.inputType;
101
+ if (opts.dimensions) mmOpts.outputDimension = opts.dimensions;
102
+
103
+ const result = await generateMultimodalEmbeddings([contentItems], mmOpts);
104
+
105
+ if (spin) spin.stop();
106
+
107
+ if (opts.outputFormat === 'array') {
108
+ console.log(JSON.stringify(result.data[0].embedding));
109
+ return;
110
+ }
111
+
112
+ if (opts.json) {
113
+ console.log(JSON.stringify(result, null, 2));
114
+ return;
115
+ }
116
+
117
+ // Friendly output
118
+ if (!opts.quiet) {
119
+ console.log(ui.label('Model', ui.cyan(model)));
120
+ console.log(ui.label('Mode', ui.cyan('multimodal')));
121
+ for (const m of mediaMeta) {
122
+ const sizeStr = m.size < 1024 * 1024
123
+ ? `${(m.size / 1024).toFixed(1)} KB`
124
+ : `${(m.size / (1024 * 1024)).toFixed(1)} MB`;
125
+ console.log(ui.label(m.type === 'image' ? 'Image' : 'Video', `${m.path} ${ui.dim(`(${m.mime}, ${sizeStr})`)}`));
126
+ }
127
+ if (text) {
128
+ console.log(ui.label('Text', ui.dim(text.slice(0, 80) + (text.length > 80 ? '...' : ''))));
129
+ }
130
+ if (result.usage) {
131
+ console.log(ui.label('Tokens', ui.dim(String(result.usage.total_tokens))));
132
+ }
133
+ const dims = result.data[0]?.embedding?.length || 'N/A';
134
+ console.log(ui.label('Dimensions', ui.bold(String(dims))));
135
+ console.log('');
136
+ }
137
+
138
+ const vector = result.data[0].embedding;
139
+ const preview = vector.slice(0, 5).map(v => v.toFixed(6)).join(', ');
140
+ console.log(`[${preview}, ...] (${vector.length} dims)`);
141
+
142
+ console.log('');
143
+ console.log(ui.success('Multimodal embedding generated'));
144
+
145
+ done({ dimensions: result.data[0]?.embedding?.length });
146
+ return;
147
+ }
148
+
149
+ // Standard text embedding path
31
150
  const texts = await resolveTextInput(text, opts.file);
32
151
 
33
152
  // --estimate: show cost comparison, optionally switch model
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('path');
4
- const ora = require('ora');
4
+ let ora;
5
+ async function getOra() {
6
+ if (!ora) { ora = (await import('ora')).default; }
7
+ return ora;
8
+ }
5
9
  const pc = require('picocolors');
6
10
 
7
11
  /**
@@ -31,7 +35,7 @@ function registerIndexWorkspace(program) {
31
35
  const { handleIndexWorkspace } = require('../mcp/tools/workspace');
32
36
  const resolvedPath = workspacePath ? path.resolve(workspacePath) : process.cwd();
33
37
 
34
- const spinner = ora(`Indexing ${resolvedPath}...`).start();
38
+ const spinner = (await getOra())(`Indexing ${resolvedPath}...`).start();
35
39
 
36
40
  try {
37
41
  const result = await handleIndexWorkspace({
@@ -74,7 +78,7 @@ function registerIndexWorkspace(program) {
74
78
 
75
79
  // Create index if requested
76
80
  if (opts.createIndex) {
77
- const indexSpinner = ora('Creating vector search index...').start();
81
+ const indexSpinner = (await getOra())('Creating vector search index...').start();
78
82
  try {
79
83
  const { createVectorIndex } = require('../lib/mongo');
80
84
  await createVectorIndex(
@@ -112,7 +116,7 @@ function registerIndexWorkspace(program) {
112
116
  telemetry.send('cli_search_code_run', { language: opts.language });
113
117
 
114
118
  const { handleSearchCode } = require('../mcp/tools/workspace');
115
- const spinner = ora('Searching...').start();
119
+ const spinner = (await getOra())('Searching...').start();
116
120
 
117
121
  try {
118
122
  const result = await handleSearchCode({
@@ -198,7 +202,7 @@ function registerIndexWorkspace(program) {
198
202
  process.exit(1);
199
203
  }
200
204
 
201
- const spinner = ora('Finding relevant context...').start();
205
+ const spinner = (await getOra())('Finding relevant context...').start();
202
206
 
203
207
  try {
204
208
  const result = await handleExplainCode({
@@ -134,7 +134,7 @@ function registerPlayground(program) {
134
134
  */
135
135
  function createPlaygroundServer() {
136
136
  const { getApiBase, requireApiKey, generateEmbeddings } = require('../lib/api');
137
- const { MODEL_CATALOG } = require('../lib/catalog');
137
+ const { MODEL_CATALOG, BENCHMARK_SCORES } = require('../lib/catalog');
138
138
  const { cosineSimilarity } = require('../lib/math');
139
139
  const { getConfigValue } = require('../lib/config');
140
140
 
@@ -273,6 +273,13 @@ function createPlaygroundServer() {
273
273
  return;
274
274
  }
275
275
 
276
+ // API: Full Model Catalog (for Models tab)
277
+ if (req.method === 'GET' && req.url === '/api/models/catalog') {
278
+ res.writeHead(200, { 'Content-Type': 'application/json' });
279
+ res.end(JSON.stringify({ models: MODEL_CATALOG, benchmarks: BENCHMARK_SCORES }));
280
+ return;
281
+ }
282
+
276
283
  // API: Generate code
277
284
  if (req.method === 'POST' && req.url === '/api/generate') {
278
285
  let body = '';
@@ -1399,9 +1406,58 @@ function createPlaygroundServer() {
1399
1406
  res.end(JSON.stringify({ error: 'inputs must be a non-empty array' }));
1400
1407
  return;
1401
1408
  }
1409
+
1410
+ // Optimize video inputs: downsample to 1fps to fit within 32k token context
1411
+ const os = require('os');
1412
+ const path = require('path');
1413
+ const fs = require('fs');
1414
+ const { execFileSync } = require('child_process');
1415
+ const optimizedInputs = [];
1416
+ for (const input of inputs) {
1417
+ const content = input.content;
1418
+ if (content && Array.isArray(content)) {
1419
+ const optimizedContent = [];
1420
+ for (const item of content) {
1421
+ if (item.type === 'video_base64' && item.video_base64) {
1422
+ // Downsample video to 1fps using ffmpeg to reduce token count
1423
+ try {
1424
+ const b64 = item.video_base64.replace(/^data:[^;]+;base64,/, '');
1425
+ const tmpIn = path.join(os.tmpdir(), `vai_vid_in_${Date.now()}.mp4`);
1426
+ const tmpOut = path.join(os.tmpdir(), `vai_vid_out_${Date.now()}.mp4`);
1427
+ fs.writeFileSync(tmpIn, Buffer.from(b64, 'base64'));
1428
+ try {
1429
+ execFileSync('ffmpeg', [
1430
+ '-y', '-i', tmpIn,
1431
+ '-vf', 'fps=1',
1432
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
1433
+ '-an', // strip audio
1434
+ tmpOut
1435
+ ], { timeout: 30000, stdio: 'pipe' });
1436
+ const optimizedBuf = fs.readFileSync(tmpOut);
1437
+ const optimizedB64 = `data:video/mp4;base64,${optimizedBuf.toString('base64')}`;
1438
+ optimizedContent.push({ type: 'video_base64', video_base64: optimizedB64 });
1439
+ } finally {
1440
+ try { fs.unlinkSync(tmpIn); } catch (_) {}
1441
+ try { fs.unlinkSync(tmpOut); } catch (_) {}
1442
+ }
1443
+ } catch (err) {
1444
+ // If optimization fails, send original and let API error naturally
1445
+ console.warn('[Playground] Video optimization failed:', err.message);
1446
+ optimizedContent.push(item);
1447
+ }
1448
+ } else {
1449
+ optimizedContent.push(item);
1450
+ }
1451
+ }
1452
+ optimizedInputs.push({ ...input, content: optimizedContent });
1453
+ } else {
1454
+ optimizedInputs.push(input);
1455
+ }
1456
+ }
1457
+
1402
1458
  const { apiRequest } = require('../lib/api');
1403
1459
  const mmBody = {
1404
- inputs,
1460
+ inputs: optimizedInputs,
1405
1461
  model: model || 'voyage-multimodal-3.5',
1406
1462
  };
1407
1463
  if (input_type) mmBody.input_type = input_type;
@@ -1583,9 +1639,13 @@ function createPlaygroundServer() {
1583
1639
  else if (output.text) summary = output.text.slice(0, 100) + (output.text.length > 100 ? '...' : '');
1584
1640
  else summary = JSON.stringify(output).slice(0, 200);
1585
1641
  }
1642
+ // Extract usage data for cost tracking (then strip from output payload)
1643
+ const _usage = (output && output._usage) ? output._usage : undefined;
1644
+ const cleanOutput = _usage ? { ...output } : output;
1645
+ if (cleanOutput && cleanOutput._usage) delete cleanOutput._usage;
1586
1646
  res.write(`event: step_complete\ndata: ${JSON.stringify({
1587
- stepId, timeMs, summary,
1588
- output: JSON.stringify(output).length < 5000 ? output : { _truncated: true, summary },
1647
+ stepId, timeMs, summary, _usage,
1648
+ output: JSON.stringify(cleanOutput).length < 5000 ? cleanOutput : { _truncated: true, summary },
1589
1649
  })}\n\n`);
1590
1650
  },
1591
1651
  onStepSkip: (stepId, reason) => {
@@ -1604,6 +1664,7 @@ function createPlaygroundServer() {
1604
1664
  totalTimeMs: result.totalTimeMs,
1605
1665
  layers: result.layers,
1606
1666
  steps: result.steps,
1667
+ formatters: result.formatters || null,
1607
1668
  })}\n\n`);
1608
1669
  } catch (err) {
1609
1670
  res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
@@ -83,7 +83,7 @@ We'll embed these ${SAMPLE_DOCS.length} sample documents:
83
83
  console.log(pc.bold('\nStep 2: Embedding Documents'));
84
84
  console.log(pc.dim('─'.repeat(40)));
85
85
  console.log(`
86
- Running: ${pc.cyan('vai embed --model voyage-3-lite')}
86
+ Running: ${pc.cyan('vai embed --model voyage-4-lite')}
87
87
  `);
88
88
 
89
89
  let embeddings;
@@ -91,7 +91,7 @@ Running: ${pc.cyan('vai embed --model voyage-3-lite')}
91
91
  process.stdout.write(' Embedding documents... ');
92
92
  const result = await embed({
93
93
  texts: SAMPLE_DOCS,
94
- model: 'voyage-3-lite',
94
+ model: 'voyage-4-lite',
95
95
  inputType: 'document',
96
96
  });
97
97
  embeddings = result.embeddings;
@@ -99,7 +99,7 @@ Running: ${pc.cyan('vai embed --model voyage-3-lite')}
99
99
  console.log(`
100
100
  ${pc.green('✓')} Created ${embeddings.length} embeddings
101
101
  ${pc.dim(` Dimensions: ${embeddings[0].length}`)}
102
- ${pc.dim(` Model: voyage-3-lite`)}
102
+ ${pc.dim(` Model: voyage-4-lite`)}
103
103
  `);
104
104
  } catch (err) {
105
105
  console.log(pc.red('✗'));
@@ -123,7 +123,7 @@ Query: "${pc.cyan(query)}"
123
123
  process.stdout.write(' Embedding query... ');
124
124
  const queryResult = await embed({
125
125
  texts: [query],
126
- model: 'voyage-3-lite',
126
+ model: 'voyage-4-lite',
127
127
  inputType: 'query',
128
128
  });
129
129
  const queryEmbedding = queryResult.embeddings[0];
@@ -48,8 +48,13 @@ async function promptForInputs(definition, existingInputs) {
48
48
  const { buildInputSteps } = require('../lib/workflow');
49
49
  const { createCLIRenderer } = require('../lib/wizard-cli');
50
50
  const { runWizard } = require('../lib/wizard');
51
+ const { loadInputCache } = require('../lib/workflow-input-cache');
51
52
 
52
- const allSteps = buildInputSteps(definition);
53
+ // Load cached inputs from last run to use as defaults
54
+ const workflowName = definition.name || '';
55
+ const cachedInputs = loadInputCache(workflowName);
56
+
57
+ const allSteps = buildInputSteps(definition, cachedInputs);
53
58
  // Only prompt for inputs not already provided
54
59
  const steps = allSteps.filter(s => !(s.id in existingInputs));
55
60
  if (steps.length === 0) return existingInputs;
@@ -93,6 +98,7 @@ function registerWorkflow(program) {
93
98
  .option('--db <name>', 'Override default database')
94
99
  .option('--collection <name>', 'Override default collection')
95
100
  .option('--json', 'Output results as JSON', false)
101
+ .option('-o, --output <format>', 'Output format: json, table, markdown, text, csv, value:<path>')
96
102
  .option('--quiet', 'Suppress progress output', false)
97
103
  .option('--dry-run', 'Show execution plan without running', false)
98
104
  .option('--verbose', 'Show step details', false)
@@ -232,33 +238,24 @@ function registerWorkflow(program) {
232
238
 
233
239
  wfDone();
234
240
 
241
+ // Cache inputs for next run
242
+ try {
243
+ const { saveInputCache } = require('../lib/workflow-input-cache');
244
+ saveInputCache(workflowName, opts.input);
245
+ } catch { /* non-critical, don't fail the run */ }
246
+
235
247
  // Output
236
- if (opts.json) {
248
+ const { formatWorkflowOutput, autoDetectFormat } = require('../lib/workflow-formatters');
249
+ const fmtHints = definition.formatters || {};
250
+
251
+ if (opts.json || opts.output === 'json') {
237
252
  console.log(JSON.stringify(result.output, null, 2));
253
+ } else if (opts.output) {
254
+ console.log(formatWorkflowOutput(result.output, opts.output, fmtHints));
238
255
  } else if (result.output) {
239
- // Pretty-print top results if they exist
240
- const output = result.output;
241
- if (output.results && Array.isArray(output.results)) {
242
- const top = output.results.slice(0, 5);
243
- console.log(pc.bold('Top results:'));
244
- for (let i = 0; i < top.length; i++) {
245
- const r = top[i];
246
- const source = r.source || r.text?.slice(0, 50) || `result ${i + 1}`;
247
- const score = r.score != null ? ` (${r.score.toFixed(2)})` : '';
248
- console.log(` ${pc.dim(`[${i + 1}]`)} ${source}${pc.dim(score)}`);
249
- }
250
- } else if (output.summary) {
251
- console.log(output.summary);
252
- } else if (output.comparison) {
253
- console.log(pc.bold('Cost comparison:'));
254
- for (const item of output.comparison) {
255
- if (item && item.model) {
256
- console.log(` ${pc.cyan(item.model)}: $${item.totalCost} total (embed: $${item.embeddingCost}, queries: $${item.monthlyQueryCost}/mo)`);
257
- }
258
- }
259
- } else {
260
- console.log(JSON.stringify(output, null, 2));
261
- }
256
+ // Auto-detect best format from output shape and workflow hints
257
+ const bestFormat = autoDetectFormat(result.output, fmtHints);
258
+ console.log(formatWorkflowOutput(result.output, bestFormat, fmtHints));
262
259
  }
263
260
  } catch (err) {
264
261
  console.error(ui.error(err.message));
@@ -862,8 +859,9 @@ function registerWorkflow(program) {
862
859
  // ── workflow create ──
863
860
  wfCmd
864
861
  .command('create')
865
- .description('Scaffold a publish-ready npm package from a workflow')
862
+ .description('Interactive workflow builder -- scaffold a validated, publish-ready workflow package')
866
863
  .option('--from <file>', 'Existing workflow JSON to package')
864
+ .option('--from-description <desc>', 'Generate a workflow skeleton from a text description')
867
865
  .option('--name <name>', 'Package name (without vai-workflow- prefix)')
868
866
  .option('--author <name>', 'Author name')
869
867
  .option('--description <desc>', 'Package description')
@@ -871,8 +869,9 @@ function registerWorkflow(program) {
871
869
  .option('--scope <scope>', 'Package scope (e.g. "vaicli" for @vaicli/vai-workflow-*)')
872
870
  .option('--output <dir>', 'Output directory')
873
871
  .action(async (opts) => {
874
- const { scaffoldPackage, toPackageName, CATEGORIES, emptyWorkflowTemplate } = require('../lib/workflow-scaffold');
875
- const { loadWorkflow } = require('../lib/workflow');
872
+ const { scaffoldPackage, toPackageName, CATEGORIES } = require('../lib/workflow-scaffold');
873
+ const { loadWorkflow, validateWorkflow, buildExecutionPlan } = require('../lib/workflow');
874
+ const { runInteractiveBuilder, workflowFromDescription } = require('../lib/workflow-builder');
876
875
 
877
876
  let definition;
878
877
  let name = opts.name;
@@ -894,50 +893,85 @@ function registerWorkflow(program) {
894
893
  if (!description) {
895
894
  description = definition.description;
896
895
  }
897
- } else if (process.stdin.isTTY) {
898
- // Interactive mode
896
+ } else if (opts.fromDescription) {
897
+ // Generate from text description
899
898
  try {
899
+ definition = workflowFromDescription(opts.fromDescription);
900
+ name = name || definition.name;
901
+ description = description || definition.description;
902
+
900
903
  const p = require('@clack/prompts');
901
- p.intro(pc.bold('Create a new workflow package'));
902
-
903
- const answers = await p.group({
904
- name: () => p.text({ message: 'Workflow name', placeholder: 'my-workflow', validate: v => v ? undefined : 'Required' }),
905
- description: () => p.text({ message: 'Description', placeholder: 'A brief description of what this workflow does' }),
906
- category: () => p.select({
907
- message: 'Category',
908
- options: CATEGORIES.map(c => ({ value: c, label: c })),
909
- }),
910
- author: () => p.text({ message: 'Author', placeholder: 'Your Name', defaultValue: getGitAuthor() }),
911
- });
912
-
913
- if (p.isCancel(answers)) {
914
- p.cancel('Cancelled.');
915
- process.exit(0);
904
+ p.intro(pc.bold('Generated workflow from description'));
905
+
906
+ // Show what was generated
907
+ p.log.info(`Name: ${pc.cyan(definition.name)}`);
908
+ p.log.info(`Steps: ${definition.steps.map(s => `${pc.cyan(s.id)} (${s.tool})`).join(' -> ')}`);
909
+ p.log.info(`Inputs: ${Object.keys(definition.inputs).join(', ') || 'none'}`);
910
+
911
+ // Show execution plan
912
+ try {
913
+ const layers = buildExecutionPlan(definition.steps);
914
+ p.log.info(pc.bold('Execution plan:'));
915
+ for (let i = 0; i < layers.length; i++) {
916
+ p.log.message(` Layer ${i + 1}: ${layers[i].join(', ')}`);
917
+ }
918
+ } catch (e) { /* skip */ }
919
+
920
+ // Validate
921
+ const errors = validateWorkflow(definition);
922
+ if (errors.length > 0) {
923
+ p.log.warn('Validation issues:');
924
+ for (const err of errors) {
925
+ p.log.warn(` ${err}`);
926
+ }
927
+ } else {
928
+ p.log.success('Workflow validates successfully!');
916
929
  }
917
930
 
918
- name = answers.name;
919
- description = answers.description;
920
- category = answers.category;
921
- author = answers.author;
922
- definition = emptyWorkflowTemplate();
923
- definition.name = name;
924
- definition.description = description || '';
925
- // Add a placeholder step so validation passes
926
- definition.steps = [{
927
- id: 'search',
928
- tool: 'query',
929
- name: 'Search',
930
- inputs: { query: '{{ inputs.query }}' },
931
- }];
932
- definition.inputs = {
933
- query: { type: 'string', required: true, description: 'Search query' },
934
- };
931
+ // Ask for category if not provided
932
+ if (!category) {
933
+ const { guessCategory, extractTools } = require('../lib/workflow-scaffold');
934
+ category = guessCategory(extractTools(definition));
935
+ }
936
+
937
+ // Ask for author if not provided
938
+ if (!author) {
939
+ author = getGitAuthor();
940
+ }
941
+
942
+ // Confirm or edit
943
+ const proceed = await p.confirm({ message: 'Scaffold this workflow as a package?', initialValue: true });
944
+ if (p.isCancel(proceed) || !proceed) {
945
+ // Write just the workflow.json for manual editing
946
+ const fs = require('fs');
947
+ const filename = `${definition.name}.vai-workflow.json`;
948
+ fs.writeFileSync(filename, JSON.stringify(definition, null, 2) + '\n');
949
+ p.log.info(`Wrote ${pc.cyan(filename)} for manual editing.`);
950
+ p.outro('Edit the file and run `vai workflow create --from <file>` when ready.');
951
+ return;
952
+ }
935
953
  } catch (err) {
954
+ console.error(ui.error(`Generation failed: ${err.message}`));
955
+ process.exit(1);
956
+ }
957
+ } else if (process.stdin.isTTY) {
958
+ // Full interactive builder
959
+ try {
960
+ const result = await runInteractiveBuilder();
961
+ definition = result.definition;
962
+ name = name || result.name;
963
+ description = description || result.description;
964
+ category = category || result.category;
965
+ author = author || result.author;
966
+ } catch (err) {
967
+ if (err.message && err.message.includes('cancelled')) {
968
+ process.exit(0);
969
+ }
936
970
  console.error(ui.error(`Interactive mode failed: ${err.message}`));
937
971
  process.exit(1);
938
972
  }
939
973
  } else {
940
- console.error(ui.error('Provide --from <file> or run interactively (TTY required).'));
974
+ console.error(ui.error('Provide --from <file>, --from-description "text", or run interactively (TTY required).'));
941
975
  process.exit(1);
942
976
  }
943
977
 
@@ -965,8 +999,8 @@ function registerWorkflow(program) {
965
999
  }
966
1000
  console.log();
967
1001
  console.log('Next steps:');
968
- console.log(` 1. ${opts.from ? '' : pc.dim('Edit workflow.json with your workflow definition')}${opts.from ? 'Review README.md' : ''}`);
969
- console.log(` 2. cd ${pkgName}`);
1002
+ console.log(` 1. ${opts.from ? 'Review README.md' : `cd ${pkgName} && review workflow.json`}`);
1003
+ console.log(` 2. ${pc.dim('vai workflow validate')} ${pkgName}/workflow.json`);
970
1004
  console.log(` 3. npm publish`);
971
1005
  console.log();
972
1006
  } catch (err) {
@@ -1021,6 +1055,39 @@ function registerWorkflow(program) {
1021
1055
  console.log(` ${pc.dim('Run with:')} vai workflow run ${filename} --input query="your question"`);
1022
1056
  console.log(` ${pc.dim('Validate:')} vai workflow validate ${filename}`);
1023
1057
  });
1058
+
1059
+ // ── workflow clear-cache [name] ──
1060
+ wfCmd
1061
+ .command('clear-cache [name]')
1062
+ .description('Clear cached workflow inputs (from previous runs)')
1063
+ .action((name) => {
1064
+ const { clearInputCache, loadInputCache, slugify, CACHE_PATH } = require('../lib/workflow-input-cache');
1065
+ const fs = require('fs');
1066
+
1067
+ if (name) {
1068
+ const cached = loadInputCache(name);
1069
+ const keys = Object.keys(cached);
1070
+ if (keys.length === 0) {
1071
+ console.log(pc.dim(`No cached inputs for "${name}".`));
1072
+ return;
1073
+ }
1074
+ clearInputCache(name);
1075
+ console.log(ui.success(`Cleared cached inputs for "${name}" (${keys.length} field${keys.length === 1 ? '' : 's'}).`));
1076
+ } else {
1077
+ // Clear all
1078
+ let count = 0;
1079
+ try {
1080
+ const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
1081
+ count = Object.keys(JSON.parse(raw)).length;
1082
+ } catch { /* no file */ }
1083
+ if (count === 0) {
1084
+ console.log(pc.dim('No cached workflow inputs.'));
1085
+ return;
1086
+ }
1087
+ clearInputCache();
1088
+ console.log(ui.success(`Cleared cached inputs for ${count} workflow${count === 1 ? '' : 's'}.`));
1089
+ }
1090
+ });
1024
1091
  }
1025
1092
 
1026
1093
  /**
package/src/lib/api.js CHANGED
@@ -195,6 +195,36 @@ async function generateEmbeddings(texts, options = {}) {
195
195
  return apiRequest('/embeddings', body);
196
196
  }
197
197
 
198
+ /**
199
+ * Generate multimodal embeddings for inputs containing text, images, and/or video.
200
+ * Uses the /multimodalembeddings endpoint with a different input format.
201
+ * @param {Array<Array<{type: string, text?: string, image_base64?: string, video_base64?: string}>>} inputs
202
+ * Array of content arrays. Each content array is a list of content items for one input.
203
+ * Example: [[{type: 'text', text: 'hello'}, {type: 'image_base64', image_base64: 'data:image/png;base64,...'}]]
204
+ * @param {object} options
205
+ * @param {string} [options.model] - Model name (default: voyage-multimodal-3.5)
206
+ * @param {string} [options.inputType] - Input type (query|document)
207
+ * @param {number} [options.outputDimension] - Output dimensions
208
+ * @returns {Promise<object>} API response with embeddings
209
+ */
210
+ async function generateMultimodalEmbeddings(inputs, options = {}) {
211
+ const model = options.model || 'voyage-multimodal-3.5';
212
+
213
+ const body = {
214
+ inputs: inputs.map(contentArray => ({ content: contentArray })),
215
+ model,
216
+ };
217
+
218
+ if (options.inputType) {
219
+ body.input_type = options.inputType;
220
+ }
221
+ if (options.outputDimension) {
222
+ body.output_dimension = options.outputDimension;
223
+ }
224
+
225
+ return apiRequest('/multimodalembeddings', body);
226
+ }
227
+
198
228
  module.exports = {
199
229
  API_BASE,
200
230
  ATLAS_API_BASE,
@@ -204,4 +234,5 @@ module.exports = {
204
234
  requireApiKey,
205
235
  apiRequest,
206
236
  generateEmbeddings,
237
+ generateMultimodalEmbeddings,
207
238
  };