voyageai-cli 1.30.2 → 1.30.5

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.
package/README.md CHANGED
@@ -512,6 +512,26 @@ echo "your-key" | vai config set api-key --stdin
512
512
  vai config set mongodb-uri "mongodb+srv://..."
513
513
  ```
514
514
 
515
+ #### All Config Keys
516
+
517
+ | CLI Key | Description | Example |
518
+ |---------|-------------|---------|
519
+ | `api-key` | Voyage AI API key | `vai config set api-key pa-...` |
520
+ | `mongodb-uri` | MongoDB Atlas connection string | `vai config set mongodb-uri "mongodb+srv://..."` |
521
+ | `base-url` | Override API endpoint (Atlas AI or Voyage) | `vai config set base-url https://ai.mongodb.com/v1` |
522
+ | `default-model` | Default embedding model | `vai config set default-model voyage-3` |
523
+ | `default-dimensions` | Default output dimensions | `vai config set default-dimensions 512` |
524
+ | `default-db` | Default MongoDB database for workflows/commands | `vai config set default-db my_knowledge_base` |
525
+ | `default-collection` | Default MongoDB collection for workflows/commands | `vai config set default-collection documents` |
526
+ | `llm-provider` | LLM provider for chat/generate (`anthropic`, `openai`, `ollama`) | `vai config set llm-provider anthropic` |
527
+ | `llm-api-key` | LLM provider API key | `vai config set llm-api-key sk-...` |
528
+ | `llm-model` | LLM model override | `vai config set llm-model claude-sonnet-4-5-20250929` |
529
+ | `llm-base-url` | LLM endpoint override (e.g. for Ollama) | `vai config set llm-base-url http://localhost:11434` |
530
+ | `show-cost` | Show cost estimates after operations | `vai config set show-cost true` |
531
+ | `telemetry` | Enable/disable anonymous usage telemetry | `vai config set telemetry false` |
532
+
533
+ Config is stored in `~/.vai/config.json`. Use `vai config get` to see all values (secrets are masked) or `vai config get <key>` for a specific value. The desktop app's Settings → Database page also reads and writes this file.
534
+
515
535
  ### Shell Completions
516
536
 
517
537
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.30.2",
3
+ "version": "1.30.5",
4
4
  "description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
5
5
  "bin": {
6
6
  "vai": "./src/cli.js"
@@ -34,7 +34,8 @@
34
34
  "url": "https://github.com/mrlynn/voyageai-cli/issues"
35
35
  },
36
36
  "scripts": {
37
- "test": "node --test test/**/*.test.js"
37
+ "test": "node --test test/**/*.test.js",
38
+ "release": "./scripts/release.sh"
38
39
  },
39
40
  "engines": {
40
41
  "node": ">=20.0.0"
@@ -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
@@ -541,6 +541,87 @@ function createPlaygroundServer() {
541
541
  return;
542
542
  }
543
543
 
544
+ // API: Settings — read/write ~/.vai/config.json
545
+ if (req.method === 'GET' && req.url === '/api/settings') {
546
+ const { loadConfig, KEY_MAP, SECRET_KEYS, maskSecret } = require('../lib/config');
547
+ const config = loadConfig();
548
+
549
+ // Build response: CLI key name → masked/raw value, for every known key
550
+ const reverseMap = {};
551
+ for (const [cliKey, internalKey] of Object.entries(KEY_MAP)) {
552
+ reverseMap[internalKey] = cliKey;
553
+ }
554
+
555
+ const settings = {};
556
+ for (const [internalKey, cliKey] of Object.entries(reverseMap)) {
557
+ const value = config[internalKey];
558
+ settings[cliKey] = {
559
+ value: value != null ? (SECRET_KEYS.has(internalKey) ? maskSecret(value) : value) : null,
560
+ isSet: value != null,
561
+ isSecret: SECRET_KEYS.has(internalKey),
562
+ internalKey,
563
+ };
564
+ }
565
+
566
+ res.writeHead(200, { 'Content-Type': 'application/json' });
567
+ res.end(JSON.stringify(settings));
568
+ return;
569
+ }
570
+
571
+ if (req.method === 'PUT' && req.url === '/api/settings') {
572
+ const { loadConfig, saveConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
573
+ const body = await readBody(req);
574
+ let updates;
575
+ try {
576
+ updates = JSON.parse(body);
577
+ } catch {
578
+ res.writeHead(400, { 'Content-Type': 'application/json' });
579
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
580
+ return;
581
+ }
582
+
583
+ const config = loadConfig();
584
+ const applied = [];
585
+
586
+ for (const [cliKey, value] of Object.entries(updates)) {
587
+ const internalKey = KEY_MAP[cliKey];
588
+ if (!internalKey) {
589
+ continue; // Skip unknown keys
590
+ }
591
+ // Don't overwrite secrets with masked values
592
+ if (SECRET_KEYS.has(internalKey) && typeof value === 'string' && value.includes('...')) {
593
+ continue;
594
+ }
595
+ if (value === null || value === '') {
596
+ delete config[internalKey];
597
+ } else {
598
+ config[internalKey] = value;
599
+ }
600
+ applied.push(cliKey);
601
+ }
602
+
603
+ saveConfig(config);
604
+ res.writeHead(200, { 'Content-Type': 'application/json' });
605
+ res.end(JSON.stringify({ applied, message: `Updated ${applied.length} setting(s)` }));
606
+ return;
607
+ }
608
+
609
+ // API: Settings reveal — return unmasked value for a specific secret key
610
+ if (req.method === 'GET' && req.url.startsWith('/api/settings/reveal/')) {
611
+ const { loadConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
612
+ const cliKey = req.url.replace('/api/settings/reveal/', '');
613
+ const internalKey = KEY_MAP[cliKey];
614
+ if (!internalKey || !SECRET_KEYS.has(internalKey)) {
615
+ res.writeHead(404, { 'Content-Type': 'application/json' });
616
+ res.end(JSON.stringify({ error: 'Not found or not a secret key' }));
617
+ return;
618
+ }
619
+ const config = loadConfig();
620
+ res.writeHead(200, { 'Content-Type': 'application/json' });
621
+ res.end(JSON.stringify({ value: config[internalKey] || null }));
622
+ return;
623
+ }
624
+
544
625
  // API: Settings origins — where each config value comes from
545
626
  if (req.method === 'GET' && req.url === '/api/settings/origins') {
546
627
  const { resolveLLMConfig } = require('../lib/llm');
@@ -561,8 +642,8 @@ function createPlaygroundServer() {
561
642
  provider: resolveOrigin('VAI_LLM_PROVIDER', 'llmProvider', chatConf.provider),
562
643
  model: resolveOrigin('VAI_LLM_MODEL', 'llmModel', chatConf.model),
563
644
  llmApiKey: resolveOrigin('VAI_LLM_API_KEY', 'llmApiKey'),
564
- db: proj.db ? 'project' : 'default',
565
- collection: proj.collection ? 'project' : 'default',
645
+ db: resolveOrigin(null, 'defaultDb', proj.db),
646
+ collection: resolveOrigin(null, 'defaultCollection', proj.collection),
566
647
  };
567
648
 
568
649
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1406,9 +1487,58 @@ function createPlaygroundServer() {
1406
1487
  res.end(JSON.stringify({ error: 'inputs must be a non-empty array' }));
1407
1488
  return;
1408
1489
  }
1490
+
1491
+ // Optimize video inputs: downsample to 1fps to fit within 32k token context
1492
+ const os = require('os');
1493
+ const path = require('path');
1494
+ const fs = require('fs');
1495
+ const { execFileSync } = require('child_process');
1496
+ const optimizedInputs = [];
1497
+ for (const input of inputs) {
1498
+ const content = input.content;
1499
+ if (content && Array.isArray(content)) {
1500
+ const optimizedContent = [];
1501
+ for (const item of content) {
1502
+ if (item.type === 'video_base64' && item.video_base64) {
1503
+ // Downsample video to 1fps using ffmpeg to reduce token count
1504
+ try {
1505
+ const b64 = item.video_base64.replace(/^data:[^;]+;base64,/, '');
1506
+ const tmpIn = path.join(os.tmpdir(), `vai_vid_in_${Date.now()}.mp4`);
1507
+ const tmpOut = path.join(os.tmpdir(), `vai_vid_out_${Date.now()}.mp4`);
1508
+ fs.writeFileSync(tmpIn, Buffer.from(b64, 'base64'));
1509
+ try {
1510
+ execFileSync('ffmpeg', [
1511
+ '-y', '-i', tmpIn,
1512
+ '-vf', 'fps=1',
1513
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
1514
+ '-an', // strip audio
1515
+ tmpOut
1516
+ ], { timeout: 30000, stdio: 'pipe' });
1517
+ const optimizedBuf = fs.readFileSync(tmpOut);
1518
+ const optimizedB64 = `data:video/mp4;base64,${optimizedBuf.toString('base64')}`;
1519
+ optimizedContent.push({ type: 'video_base64', video_base64: optimizedB64 });
1520
+ } finally {
1521
+ try { fs.unlinkSync(tmpIn); } catch (_) {}
1522
+ try { fs.unlinkSync(tmpOut); } catch (_) {}
1523
+ }
1524
+ } catch (err) {
1525
+ // If optimization fails, send original and let API error naturally
1526
+ console.warn('[Playground] Video optimization failed:', err.message);
1527
+ optimizedContent.push(item);
1528
+ }
1529
+ } else {
1530
+ optimizedContent.push(item);
1531
+ }
1532
+ }
1533
+ optimizedInputs.push({ ...input, content: optimizedContent });
1534
+ } else {
1535
+ optimizedInputs.push(input);
1536
+ }
1537
+ }
1538
+
1409
1539
  const { apiRequest } = require('../lib/api');
1410
1540
  const mmBody = {
1411
- inputs,
1541
+ inputs: optimizedInputs,
1412
1542
  model: model || 'voyage-multimodal-3.5',
1413
1543
  };
1414
1544
  if (input_type) mmBody.input_type = input_type;
@@ -1590,9 +1720,13 @@ function createPlaygroundServer() {
1590
1720
  else if (output.text) summary = output.text.slice(0, 100) + (output.text.length > 100 ? '...' : '');
1591
1721
  else summary = JSON.stringify(output).slice(0, 200);
1592
1722
  }
1723
+ // Extract usage data for cost tracking (then strip from output payload)
1724
+ const _usage = (output && output._usage) ? output._usage : undefined;
1725
+ const cleanOutput = _usage ? { ...output } : output;
1726
+ if (cleanOutput && cleanOutput._usage) delete cleanOutput._usage;
1593
1727
  res.write(`event: step_complete\ndata: ${JSON.stringify({
1594
- stepId, timeMs, summary,
1595
- output: JSON.stringify(output).length < 5000 ? output : { _truncated: true, summary },
1728
+ stepId, timeMs, summary, _usage,
1729
+ output: JSON.stringify(cleanOutput).length < 5000 ? cleanOutput : { _truncated: true, summary },
1596
1730
  })}\n\n`);
1597
1731
  },
1598
1732
  onStepSkip: (stepId, reason) => {
@@ -529,6 +529,36 @@ function registerWorkflow(program) {
529
529
  }
530
530
  });
531
531
 
532
+ // ── workflow integration-test ──
533
+ wfCmd
534
+ .command('integration-test')
535
+ .description('Run live integration tests against use-case domain datasets')
536
+ .option('--domain <slug>', 'Test only a specific domain (devdocs, healthcare, finance, legal)')
537
+ .option('--workflow <name>', 'Test only a specific workflow')
538
+ .option('--no-seed', 'Skip seeding (assumes data already exists)')
539
+ .option('--teardown', 'Drop test collections after running')
540
+ .option('--sample-docs <path>', 'Override base path for sample documents')
541
+ .option('--json', 'Output machine-readable JSON', false)
542
+ .action(async (opts) => {
543
+ // Delegate to the integration test runner
544
+ const { execFileSync } = require('child_process');
545
+ const runnerPath = path.join(__dirname, '../../test/integration/run.js');
546
+ const args = [];
547
+ if (opts.domain) args.push('--domain', opts.domain);
548
+ if (opts.workflow) args.push('--workflow', opts.workflow);
549
+ if (opts.seed === false) args.push('--no-seed');
550
+ if (opts.teardown) args.push('--teardown');
551
+ if (opts.sampleDocs) args.push('--sample-docs', opts.sampleDocs);
552
+ if (opts.json) args.push('--json');
553
+
554
+ try {
555
+ execFileSync(process.execPath, [runnerPath, ...args], { stdio: 'inherit' });
556
+ } catch (err) {
557
+ if (err.status) process.exit(err.status);
558
+ throw err;
559
+ }
560
+ });
561
+
532
562
  // ── workflow validate <file> ──
533
563
  wfCmd
534
564
  .command('validate <file>')
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
  };
package/src/lib/config.js CHANGED
@@ -18,7 +18,9 @@ const KEY_MAP = {
18
18
  'llm-api-key': 'llmApiKey',
19
19
  'llm-model': 'llmModel',
20
20
  'llm-base-url': 'llmBaseUrl',
21
- 'show-cost': 'show-cost',
21
+ 'default-db': 'defaultDb',
22
+ 'default-collection': 'defaultCollection',
23
+ 'show-cost': 'showCost',
22
24
  'telemetry': 'telemetry',
23
25
  };
24
26
 
@@ -89,7 +89,7 @@ function showCombinedCostSummary(operations, opts = {}) {
89
89
  * @returns {boolean}
90
90
  */
91
91
  function isEnabled() {
92
- const val = getConfigValue('show-cost');
92
+ const val = getConfigValue('showCost') || getConfigValue('show-cost');
93
93
  return val === true || val === 'true';
94
94
  }
95
95
 
package/src/lib/input.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const path = require('path');
4
5
 
5
6
  /**
6
7
  * Read text input from argument, --file flag, or stdin.
@@ -37,4 +38,94 @@ async function resolveTextInput(textArg, filePath) {
37
38
  process.exit(1);
38
39
  }
39
40
 
40
- module.exports = { resolveTextInput };
41
+ /**
42
+ * MIME type mappings for supported image formats.
43
+ */
44
+ const IMAGE_MIME_TYPES = {
45
+ '.jpg': 'image/jpeg',
46
+ '.jpeg': 'image/jpeg',
47
+ '.png': 'image/png',
48
+ '.gif': 'image/gif',
49
+ '.webp': 'image/webp',
50
+ '.bmp': 'image/bmp',
51
+ '.tiff': 'image/tiff',
52
+ '.tif': 'image/tiff',
53
+ };
54
+
55
+ /**
56
+ * MIME type mappings for supported video formats.
57
+ */
58
+ const VIDEO_MIME_TYPES = {
59
+ '.mp4': 'video/mp4',
60
+ '.mpeg': 'video/mpeg',
61
+ '.mpg': 'video/mpeg',
62
+ '.mov': 'video/quicktime',
63
+ '.avi': 'video/x-msvideo',
64
+ '.mkv': 'video/x-matroska',
65
+ '.webm': 'video/webm',
66
+ '.flv': 'video/x-flv',
67
+ '.wmv': 'video/x-ms-wmv',
68
+ };
69
+
70
+ /**
71
+ * Check if a file path is a supported image format.
72
+ * @param {string} filePath
73
+ * @returns {boolean}
74
+ */
75
+ function isImageFile(filePath) {
76
+ const ext = path.extname(filePath).toLowerCase();
77
+ return ext in IMAGE_MIME_TYPES;
78
+ }
79
+
80
+ /**
81
+ * Check if a file path is a supported video format.
82
+ * @param {string} filePath
83
+ * @returns {boolean}
84
+ */
85
+ function isVideoFile(filePath) {
86
+ const ext = path.extname(filePath).toLowerCase();
87
+ return ext in VIDEO_MIME_TYPES;
88
+ }
89
+
90
+ /**
91
+ * Read a media file (image or video) and return it as a base64 data URL.
92
+ * @param {string} filePath - Path to the media file
93
+ * @returns {{ base64DataUrl: string, mimeType: string, sizeBytes: number }}
94
+ */
95
+ function readMediaAsBase64(filePath) {
96
+ const ext = path.extname(filePath).toLowerCase();
97
+ const mimeType = IMAGE_MIME_TYPES[ext] || VIDEO_MIME_TYPES[ext];
98
+
99
+ if (!mimeType) {
100
+ const supported = [
101
+ ...Object.keys(IMAGE_MIME_TYPES),
102
+ ...Object.keys(VIDEO_MIME_TYPES),
103
+ ].join(', ');
104
+ throw new Error(
105
+ `Unsupported media format "${ext}". Supported: ${supported}`
106
+ );
107
+ }
108
+
109
+ if (!fs.existsSync(filePath)) {
110
+ throw new Error(`File not found: ${filePath}`);
111
+ }
112
+
113
+ const buffer = fs.readFileSync(filePath);
114
+ const base64 = buffer.toString('base64');
115
+ const base64DataUrl = `data:${mimeType};base64,${base64}`;
116
+
117
+ return {
118
+ base64DataUrl,
119
+ mimeType,
120
+ sizeBytes: buffer.length,
121
+ };
122
+ }
123
+
124
+ module.exports = {
125
+ resolveTextInput,
126
+ readMediaAsBase64,
127
+ isImageFile,
128
+ isVideoFile,
129
+ IMAGE_MIME_TYPES,
130
+ VIDEO_MIME_TYPES,
131
+ };