voyageai-cli 1.24.0 → 1.26.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +2 -0
  3. package/src/commands/about.js +1 -1
  4. package/src/commands/bug.js +1 -1
  5. package/src/commands/playground.js +31 -0
  6. package/src/commands/scaffold.js +23 -1
  7. package/src/commands/workflow.js +336 -0
  8. package/src/lib/explanations.js +53 -0
  9. package/src/lib/scaffold-structure.js +8 -9
  10. package/src/lib/telemetry.js +1 -1
  11. package/src/lib/template-engine.js +240 -0
  12. package/src/lib/templates/nextjs/README.md.tpl +78 -55
  13. package/src/lib/templates/nextjs/favicon.svg.tpl +11 -0
  14. package/src/lib/templates/nextjs/footer.jsx.tpl +49 -0
  15. package/src/lib/templates/nextjs/layout.jsx.tpl +16 -10
  16. package/src/lib/templates/nextjs/lib-mongo.js.tpl +5 -5
  17. package/src/lib/templates/nextjs/lib-voyage.js.tpl +13 -8
  18. package/src/lib/templates/nextjs/navbar.jsx.tpl +98 -0
  19. package/src/lib/templates/nextjs/page-home.jsx.tpl +201 -0
  20. package/src/lib/templates/nextjs/page-search.jsx.tpl +184 -82
  21. package/src/lib/templates/nextjs/theme-registry.jsx.tpl +51 -0
  22. package/src/lib/templates/nextjs/theme.js.tpl +138 -65
  23. package/src/lib/templates/nextjs/vai-logo-256.png +0 -0
  24. package/src/lib/workflow-utils.js +65 -0
  25. package/src/lib/workflow.js +1259 -0
  26. package/src/mcp/tools/management.js +1 -60
  27. package/src/playground/icons/dark/128.png +0 -0
  28. package/src/playground/icons/dark/16.png +0 -0
  29. package/src/playground/icons/dark/256.png +0 -0
  30. package/src/playground/icons/dark/32.png +0 -0
  31. package/src/playground/icons/dark/64.png +0 -0
  32. package/src/playground/icons/light/128.png +0 -0
  33. package/src/playground/icons/light/16.png +0 -0
  34. package/src/playground/icons/light/256.png +0 -0
  35. package/src/playground/icons/light/32.png +0 -0
  36. package/src/playground/icons/light/64.png +0 -0
  37. package/src/playground/icons/watermark.png +0 -0
  38. package/src/playground/index.html +125 -73
  39. package/src/workflows/consistency-check.json +64 -0
  40. package/src/workflows/cost-analysis.json +69 -0
  41. package/src/workflows/multi-collection-search.json +80 -0
  42. package/src/workflows/research-and-summarize.json +46 -0
  43. package/src/workflows/smart-ingest.json +63 -0
@@ -0,0 +1,1259 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const {
6
+ resolveTemplate,
7
+ resolveString,
8
+ extractDependencies,
9
+ isTemplateString,
10
+ } = require('./template-engine');
11
+
12
+ // ════════════════════════════════════════════════════════════════════
13
+ // Valid tool names — vai tools + control flow
14
+ // ════════════════════════════════════════════════════════════════════
15
+
16
+ const VAI_TOOLS = new Set([
17
+ 'query', 'search', 'rerank', 'embed', 'similarity',
18
+ 'ingest', 'collections', 'models', 'explain', 'estimate',
19
+ ]);
20
+
21
+ const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate']);
22
+
23
+ const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS]);
24
+
25
+ // ════════════════════════════════════════════════════════════════════
26
+ // Validation
27
+ // ════════════════════════════════════════════════════════════════════
28
+
29
+ /**
30
+ * Validate a workflow definition object.
31
+ * Returns an array of error strings (empty = valid).
32
+ *
33
+ * @param {object} definition - Parsed workflow JSON
34
+ * @returns {string[]} errors
35
+ */
36
+ function validateWorkflow(definition) {
37
+ const errors = [];
38
+
39
+ // Top-level required fields
40
+ if (!definition || typeof definition !== 'object') {
41
+ return ['Workflow definition must be a JSON object'];
42
+ }
43
+ if (!definition.name || typeof definition.name !== 'string') {
44
+ errors.push('Workflow must have a "name" string');
45
+ }
46
+ if (!Array.isArray(definition.steps) || definition.steps.length === 0) {
47
+ errors.push('Workflow must have a non-empty "steps" array');
48
+ }
49
+
50
+ if (errors.length > 0) return errors; // Can't validate steps without them
51
+
52
+ // Validate inputs schema
53
+ if (definition.inputs) {
54
+ for (const [key, schema] of Object.entries(definition.inputs)) {
55
+ if (schema.type && !['string', 'number', 'boolean'].includes(schema.type)) {
56
+ errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, or boolean)`);
57
+ }
58
+ }
59
+ }
60
+
61
+ // Step-level validation
62
+ const stepIds = new Set();
63
+ const duplicateIds = new Set();
64
+
65
+ for (let i = 0; i < definition.steps.length; i++) {
66
+ const step = definition.steps[i];
67
+ const prefix = `Step ${i}`;
68
+
69
+ if (!step.id || typeof step.id !== 'string') {
70
+ errors.push(`${prefix}: must have a string "id"`);
71
+ continue;
72
+ }
73
+
74
+ const stepPrefix = `Step "${step.id}"`;
75
+
76
+ // Duplicate check
77
+ if (stepIds.has(step.id)) {
78
+ duplicateIds.add(step.id);
79
+ }
80
+ stepIds.add(step.id);
81
+
82
+ // Tool validation
83
+ if (!step.tool || typeof step.tool !== 'string') {
84
+ errors.push(`${stepPrefix}: must have a string "tool"`);
85
+ } else if (!ALL_TOOLS.has(step.tool)) {
86
+ errors.push(`${stepPrefix}: unknown tool "${step.tool}" (available: ${[...ALL_TOOLS].join(', ')})`);
87
+ }
88
+
89
+ // Inputs validation
90
+ if (step.tool !== 'generate' && (!step.inputs || typeof step.inputs !== 'object')) {
91
+ errors.push(`${stepPrefix}: must have an "inputs" object`);
92
+ }
93
+
94
+ // Check template references point to known step IDs or reserved prefixes
95
+ if (step.inputs) {
96
+ const deps = extractDependencies(step.inputs);
97
+ for (const dep of deps) {
98
+ if (!stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
99
+ errors.push(`${stepPrefix}: references unknown step "${dep}"`);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Condition validation (if present, should be a string)
105
+ if (step.condition !== undefined && typeof step.condition !== 'string') {
106
+ errors.push(`${stepPrefix}: "condition" must be a string`);
107
+ }
108
+
109
+ // forEach validation (if present, should be a template string)
110
+ if (step.forEach !== undefined && typeof step.forEach !== 'string') {
111
+ errors.push(`${stepPrefix}: "forEach" must be a string`);
112
+ }
113
+ }
114
+
115
+ // Report duplicates
116
+ for (const id of duplicateIds) {
117
+ errors.push(`Duplicate step id: "${id}"`);
118
+ }
119
+
120
+ // Check for circular dependencies
121
+ const cycleErrors = detectCycles(definition.steps);
122
+ errors.push(...cycleErrors);
123
+
124
+ return errors;
125
+ }
126
+
127
+ /**
128
+ * Detect circular dependencies in steps using DFS.
129
+ * @param {Array} steps
130
+ * @returns {string[]} errors
131
+ */
132
+ function detectCycles(steps) {
133
+ const errors = [];
134
+ const stepMap = new Map(steps.map(s => [s.id, s]));
135
+ const adjList = new Map();
136
+
137
+ // Build adjacency list from template dependencies
138
+ for (const step of steps) {
139
+ const deps = extractDependencies(step.inputs || {});
140
+ if (step.condition) {
141
+ const condDeps = extractDependencies(step.condition);
142
+ for (const d of condDeps) deps.add(d);
143
+ }
144
+ if (step.forEach) {
145
+ const forDeps = extractDependencies(step.forEach);
146
+ for (const d of forDeps) deps.add(d);
147
+ }
148
+ adjList.set(step.id, deps);
149
+ }
150
+
151
+ // DFS cycle detection
152
+ const WHITE = 0, GRAY = 1, BLACK = 2;
153
+ const color = new Map(steps.map(s => [s.id, WHITE]));
154
+
155
+ function dfs(nodeId, path) {
156
+ color.set(nodeId, GRAY);
157
+ path.push(nodeId);
158
+
159
+ const neighbors = adjList.get(nodeId) || new Set();
160
+ for (const dep of neighbors) {
161
+ if (!stepMap.has(dep)) continue; // Unknown deps caught by validateWorkflow
162
+ if (color.get(dep) === GRAY) {
163
+ const cycleStart = path.indexOf(dep);
164
+ const cycle = path.slice(cycleStart).concat(dep);
165
+ errors.push(`Circular dependency: ${cycle.join(' -> ')}`);
166
+ return;
167
+ }
168
+ if (color.get(dep) === WHITE) {
169
+ dfs(dep, path);
170
+ }
171
+ }
172
+
173
+ path.pop();
174
+ color.set(nodeId, BLACK);
175
+ }
176
+
177
+ for (const step of steps) {
178
+ if (color.get(step.id) === WHITE) {
179
+ dfs(step.id, []);
180
+ }
181
+ }
182
+
183
+ return errors;
184
+ }
185
+
186
+ // ════════════════════════════════════════════════════════════════════
187
+ // Dependency Resolution + Execution Plan
188
+ // ════════════════════════════════════════════════════════════════════
189
+
190
+ /**
191
+ * Build a dependency graph: stepId -> Set of step IDs it depends on.
192
+ * @param {Array} steps
193
+ * @returns {Map<string, Set<string>>}
194
+ */
195
+ function buildDependencyGraph(steps) {
196
+ const graph = new Map();
197
+
198
+ for (const step of steps) {
199
+ const deps = extractDependencies(step.inputs || {});
200
+ if (step.condition) {
201
+ const condDeps = extractDependencies(step.condition);
202
+ for (const d of condDeps) deps.add(d);
203
+ }
204
+ if (step.forEach) {
205
+ const forDeps = extractDependencies(step.forEach);
206
+ for (const d of forDeps) deps.add(d);
207
+ }
208
+ graph.set(step.id, deps);
209
+ }
210
+
211
+ return graph;
212
+ }
213
+
214
+ /**
215
+ * Topological sort with layer grouping (Kahn's algorithm).
216
+ * Returns layers: each layer is an array of step IDs that can run in parallel.
217
+ *
218
+ * @param {Array} steps
219
+ * @returns {string[][]} layers
220
+ */
221
+ function buildExecutionPlan(steps) {
222
+ const graph = buildDependencyGraph(steps);
223
+ const stepIds = new Set(steps.map(s => s.id));
224
+
225
+ // In-degree: count of dependencies that are actual steps
226
+ const inDegree = new Map();
227
+ for (const step of steps) {
228
+ const deps = graph.get(step.id);
229
+ let count = 0;
230
+ for (const dep of deps) {
231
+ if (stepIds.has(dep)) count++;
232
+ }
233
+ inDegree.set(step.id, count);
234
+ }
235
+
236
+ const layers = [];
237
+ const remaining = new Set(stepIds);
238
+
239
+ while (remaining.size > 0) {
240
+ // Find all nodes with in-degree 0
241
+ const ready = [];
242
+ for (const id of remaining) {
243
+ if (inDegree.get(id) === 0) {
244
+ ready.push(id);
245
+ }
246
+ }
247
+
248
+ if (ready.length === 0) {
249
+ // Should not happen if cycle detection passed, but guard against it
250
+ break;
251
+ }
252
+
253
+ layers.push(ready);
254
+
255
+ // Remove ready nodes and decrease in-degree of dependents
256
+ for (const id of ready) {
257
+ remaining.delete(id);
258
+ // Decrease in-degree of all steps that depend on this id
259
+ for (const otherId of remaining) {
260
+ const deps = graph.get(otherId);
261
+ if (deps.has(id)) {
262
+ inDegree.set(otherId, inDegree.get(otherId) - 1);
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ return layers;
269
+ }
270
+
271
+ // ════════════════════════════════════════════════════════════════════
272
+ // Condition Evaluator
273
+ // ════════════════════════════════════════════════════════════════════
274
+
275
+ /**
276
+ * Evaluate a simple condition expression.
277
+ * Supports: comparisons (>, <, >=, <=, ===, !==, ==, !=),
278
+ * boolean operators (&&, ||), negation (!), property access, array .length.
279
+ *
280
+ * This is NOT eval(). It's a very restricted expression evaluator.
281
+ *
282
+ * @param {string} expr - e.g. "check.output.results.length > 0"
283
+ * @param {object} context
284
+ * @returns {boolean}
285
+ */
286
+ function evaluateCondition(expr, context) {
287
+ let resolved = expr;
288
+
289
+ if (isTemplateString(expr)) {
290
+ // Check if the entire expression is wrapped in {{ }}
291
+ // If so, extract the inner content and evaluate it as a condition
292
+ const soleMatch = expr.match(/^\{\{\s*(.+?)\s*\}\}$/);
293
+ if (soleMatch) {
294
+ const inner = soleMatch[1];
295
+ // If the inner expression contains operators, evaluate as condition
296
+ if (/[><=!&|]/.test(inner)) {
297
+ try {
298
+ return evaluateSimpleExpr(inner.trim(), context);
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+ // Otherwise resolve as template value and check truthiness
304
+ resolved = resolveString(expr, context);
305
+ if (typeof resolved !== 'string') {
306
+ return Boolean(resolved);
307
+ }
308
+ } else {
309
+ // Mixed text + templates: resolve then evaluate
310
+ resolved = resolveString(expr, context);
311
+ if (typeof resolved !== 'string') {
312
+ return Boolean(resolved);
313
+ }
314
+ }
315
+ }
316
+
317
+ // Try to evaluate as a simple expression
318
+ try {
319
+ return evaluateSimpleExpr(resolved.trim(), context);
320
+ } catch {
321
+ // If evaluation fails, treat as falsy
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Simple expression evaluator for conditions.
328
+ * Handles: path lookups, comparisons, &&, ||, !, literals.
329
+ */
330
+ function evaluateSimpleExpr(expr, context) {
331
+ // Handle boolean operators (lowest precedence)
332
+ // Split on || first
333
+ const orParts = splitOutsideParens(expr, '||');
334
+ if (orParts.length > 1) {
335
+ return orParts.some(part => evaluateSimpleExpr(part.trim(), context));
336
+ }
337
+
338
+ // Split on &&
339
+ const andParts = splitOutsideParens(expr, '&&');
340
+ if (andParts.length > 1) {
341
+ return andParts.every(part => evaluateSimpleExpr(part.trim(), context));
342
+ }
343
+
344
+ // Handle negation
345
+ if (expr.startsWith('!') && !expr.startsWith('!=')) {
346
+ return !evaluateSimpleExpr(expr.slice(1).trim(), context);
347
+ }
348
+
349
+ // Handle parentheses
350
+ if (expr.startsWith('(') && expr.endsWith(')')) {
351
+ return evaluateSimpleExpr(expr.slice(1, -1).trim(), context);
352
+ }
353
+
354
+ // Handle comparisons
355
+ const compOps = ['===', '!==', '>=', '<=', '==', '!=', '>', '<'];
356
+ for (const op of compOps) {
357
+ const idx = expr.indexOf(op);
358
+ if (idx !== -1) {
359
+ const left = resolveValue(expr.slice(0, idx).trim(), context);
360
+ const right = resolveValue(expr.slice(idx + op.length).trim(), context);
361
+ switch (op) {
362
+ case '===': return left === right;
363
+ case '!==': return left !== right;
364
+ case '==': return left == right;
365
+ case '!=': return left != right;
366
+ case '>': return left > right;
367
+ case '<': return left < right;
368
+ case '>=': return left >= right;
369
+ case '<=': return left <= right;
370
+ }
371
+ }
372
+ }
373
+
374
+ // No operator: evaluate as truthy/falsy
375
+ return Boolean(resolveValue(expr, context));
376
+ }
377
+
378
+ /**
379
+ * Split a string by an operator, but not inside parentheses.
380
+ */
381
+ function splitOutsideParens(str, op) {
382
+ const parts = [];
383
+ let depth = 0;
384
+ let current = '';
385
+
386
+ for (let i = 0; i < str.length; i++) {
387
+ if (str[i] === '(') depth++;
388
+ if (str[i] === ')') depth--;
389
+
390
+ if (depth === 0 && str.slice(i, i + op.length) === op) {
391
+ parts.push(current);
392
+ current = '';
393
+ i += op.length - 1;
394
+ } else {
395
+ current += str[i];
396
+ }
397
+ }
398
+ parts.push(current);
399
+ return parts;
400
+ }
401
+
402
+ /**
403
+ * Resolve a value from an expression fragment.
404
+ * Handles: number literals, string literals, true/false, null, undefined, path lookups.
405
+ */
406
+ function resolveValue(expr, context) {
407
+ const trimmed = expr.trim();
408
+
409
+ // Number
410
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
411
+ return parseFloat(trimmed);
412
+ }
413
+
414
+ // String literal (single or double quotes)
415
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
416
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
417
+ return trimmed.slice(1, -1);
418
+ }
419
+
420
+ // Boolean / null / undefined
421
+ if (trimmed === 'true') return true;
422
+ if (trimmed === 'false') return false;
423
+ if (trimmed === 'null') return null;
424
+ if (trimmed === 'undefined') return undefined;
425
+
426
+ // Path lookup (e.g., "check.output.results.length")
427
+ // Walk the context object
428
+ try {
429
+ const parts = trimmed.split('.');
430
+ let current = context;
431
+ for (const part of parts) {
432
+ // Handle array indexing
433
+ const bracketMatch = part.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]$/);
434
+ if (bracketMatch) {
435
+ current = current[bracketMatch[1]];
436
+ if (current == null) return undefined;
437
+ current = current[parseInt(bracketMatch[2], 10)];
438
+ } else {
439
+ if (current == null) return undefined;
440
+ current = current[part];
441
+ }
442
+ }
443
+ return current;
444
+ } catch {
445
+ return undefined;
446
+ }
447
+ }
448
+
449
+ // ════════════════════════════════════════════════════════════════════
450
+ // Control Flow Executors
451
+ // ════════════════════════════════════════════════════════════════════
452
+
453
+ /**
454
+ * Execute a merge step: concatenate arrays with optional dedup.
455
+ *
456
+ * @param {object} inputs - { arrays: any[][], dedup?: boolean, dedup_field?: string }
457
+ * @returns {{ results: any[], resultCount: number }}
458
+ */
459
+ function executeMerge(inputs) {
460
+ const { arrays, dedup, dedup_field } = inputs;
461
+
462
+ if (!Array.isArray(arrays)) {
463
+ throw new Error('merge: "arrays" input must be an array of arrays');
464
+ }
465
+
466
+ let merged = [];
467
+ for (const arr of arrays) {
468
+ if (Array.isArray(arr)) {
469
+ merged = merged.concat(arr);
470
+ }
471
+ }
472
+
473
+ if (dedup && dedup_field) {
474
+ const seen = new Set();
475
+ merged = merged.filter(item => {
476
+ const key = item && typeof item === 'object' ? item[dedup_field] : item;
477
+ if (seen.has(key)) return false;
478
+ seen.add(key);
479
+ return true;
480
+ });
481
+ }
482
+
483
+ return { results: merged, resultCount: merged.length };
484
+ }
485
+
486
+ /**
487
+ * Execute a filter step: filter array by condition.
488
+ *
489
+ * @param {object} inputs - { array: any[], condition: string }
490
+ * @param {object} context - workflow context for evaluating conditions
491
+ * @returns {{ results: any[], resultCount: number }}
492
+ */
493
+ function executeFilter(inputs, context) {
494
+ const { array, condition } = inputs;
495
+
496
+ if (!Array.isArray(array)) {
497
+ throw new Error('filter: "array" input must be an array');
498
+ }
499
+
500
+ if (!condition || typeof condition !== 'string') {
501
+ throw new Error('filter: "condition" must be a string expression');
502
+ }
503
+
504
+ const results = array.filter(item => {
505
+ // Make "item" available in the condition context
506
+ const itemContext = { ...context, item };
507
+ return evaluateCondition(condition, itemContext);
508
+ });
509
+
510
+ return { results, resultCount: results.length };
511
+ }
512
+
513
+ /**
514
+ * Execute a transform step: map/reshape array items.
515
+ *
516
+ * @param {object} inputs - { array: any[], fields?: string[], mapping?: object }
517
+ * @returns {{ results: any[], resultCount: number }}
518
+ */
519
+ function executeTransform(inputs) {
520
+ const { array, fields, mapping } = inputs;
521
+
522
+ if (!Array.isArray(array)) {
523
+ throw new Error('transform: "array" input must be an array');
524
+ }
525
+
526
+ let results;
527
+
528
+ if (fields) {
529
+ // Pick specific fields
530
+ results = array.map(item => {
531
+ if (!item || typeof item !== 'object') return item;
532
+ const picked = {};
533
+ for (const field of fields) {
534
+ if (field in item) picked[field] = item[field];
535
+ }
536
+ return picked;
537
+ });
538
+ } else if (mapping) {
539
+ // Rename/reshape fields
540
+ results = array.map(item => {
541
+ if (!item || typeof item !== 'object') return item;
542
+ const mapped = {};
543
+ for (const [newKey, oldKey] of Object.entries(mapping)) {
544
+ mapped[newKey] = typeof oldKey === 'string' && oldKey in item ? item[oldKey] : oldKey;
545
+ }
546
+ return mapped;
547
+ });
548
+ } else {
549
+ results = array;
550
+ }
551
+
552
+ return { results, resultCount: results.length };
553
+ }
554
+
555
+ // ════════════════════════════════════════════════════════════════════
556
+ // VAI Tool Executors
557
+ // ════════════════════════════════════════════════════════════════════
558
+
559
+ /**
560
+ * Execute a vai query step (embed + vector search + optional rerank).
561
+ */
562
+ async function executeQuery(inputs, defaults) {
563
+ const { generateEmbeddings, apiRequest } = require('./api');
564
+ const { getMongoCollection } = require('./mongo');
565
+ const { loadProject } = require('./project');
566
+ const { config: proj } = loadProject();
567
+
568
+ const db = inputs.db || defaults.db || proj.db;
569
+ const collection = inputs.collection || defaults.collection || proj.collection;
570
+ const model = inputs.model || defaults.model;
571
+ const query = inputs.query;
572
+ const limit = inputs.limit || 10;
573
+ const doRerank = inputs.rerank !== false;
574
+
575
+ if (!query) throw new Error('query: "query" input is required');
576
+ if (!db) throw new Error('query: database not specified (set in inputs, defaults, or vai config)');
577
+ if (!collection) throw new Error('query: collection not specified');
578
+
579
+ // Embed
580
+ const embOpts = { inputType: 'query' };
581
+ if (model) embOpts.model = model;
582
+ const embRes = await generateEmbeddings([query], embOpts);
583
+ const embedding = embRes.data[0].embedding;
584
+
585
+ // Vector search
586
+ const { client, collection: col } = await getMongoCollection(db, collection);
587
+ try {
588
+ const results = await col.aggregate([
589
+ {
590
+ $vectorSearch: {
591
+ index: 'vector_index',
592
+ path: 'embedding',
593
+ queryVector: embedding,
594
+ numCandidates: Math.min(limit * 10, 200),
595
+ limit,
596
+ },
597
+ },
598
+ {
599
+ $project: {
600
+ text: 1, content: 1, source: 1, metadata: 1,
601
+ score: { $meta: 'vectorSearchScore' },
602
+ },
603
+ },
604
+ ]).toArray();
605
+
606
+ // Rerank if requested and results exist
607
+ if (doRerank && results.length > 0) {
608
+ const documents = results.map(r => r.text || r.content || '');
609
+ const { DEFAULT_RERANK_MODEL } = require('./catalog');
610
+ const rerankRes = await apiRequest('/rerank', {
611
+ model: inputs.rerankModel || DEFAULT_RERANK_MODEL,
612
+ query,
613
+ documents,
614
+ });
615
+
616
+ const reranked = (rerankRes.data || []).map(r => ({
617
+ ...results[r.index],
618
+ score: r.relevance_score,
619
+ }));
620
+
621
+ return { results: reranked, resultCount: reranked.length };
622
+ }
623
+
624
+ return { results, resultCount: results.length };
625
+ } finally {
626
+ await client.close();
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Execute a vai search step (embed + vector search, no rerank).
632
+ */
633
+ async function executeSearch(inputs, defaults) {
634
+ return executeQuery({ ...inputs, rerank: false }, defaults);
635
+ }
636
+
637
+ /**
638
+ * Execute a vai rerank step.
639
+ */
640
+ async function executeRerank(inputs) {
641
+ const { apiRequest } = require('./api');
642
+ const { DEFAULT_RERANK_MODEL } = require('./catalog');
643
+
644
+ const query = inputs.query;
645
+ const documents = inputs.documents;
646
+ const model = inputs.model || DEFAULT_RERANK_MODEL;
647
+
648
+ if (!query) throw new Error('rerank: "query" input is required');
649
+ if (!Array.isArray(documents)) throw new Error('rerank: "documents" must be an array');
650
+
651
+ // If documents are objects, extract text
652
+ const docTexts = documents.map(d =>
653
+ typeof d === 'string' ? d : (d.text || d.content || JSON.stringify(d))
654
+ );
655
+
656
+ const res = await apiRequest('/rerank', { model, query, documents: docTexts });
657
+
658
+ const results = (res.data || []).map(r => ({
659
+ ...(typeof documents[r.index] === 'object' ? documents[r.index] : { text: documents[r.index] }),
660
+ score: r.relevance_score,
661
+ }));
662
+
663
+ return { results, resultCount: results.length };
664
+ }
665
+
666
+ /**
667
+ * Execute a vai embed step.
668
+ */
669
+ async function executeEmbed(inputs, defaults) {
670
+ const { generateEmbeddings } = require('./api');
671
+
672
+ const text = inputs.text;
673
+ const model = inputs.model || defaults.model;
674
+ const inputType = inputs.inputType || 'query';
675
+
676
+ if (!text) throw new Error('embed: "text" input is required');
677
+
678
+ const opts = { inputType };
679
+ if (model) opts.model = model;
680
+ if (inputs.dimensions) opts.dimensions = inputs.dimensions;
681
+
682
+ const res = await generateEmbeddings([text], opts);
683
+ return {
684
+ embedding: res.data[0].embedding,
685
+ model: res.model,
686
+ dimensions: res.data[0].embedding.length,
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Execute a vai similarity step.
692
+ */
693
+ async function executeSimilarity(inputs, defaults) {
694
+ const { generateEmbeddings } = require('./api');
695
+ const { cosineSimilarity } = require('./math');
696
+
697
+ const { text1, text2 } = inputs;
698
+ const model = inputs.model || defaults.model;
699
+
700
+ if (!text1 || !text2) throw new Error('similarity: "text1" and "text2" are required');
701
+
702
+ const opts = { inputType: 'document' };
703
+ if (model) opts.model = model;
704
+
705
+ const res = await generateEmbeddings([text1, text2], opts);
706
+ const similarity = cosineSimilarity(res.data[0].embedding, res.data[1].embedding);
707
+
708
+ return { similarity, model: res.model };
709
+ }
710
+
711
+ /**
712
+ * Execute a vai ingest step.
713
+ */
714
+ async function executeIngest(inputs, defaults) {
715
+ const { generateEmbeddings } = require('./api');
716
+ const { getMongoCollection } = require('./mongo');
717
+ const { chunk } = require('./chunker');
718
+ const { loadProject } = require('./project');
719
+ const { config: proj } = loadProject();
720
+
721
+ const db = inputs.db || defaults.db || proj.db;
722
+ const collection = inputs.collection || defaults.collection || proj.collection;
723
+ const text = inputs.text;
724
+ const source = inputs.source || 'workflow-ingest';
725
+ const model = inputs.model || defaults.model;
726
+
727
+ if (!text) throw new Error('ingest: "text" input is required');
728
+ if (!db) throw new Error('ingest: database not specified');
729
+ if (!collection) throw new Error('ingest: collection not specified');
730
+
731
+ // Chunk text
732
+ const chunks = chunk(text, {
733
+ strategy: inputs.chunkStrategy || 'recursive',
734
+ size: inputs.chunkSize || 512,
735
+ });
736
+
737
+ // Embed chunks
738
+ const embOpts = { inputType: 'document' };
739
+ if (model) embOpts.model = model;
740
+ const embRes = await generateEmbeddings(chunks, embOpts);
741
+
742
+ // Build docs
743
+ const docs = chunks.map((chunkText, i) => ({
744
+ text: chunkText,
745
+ source,
746
+ embedding: embRes.data[i].embedding,
747
+ metadata: inputs.metadata || {},
748
+ createdAt: new Date(),
749
+ }));
750
+
751
+ // Insert
752
+ const { client, collection: col } = await getMongoCollection(db, collection);
753
+ try {
754
+ const result = await col.insertMany(docs);
755
+ return {
756
+ insertedCount: result.insertedCount,
757
+ chunks: chunks.length,
758
+ source,
759
+ model: embRes.model,
760
+ };
761
+ } finally {
762
+ await client.close();
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Execute a vai collections step.
768
+ */
769
+ async function executeCollections(inputs, defaults) {
770
+ const { introspectCollections } = require('./workflow-utils');
771
+ const { loadProject } = require('./project');
772
+ const { config: proj } = loadProject();
773
+
774
+ const db = inputs.db || defaults.db || proj.db;
775
+ if (!db) throw new Error('collections: database not specified');
776
+
777
+ const collections = await introspectCollections(db);
778
+ return { collections, database: db };
779
+ }
780
+
781
+ /**
782
+ * Execute a vai models step.
783
+ */
784
+ function executeModels(inputs) {
785
+ const { MODEL_CATALOG } = require('./catalog');
786
+
787
+ let models = MODEL_CATALOG.filter(m => !m.legacy && !m.unreleased);
788
+ const category = inputs.category || 'all';
789
+
790
+ if (category !== 'all') {
791
+ models = models.filter(m => m.type === category);
792
+ }
793
+
794
+ return {
795
+ models: models.map(m => ({
796
+ name: m.name,
797
+ type: m.type,
798
+ dimensions: m.dimensions,
799
+ price: m.price,
800
+ bestFor: m.bestFor,
801
+ })),
802
+ category,
803
+ };
804
+ }
805
+
806
+ /**
807
+ * Execute a vai explain step.
808
+ */
809
+ function executeExplain(inputs) {
810
+ const { resolveConcept, getConcept } = require('./explanations');
811
+
812
+ const topic = inputs.topic;
813
+ if (!topic) throw new Error('explain: "topic" input is required');
814
+
815
+ const conceptKey = resolveConcept(topic);
816
+ if (!conceptKey) {
817
+ return { found: false, topic, text: `No explanation found for "${topic}"` };
818
+ }
819
+
820
+ const concept = getConcept(conceptKey);
821
+ return {
822
+ found: true,
823
+ topic: concept.title,
824
+ text: concept.content,
825
+ links: concept.links || [],
826
+ };
827
+ }
828
+
829
+ /**
830
+ * Execute a vai estimate step.
831
+ */
832
+ function executeEstimate(inputs) {
833
+ const { MODEL_CATALOG } = require('./catalog');
834
+
835
+ const docs = inputs.docs || 1000;
836
+ const queries = inputs.queries || 0;
837
+ const months = inputs.months || 12;
838
+ const model = inputs.model || 'voyage-4-large';
839
+
840
+ const modelInfo = MODEL_CATALOG.find(m => m.name === model);
841
+ if (!modelInfo) {
842
+ return { error: `Unknown model: ${model}`, model };
843
+ }
844
+
845
+ const pricePerMToken = modelInfo.pricePerMToken || 0;
846
+ // Rough estimate: avg 500 tokens per document chunk
847
+ const avgTokensPerDoc = 500;
848
+ const embeddingCost = (docs * avgTokensPerDoc / 1_000_000) * pricePerMToken;
849
+
850
+ // Query cost (embedding queries)
851
+ const avgQueryTokens = 50;
852
+ const monthlyCost = (queries * avgQueryTokens / 1_000_000) * pricePerMToken;
853
+ const totalQueryCost = monthlyCost * months;
854
+
855
+ return {
856
+ model,
857
+ docs,
858
+ embeddingCost: Math.round(embeddingCost * 10000) / 10000,
859
+ queriesPerMonth: queries,
860
+ monthlyQueryCost: Math.round(monthlyCost * 10000) / 10000,
861
+ totalCost: Math.round((embeddingCost + totalQueryCost) * 10000) / 10000,
862
+ months,
863
+ };
864
+ }
865
+
866
+ /**
867
+ * Execute a generate step (LLM call).
868
+ */
869
+ async function executeGenerate(inputs) {
870
+ const { createLLMProvider } = require('./llm');
871
+
872
+ const provider = createLLMProvider();
873
+ if (!provider) {
874
+ throw new Error(
875
+ 'generate: No LLM provider configured.\n' +
876
+ 'Set up with: vai config set llm-provider anthropic\n' +
877
+ ' vai config set llm-api-key YOUR_KEY'
878
+ );
879
+ }
880
+
881
+ const prompt = inputs.prompt;
882
+ if (!prompt) throw new Error('generate: "prompt" input is required');
883
+
884
+ const messages = [];
885
+
886
+ if (inputs.systemPrompt) {
887
+ messages.push({ role: 'system', content: inputs.systemPrompt });
888
+ }
889
+
890
+ // Build user message with context if provided
891
+ let userContent = prompt;
892
+ if (inputs.context) {
893
+ const contextStr = Array.isArray(inputs.context)
894
+ ? inputs.context.map(item =>
895
+ typeof item === 'string' ? item : (item.text || item.content || JSON.stringify(item))
896
+ ).join('\n\n---\n\n')
897
+ : String(inputs.context);
898
+ userContent = `${prompt}\n\nContext:\n${contextStr}`;
899
+ }
900
+ messages.push({ role: 'user', content: userContent });
901
+
902
+ // Collect streaming response
903
+ let text = '';
904
+ for await (const chunk of provider.chat(messages, { stream: true })) {
905
+ text += chunk;
906
+ }
907
+
908
+ return {
909
+ text,
910
+ model: provider.model,
911
+ provider: provider.name,
912
+ };
913
+ }
914
+
915
+ // ════════════════════════════════════════════════════════════════════
916
+ // Step Dispatcher
917
+ // ════════════════════════════════════════════════════════════════════
918
+
919
+ /**
920
+ * Execute a single step with resolved inputs.
921
+ *
922
+ * @param {object} step - The step definition
923
+ * @param {object} resolvedInputs - Inputs with templates already resolved
924
+ * @param {object} defaults - Workflow defaults
925
+ * @param {object} context - Full workflow context
926
+ * @returns {Promise<object>} Step output
927
+ */
928
+ async function executeStep(step, resolvedInputs, defaults, context) {
929
+ switch (step.tool) {
930
+ // Control flow
931
+ case 'merge':
932
+ return executeMerge(resolvedInputs);
933
+ case 'filter':
934
+ return executeFilter(resolvedInputs, context);
935
+ case 'transform':
936
+ return executeTransform(resolvedInputs);
937
+ case 'generate':
938
+ return executeGenerate(resolvedInputs);
939
+
940
+ // VAI tools
941
+ case 'query':
942
+ return executeQuery(resolvedInputs, defaults);
943
+ case 'search':
944
+ return executeSearch(resolvedInputs, defaults);
945
+ case 'rerank':
946
+ return executeRerank(resolvedInputs);
947
+ case 'embed':
948
+ return executeEmbed(resolvedInputs, defaults);
949
+ case 'similarity':
950
+ return executeSimilarity(resolvedInputs, defaults);
951
+ case 'ingest':
952
+ return executeIngest(resolvedInputs, defaults);
953
+ case 'collections':
954
+ return executeCollections(resolvedInputs, defaults);
955
+ case 'models':
956
+ return executeModels(resolvedInputs);
957
+ case 'explain':
958
+ return executeExplain(resolvedInputs);
959
+ case 'estimate':
960
+ return executeEstimate(resolvedInputs);
961
+
962
+ default:
963
+ throw new Error(`Unknown tool: "${step.tool}"`);
964
+ }
965
+ }
966
+
967
+ // ════════════════════════════════════════════════════════════════════
968
+ // Main Execution Loop
969
+ // ════════════════════════════════════════════════════════════════════
970
+
971
+ /**
972
+ * Execute a workflow definition.
973
+ *
974
+ * @param {object} definition - Parsed workflow JSON
975
+ * @param {object} opts
976
+ * @param {object} [opts.inputs] - Workflow input values
977
+ * @param {string} [opts.db] - Database override
978
+ * @param {string} [opts.collection] - Collection override
979
+ * @param {boolean} [opts.dryRun] - Show plan without executing
980
+ * @param {boolean} [opts.verbose] - Show step details
981
+ * @param {boolean} [opts.json] - Return JSON output
982
+ * @param {Function} [opts.onStepStart] - Callback(stepId, stepDef)
983
+ * @param {Function} [opts.onStepComplete] - Callback(stepId, output, durationMs)
984
+ * @param {Function} [opts.onStepSkip] - Callback(stepId, reason)
985
+ * @param {Function} [opts.onStepError] - Callback(stepId, error)
986
+ * @returns {Promise<{ output: object, steps: Array, totalTimeMs: number, layers: string[][] }>}
987
+ */
988
+ async function executeWorkflow(definition, opts = {}) {
989
+ const startTime = Date.now();
990
+
991
+ // Validate
992
+ const errors = validateWorkflow(definition);
993
+ if (errors.length > 0) {
994
+ throw new Error(`Workflow validation failed:\n ${errors.join('\n ')}`);
995
+ }
996
+
997
+ // Validate required inputs
998
+ const inputValues = opts.inputs || {};
999
+ if (definition.inputs) {
1000
+ for (const [key, schema] of Object.entries(definition.inputs)) {
1001
+ if (schema.required && !(key in inputValues) && !('default' in schema)) {
1002
+ throw new Error(`Missing required input: "${key}"`);
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ // Build effective inputs (fill defaults)
1008
+ const effectiveInputs = {};
1009
+ if (definition.inputs) {
1010
+ for (const [key, schema] of Object.entries(definition.inputs)) {
1011
+ if (key in inputValues) {
1012
+ effectiveInputs[key] = coerceInput(inputValues[key], schema.type);
1013
+ } else if ('default' in schema) {
1014
+ effectiveInputs[key] = schema.default;
1015
+ }
1016
+ }
1017
+ }
1018
+ // Also pass through any extra inputs not in schema
1019
+ for (const [key, val] of Object.entries(inputValues)) {
1020
+ if (!(key in effectiveInputs)) {
1021
+ effectiveInputs[key] = val;
1022
+ }
1023
+ }
1024
+
1025
+ // Build defaults with CLI overrides
1026
+ const defaults = {
1027
+ ...(definition.defaults || {}),
1028
+ ...(opts.db && { db: opts.db }),
1029
+ ...(opts.collection && { collection: opts.collection }),
1030
+ };
1031
+
1032
+ // Build execution plan
1033
+ const layers = buildExecutionPlan(definition.steps);
1034
+ const stepMap = new Map(definition.steps.map(s => [s.id, s]));
1035
+
1036
+ // Dry run: return plan without executing
1037
+ if (opts.dryRun) {
1038
+ return {
1039
+ output: null,
1040
+ steps: [],
1041
+ totalTimeMs: 0,
1042
+ layers,
1043
+ inputs: effectiveInputs,
1044
+ defaults,
1045
+ dryRun: true,
1046
+ };
1047
+ }
1048
+
1049
+ // Initialize context
1050
+ const context = {
1051
+ inputs: effectiveInputs,
1052
+ defaults,
1053
+ };
1054
+
1055
+ // Execute layer by layer
1056
+ const stepResults = [];
1057
+
1058
+ for (const layer of layers) {
1059
+ const layerPromises = layer.map(async (stepId) => {
1060
+ const step = stepMap.get(stepId);
1061
+ const stepStart = Date.now();
1062
+
1063
+ // Evaluate condition
1064
+ if (step.condition) {
1065
+ const conditionMet = evaluateCondition(step.condition, context);
1066
+ if (!conditionMet) {
1067
+ if (opts.onStepSkip) opts.onStepSkip(stepId, 'condition not met');
1068
+ context[stepId] = { output: null, skipped: true };
1069
+ stepResults.push({
1070
+ id: stepId,
1071
+ tool: step.tool,
1072
+ skipped: true,
1073
+ durationMs: Date.now() - stepStart,
1074
+ });
1075
+ return;
1076
+ }
1077
+ }
1078
+
1079
+ if (opts.onStepStart) opts.onStepStart(stepId, step);
1080
+
1081
+ try {
1082
+ let output;
1083
+
1084
+ if (step.forEach) {
1085
+ // Iterate over an array
1086
+ const iterArray = resolveTemplate(step.forEach, context);
1087
+ if (!Array.isArray(iterArray)) {
1088
+ throw new Error(`forEach in step "${stepId}" did not resolve to an array`);
1089
+ }
1090
+
1091
+ const iterResults = [];
1092
+ for (let i = 0; i < iterArray.length; i++) {
1093
+ const iterContext = { ...context, item: iterArray[i], index: i };
1094
+ const resolvedInputs = resolveTemplate(step.inputs || {}, iterContext);
1095
+ const iterOutput = await executeStep(step, resolvedInputs, defaults, iterContext);
1096
+ iterResults.push(iterOutput);
1097
+ }
1098
+ output = { results: iterResults, count: iterResults.length };
1099
+ } else {
1100
+ // Normal execution
1101
+ const resolvedInputs = resolveTemplate(step.inputs || {}, context);
1102
+ output = await executeStep(step, resolvedInputs, defaults, context);
1103
+ }
1104
+
1105
+ const durationMs = Date.now() - stepStart;
1106
+ context[stepId] = { output };
1107
+
1108
+ if (opts.onStepComplete) opts.onStepComplete(stepId, output, durationMs);
1109
+
1110
+ stepResults.push({
1111
+ id: stepId,
1112
+ tool: step.tool,
1113
+ output,
1114
+ durationMs,
1115
+ });
1116
+ } catch (err) {
1117
+ const durationMs = Date.now() - stepStart;
1118
+
1119
+ if (step.continueOnError) {
1120
+ context[stepId] = { output: null, error: err.message };
1121
+ if (opts.onStepError) opts.onStepError(stepId, err);
1122
+ stepResults.push({
1123
+ id: stepId,
1124
+ tool: step.tool,
1125
+ error: err.message,
1126
+ durationMs,
1127
+ });
1128
+ } else {
1129
+ if (opts.onStepError) opts.onStepError(stepId, err);
1130
+ throw new Error(`Step "${stepId}" failed: ${err.message}`);
1131
+ }
1132
+ }
1133
+ });
1134
+
1135
+ await Promise.all(layerPromises);
1136
+ }
1137
+
1138
+ // Resolve output templates
1139
+ const output = definition.output
1140
+ ? resolveTemplate(definition.output, context)
1141
+ : context;
1142
+
1143
+ return {
1144
+ output,
1145
+ steps: stepResults,
1146
+ totalTimeMs: Date.now() - startTime,
1147
+ layers,
1148
+ };
1149
+ }
1150
+
1151
+ /**
1152
+ * Coerce a string input value to the expected type.
1153
+ */
1154
+ function coerceInput(value, type) {
1155
+ if (type === 'number' && typeof value === 'string') {
1156
+ const num = parseFloat(value);
1157
+ return isNaN(num) ? value : num;
1158
+ }
1159
+ if (type === 'boolean' && typeof value === 'string') {
1160
+ return value === 'true' || value === '1';
1161
+ }
1162
+ return value;
1163
+ }
1164
+
1165
+ // ════════════════════════════════════════════════════════════════════
1166
+ // Built-in Templates
1167
+ // ════════════════════════════════════════════════════════════════════
1168
+
1169
+ /**
1170
+ * Get the path to the built-in workflows directory.
1171
+ */
1172
+ function getWorkflowsDir() {
1173
+ return path.join(__dirname, '..', 'workflows');
1174
+ }
1175
+
1176
+ /**
1177
+ * List built-in workflow templates.
1178
+ * @returns {Array<{ name: string, description: string, file: string }>}
1179
+ */
1180
+ function listBuiltinWorkflows() {
1181
+ const dir = getWorkflowsDir();
1182
+ if (!fs.existsSync(dir)) return [];
1183
+
1184
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
1185
+ return files.map(f => {
1186
+ try {
1187
+ const def = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
1188
+ return {
1189
+ name: f.replace('.json', ''),
1190
+ description: def.description || def.name || f,
1191
+ file: f,
1192
+ };
1193
+ } catch {
1194
+ return { name: f.replace('.json', ''), description: '(error reading)', file: f };
1195
+ }
1196
+ });
1197
+ }
1198
+
1199
+ /**
1200
+ * Load a workflow definition from a file path or built-in template name.
1201
+ *
1202
+ * @param {string} nameOrPath - File path or template name (e.g., "multi-collection-search")
1203
+ * @returns {object} Parsed workflow definition
1204
+ */
1205
+ function loadWorkflow(nameOrPath) {
1206
+ // Try as a direct file path
1207
+ if (fs.existsSync(nameOrPath)) {
1208
+ const content = fs.readFileSync(nameOrPath, 'utf8');
1209
+ return JSON.parse(content);
1210
+ }
1211
+
1212
+ // Try as a built-in template name
1213
+ const builtinPath = path.join(getWorkflowsDir(), `${nameOrPath}.json`);
1214
+ if (fs.existsSync(builtinPath)) {
1215
+ const content = fs.readFileSync(builtinPath, 'utf8');
1216
+ return JSON.parse(content);
1217
+ }
1218
+
1219
+ // Try with .json extension appended
1220
+ const withJson = nameOrPath.endsWith('.json') ? nameOrPath : `${nameOrPath}.json`;
1221
+ if (fs.existsSync(withJson)) {
1222
+ const content = fs.readFileSync(withJson, 'utf8');
1223
+ return JSON.parse(content);
1224
+ }
1225
+
1226
+ throw new Error(`Workflow not found: "${nameOrPath}"\nProvide a file path or built-in template name (see: vai workflow list)`);
1227
+ }
1228
+
1229
+ module.exports = {
1230
+ // Validation
1231
+ validateWorkflow,
1232
+ detectCycles,
1233
+
1234
+ // Dependency resolution
1235
+ buildDependencyGraph,
1236
+ buildExecutionPlan,
1237
+
1238
+ // Condition evaluation
1239
+ evaluateCondition,
1240
+
1241
+ // Control flow
1242
+ executeMerge,
1243
+ executeFilter,
1244
+ executeTransform,
1245
+
1246
+ // Main execution
1247
+ executeStep,
1248
+ executeWorkflow,
1249
+
1250
+ // Templates
1251
+ listBuiltinWorkflows,
1252
+ loadWorkflow,
1253
+ getWorkflowsDir,
1254
+
1255
+ // Constants
1256
+ VAI_TOOLS,
1257
+ CONTROL_FLOW_TOOLS,
1258
+ ALL_TOOLS,
1259
+ };