voyageai-cli 1.30.0 → 1.30.1

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +6 -0
  3. package/src/commands/chat.js +32 -11
  4. package/src/commands/export.js +124 -0
  5. package/src/commands/import.js +195 -0
  6. package/src/commands/index-workspace.js +239 -0
  7. package/src/commands/mcp-server.js +113 -3
  8. package/src/commands/playground.js +111 -3
  9. package/src/lib/export/contexts/benchmark-export.js +27 -0
  10. package/src/lib/export/contexts/chat-export.js +41 -0
  11. package/src/lib/export/contexts/explore-export.js +22 -0
  12. package/src/lib/export/contexts/search-export.js +54 -0
  13. package/src/lib/export/contexts/workflow-export.js +80 -0
  14. package/src/lib/export/formats/clipboard-export.js +29 -0
  15. package/src/lib/export/formats/csv-export.js +45 -0
  16. package/src/lib/export/formats/json-export.js +50 -0
  17. package/src/lib/export/formats/markdown-export.js +189 -0
  18. package/src/lib/export/formats/mermaid-export.js +274 -0
  19. package/src/lib/export/formats/pdf-export.js +117 -0
  20. package/src/lib/export/formats/png-export.js +96 -0
  21. package/src/lib/export/formats/svg-export.js +116 -0
  22. package/src/lib/export/index.js +175 -0
  23. package/src/lib/workflow.js +206 -27
  24. package/src/mcp/install.js +280 -7
  25. package/src/mcp/schemas/index.js +40 -0
  26. package/src/mcp/server.js +2 -0
  27. package/src/mcp/tools/workspace.js +463 -0
  28. package/src/playground/announcements.md +52 -5
  29. package/src/playground/index.html +11125 -7796
  30. package/src/playground/vendor/mermaid.min.js +2811 -0
  31. package/src/workflows/rag-chat.json +165 -0
  32. package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
  33. package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
  34. package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
  35. package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
  36. package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
  37. package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
  38. package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
  39. package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
  40. package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
  41. package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
  42. package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
  43. package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
  44. package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
  45. package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
  46. package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
  47. package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
  48. package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
  49. package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
  50. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
  51. package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
  52. package/src/playground/assets/announcements/appstore.jpg +0 -0
  53. package/src/playground/assets/announcements/circuits.jpg +0 -0
  54. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  55. package/src/playground/assets/announcements/green-wave.jpg +0 -0
@@ -0,0 +1,463 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { generateEmbeddings } = require('../../lib/api');
6
+ const { getMongoCollection } = require('../../lib/mongo');
7
+ const { getDefaultModel } = require('../../lib/catalog');
8
+ const { chunk } = require('../../lib/chunker');
9
+ const { loadProject } = require('../../lib/project');
10
+
11
+ /**
12
+ * File patterns for different content types.
13
+ */
14
+ const FILE_PATTERNS = {
15
+ code: ['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt', '.scala', '.ex', '.exs', '.clj', '.hs', '.ml', '.fs', '.vue', '.svelte'],
16
+ docs: ['.md', '.txt', '.rst', '.adoc', '.asciidoc', '.org', '.tex'],
17
+ config: ['.json', '.yaml', '.yml', '.toml', '.ini', '.env', '.conf'],
18
+ all: null, // Match everything except binary
19
+ };
20
+
21
+ /**
22
+ * Files/directories to skip by default.
23
+ */
24
+ const DEFAULT_IGNORE = [
25
+ 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', 'target',
26
+ '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.nyc_output',
27
+ 'vendor', 'venv', '.venv', 'env', '.env', '.idea', '.vscode',
28
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock',
29
+ '*.min.js', '*.min.css', '*.map', '*.chunk.js',
30
+ ];
31
+
32
+ /**
33
+ * Resolve db/collection from tool input, falling back to project config.
34
+ */
35
+ function resolveDbCollection(input) {
36
+ const { config: proj } = loadProject();
37
+ const db = input.db || proj.db;
38
+ const collection = input.collection || proj.collection;
39
+ if (!db) throw new Error('No database specified. Pass db parameter or configure via vai init.');
40
+ if (!collection) throw new Error('No collection specified. Pass collection parameter or configure via vai init.');
41
+ return { db, collection };
42
+ }
43
+
44
+ /**
45
+ * Check if a path should be ignored.
46
+ */
47
+ function shouldIgnore(filePath, ignorePatterns = DEFAULT_IGNORE) {
48
+ const basename = path.basename(filePath);
49
+ const relativePath = filePath;
50
+
51
+ for (const pattern of ignorePatterns) {
52
+ if (pattern.startsWith('*')) {
53
+ // Wildcard pattern (e.g., *.min.js)
54
+ const ext = pattern.slice(1);
55
+ if (basename.endsWith(ext)) return true;
56
+ } else if (relativePath.includes(pattern) || basename === pattern) {
57
+ return true;
58
+ }
59
+ }
60
+
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Get file extension category.
66
+ */
67
+ function getFileCategory(filePath) {
68
+ const ext = path.extname(filePath).toLowerCase();
69
+ for (const [category, extensions] of Object.entries(FILE_PATTERNS)) {
70
+ if (extensions && extensions.includes(ext)) {
71
+ return category;
72
+ }
73
+ }
74
+ return 'other';
75
+ }
76
+
77
+ /**
78
+ * Recursively find files matching criteria.
79
+ */
80
+ async function findFiles(dirPath, options = {}) {
81
+ const {
82
+ contentType = 'all',
83
+ ignorePatterns = DEFAULT_IGNORE,
84
+ maxFiles = 10000,
85
+ maxFileSize = 100000, // 100KB
86
+ } = options;
87
+
88
+ const files = [];
89
+ const extensions = FILE_PATTERNS[contentType];
90
+
91
+ async function walk(dir) {
92
+ if (files.length >= maxFiles) return;
93
+
94
+ let entries;
95
+ try {
96
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return; // Skip unreadable directories
99
+ }
100
+
101
+ for (const entry of entries) {
102
+ if (files.length >= maxFiles) break;
103
+
104
+ const fullPath = path.join(dir, entry.name);
105
+
106
+ if (shouldIgnore(fullPath, ignorePatterns)) continue;
107
+
108
+ if (entry.isDirectory()) {
109
+ await walk(fullPath);
110
+ } else if (entry.isFile()) {
111
+ const ext = path.extname(entry.name).toLowerCase();
112
+
113
+ // Check extension match
114
+ if (extensions !== null && !extensions.includes(ext)) continue;
115
+
116
+ // Check file size
117
+ try {
118
+ const stats = await fs.promises.stat(fullPath);
119
+ if (stats.size > maxFileSize) continue;
120
+ if (stats.size === 0) continue;
121
+ } catch {
122
+ continue;
123
+ }
124
+
125
+ files.push(fullPath);
126
+ }
127
+ }
128
+ }
129
+
130
+ await walk(dirPath);
131
+ return files;
132
+ }
133
+
134
+ /**
135
+ * Extract code metadata (functions, classes, etc.) from content.
136
+ */
137
+ function extractCodeMetadata(content, filePath) {
138
+ const ext = path.extname(filePath).toLowerCase();
139
+ const metadata = {
140
+ language: ext.slice(1),
141
+ lineCount: content.split('\n').length,
142
+ };
143
+
144
+ // Simple extraction of function/class names for common languages
145
+ const patterns = {
146
+ js: [
147
+ /(?:function\s+|const\s+|let\s+|var\s+)(\w+)\s*(?:=\s*(?:async\s+)?(?:function|\(|=>)|\()/g,
148
+ /class\s+(\w+)/g,
149
+ ],
150
+ ts: [
151
+ /(?:function\s+|const\s+|let\s+)(\w+)\s*(?:=\s*(?:async\s+)?(?:function|\(|=>)|[<(])/g,
152
+ /(?:class|interface|type)\s+(\w+)/g,
153
+ ],
154
+ py: [
155
+ /(?:def|async def)\s+(\w+)\s*\(/g,
156
+ /class\s+(\w+)/g,
157
+ ],
158
+ go: [
159
+ /func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/g,
160
+ /type\s+(\w+)\s+struct/g,
161
+ ],
162
+ rs: [
163
+ /fn\s+(\w+)\s*[<(]/g,
164
+ /(?:struct|enum|trait)\s+(\w+)/g,
165
+ ],
166
+ java: [
167
+ /(?:public|private|protected)?\s*(?:static)?\s*\w+\s+(\w+)\s*\(/g,
168
+ /class\s+(\w+)/g,
169
+ ],
170
+ };
171
+
172
+ const langPatterns = patterns[ext.slice(1)] || patterns.js;
173
+ const symbols = [];
174
+
175
+ for (const pattern of langPatterns) {
176
+ let match;
177
+ while ((match = pattern.exec(content)) !== null) {
178
+ if (match[1] && !symbols.includes(match[1])) {
179
+ symbols.push(match[1]);
180
+ }
181
+ }
182
+ }
183
+
184
+ if (symbols.length > 0) {
185
+ metadata.symbols = symbols.slice(0, 50); // Limit to 50 symbols
186
+ }
187
+
188
+ return metadata;
189
+ }
190
+
191
+ /**
192
+ * Handler for vai_index_workspace: index a workspace directory.
193
+ */
194
+ async function handleIndexWorkspace(input) {
195
+ const { db, collection: collName } = resolveDbCollection(input);
196
+ const { config: proj } = loadProject();
197
+ const model = input.model || proj.model || getDefaultModel();
198
+ const workspacePath = input.path || process.cwd();
199
+
200
+ const start = Date.now();
201
+ const stats = {
202
+ filesFound: 0,
203
+ filesIndexed: 0,
204
+ chunksCreated: 0,
205
+ errors: [],
206
+ };
207
+
208
+ // Find files
209
+ const files = await findFiles(workspacePath, {
210
+ contentType: input.contentType || 'code',
211
+ maxFiles: input.maxFiles || 1000,
212
+ maxFileSize: input.maxFileSize || 100000,
213
+ });
214
+
215
+ stats.filesFound = files.length;
216
+
217
+ if (files.length === 0) {
218
+ return {
219
+ structuredContent: { ...stats, timeMs: Date.now() - start },
220
+ content: [{ type: 'text', text: `No matching files found in ${workspacePath}` }],
221
+ };
222
+ }
223
+
224
+ // Process files in batches
225
+ const batchSize = input.batchSize || 10;
226
+ const { client, collection } = await getMongoCollection(db, collName);
227
+
228
+ try {
229
+ for (let i = 0; i < files.length; i += batchSize) {
230
+ const batch = files.slice(i, i + batchSize);
231
+ const documents = [];
232
+
233
+ for (const filePath of batch) {
234
+ try {
235
+ const content = await fs.promises.readFile(filePath, 'utf-8');
236
+ const relativePath = path.relative(workspacePath, filePath);
237
+ const category = getFileCategory(filePath);
238
+
239
+ // Chunk the content
240
+ const chunkStrategy = category === 'code' ? 'recursive' : 'paragraph';
241
+ const chunks = chunk(content, {
242
+ strategy: chunkStrategy,
243
+ size: input.chunkSize || 512,
244
+ overlap: input.chunkOverlap || 50,
245
+ });
246
+
247
+ // Create documents for each chunk
248
+ for (let j = 0; j < chunks.length; j++) {
249
+ const chunkText = chunks[j];
250
+ const metadata = {
251
+ source: relativePath,
252
+ filePath: filePath,
253
+ chunkIndex: j,
254
+ totalChunks: chunks.length,
255
+ category,
256
+ indexedAt: new Date().toISOString(),
257
+ ...extractCodeMetadata(chunkText, filePath),
258
+ };
259
+
260
+ documents.push({
261
+ text: chunkText,
262
+ metadata,
263
+ });
264
+ }
265
+
266
+ stats.filesIndexed++;
267
+ } catch (err) {
268
+ stats.errors.push({ file: filePath, error: err.message });
269
+ }
270
+ }
271
+
272
+ // Generate embeddings for batch
273
+ if (documents.length > 0) {
274
+ const texts = documents.map(d => d.text);
275
+ const embedResult = await generateEmbeddings(texts, { model, inputType: 'document' });
276
+
277
+ // Combine documents with embeddings and insert
278
+ const docsToInsert = documents.map((doc, idx) => ({
279
+ text: doc.text,
280
+ embedding: embedResult.data[idx].embedding,
281
+ metadata: doc.metadata,
282
+ }));
283
+
284
+ await collection.insertMany(docsToInsert);
285
+ stats.chunksCreated += docsToInsert.length;
286
+ }
287
+ }
288
+
289
+ const timeMs = Date.now() - start;
290
+
291
+ return {
292
+ structuredContent: {
293
+ ...stats,
294
+ db,
295
+ collection: collName,
296
+ model,
297
+ timeMs,
298
+ },
299
+ content: [{
300
+ type: 'text',
301
+ text: `Indexed ${stats.filesIndexed}/${stats.filesFound} files (${stats.chunksCreated} chunks) in ${timeMs}ms\n` +
302
+ `Collection: ${db}.${collName}\n` +
303
+ (stats.errors.length > 0 ? `Errors: ${stats.errors.length}` : ''),
304
+ }],
305
+ };
306
+ } finally {
307
+ await client.close();
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Handler for vai_search_code: semantic code search.
313
+ */
314
+ async function handleSearchCode(input) {
315
+ const { db, collection: collName } = resolveDbCollection(input);
316
+ const { config: proj } = loadProject();
317
+ const model = input.model || proj.model || getDefaultModel();
318
+ const index = proj.index || 'vector_index';
319
+ const field = proj.field || 'embedding';
320
+ const start = Date.now();
321
+
322
+ // Embed query
323
+ const embedResult = await generateEmbeddings([input.query], { model, inputType: 'query' });
324
+ const queryVector = embedResult.data[0].embedding;
325
+
326
+ // Build filter
327
+ const filter = { ...input.filter };
328
+ if (input.language) {
329
+ filter['metadata.language'] = input.language;
330
+ }
331
+ if (input.category) {
332
+ filter['metadata.category'] = input.category;
333
+ }
334
+
335
+ // Vector search
336
+ const { client, collection } = await getMongoCollection(db, collName);
337
+ try {
338
+ const vectorSearchStage = {
339
+ index,
340
+ path: field,
341
+ queryVector,
342
+ numCandidates: Math.min(input.limit * 15, 10000),
343
+ limit: input.limit,
344
+ };
345
+ if (Object.keys(filter).length > 0) {
346
+ vectorSearchStage.filter = filter;
347
+ }
348
+
349
+ const results = await collection.aggregate([
350
+ { $vectorSearch: vectorSearchStage },
351
+ { $addFields: { _vsScore: { $meta: 'vectorSearchScore' } } },
352
+ ]).toArray();
353
+
354
+ const mapped = results.map(doc => ({
355
+ source: doc.metadata?.source || 'unknown',
356
+ filePath: doc.metadata?.filePath,
357
+ language: doc.metadata?.language,
358
+ content: doc.text || '',
359
+ score: doc._vsScore,
360
+ lineNumber: doc.metadata?.lineNumber,
361
+ symbols: doc.metadata?.symbols,
362
+ chunkIndex: doc.metadata?.chunkIndex,
363
+ }));
364
+
365
+ const timeMs = Date.now() - start;
366
+
367
+ // Format output
368
+ const lines = mapped.map((r, i) => {
369
+ let line = `[${i + 1}] ${r.source}`;
370
+ if (r.language) line += ` (${r.language})`;
371
+ line += ` — ${(r.score * 100).toFixed(1)}%`;
372
+ if (r.symbols?.length > 0) {
373
+ line += `\n Symbols: ${r.symbols.slice(0, 5).join(', ')}`;
374
+ }
375
+ line += `\n${r.content.slice(0, 300)}${r.content.length > 300 ? '...' : ''}`;
376
+ return line;
377
+ });
378
+
379
+ return {
380
+ structuredContent: {
381
+ query: input.query,
382
+ results: mapped,
383
+ metadata: { collection: collName, model, timeMs, resultCount: mapped.length },
384
+ },
385
+ content: [{
386
+ type: 'text',
387
+ text: `Found ${mapped.length} code results for "${input.query}" (${timeMs}ms):\n\n${lines.join('\n\n')}`,
388
+ }],
389
+ };
390
+ } finally {
391
+ await client.close();
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Handler for vai_explain_code: get contextual explanation for code.
397
+ */
398
+ async function handleExplainCode(input) {
399
+ const { db, collection: collName } = resolveDbCollection(input);
400
+ const { config: proj } = loadProject();
401
+ const model = input.model || proj.model || getDefaultModel();
402
+
403
+ // Search for relevant context
404
+ const searchInput = {
405
+ query: `Explain: ${input.code.slice(0, 500)}`,
406
+ db,
407
+ collection: collName,
408
+ limit: input.contextLimit || 5,
409
+ language: input.language,
410
+ category: 'docs', // Prefer documentation for explanations
411
+ };
412
+
413
+ const results = await handleSearchCode(searchInput);
414
+
415
+ return {
416
+ structuredContent: {
417
+ code: input.code.slice(0, 200) + (input.code.length > 200 ? '...' : ''),
418
+ language: input.language,
419
+ context: results.structuredContent.results,
420
+ model,
421
+ },
422
+ content: [{
423
+ type: 'text',
424
+ text: `Context for code explanation:\n\n${results.content[0].text}`,
425
+ }],
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Register workspace tools.
431
+ */
432
+ function registerWorkspaceTools(server, schemas) {
433
+ server.tool(
434
+ 'vai_index_workspace',
435
+ 'Index a workspace/codebase for semantic code search. Recursively finds files, chunks content, generates embeddings, and stores in MongoDB. Use this to build a searchable knowledge base from a codebase.',
436
+ schemas.indexWorkspaceSchema,
437
+ handleIndexWorkspace
438
+ );
439
+
440
+ server.tool(
441
+ 'vai_search_code',
442
+ 'Semantic code search across an indexed codebase. Finds code snippets, functions, and documentation semantically related to your query. Use for understanding unfamiliar codebases or finding relevant code.',
443
+ schemas.searchCodeSchema,
444
+ handleSearchCode
445
+ );
446
+
447
+ server.tool(
448
+ 'vai_explain_code',
449
+ 'Get contextual explanation for code by finding relevant documentation and examples in the indexed knowledge base. Useful for understanding what code does or finding usage examples.',
450
+ schemas.explainCodeSchema,
451
+ handleExplainCode
452
+ );
453
+ }
454
+
455
+ module.exports = {
456
+ registerWorkspaceTools,
457
+ handleIndexWorkspace,
458
+ handleSearchCode,
459
+ handleExplainCode,
460
+ findFiles,
461
+ FILE_PATTERNS,
462
+ DEFAULT_IGNORE,
463
+ };
@@ -16,12 +16,60 @@ The title is the first `## ` heading, and the description is the paragraph below
16
16
 
17
17
  ---
18
18
 
19
+ id: ann-collapsible-layout
20
+ badge: New
21
+ published: 2026-02-15
22
+ expires: 2026-03-20
23
+ icon: 🖥️
24
+ bg_color: linear-gradient(135deg, #001E2B 0%, #0A2A3A 50%, rgba(0, 212, 170, 0.08) 100%)
25
+ cta_label: Try Workflows
26
+ cta_action: navigate
27
+ cta_target: /workflows
28
+
29
+ ## Redesigned Workflows Layout
30
+
31
+ Maximize your canvas with collapsible panels, accordion properties, and library search. Collapse the sidebar to icon-only mode, toggle panels from the toolbar, or use keyboard shortcuts — Cmd+B, Cmd+I, Cmd+\\ for instant focus.
32
+
33
+ ---
34
+
35
+ id: ann-cost-dashboard
36
+ badge: New
37
+ published: 2026-02-15
38
+ expires: 2026-03-20
39
+ icon: 💰
40
+ bg_color: linear-gradient(135deg, #0A1E2B 0%, #112733 50%, rgba(64, 224, 255, 0.07) 100%)
41
+ cta_label: View Dashboard
42
+ cta_action: navigate
43
+ cta_target: /embed
44
+
45
+ ## Live Cost Tracking Dashboard
46
+
47
+ Every embed, rerank, and tool call now shows real-time cost in the status bar. Expand the detail panel to see per-operation breakdowns, token counts, and cumulative spend across your session.
48
+
49
+ ---
50
+
51
+ id: ann-desktop-app
52
+ badge: Update
53
+ published: 2026-02-15
54
+ expires: 2026-04-01
55
+ icon: 🖥️
56
+ bg_color: linear-gradient(135deg, #001E2B 0%, #1C2D38 100%)
57
+ cta_label: Download
58
+ cta_action: link
59
+ cta_target: https://github.com/mrlynn/voyageai-cli/releases
60
+
61
+ ## VAI Desktop App v1.30
62
+
63
+ The signed & notarized macOS desktop app is updated with the full collapsible layout, cost tracking, and all 22 Explore concepts including multimodal. Auto-updates built in.
64
+
65
+ ---
66
+
19
67
  id: ann-voyage-4
20
68
  badge: New Model
21
69
  published: 2026-02-14
22
70
  expires: 2026-03-15
23
71
  icon: 🚀
24
- bg_image: /assets/announcements/circuits.jpg
72
+ bg_color: linear-gradient(135deg, rgba(0, 212, 170, 0.09) 0%, #001E2B 50%, rgba(64, 224, 255, 0.08) 100%)
25
73
  cta_label: Try It Now
26
74
  cta_action: navigate
27
75
  cta_target: /benchmark
@@ -37,7 +85,7 @@ badge: New
37
85
  published: 2026-02-12
38
86
  expires: 2026-03-01
39
87
  icon: 🏪
40
- bg_image: /assets/announcements/green-wave.jpg
88
+ bg_color: linear-gradient(135deg, #112733 0%, #001E2B 100%)
41
89
  cta_label: Explore Marketplace
42
90
  cta_action: navigate
43
91
  cta_target: /workflows
@@ -53,8 +101,7 @@ badge: Update
53
101
  published: 2026-02-10
54
102
  expires: 2026-04-01
55
103
  icon: 📊
56
- bg_image: /assets/announcements/csvingest.jpg
57
- bg_color: linear-gradient(135deg, #1B5E20 0%, #2E7D32 100%)
104
+ bg_color: linear-gradient(135deg, #001E2B 0%, rgba(0, 212, 170, 0.06) 50%, #112733 100%)
58
105
  cta_label: Learn More
59
106
  cta_action: link
60
107
  cta_target: https://docs.vaicli.com/csv-import
@@ -70,7 +117,7 @@ badge: New
70
117
  published: 2026-02-13
71
118
  expires: 2026-03-15
72
119
  icon: ⚡
73
- bg_image: /assets/announcements/appstore.jpg
120
+ bg_color: linear-gradient(135deg, rgba(64, 224, 255, 0.06) 0%, #001E2B 50%, rgba(0, 212, 170, 0.06) 100%)
74
121
  cta_label: Browse Store
75
122
  cta_action: navigate
76
123
  cta_target: /workflows