voyageai-cli 1.27.0 → 1.29.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.
@@ -18,9 +18,13 @@ const VAI_TOOLS = new Set([
18
18
  'ingest', 'collections', 'models', 'explain', 'estimate',
19
19
  ]);
20
20
 
21
- const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate']);
21
+ const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate', 'conditional', 'loop', 'template']);
22
22
 
23
- const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS]);
23
+ const PROCESSING_TOOLS = new Set(['chunk', 'aggregate']);
24
+
25
+ const INTEGRATION_TOOLS = new Set(['http']);
26
+
27
+ const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS, ...PROCESSING_TOOLS, ...INTEGRATION_TOOLS]);
24
28
 
25
29
  // ════════════════════════════════════════════════════════════════════
26
30
  // Validation
@@ -52,8 +56,8 @@ function validateWorkflow(definition) {
52
56
  // Validate inputs schema
53
57
  if (definition.inputs) {
54
58
  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)`);
59
+ if (schema.type && !['string', 'number', 'boolean', 'array'].includes(schema.type)) {
60
+ errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
57
61
  }
58
62
  }
59
63
  }
@@ -92,10 +96,24 @@ function validateWorkflow(definition) {
92
96
  }
93
97
 
94
98
  // Check template references point to known step IDs or reserved prefixes
99
+ // "item" and "index" are injected by forEach at runtime
100
+ const forEachVars = step.forEach ? new Set(['item', 'index']) : new Set();
101
+ // For loop nodes, the "as" variable and inline step refs are scoped
102
+ const loopVars = new Set();
103
+ if (step.tool === 'loop' && step.inputs) {
104
+ if (step.inputs.as) loopVars.add(step.inputs.as);
105
+ loopVars.add('item');
106
+ loopVars.add('index');
107
+ }
95
108
  if (step.inputs) {
96
- const deps = extractDependencies(step.inputs);
109
+ // For loop nodes, only check dependencies on top-level inputs (items, as, maxIterations)
110
+ // not on the inline step's inputs which may reference the loop variable
111
+ const inputsToCheck = step.tool === 'loop'
112
+ ? { items: step.inputs.items }
113
+ : step.inputs;
114
+ const deps = extractDependencies(inputsToCheck);
97
115
  for (const dep of deps) {
98
- if (!stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
116
+ if (!forEachVars.has(dep) && !loopVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
99
117
  errors.push(`${stepPrefix}: references unknown step "${dep}"`);
100
118
  }
101
119
  }
@@ -117,6 +135,44 @@ function validateWorkflow(definition) {
117
135
  errors.push(`Duplicate step id: "${id}"`);
118
136
  }
119
137
 
138
+ // Validate conditional branch references
139
+ for (const step of definition.steps) {
140
+ if (step.tool === 'conditional' && step.inputs) {
141
+ const branches = ['then', 'else'];
142
+ for (const branch of branches) {
143
+ const refs = step.inputs[branch];
144
+ if (refs && Array.isArray(refs)) {
145
+ for (const ref of refs) {
146
+ if (!stepIds.has(ref)) {
147
+ errors.push(`Step "${step.id}": conditional ${branch} references unknown step "${ref}"`);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ if (!step.inputs.condition) {
153
+ errors.push(`Step "${step.id}": conditional must have a "condition" input`);
154
+ }
155
+ if (!step.inputs.then || !Array.isArray(step.inputs.then)) {
156
+ errors.push(`Step "${step.id}": conditional must have a "then" array`);
157
+ }
158
+ }
159
+
160
+ // Validate loop inline step
161
+ if (step.tool === 'loop' && step.inputs) {
162
+ if (!step.inputs.items) {
163
+ errors.push(`Step "${step.id}": loop must have an "items" input`);
164
+ }
165
+ if (!step.inputs.as || typeof step.inputs.as !== 'string') {
166
+ errors.push(`Step "${step.id}": loop must have a string "as" input`);
167
+ }
168
+ if (!step.inputs.step || typeof step.inputs.step !== 'object') {
169
+ errors.push(`Step "${step.id}": loop must have a "step" object`);
170
+ } else if (step.inputs.step.tool && !ALL_TOOLS.has(step.inputs.step.tool)) {
171
+ errors.push(`Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
172
+ }
173
+ }
174
+ }
175
+
120
176
  // Check for circular dependencies
121
177
  const cycleErrors = detectCycles(definition.steps);
122
178
  errors.push(...cycleErrors);
@@ -195,6 +251,18 @@ function detectCycles(steps) {
195
251
  function buildDependencyGraph(steps) {
196
252
  const graph = new Map();
197
253
 
254
+ // First pass: build index of conditional branches
255
+ // Steps referenced in then/else of a conditional depend on that conditional
256
+ const conditionalDeps = new Map(); // stepId -> conditionalStepId
257
+ for (const step of steps) {
258
+ if (step.tool === 'conditional' && step.inputs) {
259
+ const branches = [...(step.inputs.then || []), ...(step.inputs.else || [])];
260
+ for (const ref of branches) {
261
+ conditionalDeps.set(ref, step.id);
262
+ }
263
+ }
264
+ }
265
+
198
266
  for (const step of steps) {
199
267
  const deps = extractDependencies(step.inputs || {});
200
268
  if (step.condition) {
@@ -205,6 +273,10 @@ function buildDependencyGraph(steps) {
205
273
  const forDeps = extractDependencies(step.forEach);
206
274
  for (const d of forDeps) deps.add(d);
207
275
  }
276
+ // If this step is referenced by a conditional, it depends on that conditional
277
+ if (conditionalDeps.has(step.id)) {
278
+ deps.add(conditionalDeps.get(step.id));
279
+ }
208
280
  graph.set(step.id, deps);
209
281
  }
210
282
 
@@ -552,6 +624,301 @@ function executeTransform(inputs) {
552
624
  return { results, resultCount: results.length };
553
625
  }
554
626
 
627
+ /**
628
+ * Execute a conditional step: evaluate condition and determine branch.
629
+ *
630
+ * NOTE: The actual branch enforcement (skipping steps) is handled by
631
+ * the main execution loop, not here. This just evaluates and returns
632
+ * which branch was taken.
633
+ *
634
+ * @param {object} inputs - { condition: string, then: string[], else?: string[] }
635
+ * @param {object} context - workflow context
636
+ * @returns {{ conditionResult: boolean, branchTaken: string, enabledSteps: string[], skippedSteps: string[] }}
637
+ */
638
+ function executeConditional(inputs, context) {
639
+ const { condition } = inputs;
640
+ const thenSteps = inputs.then || [];
641
+ const elseSteps = inputs.else || [];
642
+
643
+ if (!condition && condition !== false && condition !== 0) {
644
+ throw new Error('conditional: "condition" input is required');
645
+ }
646
+
647
+ // Condition may already be resolved by template engine to a boolean
648
+ let result;
649
+ if (typeof condition === 'boolean') {
650
+ result = condition;
651
+ } else if (typeof condition === 'string') {
652
+ result = evaluateCondition(condition, context);
653
+ } else {
654
+ result = Boolean(condition);
655
+ }
656
+
657
+ const taken = result ? 'then' : 'else';
658
+ const enabled = result ? thenSteps : elseSteps;
659
+ const skipped = result ? elseSteps : thenSteps;
660
+
661
+ return {
662
+ conditionResult: result,
663
+ branchTaken: taken,
664
+ enabledSteps: enabled,
665
+ skippedSteps: skipped,
666
+ };
667
+ }
668
+
669
+ /**
670
+ * Execute a template step: compose text from template.
671
+ *
672
+ * @param {object} inputs - { text: string }
673
+ * @returns {{ text: string, charCount: number, referencedSteps: string[] }}
674
+ */
675
+ function executeTemplate(inputs) {
676
+ const { text } = inputs;
677
+
678
+ if (text === undefined || text === null) {
679
+ throw new Error('template: "text" input is required');
680
+ }
681
+
682
+ const textStr = String(text);
683
+ // Extract referenced step IDs from the original template (before resolution)
684
+ // Since inputs are already resolved by this point, we just return the composed text
685
+ return {
686
+ text: textStr,
687
+ charCount: textStr.length,
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Execute a loop step: iterate over an array, executing a sub-step per item.
693
+ *
694
+ * @param {object} inputs - { items, as, step, maxIterations? }
695
+ * @param {object} defaults - workflow defaults
696
+ * @param {object} context - workflow context
697
+ * @returns {Promise<{ iterations: number, results: any[], errors: any[] }>}
698
+ */
699
+ async function executeLoop(inputs, defaults, context) {
700
+ const { items, as, step: subStepDef, maxIterations = 100 } = inputs;
701
+
702
+ if (!Array.isArray(items)) {
703
+ throw new Error('loop: "items" must resolve to an array');
704
+ }
705
+ if (!as || typeof as !== 'string') {
706
+ throw new Error('loop: "as" must be a string variable name');
707
+ }
708
+ if (!subStepDef || typeof subStepDef !== 'object') {
709
+ throw new Error('loop: "step" must be a step definition object');
710
+ }
711
+
712
+ const results = [];
713
+ const errors = [];
714
+
715
+ const limit = Math.min(items.length, maxIterations);
716
+
717
+ for (let i = 0; i < limit; i++) {
718
+ const item = items[i];
719
+ // Build scoped context with loop variable
720
+ const scopedContext = { ...context, [as]: item, _loopIndex: i };
721
+
722
+ try {
723
+ // Resolve the sub-step inputs in the scoped context
724
+ const resolvedInputs = resolveTemplate(subStepDef.inputs || {}, scopedContext);
725
+ // Create a temporary step object for the dispatcher
726
+ const tempStep = { id: `_loop_${i}`, tool: subStepDef.tool, inputs: subStepDef.inputs };
727
+ const output = await executeStep(tempStep, resolvedInputs, defaults, scopedContext);
728
+ results.push(output);
729
+ } catch (err) {
730
+ errors.push({ index: i, error: err.message });
731
+ // If the parent loop has continueOnError, we keep going (handled by caller)
732
+ // For now, loop always continues and collects errors
733
+ }
734
+ }
735
+
736
+ if (items.length > maxIterations) {
737
+ errors.push({ index: maxIterations, error: `Loop truncated at maxIterations (${maxIterations})` });
738
+ }
739
+
740
+ return {
741
+ iterations: results.length,
742
+ results,
743
+ errors,
744
+ };
745
+ }
746
+
747
+ /**
748
+ * Execute a chunk step: split text using vai's chunking strategies.
749
+ *
750
+ * @param {object} inputs - { text, strategy?, size?, overlap?, source? }
751
+ * @returns {{ chunks: object[], totalChunks: number, strategy: string, avgChunkSize: number }}
752
+ */
753
+ function executeChunk(inputs) {
754
+ const { chunk: doChunk } = require('./chunker');
755
+
756
+ const { text, strategy = 'recursive', size = 512, overlap = 50, source } = inputs;
757
+
758
+ if (!text && text !== '') {
759
+ throw new Error('chunk: "text" input is required');
760
+ }
761
+
762
+ const chunkTexts = doChunk(text, { strategy, size, overlap });
763
+
764
+ const chunks = chunkTexts.map((content, index) => {
765
+ const obj = {
766
+ index,
767
+ content,
768
+ charCount: content.length,
769
+ };
770
+ if (source) obj.source = source;
771
+ obj.metadata = { strategy };
772
+ // For markdown strategy, try to extract heading
773
+ if (strategy === 'markdown') {
774
+ const headingMatch = content.match(/^#+\s+(.+)/m);
775
+ if (headingMatch) obj.metadata.heading = headingMatch[1];
776
+ }
777
+ return obj;
778
+ });
779
+
780
+ const totalChars = chunks.reduce((sum, c) => sum + c.charCount, 0);
781
+
782
+ return {
783
+ chunks,
784
+ totalChunks: chunks.length,
785
+ strategy,
786
+ avgChunkSize: chunks.length > 0 ? Math.round(totalChars / chunks.length) : 0,
787
+ };
788
+ }
789
+
790
+ /**
791
+ * Execute an HTTP request step.
792
+ *
793
+ * @param {object} inputs - { url, method?, headers?, body?, timeout?, responseType?, followRedirects? }
794
+ * @returns {Promise<{ status: number, statusText: string, headers: object, body: any, durationMs: number }>}
795
+ */
796
+ async function executeHttp(inputs) {
797
+ const { url, method = 'GET', headers = {}, body, timeout = 30000, responseType = 'json', followRedirects = false } = inputs;
798
+
799
+ if (!url || typeof url !== 'string') {
800
+ throw new Error('http: "url" input is required');
801
+ }
802
+
803
+ // URL allowlisting check
804
+ try {
805
+ const { loadProject } = require('./project');
806
+ const { config: proj } = loadProject();
807
+ if (proj && proj.allowedHosts && Array.isArray(proj.allowedHosts)) {
808
+ const parsed = new URL(url);
809
+ if (!proj.allowedHosts.includes(parsed.hostname)) {
810
+ throw new Error(`http: host "${parsed.hostname}" is not in allowedHosts. Allowed: ${proj.allowedHosts.join(', ')}`);
811
+ }
812
+ }
813
+ } catch (e) {
814
+ if (e.message.includes('allowedHosts')) throw e;
815
+ // If project config can't be loaded, allow all hosts
816
+ }
817
+
818
+ const startTime = Date.now();
819
+
820
+ // Build fetch options
821
+ const fetchOpts = {
822
+ method: method.toUpperCase(),
823
+ headers: { ...headers },
824
+ signal: AbortSignal.timeout(timeout),
825
+ redirect: followRedirects ? 'follow' : 'manual',
826
+ };
827
+
828
+ if (body && ['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
829
+ if (typeof body === 'object') {
830
+ fetchOpts.body = JSON.stringify(body);
831
+ if (!fetchOpts.headers['Content-Type'] && !fetchOpts.headers['content-type']) {
832
+ fetchOpts.headers['Content-Type'] = 'application/json';
833
+ }
834
+ } else {
835
+ fetchOpts.body = String(body);
836
+ }
837
+ }
838
+
839
+ const response = await fetch(url, fetchOpts);
840
+ const durationMs = Date.now() - startTime;
841
+
842
+ // Response size limit: 5MB
843
+ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024;
844
+ const responseText = await response.text();
845
+ const truncated = responseText.length > MAX_RESPONSE_SIZE;
846
+ const rawBody = truncated ? responseText.slice(0, MAX_RESPONSE_SIZE) : responseText;
847
+
848
+ // Parse body
849
+ let parsedBody;
850
+ if (responseType === 'json') {
851
+ try {
852
+ parsedBody = JSON.parse(rawBody);
853
+ } catch {
854
+ parsedBody = rawBody; // Fall back to text
855
+ }
856
+ } else {
857
+ parsedBody = rawBody;
858
+ }
859
+
860
+ // Collect response headers
861
+ const respHeaders = {};
862
+ response.headers.forEach((value, key) => {
863
+ respHeaders[key] = value;
864
+ });
865
+
866
+ return {
867
+ status: response.status,
868
+ statusText: response.statusText,
869
+ headers: respHeaders,
870
+ body: parsedBody,
871
+ durationMs,
872
+ ...(truncated && { warning: 'Response truncated at 5MB' }),
873
+ };
874
+ }
875
+
876
+ /**
877
+ * Execute a MongoDB aggregation pipeline step.
878
+ *
879
+ * @param {object} inputs - { db?, collection?, pipeline, allowWrites? }
880
+ * @param {object} defaults - workflow defaults
881
+ * @returns {Promise<{ results: any[], count: number, durationMs: number }>}
882
+ */
883
+ async function executeAggregate(inputs, defaults) {
884
+ const { getMongoCollection } = require('./mongo');
885
+ const { loadProject } = require('./project');
886
+ const { config: proj } = loadProject();
887
+
888
+ const db = inputs.db || defaults.db || proj.db;
889
+ const collection = inputs.collection || defaults.collection || proj.collection;
890
+ const pipeline = inputs.pipeline;
891
+ const allowWrites = inputs.allowWrites || false;
892
+
893
+ if (!db) throw new Error('aggregate: database not specified');
894
+ if (!collection) throw new Error('aggregate: collection not specified');
895
+ if (!Array.isArray(pipeline)) throw new Error('aggregate: "pipeline" must be an array');
896
+ if (pipeline.length > 20) throw new Error('aggregate: pipeline limited to 20 stages');
897
+
898
+ // Block write stages unless explicitly allowed
899
+ if (!allowWrites) {
900
+ for (const stage of pipeline) {
901
+ const stageKey = Object.keys(stage)[0];
902
+ if (stageKey === '$out' || stageKey === '$merge') {
903
+ throw new Error(`aggregate: "${stageKey}" stage is not allowed without allowWrites: true`);
904
+ }
905
+ }
906
+ }
907
+
908
+ const startTime = Date.now();
909
+ const { client, collection: col } = await getMongoCollection(db, collection);
910
+ try {
911
+ const results = await col.aggregate(pipeline).toArray();
912
+ return {
913
+ results,
914
+ count: results.length,
915
+ durationMs: Date.now() - startTime,
916
+ };
917
+ } finally {
918
+ await client.close();
919
+ }
920
+ }
921
+
555
922
  // ════════════════════════════════════════════════════════════════════
556
923
  // VAI Tool Executors
557
924
  // ════════════════════════════════════════════════════════════════════
@@ -936,6 +1303,18 @@ async function executeStep(step, resolvedInputs, defaults, context) {
936
1303
  return executeTransform(resolvedInputs);
937
1304
  case 'generate':
938
1305
  return executeGenerate(resolvedInputs);
1306
+ case 'conditional':
1307
+ return executeConditional(resolvedInputs, context);
1308
+ case 'template':
1309
+ return executeTemplate(resolvedInputs);
1310
+ case 'loop':
1311
+ return executeLoop(resolvedInputs, defaults, context);
1312
+ case 'chunk':
1313
+ return executeChunk(resolvedInputs);
1314
+ case 'http':
1315
+ return executeHttp(resolvedInputs);
1316
+ case 'aggregate':
1317
+ return executeAggregate(resolvedInputs, defaults);
939
1318
 
940
1319
  // VAI tools
941
1320
  case 'query':
@@ -1054,12 +1433,27 @@ async function executeWorkflow(definition, opts = {}) {
1054
1433
 
1055
1434
  // Execute layer by layer
1056
1435
  const stepResults = [];
1436
+ const skippedByConditional = new Set(); // Steps skipped by conditional branches
1057
1437
 
1058
1438
  for (const layer of layers) {
1059
1439
  const layerPromises = layer.map(async (stepId) => {
1060
1440
  const step = stepMap.get(stepId);
1061
1441
  const stepStart = Date.now();
1062
1442
 
1443
+ // Check if this step was skipped by a conditional branch
1444
+ if (skippedByConditional.has(stepId)) {
1445
+ if (opts.onStepSkip) opts.onStepSkip(stepId, 'conditional branch not taken');
1446
+ context[stepId] = { output: null, skipped: true };
1447
+ stepResults.push({
1448
+ id: stepId,
1449
+ tool: step.tool,
1450
+ skipped: true,
1451
+ reason: 'conditional branch not taken',
1452
+ durationMs: Date.now() - stepStart,
1453
+ });
1454
+ return;
1455
+ }
1456
+
1063
1457
  // Evaluate condition
1064
1458
  if (step.condition) {
1065
1459
  const conditionMet = evaluateCondition(step.condition, context);
@@ -1081,7 +1475,26 @@ async function executeWorkflow(definition, opts = {}) {
1081
1475
  try {
1082
1476
  let output;
1083
1477
 
1084
- if (step.forEach) {
1478
+ if (step.tool === 'conditional') {
1479
+ // Special handling: resolve then/else but pass raw condition to evaluator
1480
+ const rawCondition = step.inputs.condition;
1481
+ const resolvedInputs = {
1482
+ condition: rawCondition, // Keep raw for condition evaluator
1483
+ then: step.inputs.then || [],
1484
+ else: step.inputs.else || [],
1485
+ };
1486
+ output = await executeStep(step, resolvedInputs, defaults, context);
1487
+ } else if (step.tool === 'loop') {
1488
+ // Special handling: resolve items but pass raw step def for per-iteration resolution
1489
+ const resolvedItems = resolveTemplate(step.inputs.items, context);
1490
+ const resolvedInputs = {
1491
+ items: resolvedItems,
1492
+ as: step.inputs.as,
1493
+ step: step.inputs.step, // Raw — resolved per iteration inside executeLoop
1494
+ maxIterations: step.inputs.maxIterations,
1495
+ };
1496
+ output = await executeStep(step, resolvedInputs, defaults, context);
1497
+ } else if (step.forEach) {
1085
1498
  // Iterate over an array
1086
1499
  const iterArray = resolveTemplate(step.forEach, context);
1087
1500
  if (!Array.isArray(iterArray)) {
@@ -1102,6 +1515,13 @@ async function executeWorkflow(definition, opts = {}) {
1102
1515
  output = await executeStep(step, resolvedInputs, defaults, context);
1103
1516
  }
1104
1517
 
1518
+ // If this was a conditional node, mark skipped branch steps
1519
+ if (step.tool === 'conditional' && output.skippedSteps) {
1520
+ for (const skippedId of output.skippedSteps) {
1521
+ skippedByConditional.add(skippedId);
1522
+ }
1523
+ }
1524
+
1105
1525
  const durationMs = Date.now() - stepStart;
1106
1526
  context[stepId] = { output };
1107
1527
 
@@ -1162,6 +1582,36 @@ function coerceInput(value, type) {
1162
1582
  return value;
1163
1583
  }
1164
1584
 
1585
+ // ════════════════════════════════════════════════════════════════════
1586
+ // Input Schema Helpers
1587
+ // ════════════════════════════════════════════════════════════════════
1588
+
1589
+ /**
1590
+ * Convert a workflow's `inputs` object into wizard-engine-compatible step definitions.
1591
+ * Used by both CLI (via @clack/prompts) and playground (via input modal) to
1592
+ * prompt users for missing inputs before execution.
1593
+ *
1594
+ * @param {object} definition - Workflow definition with an `inputs` property
1595
+ * @returns {import('./wizard').Step[]}
1596
+ */
1597
+ function buildInputSteps(definition) {
1598
+ if (!definition.inputs) return [];
1599
+ return Object.entries(definition.inputs).map(([key, spec]) => ({
1600
+ id: key,
1601
+ label: spec.description || key,
1602
+ type: 'text',
1603
+ required: !!spec.required,
1604
+ placeholder: spec.type === 'number' ? 'number' : (spec.type || 'string'),
1605
+ defaultValue: spec.default !== undefined ? String(spec.default) : undefined,
1606
+ validate: (val) => {
1607
+ if (spec.type === 'number' && val && isNaN(Number(val))) {
1608
+ return 'Must be a number';
1609
+ }
1610
+ return true;
1611
+ },
1612
+ }));
1613
+ }
1614
+
1165
1615
  // ════════════════════════════════════════════════════════════════════
1166
1616
  // Built-in Templates
1167
1617
  // ════════════════════════════════════════════════════════════════════
@@ -1196,8 +1646,60 @@ function listBuiltinWorkflows() {
1196
1646
  });
1197
1647
  }
1198
1648
 
1649
+ // ════════════════════════════════════════════════════════════════════
1650
+ // Example Workflows
1651
+ // ════════════════════════════════════════════════════════════════════
1652
+
1653
+ const EXAMPLE_CATEGORIES = {
1654
+ 'search-filter-transform': 'Retrieval',
1655
+ 'conditional-fallback-search': 'Retrieval',
1656
+ 'multi-query-fusion': 'Retrieval',
1657
+ 'rag-with-guardrails': 'RAG',
1658
+ 'question-answer-with-citations': 'RAG',
1659
+ 'topic-deep-dive': 'RAG',
1660
+ 'dedup-and-ingest': 'Ingestion',
1661
+ 'content-quality-gate': 'Ingestion',
1662
+ 'embedding-model-comparison': 'Analysis',
1663
+ 'batch-similarity-check': 'Analysis',
1664
+ 'collection-inventory': 'Analysis',
1665
+ };
1666
+
1667
+ /**
1668
+ * Get the path to the example workflows directory.
1669
+ */
1670
+ function getExamplesDir() {
1671
+ return path.join(__dirname, '..', '..', 'examples', 'workflows');
1672
+ }
1673
+
1674
+ /**
1675
+ * List example workflow files with category metadata.
1676
+ * @returns {Array<{ name: string, description: string, file: string, category: string, isExample: boolean }>}
1677
+ */
1678
+ function listExampleWorkflows() {
1679
+ const dir = getExamplesDir();
1680
+ if (!fs.existsSync(dir)) return [];
1681
+
1682
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
1683
+ return files.map(f => {
1684
+ try {
1685
+ const def = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
1686
+ const stem = f.replace('.json', '');
1687
+ return {
1688
+ name: stem,
1689
+ description: def.description || def.name || f,
1690
+ file: f,
1691
+ category: EXAMPLE_CATEGORIES[stem] || 'Other',
1692
+ isExample: true,
1693
+ };
1694
+ } catch {
1695
+ return null;
1696
+ }
1697
+ }).filter(Boolean);
1698
+ }
1699
+
1199
1700
  /**
1200
- * Load a workflow definition from a file path or built-in template name.
1701
+ * Load a workflow definition from a file path, built-in template name,
1702
+ * or example workflow name.
1201
1703
  *
1202
1704
  * @param {string} nameOrPath - File path or template name (e.g., "multi-collection-search")
1203
1705
  * @returns {object} Parsed workflow definition
@@ -1216,6 +1718,13 @@ function loadWorkflow(nameOrPath) {
1216
1718
  return JSON.parse(content);
1217
1719
  }
1218
1720
 
1721
+ // Try as an example workflow name
1722
+ const examplePath = path.join(getExamplesDir(), `${nameOrPath}.json`);
1723
+ if (fs.existsSync(examplePath)) {
1724
+ const content = fs.readFileSync(examplePath, 'utf8');
1725
+ return JSON.parse(content);
1726
+ }
1727
+
1219
1728
  // Try with .json extension appended
1220
1729
  const withJson = nameOrPath.endsWith('.json') ? nameOrPath : `${nameOrPath}.json`;
1221
1730
  if (fs.existsSync(withJson)) {
@@ -1242,6 +1751,12 @@ module.exports = {
1242
1751
  executeMerge,
1243
1752
  executeFilter,
1244
1753
  executeTransform,
1754
+ executeConditional,
1755
+ executeTemplate,
1756
+ executeLoop,
1757
+ executeChunk,
1758
+ executeHttp,
1759
+ executeAggregate,
1245
1760
 
1246
1761
  // Main execution
1247
1762
  executeStep,
@@ -1249,11 +1764,18 @@ module.exports = {
1249
1764
 
1250
1765
  // Templates
1251
1766
  listBuiltinWorkflows,
1767
+ listExampleWorkflows,
1252
1768
  loadWorkflow,
1253
1769
  getWorkflowsDir,
1770
+ getExamplesDir,
1771
+
1772
+ // Input helpers
1773
+ buildInputSteps,
1254
1774
 
1255
1775
  // Constants
1256
1776
  VAI_TOOLS,
1257
1777
  CONTROL_FLOW_TOOLS,
1778
+ PROCESSING_TOOLS,
1779
+ INTEGRATION_TOOLS,
1258
1780
  ALL_TOOLS,
1259
1781
  };
package/src/mcp/server.js CHANGED
@@ -51,7 +51,7 @@ async function runStdioServer() {
51
51
  * @param {number} options.port
52
52
  * @param {string} options.host
53
53
  */
54
- async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
54
+ async function runHttpServer({ port = 3100, host = '127.0.0.1', sse = false } = {}) {
55
55
  const express = require('express');
56
56
  const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
57
57
  const { getConfigValue } = require('../lib/config');
@@ -131,8 +131,21 @@ async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
131
131
  res.status(405).json({ error: 'Method not allowed. Stateless server — no sessions to delete.' });
132
132
  });
133
133
 
134
+ // SSE transport (opt-in via --sse flag)
135
+ if (sse) {
136
+ const { setupSSE, getSessionCount } = require('./sse-transport');
137
+ setupSSE(app, authenticateRequest);
138
+
139
+ // Add SSE session count endpoint
140
+ app.get('/health/sse', (_req, res) => {
141
+ res.json({ sseSessions: getSessionCount() });
142
+ });
143
+ }
144
+
134
145
  app.listen(port, host, () => {
135
- const msg = `vai MCP server v${VERSION} running on http://${host}:${port}/mcp`;
146
+ const transports = ['Streamable HTTP (POST /mcp)'];
147
+ if (sse) transports.push('SSE (GET /sse)');
148
+ const msg = `vai MCP server v${VERSION} running on http://${host}:${port}\n Transports: ${transports.join(', ')}`;
136
149
  if (process.env.VAI_MCP_VERBOSE) {
137
150
  process.stderr.write(msg + '\n');
138
151
  process.stderr.write(`Authentication: ${requireAuth ? 'enabled' : 'disabled (no keys configured)'}\n`);