voyageai-cli 1.28.0 → 1.30.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 (58) hide show
  1. package/README.md +82 -8
  2. package/package.json +2 -1
  3. package/src/commands/app.js +15 -0
  4. package/src/commands/benchmark.js +22 -8
  5. package/src/commands/chat.js +18 -0
  6. package/src/commands/chunk.js +10 -0
  7. package/src/commands/demo.js +4 -0
  8. package/src/commands/embed.js +13 -0
  9. package/src/commands/estimate.js +3 -0
  10. package/src/commands/eval.js +6 -0
  11. package/src/commands/explain.js +2 -0
  12. package/src/commands/generate.js +2 -0
  13. package/src/commands/ingest.js +4 -0
  14. package/src/commands/init.js +2 -0
  15. package/src/commands/mcp-server.js +2 -0
  16. package/src/commands/models.js +2 -0
  17. package/src/commands/ping.js +7 -0
  18. package/src/commands/pipeline.js +15 -0
  19. package/src/commands/playground.js +685 -8
  20. package/src/commands/query.js +16 -0
  21. package/src/commands/rerank.js +12 -0
  22. package/src/commands/scaffold.js +2 -0
  23. package/src/commands/search.js +11 -0
  24. package/src/commands/similarity.js +9 -0
  25. package/src/commands/store.js +4 -0
  26. package/src/commands/workflow.js +702 -13
  27. package/src/lib/capability-report.js +134 -0
  28. package/src/lib/chat.js +32 -1
  29. package/src/lib/config.js +2 -0
  30. package/src/lib/cost-display.js +107 -0
  31. package/src/lib/explanations.js +94 -0
  32. package/src/lib/llm.js +125 -18
  33. package/src/lib/npm-utils.js +265 -0
  34. package/src/lib/quality-audit.js +71 -0
  35. package/src/lib/security/blocked-domains.json +17 -0
  36. package/src/lib/security-audit.js +198 -0
  37. package/src/lib/telemetry.js +23 -1
  38. package/src/lib/workflow-registry.js +416 -0
  39. package/src/lib/workflow-scaffold.js +380 -0
  40. package/src/lib/workflow-test-runner.js +208 -0
  41. package/src/lib/workflow.js +559 -7
  42. package/src/playground/announcements.md +80 -0
  43. package/src/playground/assets/announcements/appstore.jpg +0 -0
  44. package/src/playground/assets/announcements/circuits.jpg +0 -0
  45. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  46. package/src/playground/assets/announcements/green-wave.jpg +0 -0
  47. package/src/playground/help/workflow-nodes.js +472 -0
  48. package/src/playground/icons/V.png +0 -0
  49. package/src/playground/index.html +3634 -226
  50. package/src/workflows/consistency-check.json +4 -0
  51. package/src/workflows/cost-analysis.json +4 -0
  52. package/src/workflows/enrich-and-ingest.json +56 -0
  53. package/src/workflows/intelligent-ingest.json +66 -0
  54. package/src/workflows/kb-health-report.json +45 -0
  55. package/src/workflows/multi-collection-search.json +4 -0
  56. package/src/workflows/research-and-summarize.json +4 -0
  57. package/src/workflows/search-with-fallback.json +66 -0
  58. package/src/workflows/smart-ingest.json +4 -0
@@ -5,6 +5,82 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { exec } = require('child_process');
7
7
 
8
+ /**
9
+ * Load announcements from the markdown file.
10
+ * Format: Each announcement is separated by `---` and has YAML-like metadata
11
+ * followed by a ## title and description paragraph.
12
+ */
13
+ function loadAnnouncementsFromMarkdown() {
14
+ const mdPath = path.join(__dirname, '..', 'playground', 'announcements.md');
15
+
16
+ if (!fs.existsSync(mdPath)) {
17
+ console.warn('Announcements file not found:', mdPath);
18
+ return [];
19
+ }
20
+
21
+ const content = fs.readFileSync(mdPath, 'utf-8');
22
+
23
+ // Split by --- separator (skip the header section before first ---)
24
+ const sections = content.split(/\n---\n/).slice(1);
25
+
26
+ const announcements = [];
27
+
28
+ for (const section of sections) {
29
+ const lines = section.trim().split('\n');
30
+ const metadata = {};
31
+ let titleIndex = -1;
32
+
33
+ // Parse metadata lines (key: value format)
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i].trim();
36
+
37
+ // Stop at the title (## heading)
38
+ if (line.startsWith('## ')) {
39
+ titleIndex = i;
40
+ break;
41
+ }
42
+
43
+ // Parse key: value
44
+ const match = line.match(/^([a-z_]+):\s*(.+)$/i);
45
+ if (match) {
46
+ metadata[match[1]] = match[2].trim();
47
+ }
48
+ }
49
+
50
+ if (titleIndex === -1 || !metadata.id) continue;
51
+
52
+ // Extract title (## heading)
53
+ const title = lines[titleIndex].replace(/^##\s*/, '').trim();
54
+
55
+ // Extract description (paragraphs after the title)
56
+ const descriptionLines = [];
57
+ for (let i = titleIndex + 1; i < lines.length; i++) {
58
+ const line = lines[i].trim();
59
+ if (line) descriptionLines.push(line);
60
+ }
61
+ const description = descriptionLines.join(' ');
62
+
63
+ announcements.push({
64
+ id: metadata.id,
65
+ title,
66
+ description,
67
+ badge: metadata.badge || 'Info',
68
+ published: metadata.published || new Date().toISOString().split('T')[0],
69
+ expires: metadata.expires || '2099-12-31',
70
+ bg_image: metadata.bg_image || null,
71
+ bg_color: metadata.bg_color || null,
72
+ icon: metadata.icon || null,
73
+ cta: {
74
+ label: metadata.cta_label || 'Learn More',
75
+ action: metadata.cta_action || 'link',
76
+ target: metadata.cta_target || '#'
77
+ }
78
+ });
79
+ }
80
+
81
+ return announcements;
82
+ }
83
+
8
84
  /**
9
85
  * Register the playground command on a Commander program.
10
86
  * @param {import('commander').Command} program
@@ -67,6 +143,10 @@ function createPlaygroundServer() {
67
143
  // Chat history — scoped to the server lifetime (in-memory, no persistence)
68
144
  let _chatHistory = null;
69
145
 
146
+ // Workflow store catalog cache (15 min TTL)
147
+ let _catalogCache = null;
148
+ let _catalogCacheTime = 0;
149
+
70
150
  const server = http.createServer(async (req, res) => {
71
151
  // CORS headers for local dev
72
152
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -82,7 +162,9 @@ function createPlaygroundServer() {
82
162
  try {
83
163
  // Serve HTML
84
164
  if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
85
- const html = fs.readFileSync(htmlPath, 'utf8');
165
+ const { getVersion } = require('../lib/banner');
166
+ let html = fs.readFileSync(htmlPath, 'utf8');
167
+ html = html.replace('</head>', `<script>window.__VAI_VERSION__="${getVersion()}";</script></head>`);
86
168
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
87
169
  res.end(html);
88
170
  return;
@@ -105,6 +187,23 @@ function createPlaygroundServer() {
105
187
  return;
106
188
  }
107
189
 
190
+ // Serve V.png logo
191
+ if (req.method === 'GET' && req.url === '/icons/V.png') {
192
+ const logoPath = path.join(__dirname, '..', 'playground', 'icons', 'V.png');
193
+ if (fs.existsSync(logoPath)) {
194
+ const data = fs.readFileSync(logoPath);
195
+ res.writeHead(200, {
196
+ 'Content-Type': 'image/png',
197
+ 'Cache-Control': 'public, max-age=86400',
198
+ });
199
+ res.end(data);
200
+ } else {
201
+ res.writeHead(404);
202
+ res.end('Logo not found');
203
+ }
204
+ return;
205
+ }
206
+
108
207
  // Serve icon assets: /icons/{dark|light}/{size}.png
109
208
  const iconMatch = req.url.match(/^\/icons\/(dark|light)\/(\d+)\.png$/);
110
209
  if (req.method === 'GET' && iconMatch) {
@@ -129,6 +228,25 @@ function createPlaygroundServer() {
129
228
  return;
130
229
  }
131
230
 
231
+ // Serve announcement assets: /assets/announcements/{filename}
232
+ const assetMatch = req.url.match(/^\/assets\/announcements\/([a-zA-Z0-9_.-]+\.(jpg|jpeg|png|webp|gif))$/);
233
+ if (req.method === 'GET' && assetMatch) {
234
+ const assetPath = path.join(__dirname, '..', 'playground', 'assets', 'announcements', assetMatch[1]);
235
+ if (fs.existsSync(assetPath)) {
236
+ const mimeTypes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif' };
237
+ const data = fs.readFileSync(assetPath);
238
+ res.writeHead(200, {
239
+ 'Content-Type': mimeTypes[assetMatch[2]] || 'application/octet-stream',
240
+ 'Cache-Control': 'public, max-age=86400',
241
+ });
242
+ res.end(data);
243
+ } else {
244
+ res.writeHead(404);
245
+ res.end('Asset not found');
246
+ }
247
+ return;
248
+ }
249
+
132
250
  // API: Models
133
251
  if (req.method === 'GET' && req.url === '/api/models') {
134
252
  const models = MODEL_CATALOG.filter(m => !m.legacy && !m.local && !m.unreleased);
@@ -266,6 +384,14 @@ function createPlaygroundServer() {
266
384
  return;
267
385
  }
268
386
 
387
+ // API: Workflow node help
388
+ if (req.method === 'GET' && req.url === '/api/workflows/node-help') {
389
+ const nodeHelp = require('../playground/help/workflow-nodes');
390
+ res.writeHead(200, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify({ nodeHelp }));
392
+ return;
393
+ }
394
+
269
395
  // API: Chat config (GET)
270
396
  if (req.method === 'GET' && req.url === '/api/chat/config') {
271
397
  const { resolveLLMConfig } = require('../lib/llm');
@@ -312,6 +438,14 @@ function createPlaygroundServer() {
312
438
  return;
313
439
  }
314
440
 
441
+ // API: Version — return CLI package version
442
+ if (req.method === 'GET' && req.url === '/api/version') {
443
+ const pkg = require('../../package.json');
444
+ res.writeHead(200, { 'Content-Type': 'application/json' });
445
+ res.end(JSON.stringify({ version: pkg.version }));
446
+ return;
447
+ }
448
+
315
449
  // API: Doctor health checks
316
450
  if (req.method === 'GET' && req.url === '/api/doctor') {
317
451
  try {
@@ -355,13 +489,367 @@ function createPlaygroundServer() {
355
489
  return;
356
490
  }
357
491
 
492
+ // API: Workflow Store catalog (cached 15 min)
493
+ if (req.method === 'GET' && req.url === '/api/workflows/catalog') {
494
+ const _catStart = Date.now();
495
+ try {
496
+ // Check cache
497
+ if (_catalogCache && (Date.now() - _catalogCacheTime < 15 * 60 * 1000)) {
498
+ console.log(`[catalog] served from cache in ${Date.now() - _catStart}ms`);
499
+ res.writeHead(200, { 'Content-Type': 'application/json' });
500
+ res.end(JSON.stringify(_catalogCache));
501
+ return;
502
+ }
503
+
504
+ const { getRegistry } = require('../lib/workflow-registry');
505
+ const registry = getRegistry({ force: true });
506
+
507
+ // Build set of installed package names
508
+ const installedNames = new Set();
509
+ for (const c of [...(registry.official || []), ...(registry.community || [])]) {
510
+ if (c.name) installedNames.add(c.name);
511
+ }
512
+
513
+ // Try to fetch from npm
514
+ let npmWorkflows = [];
515
+ try {
516
+ const { searchNpm } = require('../lib/npm-utils');
517
+ const results = await searchNpm('', { limit: 50 });
518
+ npmWorkflows = results || [];
519
+ } catch (e) {
520
+ // npm unreachable — fall back to installed only
521
+ }
522
+
523
+ // Fetch registry metadata (for vai-workflow field, inputs, author)
524
+ // Only one request per package — no unpkg or downloads API on the critical path
525
+ const metadataCache = {};
526
+ await Promise.all(npmWorkflows.map(async (r) => {
527
+ try {
528
+ const encodedName = r.name.startsWith('@')
529
+ ? `@${encodeURIComponent(r.name.slice(1))}`
530
+ : encodeURIComponent(r.name);
531
+ const regRes = await fetch(`https://registry.npmjs.org/${encodedName}/latest`, {
532
+ headers: { 'Accept': 'application/json' },
533
+ signal: AbortSignal.timeout(5000),
534
+ });
535
+ if (regRes.ok) {
536
+ metadataCache[r.name] = await regRes.json();
537
+ }
538
+ } catch {
539
+ // Fall back to basic data from search
540
+ }
541
+ }));
542
+
543
+ // ── Lucide icon paths for workflow branding ──
544
+ // Curated subset of Lucide icons (lucide.dev, MIT) for the store.
545
+ // Each value is an SVG path (or multiple paths separated by a convention
546
+ // that the client renders inside a 24x24 viewBox with stroke).
547
+ const STORE_ICONS = {
548
+ trophy: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z',
549
+ search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
550
+ 'dollar-sign':'M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6',
551
+ split: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
552
+ 'file-search': 'M14 2v4a2 2 0 0 0 2 2h4M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM9.5 12.5a2.5 2.5 0 1 0 5 0 2.5 2.5 0 1 0-5 0M13.3 14.3 15 16',
553
+ database: 'M21 5c0 1.1-3.134 3-9 3S3 6.1 3 5M21 5c0-1.1-3.134-3-9-3S3 3.9 3 5M21 5v14c0 1.1-3.134 3-9 3s-9-1.9-9-3V5M21 12c0 1.1-3.134 3-9 3s-9-1.9-9-3',
554
+ activity: 'M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36-3.18-19.64A2 2 0 0 0 10.12 1h-.24a2 2 0 0 0-1.94 1.55L5.18 12H2',
555
+ globe: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z',
556
+ 'shield-alert':'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .5-.87l7-4a1 1 0 0 1 1 0l7 4A1 1 0 0 1 20 6zM12 8v4M12 16h.01',
557
+ timer: 'M10 2h4M12 14l3-3M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
558
+ 'refresh-cw': 'M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16',
559
+ 'flask-conical':'M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2M8.5 2h7M7 16h10',
560
+ target: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0',
561
+ code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
562
+ 'clipboard-list':'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM12 11h4M12 16h4M8 11h.01M8 16h.01',
563
+ layers: 'M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84zM2 12l8.58 3.91a2 2 0 0 0 1.66 0L22 12M2 17l8.58 3.91a2 2 0 0 0 1.66 0L22 17',
564
+ 'bar-chart-3': 'M12 20V10M18 20V4M6 20v-4',
565
+ 'heart-pulse': 'M19.5 12.572l-7.5 7.428-7.5-7.428A5 5 0 0 1 7.5 5c1.8 0 3.3.9 4.5 2.7C13.2 5.9 14.7 5 16.5 5a5 5 0 0 1 3 9.572zM12 6l-1 4h4l-1 4',
566
+ brain: 'M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2zM14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z',
567
+ 'check-circle':'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3',
568
+ zap: 'M13 2 3 14h9l-1 8 10-12h-9l1-8z',
569
+ package: 'M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12',
570
+ microscope: 'M6 18h8M3 22h18M14 22a7 7 0 1 0 0-14h-1M9 14h2M9 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2z',
571
+ sparkle: 'M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z',
572
+ scale: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
573
+ 'file-text': 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v4a2 2 0 0 0 2 2h4M10 13h4M10 17h4M10 9h1',
574
+ filter: 'M3 6h18M7 12h10M10 18h4',
575
+ };
576
+
577
+ // Category fallback icons (used when a workflow has no branding)
578
+ const CATEGORY_ICONS = {
579
+ retrieval: 'search',
580
+ analysis: 'bar-chart-3',
581
+ 'domain-specific': 'target',
582
+ ingestion: 'database',
583
+ utility: 'zap',
584
+ integration: 'package',
585
+ };
586
+
587
+ // Default branding for the 20 official workflows
588
+ const DEFAULT_BRANDING = {
589
+ 'model-shootout': { icon: 'trophy', color: '#0D9488' },
590
+ 'asymmetric-search': { icon: 'split', color: '#00D4AA' },
591
+ 'cost-optimizer': { icon: 'dollar-sign', color: '#F59E0B' },
592
+ 'question-decomposition': { icon: 'sparkle', color: '#8B5CF6' },
593
+ 'contract-clause-finder': { icon: 'file-search', color: '#1E40AF' },
594
+ 'knowledge-base-bootstrap': { icon: 'database', color: '#059669' },
595
+ 'embedding-drift-detector': { icon: 'activity', color: '#DC2626' },
596
+ 'multilingual-search': { icon: 'globe', color: '#0EA5E9' },
597
+ 'financial-risk-scanner': { icon: 'shield-alert', color: '#B45309' },
598
+ 'doc-freshness': { icon: 'timer', color: '#4338CA' },
599
+ 'incremental-sync': { icon: 'refresh-cw', color: '#15803D' },
600
+ 'rag-ab-test': { icon: 'flask-conical', color: '#BE185D' },
601
+ 'hybrid-precision-search': { icon: 'target', color: '#0891B2' },
602
+ 'code-migration-helper': { icon: 'code', color: '#475569' },
603
+ 'meeting-action-items': { icon: 'clipboard-list',color: '#7C2D12' },
604
+ 'collection-overlap-audit': { icon: 'layers', color: '#6D28D9' },
605
+ 'query-quality-scorer': { icon: 'microscope', color: '#9333EA' },
606
+ 'clinical-protocol-match': { icon: 'heart-pulse', color: '#0F766E' },
607
+ 'batch-quality-gate': { icon: 'check-circle', color: '#166534' },
608
+ 'index-health-check': { icon: 'bar-chart-3', color: '#1D4ED8' },
609
+ };
610
+
611
+ // Static gradient/featured config
612
+ const GRADIENTS = {
613
+ 'model-shootout': 'linear-gradient(135deg, #0D9488, #06B6D4)',
614
+ 'asymmetric-search': 'linear-gradient(135deg, #00D4AA, #40E0FF)',
615
+ 'cost-optimizer': 'linear-gradient(135deg, #F59E0B, #EF4444)',
616
+ 'question-decomposition': 'linear-gradient(135deg, #8B5CF6, #EC4899)',
617
+ 'contract-clause-finder': 'linear-gradient(135deg, #1E40AF, #7C3AED)',
618
+ 'knowledge-base-bootstrap': 'linear-gradient(135deg, #059669, #10B981)',
619
+ 'embedding-drift-detector': 'linear-gradient(135deg, #DC2626, #F97316)',
620
+ 'multilingual-search': 'linear-gradient(135deg, #0EA5E9, #6366F1)',
621
+ 'financial-risk-scanner': 'linear-gradient(135deg, #B45309, #D97706)',
622
+ 'doc-freshness': 'linear-gradient(135deg, #4338CA, #7C3AED)',
623
+ 'incremental-sync': 'linear-gradient(135deg, #15803D, #4ADE80)',
624
+ 'rag-ab-test': 'linear-gradient(135deg, #BE185D, #F472B6)',
625
+ 'hybrid-precision-search': 'linear-gradient(135deg, #0891B2, #22D3EE)',
626
+ 'code-migration-helper': 'linear-gradient(135deg, #475569, #94A3B8)',
627
+ 'meeting-action-items': 'linear-gradient(135deg, #7C2D12, #EA580C)',
628
+ 'collection-overlap-audit': 'linear-gradient(135deg, #6D28D9, #A78BFA)',
629
+ 'query-quality-scorer': 'linear-gradient(135deg, #9333EA, #C084FC)',
630
+ 'clinical-protocol-match': 'linear-gradient(135deg, #0F766E, #2DD4BF)',
631
+ 'batch-quality-gate': 'linear-gradient(135deg, #166534, #86EFAC)',
632
+ 'index-health-check': 'linear-gradient(135deg, #1D4ED8, #60A5FA)',
633
+ };
634
+ const FEATURED = ['model-shootout', 'asymmetric-search', 'cost-optimizer'];
635
+ const DEFAULT_GRADIENT = 'linear-gradient(135deg, #334155, #64748B)';
636
+
637
+ const workflows = npmWorkflows.map(r => {
638
+ const shortName = (r.name || '').replace(/^@vaicli\/vai-workflow-/, '').replace(/^vai-workflow-/, '');
639
+ const meta = metadataCache[r.name]; // raw registry JSON
640
+ const vai = (meta && meta['vai-workflow']) || (meta && meta.vai) || r.vai || {};
641
+ const vaiAuthor = vai.author || null;
642
+ const version = (meta && meta.version) || r.version || '1.0.0';
643
+
644
+ // Author attribution: vai.author > package.json author
645
+ let author = { name: 'unknown' };
646
+ if (vaiAuthor && vaiAuthor.name) {
647
+ author = { name: vaiAuthor.name, url: vaiAuthor.url || undefined };
648
+ if (vaiAuthor.avatar) {
649
+ author.avatar = `https://unpkg.com/${r.name}@${version}/${vaiAuthor.avatar}`;
650
+ }
651
+ } else if (meta && meta.author) {
652
+ const rawAuthor = meta.author;
653
+ author = { name: typeof rawAuthor === 'string' ? rawAuthor : (rawAuthor.name || 'unknown') };
654
+ } else if (r.author) {
655
+ author = { name: r.author };
656
+ }
657
+
658
+ // Assets: construct CDN URLs from vai.assets paths
659
+ const vaiAssets = vai.assets || {};
660
+ const assets = {};
661
+ if (vaiAssets.icon) assets.icon = `https://unpkg.com/${r.name}@${version}/${vaiAssets.icon}`;
662
+ if (vaiAssets.banner) assets.banner = `https://unpkg.com/${r.name}@${version}/${vaiAssets.banner}`;
663
+ if (vaiAssets.screenshots && Array.isArray(vaiAssets.screenshots)) {
664
+ assets.screenshots = vaiAssets.screenshots.map(s => `https://unpkg.com/${r.name}@${version}/${s}`);
665
+ }
666
+
667
+ // Branding: vai.branding from package > DEFAULT_BRANDING > category fallback
668
+ const vaiBranding = vai.branding || {};
669
+ const defaultBrand = DEFAULT_BRANDING[shortName] || {};
670
+ const category = vai.category || 'utility';
671
+ const brandingIcon = vaiBranding.icon || defaultBrand.icon || CATEGORY_ICONS[category] || 'zap';
672
+ const brandingColor = vaiBranding.color || defaultBrand.color || '#64748B';
673
+ const branding = {
674
+ icon: brandingIcon,
675
+ color: brandingColor,
676
+ // Resolve the icon name to its SVG path data for client rendering
677
+ iconPath: STORE_ICONS[brandingIcon] || STORE_ICONS.zap,
678
+ };
679
+
680
+ // Inputs: extract from vai-workflow field (has inputs map), fall back to vai.inputs
681
+ const vaiWorkflowField = meta && meta['vai-workflow'] ? meta['vai-workflow'] : {};
682
+ const rawInputs = vaiWorkflowField.inputs || vai.inputs || {};
683
+ const inputs = Object.entries(rawInputs).map(([name, def]) => ({
684
+ name,
685
+ type: def.type || 'string',
686
+ required: !!def.required,
687
+ default: def.default !== undefined ? def.default : undefined,
688
+ description: def.description || '',
689
+ }));
690
+
691
+ // Derive capabilities from tools
692
+ const toolsList = vai.tools || [];
693
+ const capabilities = [];
694
+ if (toolsList.includes('http')) capabilities.push('NETWORK');
695
+ if (toolsList.includes('ingest') || toolsList.includes('aggregate')) capabilities.push('WRITE_DB');
696
+ if (toolsList.includes('generate')) capabilities.push('LLM');
697
+ if (toolsList.includes('loop') || toolsList.includes('forEach')) capabilities.push('LOOP');
698
+ if (toolsList.some(t => ['query','search','collections','aggregate'].includes(t))) capabilities.push('READ_DB');
699
+
700
+ const isOfficial = (r.name || '').startsWith('@vaicli/');
701
+
702
+ return {
703
+ name: shortName,
704
+ packageName: r.name,
705
+ version,
706
+ description: r.description || '',
707
+ category,
708
+ tags: vai.tags || [],
709
+ tools: toolsList,
710
+ steps: vai.steps || toolsList.length || 0,
711
+ toolCount: toolsList.length,
712
+ tier: isOfficial ? 'official' : 'community',
713
+ downloads: 0,
714
+ featured: FEATURED.includes(shortName),
715
+ installed: installedNames.has(r.name),
716
+ gradient: GRADIENTS[shortName] || DEFAULT_GRADIENT,
717
+ branding,
718
+ author,
719
+ assets,
720
+ inputs,
721
+ capabilities,
722
+ verified: isOfficial,
723
+ security: [],
724
+ rating: null,
725
+ };
726
+ });
727
+
728
+ const result = { workflows, icons: STORE_ICONS, lastUpdated: new Date().toISOString() };
729
+ _catalogCache = result;
730
+ _catalogCacheTime = Date.now();
731
+
732
+ console.log(`[catalog] built fresh in ${Date.now() - _catStart}ms (${workflows.length} workflows)`);
733
+ res.writeHead(200, { 'Content-Type': 'application/json' });
734
+ res.end(JSON.stringify(result));
735
+
736
+ // Background enrichment: fetch real step counts + download stats
737
+ // Updates the cache silently — next client request gets enriched data
738
+ (async () => {
739
+ try {
740
+ let enriched = false;
741
+ await Promise.all(workflows.map(async (wf) => {
742
+ const [defRes, dlRes] = await Promise.all([
743
+ fetch(`https://unpkg.com/${wf.packageName}@${wf.version || 'latest'}/workflow.json`, {
744
+ headers: { 'Accept': 'application/json' },
745
+ signal: AbortSignal.timeout(8000),
746
+ }).catch(() => null),
747
+ fetch(`https://api.npmjs.org/downloads/point/last-month/${encodeURIComponent(wf.packageName)}`, {
748
+ headers: { 'Accept': 'application/json' },
749
+ signal: AbortSignal.timeout(8000),
750
+ }).catch(() => null),
751
+ ]);
752
+ if (defRes && defRes.ok) {
753
+ try {
754
+ const def = await defRes.json();
755
+ if (def.steps && Array.isArray(def.steps) && def.steps.length > 0) {
756
+ wf.steps = def.steps.length;
757
+ enriched = true;
758
+ }
759
+ } catch {}
760
+ }
761
+ if (dlRes && dlRes.ok) {
762
+ try {
763
+ const dlData = await dlRes.json();
764
+ if (dlData.downloads > 0) {
765
+ wf.downloads = dlData.downloads;
766
+ enriched = true;
767
+ }
768
+ } catch {}
769
+ }
770
+ }));
771
+ if (enriched) {
772
+ _catalogCache = { ...result, workflows, lastUpdated: new Date().toISOString() };
773
+ }
774
+ } catch {}
775
+ })();
776
+ } catch (err) {
777
+ res.writeHead(500, { 'Content-Type': 'application/json' });
778
+ res.end(JSON.stringify({ error: err.message }));
779
+ }
780
+ return;
781
+ }
782
+
358
783
  // API: List built-in workflows
359
784
  if (req.method === 'GET' && req.url === '/api/workflows') {
360
785
  try {
361
- const { listBuiltinWorkflows } = require('../lib/workflow');
362
- const workflows = listBuiltinWorkflows();
786
+ const { getRegistry } = require('../lib/workflow-registry');
787
+ const registry = getRegistry({ force: true });
788
+ const workflows = registry.builtIn;
789
+ const mapPkg = (c, source) => ({
790
+ name: c.name,
791
+ description: c.pkg?.description || c.definition?.description || '',
792
+ version: c.pkg?.version,
793
+ author: typeof c.pkg?.author === 'string' ? c.pkg.author : c.pkg?.author?.name || '',
794
+ category: c.pkg?.vai?.category || 'utility',
795
+ tags: c.pkg?.vai?.tags || [],
796
+ tools: c.pkg?.vai?.tools || [],
797
+ source,
798
+ scope: c.scope,
799
+ });
800
+ // Include workflows that have a definition, even if they have non-fatal errors
801
+ // (e.g., "Missing vai field" is a warning, not a blocker)
802
+ const official = registry.official
803
+ .filter(c => c.definition)
804
+ .map(c => mapPkg(c, 'official'));
805
+ const community = registry.community
806
+ .filter(c => c.definition)
807
+ .map(c => mapPkg(c, 'community'));
808
+ res.writeHead(200, { 'Content-Type': 'application/json' });
809
+ res.end(JSON.stringify({ workflows, official, community }));
810
+ } catch (err) {
811
+ res.writeHead(500, { 'Content-Type': 'application/json' });
812
+ res.end(JSON.stringify({ error: err.message }));
813
+ }
814
+ return;
815
+ }
816
+
817
+ // API: Community workflow operations
818
+ if (req.method === 'GET' && req.url === '/api/workflows/community') {
819
+ try {
820
+ const { getRegistry } = require('../lib/workflow-registry');
821
+ const registry = getRegistry({ force: true });
822
+ const mapPkg = (c) => ({
823
+ name: c.name,
824
+ description: c.pkg?.description || '',
825
+ version: c.pkg?.version,
826
+ author: typeof c.pkg?.author === 'string' ? c.pkg.author : c.pkg?.author?.name || '',
827
+ category: c.pkg?.vai?.category || 'utility',
828
+ tags: c.pkg?.vai?.tags || [],
829
+ valid: c.errors.length === 0,
830
+ errors: c.errors,
831
+ warnings: c.warnings,
832
+ });
833
+ const official = registry.official.map(mapPkg);
834
+ const community = registry.community.map(mapPkg);
363
835
  res.writeHead(200, { 'Content-Type': 'application/json' });
364
- res.end(JSON.stringify({ workflows }));
836
+ res.end(JSON.stringify({ official, community }));
837
+ } catch (err) {
838
+ res.writeHead(500, { 'Content-Type': 'application/json' });
839
+ res.end(JSON.stringify({ error: err.message }));
840
+ }
841
+ return;
842
+ }
843
+
844
+ if (req.method === 'GET' && req.url?.startsWith('/api/workflows/community/search?')) {
845
+ try {
846
+ const { searchNpm } = require('../lib/npm-utils');
847
+ const urlObj = new URL(req.url, `http://localhost`);
848
+ const query = urlObj.searchParams.get('q') || '';
849
+ const limit = parseInt(urlObj.searchParams.get('limit') || '10', 10);
850
+ const results = await searchNpm(query, { limit });
851
+ res.writeHead(200, { 'Content-Type': 'application/json' });
852
+ res.end(JSON.stringify({ results }));
365
853
  } catch (err) {
366
854
  res.writeHead(500, { 'Content-Type': 'application/json' });
367
855
  res.end(JSON.stringify({ error: err.message }));
@@ -383,7 +871,7 @@ function createPlaygroundServer() {
383
871
  return;
384
872
  }
385
873
 
386
- // API: Get a specific workflow by name
874
+ // API: Get a specific workflow by name (built-in, community, or example)
387
875
  if (req.method === 'GET' && req.url?.startsWith('/api/workflows/')) {
388
876
  const name = decodeURIComponent(req.url.replace('/api/workflows/', ''));
389
877
  if (!name) {
@@ -392,10 +880,10 @@ function createPlaygroundServer() {
392
880
  return;
393
881
  }
394
882
  try {
395
- const { loadWorkflow } = require('../lib/workflow');
396
- const definition = loadWorkflow(name);
883
+ const { resolveWorkflow } = require('../lib/workflow-registry');
884
+ const resolved = resolveWorkflow(name);
397
885
  res.writeHead(200, { 'Content-Type': 'application/json' });
398
- res.end(JSON.stringify({ definition }));
886
+ res.end(JSON.stringify({ definition: resolved.definition, source: resolved.source, metadata: resolved.metadata }));
399
887
  } catch (err) {
400
888
  res.writeHead(404, { 'Content-Type': 'application/json' });
401
889
  res.end(JSON.stringify({ error: err.message }));
@@ -403,6 +891,148 @@ function createPlaygroundServer() {
403
891
  return;
404
892
  }
405
893
 
894
+ // API: Home announcements (loaded from markdown file)
895
+ if (req.method === 'GET' && req.url === '/api/home/announcements') {
896
+ try {
897
+ const announcements = loadAnnouncementsFromMarkdown();
898
+
899
+ // Filter out expired announcements
900
+ const now = new Date();
901
+ const activeAnnouncements = announcements.filter(a => {
902
+ const expires = new Date(a.expires);
903
+ return expires > now;
904
+ });
905
+
906
+ res.writeHead(200, { 'Content-Type': 'application/json' });
907
+ res.end(JSON.stringify({ announcements: activeAnnouncements }));
908
+ } catch (err) {
909
+ console.error('Failed to load announcements:', err);
910
+ res.writeHead(200, { 'Content-Type': 'application/json' });
911
+ res.end(JSON.stringify({ announcements: [] }));
912
+ }
913
+ return;
914
+ }
915
+
916
+ // API: Home releases
917
+ if (req.method === 'GET' && req.url === '/api/home/releases') {
918
+ try {
919
+ // Fetch from GitHub API with caching
920
+ const cacheKey = 'github-releases-cache';
921
+ const cached = global[cacheKey];
922
+
923
+ if (cached && Date.now() - cached.timestamp < 30 * 60 * 1000) {
924
+ // Use cached data if less than 30 minutes old
925
+ res.writeHead(200, { 'Content-Type': 'application/json' });
926
+ res.end(JSON.stringify(cached.data));
927
+ return;
928
+ }
929
+
930
+ const https = require('https');
931
+
932
+ const fetchGitHub = () => new Promise((resolve, reject) => {
933
+ const options = {
934
+ hostname: 'api.github.com',
935
+ path: '/repos/mrlynn/voyageai-cli/releases?per_page=5',
936
+ method: 'GET',
937
+ headers: {
938
+ 'User-Agent': 'VAI-Playground',
939
+ 'Accept': 'application/vnd.github.v3+json'
940
+ }
941
+ };
942
+
943
+ const req = https.request(options, (response) => {
944
+ let data = '';
945
+ response.on('data', chunk => data += chunk);
946
+ response.on('end', () => {
947
+ if (response.statusCode === 200) {
948
+ try {
949
+ resolve(JSON.parse(data));
950
+ } catch (e) {
951
+ reject(new Error('Failed to parse GitHub response'));
952
+ }
953
+ } else {
954
+ console.error(`GitHub API error: ${response.statusCode} - ${data.substring(0, 200)}`);
955
+ reject(new Error(`GitHub API returned ${response.statusCode}`));
956
+ }
957
+ });
958
+ });
959
+ req.on('error', (err) => {
960
+ console.error('GitHub request error:', err.message);
961
+ reject(err);
962
+ });
963
+ req.setTimeout(10000, () => {
964
+ req.destroy();
965
+ reject(new Error('Request timeout'));
966
+ });
967
+ req.end();
968
+ });
969
+
970
+ const githubReleases = await fetchGitHub();
971
+
972
+ // Parse release notes
973
+ const releases = githubReleases.map(release => {
974
+ let highlights = [];
975
+
976
+ if (release.body) {
977
+ // Extract bullet points from markdown body
978
+ const lines = release.body.split('\n');
979
+ highlights = lines
980
+ .filter(line => line.trim().startsWith('-') || line.trim().startsWith('*'))
981
+ .map(line => line.replace(/^[-*]\s*/, '').trim())
982
+ .filter(line => line.length > 0)
983
+ .slice(0, 5); // Max 5 highlights
984
+ }
985
+
986
+ if (highlights.length === 0) {
987
+ highlights = ['New features and improvements'];
988
+ }
989
+
990
+ return {
991
+ version: release.tag_name || release.name,
992
+ date: release.published_at,
993
+ highlights,
994
+ url: release.html_url
995
+ };
996
+ });
997
+
998
+ const result = { releases };
999
+
1000
+ // Cache the result
1001
+ global[cacheKey] = {
1002
+ data: result,
1003
+ timestamp: Date.now()
1004
+ };
1005
+
1006
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1007
+ res.end(JSON.stringify(result));
1008
+
1009
+ } catch (err) {
1010
+ console.error('Failed to fetch GitHub releases:', err);
1011
+
1012
+ // Return fallback data
1013
+ const fallback = {
1014
+ releases: [
1015
+ {
1016
+ version: 'v1.0.0',
1017
+ date: '2026-02-14T00:00:00Z',
1018
+ highlights: [
1019
+ 'Initial release of VAI Playground',
1020
+ 'Support for all Voyage AI models',
1021
+ 'Interactive embedding visualization',
1022
+ 'Model comparison tools',
1023
+ 'Vector similarity analysis'
1024
+ ],
1025
+ url: 'https://github.com/mrlynn/voyageai-cli/releases'
1026
+ }
1027
+ ]
1028
+ };
1029
+
1030
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify(fallback));
1032
+ }
1033
+ return;
1034
+ }
1035
+
406
1036
  // API: Save chat config (POST) — persists to .vai.json
407
1037
  // Placed before generic POST handler so it doesn't require Voyage API key
408
1038
  if (req.method === 'POST' && req.url === '/api/chat/config') {
@@ -444,6 +1074,53 @@ function createPlaygroundServer() {
444
1074
  }
445
1075
 
446
1076
  // Parse JSON body for POST routes
1077
+ // Community workflow install (no API key needed)
1078
+ if (req.method === 'POST' && req.url === '/api/workflows/community/install') {
1079
+ try {
1080
+ const body = await readBody(req);
1081
+ const { name } = JSON.parse(body);
1082
+ if (!name) {
1083
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1084
+ res.end(JSON.stringify({ error: 'Package name is required' }));
1085
+ return;
1086
+ }
1087
+ const { installPackage, WORKFLOW_PREFIX, isWorkflowPackage } = require('../lib/npm-utils');
1088
+ const { validatePackage, clearRegistryCache } = require('../lib/workflow-registry');
1089
+ const packageName = name.startsWith('@') || name.startsWith(WORKFLOW_PREFIX) ? name : WORKFLOW_PREFIX + name;
1090
+ const result = installPackage(packageName);
1091
+ clearRegistryCache();
1092
+ _catalogCache = null; _catalogCacheTime = 0; // Invalidate store catalog cache
1093
+ let validation = null;
1094
+ if (result.path) {
1095
+ validation = validatePackage(result.path);
1096
+ }
1097
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1098
+ res.end(JSON.stringify({ success: true, version: result.version, path: result.path, validation: validation ? { valid: validation.errors.length === 0, errors: validation.errors, warnings: validation.warnings } : null }));
1099
+ } catch (err) {
1100
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1101
+ res.end(JSON.stringify({ error: err.message }));
1102
+ }
1103
+ return;
1104
+ }
1105
+
1106
+ // Community workflow uninstall (no API key needed)
1107
+ if (req.method === 'DELETE' && req.url?.startsWith('/api/workflows/community/')) {
1108
+ try {
1109
+ const packageName = decodeURIComponent(req.url.replace('/api/workflows/community/', ''));
1110
+ const { uninstallPackage } = require('../lib/npm-utils');
1111
+ const { clearRegistryCache } = require('../lib/workflow-registry');
1112
+ uninstallPackage(packageName);
1113
+ clearRegistryCache();
1114
+ _catalogCache = null; _catalogCacheTime = 0; // Invalidate store catalog cache
1115
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1116
+ res.end(JSON.stringify({ success: true }));
1117
+ } catch (err) {
1118
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1119
+ res.end(JSON.stringify({ error: err.message }));
1120
+ }
1121
+ return;
1122
+ }
1123
+
447
1124
  if (req.method === 'POST') {
448
1125
  // Check for API key before processing any API calls
449
1126
  const apiKeyConfigured = !!(process.env.VOYAGE_API_KEY || getConfigValue('apiKey'));