voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
754 lines (682 loc) • 23.9 kB
JavaScript
'use strict';
const pc = require('picocolors');
const { ALL_TOOLS } = require('./workflow');
const { CATEGORIES } = require('./workflow-scaffold');
// Reserved words that cannot be used as step IDs
const RESERVED_STEP_IDS = new Set([
'index', 'inputs', 'defaults', 'item', 'true', 'false', 'null', 'undefined',
'output', 'input', 'step', 'steps',
]);
/**
* Validate a step ID.
* @param {string} id
* @param {Set<string>} existingIds
* @returns {string|undefined} error message or undefined if valid
*/
function validateStepId(id, existingIds) {
if (!id) return 'Step ID is required';
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(id)) return 'Step ID must start with a letter or underscore and contain only alphanumeric characters and underscores';
if (RESERVED_STEP_IDS.has(id)) return `"${id}" is a reserved keyword and cannot be used as a step ID`;
if (existingIds.has(id)) return `Step ID "${id}" is already used`;
return undefined;
}
/**
* Build tool options for selection, sorted alphabetically.
* @returns {Array<{value: string, label: string, hint?: string}>}
*/
function buildToolOptions() {
const toolHints = {
query: 'Vector search with Voyage AI embeddings',
search: 'Full-text or hybrid search',
rerank: 'Rerank documents by relevance',
embed: 'Generate embeddings for text',
similarity: 'Compute similarity between texts',
ingest: 'Ingest documents into a collection',
collections: 'List or manage collections',
models: 'List available models',
explain: 'Explain an embedding or result',
estimate: 'Estimate embedding cost',
code_index: 'Index a codebase',
code_search: 'Search indexed code',
code_query: 'Query code with natural language',
code_find_similar: 'Find similar code snippets',
code_status: 'Check code index status',
merge: 'Merge multiple arrays',
filter: 'Filter results by condition',
transform: 'Transform data with expressions',
generate: 'Generate text with an LLM',
conditional: 'Branch based on a condition',
loop: 'Loop over items',
template: 'Render a template string',
chunk: 'Split text into chunks',
aggregate: 'Aggregate values',
http: 'Make an HTTP request',
};
return [...ALL_TOOLS].sort().map(tool => ({
value: tool,
label: tool,
hint: toolHints[tool] || '',
}));
}
/**
* Suggest input template references based on existing steps and workflow inputs.
* @param {object} definition - Partial workflow definition
* @param {number} stepIndex - Current step index
* @returns {string[]}
*/
function suggestReferences(definition, stepIndex) {
const refs = [];
// Workflow-level inputs
for (const key of Object.keys(definition.inputs || {})) {
refs.push(`{{ inputs.${key} }}`);
}
// Previous steps
for (let i = 0; i < stepIndex; i++) {
const step = definition.steps[i];
refs.push(`{{ ${step.id}.output }}`);
if (step.tool === 'query' || step.tool === 'search' || step.tool === 'rerank') {
refs.push(`{{ ${step.id}.output.results }}`);
}
if (step.tool === 'generate') {
refs.push(`{{ ${step.id}.output.response }}`);
}
if (step.tool === 'estimate') {
refs.push(`{{ ${step.id}.output }}`);
}
if (step.tool === 'embed') {
refs.push(`{{ ${step.id}.output.embedding }}`);
}
if (step.tool === 'merge') {
refs.push(`{{ ${step.id}.output.results }}`);
}
}
return refs;
}
/**
* Run the full interactive workflow builder using @clack/prompts.
* @returns {Promise<{definition: object, name: string, description: string, category: string, author: string}>}
*/
async function runInteractiveBuilder() {
const p = require('@clack/prompts');
p.intro(pc.bold(pc.cyan('Interactive Workflow Builder')));
// Step 1: Basic info
const basicInfo = await p.group({
name: () => p.text({
message: 'Workflow name',
placeholder: 'my-workflow',
validate: v => {
if (!v) return 'Required';
if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Use lowercase letters, numbers, and hyphens only (start with a letter)';
return undefined;
},
}),
description: () => p.text({
message: 'Description',
placeholder: 'What does this workflow do?',
validate: v => v ? undefined : 'Required',
}),
category: () => p.select({
message: 'Category',
options: CATEGORIES.map(c => ({ value: c, label: c })),
}),
author: () => p.text({
message: 'Author',
placeholder: 'Your Name',
defaultValue: getGitAuthor(),
}),
});
if (p.isCancel(basicInfo)) {
p.cancel('Cancelled.');
process.exit(0);
}
const definition = {
name: basicInfo.name,
description: basicInfo.description,
version: '1.0.0',
inputs: {},
defaults: {},
steps: [],
output: {},
};
// Step 2: Workflow inputs
p.log.info(pc.bold('Define workflow-level inputs'));
p.log.message(pc.dim('These are the parameters users provide when running the workflow.'));
let addingInputs = true;
while (addingInputs) {
const addInput = await p.confirm({
message: Object.keys(definition.inputs).length === 0
? 'Add a workflow input?'
: 'Add another workflow input?',
initialValue: Object.keys(definition.inputs).length === 0,
});
if (p.isCancel(addInput)) {
p.cancel('Cancelled.');
process.exit(0);
}
if (!addInput) {
addingInputs = false;
break;
}
const inputInfo = await p.group({
name: () => p.text({
message: 'Input name',
placeholder: 'query',
validate: v => {
if (!v) return 'Required';
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v)) return 'Must be a valid identifier';
if (definition.inputs[v]) return `Input "${v}" already exists`;
return undefined;
},
}),
type: () => p.select({
message: 'Type',
options: [
{ value: 'string', label: 'string' },
{ value: 'number', label: 'number' },
{ value: 'boolean', label: 'boolean' },
{ value: 'array', label: 'array' },
],
}),
required: () => p.confirm({ message: 'Required?', initialValue: true }),
description: () => p.text({ message: 'Description', placeholder: 'What is this input for?' }),
});
if (p.isCancel(inputInfo)) {
p.cancel('Cancelled.');
process.exit(0);
}
const inputDef = {
type: inputInfo.type,
required: inputInfo.required,
};
if (inputInfo.description) inputDef.description = inputInfo.description;
if (!inputInfo.required) {
const hasDefault = await p.confirm({ message: 'Set a default value?', initialValue: false });
if (p.isCancel(hasDefault)) { p.cancel('Cancelled.'); process.exit(0); }
if (hasDefault) {
const defaultVal = await p.text({ message: 'Default value' });
if (p.isCancel(defaultVal)) { p.cancel('Cancelled.'); process.exit(0); }
if (inputInfo.type === 'number') {
inputDef.default = Number(defaultVal);
} else if (inputInfo.type === 'boolean') {
inputDef.default = defaultVal === 'true';
} else {
inputDef.default = defaultVal;
}
}
}
definition.inputs[inputInfo.name] = inputDef;
p.log.success(`Added input: ${pc.cyan(inputInfo.name)} (${inputInfo.type}${inputInfo.required ? ', required' : ''})`);
}
// Step 3: Workflow steps
p.log.info(pc.bold('Define workflow steps'));
const refs = suggestReferences(definition, 0);
if (refs.length > 0) {
p.log.message(pc.dim(`Available references: ${refs.join(', ')}`));
}
const existingStepIds = new Set();
let addingSteps = true;
while (addingSteps) {
const stepNum = definition.steps.length + 1;
p.log.step(`Step ${stepNum}`);
// Show available references
const currentRefs = suggestReferences(definition, definition.steps.length);
if (currentRefs.length > 0) {
p.log.message(pc.dim(`Available references:\n ${currentRefs.join('\n ')}`));
}
const stepId = await p.text({
message: 'Step ID',
placeholder: `step_${stepNum}`,
validate: v => validateStepId(v, existingStepIds),
});
if (p.isCancel(stepId)) { p.cancel('Cancelled.'); process.exit(0); }
const tool = await p.select({
message: 'Tool',
options: buildToolOptions(),
});
if (p.isCancel(tool)) { p.cancel('Cancelled.'); process.exit(0); }
const stepName = await p.text({
message: 'Step name (human readable)',
placeholder: `${tool} step`,
validate: v => v ? undefined : 'Required',
});
if (p.isCancel(stepName)) { p.cancel('Cancelled.'); process.exit(0); }
// Collect step inputs as key-value pairs
const stepInputs = {};
p.log.message(pc.dim('Add inputs for this step. Use template syntax like {{ inputs.query }} or {{ previousStep.output.results }}'));
let addingInputPairs = true;
while (addingInputPairs) {
const addPair = await p.confirm({
message: Object.keys(stepInputs).length === 0
? 'Add a step input?'
: 'Add another step input?',
initialValue: Object.keys(stepInputs).length === 0,
});
if (p.isCancel(addPair)) { p.cancel('Cancelled.'); process.exit(0); }
if (!addPair) {
addingInputPairs = false;
break;
}
// Suggest common input keys per tool
const suggestedKeys = getSuggestedInputKeys(tool);
const inputKey = await p.text({
message: 'Input key',
placeholder: suggestedKeys[0] || 'key',
validate: v => v ? undefined : 'Required',
});
if (p.isCancel(inputKey)) { p.cancel('Cancelled.'); process.exit(0); }
const inputValue = await p.text({
message: `Value for "${inputKey}"`,
placeholder: currentRefs.length > 0 ? currentRefs[0] : 'value or {{ template }}',
validate: v => (v !== undefined && v !== '') ? undefined : 'Required',
});
if (p.isCancel(inputValue)) { p.cancel('Cancelled.'); process.exit(0); }
// Try to parse numbers and booleans for non-template values
if (!/\{\{/.test(inputValue)) {
if (inputValue === 'true') stepInputs[inputKey] = true;
else if (inputValue === 'false') stepInputs[inputKey] = false;
else if (!isNaN(inputValue) && inputValue.trim() !== '') stepInputs[inputKey] = Number(inputValue);
else stepInputs[inputKey] = inputValue;
} else {
stepInputs[inputKey] = inputValue;
}
}
const step = {
id: stepId,
tool: tool,
name: stepName,
inputs: stepInputs,
};
definition.steps.push(step);
existingStepIds.add(stepId);
p.log.success(`Added step: ${pc.cyan(stepId)} (${tool})`);
const addMore = await p.confirm({
message: 'Add another step?',
initialValue: false,
});
if (p.isCancel(addMore)) { p.cancel('Cancelled.'); process.exit(0); }
if (!addMore) addingSteps = false;
}
if (definition.steps.length === 0) {
p.log.error('Workflow must have at least one step.');
process.exit(1);
}
// Step 4: Output mapping
p.log.info(pc.bold('Define workflow output'));
p.log.message(pc.dim('Map output keys to step results.'));
const allRefs = suggestReferences(definition, definition.steps.length);
if (allRefs.length > 0) {
p.log.message(pc.dim(`Available references:\n ${allRefs.join('\n ')}`));
}
let addingOutputs = true;
while (addingOutputs) {
const addOutput = await p.confirm({
message: Object.keys(definition.output).length === 0
? 'Add an output mapping?'
: 'Add another output mapping?',
initialValue: true,
});
if (p.isCancel(addOutput)) { p.cancel('Cancelled.'); process.exit(0); }
if (!addOutput) { addingOutputs = false; break; }
const outputKey = await p.text({
message: 'Output key',
placeholder: 'result',
validate: v => v ? undefined : 'Required',
});
if (p.isCancel(outputKey)) { p.cancel('Cancelled.'); process.exit(0); }
const outputValue = await p.text({
message: `Value for "${outputKey}"`,
placeholder: allRefs.length > 0 ? allRefs[allRefs.length - 1] : '{{ stepId.output }}',
validate: v => v ? undefined : 'Required',
});
if (p.isCancel(outputValue)) { p.cancel('Cancelled.'); process.exit(0); }
definition.output[outputKey] = outputValue;
}
// Step 5: Validate
const { validateWorkflow, buildExecutionPlan } = require('./workflow');
const errors = validateWorkflow(definition);
if (errors.length > 0) {
p.log.error(pc.bold('Validation errors:'));
for (const err of errors) {
p.log.error(` ${pc.red('x')} ${err}`);
}
const fixOrWrite = await p.confirm({
message: 'Write anyway (you can fix later)?',
initialValue: false,
});
if (p.isCancel(fixOrWrite) || !fixOrWrite) {
p.cancel('Workflow not saved. Fix the errors and try again.');
process.exit(1);
}
} else {
p.log.success('Workflow validates successfully!');
}
// Step 6: Show execution plan
try {
const layers = buildExecutionPlan(definition.steps);
p.log.info(pc.bold('Execution plan:'));
for (let i = 0; i < layers.length; i++) {
const layerSteps = layers[i].map(id => {
const step = definition.steps.find(s => s.id === id);
return `${pc.cyan(id)} (${step ? step.tool : '?'})`;
});
p.log.message(` Layer ${i + 1}: ${layerSteps.join(', ')}`);
}
if (layers.length > 1) {
p.log.message(pc.dim(` Steps in the same layer run in parallel.`));
}
} catch (err) {
p.log.warn(`Could not build execution plan: ${err.message}`);
}
return {
definition,
name: basicInfo.name,
description: basicInfo.description,
category: basicInfo.category,
author: basicInfo.author,
};
}
/**
* Generate a workflow skeleton from a text description.
* Uses pattern matching against known tool capabilities.
* @param {string} description
* @returns {object} workflow definition
*/
function workflowFromDescription(description) {
const desc = description.toLowerCase();
const definition = {
name: '',
description: description,
version: '1.0.0',
inputs: {},
defaults: {},
steps: [],
output: {},
};
// Extract a name from the description
const nameMatch = description.match(/^(\w[\w\s-]{2,30})/);
if (nameMatch) {
definition.name = nameMatch[1].trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
} else {
definition.name = 'generated-workflow';
}
// Pattern matching for common workflow types
const hasSearch = /search|find|look up|retrieve|query/i.test(desc);
const hasCompare = /compare|versus|vs|shootout|side.by.side/i.test(desc);
const hasGenerate = /generate|summarize|synthesize|answer|write|create text|explain/i.test(desc);
const hasRerank = /rerank|re-rank|rank|sort by relevance/i.test(desc);
const hasMerge = /merge|combine|aggregate results/i.test(desc);
const hasIngest = /ingest|import|load|index documents/i.test(desc);
const hasEmbed = /embed|embedding|vectorize/i.test(desc);
const hasSimilarity = /similar|similarity|compare embeddings/i.test(desc);
const hasDecompose = /decompos|break down|sub.quer/i.test(desc);
const hasHttp = /http|api|fetch|request|webhook/i.test(desc);
const hasLoop = /loop|iterate|each|batch/i.test(desc);
const hasFilter = /filter|exclude|only keep/i.test(desc);
const hasChunk = /chunk|split|segment/i.test(desc);
const hasCost = /cost|estimat|price/i.test(desc);
// Common inputs
if (hasSearch || hasRerank || hasGenerate || hasDecompose) {
definition.inputs.query = { type: 'string', required: true, description: 'The search query or question' };
}
if (hasSearch || hasIngest) {
definition.inputs.collection = { type: 'string', required: true, description: 'MongoDB collection name' };
}
if (hasSearch) {
definition.inputs.limit = { type: 'number', default: 10, description: 'Maximum results to return' };
}
if (hasHttp) {
definition.inputs.url = { type: 'string', required: true, description: 'URL to fetch' };
}
if (hasEmbed || hasChunk) {
definition.inputs.text = { type: 'string', required: true, description: 'Text to process' };
}
let stepIndex = 0;
// Build steps based on detected patterns
if (hasDecompose) {
definition.steps.push({
id: 'decompose',
tool: 'generate',
name: 'Decompose into sub-queries',
inputs: {
prompt: 'Break the following question into 3 focused sub-questions. Return ONLY a JSON array of strings.\n\nQuestion: {{ inputs.query }}',
format: 'json',
},
});
stepIndex++;
}
if (hasChunk) {
definition.steps.push({
id: 'chunk_text',
tool: 'chunk',
name: 'Split text into chunks',
inputs: { text: '{{ inputs.text }}', chunkSize: 512 },
});
stepIndex++;
}
if (hasEmbed && !hasSearch) {
definition.steps.push({
id: 'embed_text',
tool: 'embed',
name: 'Generate embeddings',
inputs: { text: '{{ inputs.text }}', model: 'voyage-4-large' },
});
stepIndex++;
}
if (hasIngest) {
definition.steps.push({
id: 'ingest_data',
tool: 'ingest',
name: 'Ingest documents',
inputs: { collection: '{{ inputs.collection }}' },
});
stepIndex++;
}
if (hasHttp) {
definition.steps.push({
id: 'fetch_data',
tool: 'http',
name: 'Fetch external data',
inputs: { url: '{{ inputs.url }}', method: 'GET' },
});
stepIndex++;
}
if (hasSearch && hasCompare) {
// Comparison pattern: search with multiple models
for (const model of ['voyage-4-large', 'voyage-4', 'voyage-4-lite']) {
const suffix = model.replace('voyage-4', '').replace('-', '') || 'base';
const id = `search_${suffix === '' ? 'base' : suffix}`;
definition.steps.push({
id,
tool: 'query',
name: `Search with ${model}`,
inputs: {
query: '{{ inputs.query }}',
collection: '{{ inputs.collection }}',
model,
limit: '{{ inputs.limit }}',
},
});
}
stepIndex += 3;
} else if (hasSearch) {
const searchId = hasDecompose ? 'search_results' : 'search';
const queryRef = hasDecompose ? '{{ decompose.output.response[0] }}' : '{{ inputs.query }}';
definition.steps.push({
id: searchId,
tool: 'query',
name: 'Search knowledge base',
inputs: {
query: queryRef,
collection: '{{ inputs.collection }}',
limit: '{{ inputs.limit }}',
},
});
stepIndex++;
}
if (hasSimilarity) {
definition.steps.push({
id: 'check_similarity',
tool: 'similarity',
name: 'Compute similarity',
inputs: { text1: '{{ inputs.query }}', text2: '{{ inputs.text }}' },
});
stepIndex++;
}
if (hasCost) {
definition.steps.push({
id: 'estimate_cost',
tool: 'estimate',
name: 'Estimate cost',
inputs: { text: '{{ inputs.query }}', model: 'voyage-4-large' },
});
stepIndex++;
}
if (hasMerge && definition.steps.length >= 2) {
const arrayRefs = definition.steps
.filter(s => s.tool === 'query' || s.tool === 'search')
.map(s => `{{ ${s.id}.output.results }}`);
if (arrayRefs.length >= 2) {
definition.steps.push({
id: 'merged',
tool: 'merge',
name: 'Merge results',
inputs: { arrays: arrayRefs, dedup: true },
});
stepIndex++;
}
}
if (hasFilter) {
const lastResultStep = [...definition.steps].reverse().find(s =>
s.tool === 'query' || s.tool === 'search' || s.tool === 'merge'
);
if (lastResultStep) {
definition.steps.push({
id: 'filtered',
tool: 'filter',
name: 'Filter results',
inputs: {
items: `{{ ${lastResultStep.id}.output.results }}`,
condition: 'item.score > 0.5',
},
});
stepIndex++;
}
}
if (hasRerank) {
const lastResultStep = [...definition.steps].reverse().find(s =>
s.tool === 'query' || s.tool === 'search' || s.tool === 'merge' || s.tool === 'filter'
);
if (lastResultStep) {
definition.steps.push({
id: 'reranked',
tool: 'rerank',
name: 'Rerank results',
inputs: {
query: '{{ inputs.query }}',
documents: `{{ ${lastResultStep.id}.output.results }}`,
model: 'rerank-2.5',
limit: '{{ inputs.limit }}',
},
});
stepIndex++;
}
}
if (hasGenerate) {
const contextStep = [...definition.steps].reverse().find(s =>
s.tool === 'rerank' || s.tool === 'query' || s.tool === 'search' || s.tool === 'merge' || s.tool === 'filter'
);
definition.steps.push({
id: 'generate_response',
tool: 'generate',
name: 'Generate response',
inputs: {
prompt: `Using the following context, provide a comprehensive answer.\n\nQuestion: {{ inputs.query }}`,
...(contextStep ? { context: `{{ ${contextStep.id}.output.results }}` } : {}),
},
});
stepIndex++;
}
// Fallback: if no steps were generated, add a basic search step
if (definition.steps.length === 0) {
if (!definition.inputs.query) {
definition.inputs.query = { type: 'string', required: true, description: 'Search query' };
}
if (!definition.inputs.collection) {
definition.inputs.collection = { type: 'string', required: true, description: 'Collection name' };
}
definition.steps.push({
id: 'search',
tool: 'query',
name: 'Search',
inputs: {
query: '{{ inputs.query }}',
collection: '{{ inputs.collection }}',
limit: 10,
},
});
}
// Build output mapping from last step(s)
const lastStep = definition.steps[definition.steps.length - 1];
if (lastStep.tool === 'generate') {
definition.output.response = `{{ ${lastStep.id}.output.response }}`;
} else {
definition.output.results = `{{ ${lastStep.id}.output.results }}`;
}
// Also expose query if it exists
if (definition.inputs.query) {
definition.output.query = '{{ inputs.query }}';
}
return definition;
}
/**
* Get suggested input keys for a tool.
* @param {string} tool
* @returns {string[]}
*/
function getSuggestedInputKeys(tool) {
const suggestions = {
query: ['query', 'collection', 'model', 'limit', 'filter'],
search: ['query', 'collection', 'limit'],
rerank: ['query', 'documents', 'model', 'limit'],
embed: ['text', 'model'],
similarity: ['text1', 'text2', 'model'],
ingest: ['collection', 'documents', 'model'],
estimate: ['text', 'model'],
generate: ['prompt', 'context', 'format', 'model'],
merge: ['arrays', 'dedup'],
filter: ['items', 'condition'],
transform: ['items', 'expression'],
conditional: ['condition', 'ifTrue', 'ifFalse'],
loop: ['items', 'step', 'as'],
template: ['template', 'data'],
chunk: ['text', 'chunkSize', 'overlap'],
aggregate: ['items', 'operation'],
http: ['url', 'method', 'headers', 'body'],
collections: [],
models: [],
explain: ['text', 'model'],
code_index: ['path'],
code_search: ['query'],
code_query: ['query'],
code_find_similar: ['code'],
code_status: [],
};
return suggestions[tool] || [];
}
function getGitAuthor() {
try {
const { execSync } = require('child_process');
return execSync('git config user.name', { encoding: 'utf8' }).trim();
} catch {
return '';
}
}
module.exports = {
runInteractiveBuilder,
workflowFromDescription,
validateStepId,
buildToolOptions,
suggestReferences,
getSuggestedInputKeys,
RESERVED_STEP_IDS,
};