voyageai-cli 1.30.3 → 1.30.6

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.3",
3
+ "version": "1.30.6",
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"
@@ -57,8 +57,8 @@ function registerApp(program) {
57
57
  .action(async (opts) => {
58
58
  // --version: print version and exit
59
59
  if (opts.version) {
60
- const pkg = require(path.join(__dirname, '..', '..', 'package.json'));
61
- console.log(`voyageai-cli v${pkg.version}`);
60
+ const { getVersion } = require('../lib/banner');
61
+ console.log(`voyageai-cli v${getVersion()}`);
62
62
  return;
63
63
  }
64
64
 
@@ -8,7 +8,8 @@ const { send: sendTelemetry } = require('../lib/telemetry');
8
8
  // Try to get package version safely
9
9
  function getVersion() {
10
10
  try {
11
- return require('../../package.json').version;
11
+ const { getVersion: _getVersion } = require('../lib/banner');
12
+ return _getVersion();
12
13
  } catch {
13
14
  return 'unknown';
14
15
  }
@@ -735,8 +735,7 @@ async function cleanup(mongo) {
735
735
 
736
736
  function getVersion() {
737
737
  try {
738
- const pkg = require('../../package.json');
739
- return pkg.version;
738
+ return require('../lib/banner').getVersion();
740
739
  } catch {
741
740
  return '0.0.0';
742
741
  }
@@ -18,7 +18,7 @@ function saveResults(filePath, results) {
18
18
  const output = {
19
19
  ...results,
20
20
  savedAt: new Date().toISOString(),
21
- vaiVersion: require('../../package.json').version,
21
+ vaiVersion: require('../lib/banner').getVersion(),
22
22
  };
23
23
  fs.writeFileSync(filePath, JSON.stringify(output, null, 2), 'utf8');
24
24
  }
@@ -945,7 +945,7 @@ function registerEvalCompare(evalCmd) {
945
945
  kValues,
946
946
  configs: results,
947
947
  comparedAt: new Date().toISOString(),
948
- vaiVersion: require('../../package.json').version,
948
+ vaiVersion: require('../lib/banner').getVersion(),
949
949
  };
950
950
  fs.writeFileSync(opts.save, JSON.stringify(output, null, 2), 'utf8');
951
951
  if (verbose) {
@@ -465,9 +465,9 @@ function createPlaygroundServer() {
465
465
 
466
466
  // API: Version — return CLI package version
467
467
  if (req.method === 'GET' && req.url === '/api/version') {
468
- const pkg = require('../../package.json');
468
+ const { getVersion } = require('../lib/banner');
469
469
  res.writeHead(200, { 'Content-Type': 'application/json' });
470
- res.end(JSON.stringify({ version: pkg.version }));
470
+ res.end(JSON.stringify({ version: getVersion() }));
471
471
  return;
472
472
  }
473
473
 
@@ -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' });
@@ -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/banner.js CHANGED
@@ -7,8 +7,19 @@ const pc = require('picocolors');
7
7
  * @returns {string}
8
8
  */
9
9
  function getVersion() {
10
- const pkg = require('../../package.json');
11
- return pkg.version || '0.0.0';
10
+ const path = require('path');
11
+ // Walk up from this file to find package.json works both in dev and packaged Electron
12
+ let dir = __dirname;
13
+ for (let i = 0; i < 5; i++) {
14
+ try {
15
+ const pkg = require(path.join(dir, 'package.json'));
16
+ if (pkg.version) return pkg.version;
17
+ } catch (_) { /* keep looking */ }
18
+ const parent = path.dirname(dir);
19
+ if (parent === dir) break;
20
+ dir = parent;
21
+ }
22
+ return '0.0.0';
12
23
  }
13
24
 
14
25
  /**
@@ -322,7 +322,6 @@ function buildContext(project, options = {}) {
322
322
  // Metadata
323
323
  generatedAt: new Date().toISOString(),
324
324
  vaiVersion: getCliVersion(),
325
- vaiVersion: require('../../package.json').version,
326
325
  };
327
326
 
328
327
  return context;
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
 
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const path = require('path');
4
- const pkg = require(path.resolve(__dirname, '..', '..', '..', '..', 'package.json'));
3
+ const { getVersion } = require('../../../lib/banner');
4
+ const pkg = { version: getVersion() };
5
5
 
6
6
  /**
7
7
  * Render normalized data as JSON.
@@ -0,0 +1,459 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { executeWorkflow } = require('./workflow');
6
+
7
+ /**
8
+ * Domain-to-workflow mapping. Each entry defines which workflows are compatible
9
+ * with a given use-case domain, and how to map domain data into workflow inputs.
10
+ */
11
+ const WORKFLOW_DOMAIN_MAP = {
12
+ 'rag-chat': {
13
+ inputMapper: (domain, query) => ({
14
+ question: query.query,
15
+ collection: domain.collectionName,
16
+ collection2: '',
17
+ limit: 10,
18
+ min_score: 0.3,
19
+ system_prompt: 'You are a helpful assistant. Answer based on provided context. Cite sources.',
20
+ chat_history: '',
21
+ }),
22
+ assertions: ['noErrors', 'stepsCompleted', 'nonEmptyOutput'],
23
+ },
24
+ 'search-with-fallback': {
25
+ inputMapper: (domain, query) => ({
26
+ query: query.query,
27
+ primary_collection: domain.collectionName,
28
+ fallback_collection: domain.collectionName, // same collection for testing
29
+ }),
30
+ assertions: ['noErrors', 'stepsCompleted'],
31
+ },
32
+ 'research-and-summarize': {
33
+ inputMapper: (domain, query) => ({
34
+ question: query.query,
35
+ limit: 5,
36
+ }),
37
+ assertions: ['noErrors', 'stepsCompleted'],
38
+ },
39
+ 'multi-collection-search': {
40
+ inputMapper: (domain, query) => ({
41
+ query: query.query,
42
+ collection1: domain.collectionName,
43
+ collection2: domain.collectionName,
44
+ limit: 5,
45
+ }),
46
+ assertions: ['noErrors', 'stepsCompleted'],
47
+ },
48
+ 'consistency-check': {
49
+ inputMapper: (domain, _query) => ({
50
+ topic: domain.title,
51
+ collection1: domain.collectionName,
52
+ collection2: domain.collectionName,
53
+ }),
54
+ assertions: ['noErrors'],
55
+ },
56
+ 'cost-analysis': {
57
+ inputMapper: (domain, _query) => ({
58
+ docs: 100,
59
+ queries: 500,
60
+ months: 1,
61
+ }),
62
+ assertions: ['noErrors', 'stepsCompleted'],
63
+ },
64
+ 'smart-ingest': {
65
+ inputMapper: (domain, _query, sampleDocPath) => {
66
+ // Use first sample doc content if available
67
+ let text = 'This is a test document for integration testing.';
68
+ if (sampleDocPath) {
69
+ const docs = fs.readdirSync(sampleDocPath).filter(f => f.endsWith('.md'));
70
+ if (docs.length > 0) {
71
+ text = fs.readFileSync(path.join(sampleDocPath, docs[0]), 'utf8').slice(0, 2000);
72
+ }
73
+ }
74
+ return {
75
+ text,
76
+ source: 'integration-test',
77
+ threshold: 0.95,
78
+ };
79
+ },
80
+ assertions: ['noErrors'],
81
+ },
82
+ };
83
+
84
+ /**
85
+ * Load a use-case domain dataset.
86
+ *
87
+ * @param {string} domainDataPath - Path to the use-case .ts/.json data file or parsed object
88
+ * @returns {object} Parsed domain data with sampleDocs, exampleQueries, etc.
89
+ */
90
+ function loadDomainData(domainData) {
91
+ if (typeof domainData === 'object') return domainData;
92
+ const raw = fs.readFileSync(domainData, 'utf8');
93
+ return JSON.parse(raw);
94
+ }
95
+
96
+ /**
97
+ * Seed a test collection by ingesting sample documents.
98
+ *
99
+ * @param {object} options
100
+ * @param {string} options.sampleDocsPath - Path to folder of sample .md files
101
+ * @param {string} options.dbName - Target database
102
+ * @param {string} options.collectionName - Target collection
103
+ * @param {string} [options.model] - Voyage model to use
104
+ * @returns {Promise<{ docCount: number, collection: string }>}
105
+ */
106
+ async function seedCollection({ sampleDocsPath, dbName, collectionName, model }) {
107
+ const { connectAndClose } = require('./mongo');
108
+
109
+ // Check if collection already has documents (skip re-seeding)
110
+ const existingCount = await connectAndClose(dbName, collectionName, async (col) => {
111
+ return col.countDocuments();
112
+ });
113
+
114
+ if (existingCount > 0) {
115
+ return { docCount: existingCount, collection: collectionName, seeded: false };
116
+ }
117
+
118
+ // Read all .md files from sample docs
119
+ const files = fs.readdirSync(sampleDocsPath).filter(f => f.endsWith('.md'));
120
+ if (files.length === 0) {
121
+ throw new Error(`No .md files found in ${sampleDocsPath}`);
122
+ }
123
+
124
+ const documents = files.map(f => ({
125
+ text: fs.readFileSync(path.join(sampleDocsPath, f), 'utf8'),
126
+ source: f,
127
+ metadata: { filename: f },
128
+ }));
129
+
130
+ // Chunk and embed
131
+ const { chunk } = require('./chunker');
132
+ const { generateEmbeddings } = require('./api');
133
+
134
+ const allChunks = [];
135
+ for (const doc of documents) {
136
+ const chunks = chunk(doc.text, { strategy: 'recursive', chunkSize: 512, chunkOverlap: 50 });
137
+ for (const c of chunks) {
138
+ allChunks.push({
139
+ text: c.text || c,
140
+ source: doc.source,
141
+ metadata: doc.metadata,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Embed in batches
147
+ const batchSize = 128;
148
+ const allEmbeddings = [];
149
+ for (let i = 0; i < allChunks.length; i += batchSize) {
150
+ const batch = allChunks.slice(i, i + batchSize);
151
+ const texts = batch.map(c => c.text);
152
+ const resp = await generateEmbeddings(texts, {
153
+ model: model || 'voyage-3-lite',
154
+ inputType: 'document',
155
+ });
156
+ allEmbeddings.push(...resp.data.map(d => d.embedding));
157
+ }
158
+
159
+ // Insert into MongoDB
160
+ const docsToInsert = allChunks.map((c, i) => ({
161
+ text: c.text,
162
+ source: c.source,
163
+ metadata: c.metadata,
164
+ embedding: allEmbeddings[i],
165
+ }));
166
+
167
+ await connectAndClose(dbName, collectionName, async (col) => {
168
+ await col.insertMany(docsToInsert);
169
+
170
+ // Create vector search index if it doesn't exist
171
+ try {
172
+ const indexes = await col.listSearchIndexes().toArray();
173
+ const hasIndex = indexes.some(idx => idx.name === 'vector_index');
174
+ if (!hasIndex) {
175
+ await col.createSearchIndex({
176
+ name: 'vector_index',
177
+ type: 'vectorSearch',
178
+ definition: {
179
+ fields: [{
180
+ type: 'vector',
181
+ path: 'embedding',
182
+ numDimensions: allEmbeddings[0].length,
183
+ similarity: 'cosine',
184
+ }],
185
+ },
186
+ });
187
+ }
188
+ } catch {
189
+ // May not be available on non-Atlas deployments
190
+ }
191
+ });
192
+
193
+ return { docCount: docsToInsert.length, collection: collectionName, seeded: true };
194
+ }
195
+
196
+ /**
197
+ * Check if a vector search index exists and is ready.
198
+ * Optionally waits for it to become ready.
199
+ *
200
+ * @param {string} dbName
201
+ * @param {string} collectionName
202
+ * @param {object} [options]
203
+ * @param {string} [options.indexName='vector_index']
204
+ * @param {boolean} [options.wait=false] - Wait for index to become ready
205
+ * @param {number} [options.timeoutMs=120000] - Max wait time
206
+ * @param {function} [options.onProgress] - Progress callback
207
+ * @returns {Promise<boolean>}
208
+ */
209
+ async function checkVectorIndex(dbName, collectionName, options = {}) {
210
+ const { indexName = 'vector_index', wait = false, timeoutMs = 120000, onProgress = () => {} } = options;
211
+ const { getMongoCollection } = require('./mongo');
212
+
213
+ const deadline = Date.now() + timeoutMs;
214
+
215
+ while (true) {
216
+ const { client, collection } = await getMongoCollection(dbName, collectionName);
217
+ try {
218
+ const indexes = await collection.listSearchIndexes().toArray();
219
+ const idx = indexes.find(i => i.name === indexName);
220
+ if (idx && idx.status === 'READY') return true;
221
+ if (!wait || Date.now() >= deadline) return false;
222
+ const status = idx ? idx.status : 'NOT_FOUND';
223
+ onProgress({ phase: 'index', message: `Index status: ${status}, waiting...` });
224
+ } catch {
225
+ if (!wait || Date.now() >= deadline) return false;
226
+ } finally {
227
+ await client.close();
228
+ }
229
+ // Wait 5 seconds before checking again
230
+ await new Promise(r => setTimeout(r, 5000));
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Run integration tests for a domain against compatible workflows.
236
+ *
237
+ * @param {object} options
238
+ * @param {object} options.domain - Domain data (from use-case data files)
239
+ * @param {string} options.sampleDocsPath - Path to sample doc files
240
+ * @param {string} options.workflowsDir - Path to workflow JSON definitions
241
+ * @param {string[]} [options.workflows] - Specific workflow names to test (default: all compatible)
242
+ * @param {boolean} [options.seed] - Whether to seed data first (default: true)
243
+ * @param {boolean} [options.teardown] - Whether to drop test collections after (default: false)
244
+ * @param {function} [options.onProgress] - Progress callback
245
+ * @returns {Promise<IntegrationTestResults>}
246
+ */
247
+ async function runIntegrationTests(options) {
248
+ const {
249
+ domain,
250
+ sampleDocsPath,
251
+ workflowsDir,
252
+ workflows: requestedWorkflows,
253
+ seed = true,
254
+ teardown = false,
255
+ onProgress = () => {},
256
+ } = options;
257
+
258
+ const testCollectionName = `vai_test_${domain.slug || domain.collectionName}`;
259
+ const testDomain = { ...domain, collectionName: testCollectionName };
260
+ const results = {
261
+ domain: domain.slug || domain.title,
262
+ collection: testCollectionName,
263
+ seed: null,
264
+ indexReady: false,
265
+ workflows: [],
266
+ summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
267
+ };
268
+
269
+ // Step 1: Seed
270
+ if (seed && sampleDocsPath) {
271
+ onProgress({ phase: 'seed', message: `Seeding ${testCollectionName}...` });
272
+ try {
273
+ results.seed = await seedCollection({
274
+ sampleDocsPath,
275
+ dbName: domain.dbName || 'vai_integration_test',
276
+ collectionName: testCollectionName,
277
+ model: domain.voyageModel,
278
+ });
279
+ onProgress({ phase: 'seed', message: `Seeded ${results.seed.docCount} chunks` });
280
+ } catch (err) {
281
+ results.seed = { error: err.message };
282
+ onProgress({ phase: 'seed', message: `Seed failed: ${err.message}` });
283
+ // Can't continue without data for most workflows
284
+ }
285
+ }
286
+
287
+ // Step 2: Check vector index (wait up to 2 minutes for it to become ready)
288
+ onProgress({ phase: 'index', message: 'Checking vector search index...' });
289
+ results.indexReady = await checkVectorIndex(
290
+ domain.dbName || 'vai_integration_test',
291
+ testCollectionName,
292
+ { wait: true, timeoutMs: 120000, onProgress }
293
+ );
294
+ if (!results.indexReady) {
295
+ onProgress({ phase: 'index', message: 'WARNING: Vector index not ready — query-based workflows may fail' });
296
+ } else {
297
+ onProgress({ phase: 'index', message: 'Vector index ready' });
298
+ }
299
+
300
+ // Step 3: Run workflows
301
+ const availableWorkflows = fs.readdirSync(workflowsDir)
302
+ .filter(f => f.endsWith('.json'))
303
+ .map(f => f.replace('.json', ''));
304
+
305
+ const workflowsToTest = requestedWorkflows
306
+ ? requestedWorkflows.filter(w => availableWorkflows.includes(w) && WORKFLOW_DOMAIN_MAP[w])
307
+ : availableWorkflows.filter(w => WORKFLOW_DOMAIN_MAP[w]);
308
+
309
+ const queries = domain.exampleQueries || [];
310
+ if (queries.length === 0) {
311
+ queries.push({ query: domain.title, explanation: 'Fallback query from domain title' });
312
+ }
313
+
314
+ for (const wfName of workflowsToTest) {
315
+ const mapping = WORKFLOW_DOMAIN_MAP[wfName];
316
+ if (!mapping) {
317
+ results.workflows.push({ name: wfName, status: 'skipped', reason: 'No domain mapping' });
318
+ results.summary.skipped++;
319
+ results.summary.total++;
320
+ continue;
321
+ }
322
+
323
+ const wfPath = path.join(workflowsDir, `${wfName}.json`);
324
+ const definition = JSON.parse(fs.readFileSync(wfPath, 'utf8'));
325
+
326
+ // Test with first example query
327
+ const testQuery = queries[0];
328
+ const inputs = mapping.inputMapper(testDomain, testQuery, sampleDocsPath);
329
+
330
+ onProgress({ phase: 'workflow', message: `Running ${wfName} with query: "${testQuery.query}"` });
331
+
332
+ const wfResult = {
333
+ name: wfName,
334
+ query: testQuery.query,
335
+ inputs,
336
+ status: 'passed',
337
+ steps: [],
338
+ assertions: [],
339
+ errors: [],
340
+ durationMs: 0,
341
+ };
342
+
343
+ const start = Date.now();
344
+ try {
345
+ // Inject db and embedding model into workflow defaults so query/rerank
346
+ // steps use the same model the documents were embedded with
347
+ const testDefinition = {
348
+ ...definition,
349
+ defaults: {
350
+ ...(definition.defaults || {}),
351
+ db: domain.dbName || 'vai_integration_test',
352
+ model: domain.voyageModel,
353
+ },
354
+ };
355
+ const result = await executeWorkflow(testDefinition, {
356
+ inputs,
357
+ db: domain.dbName || 'vai_integration_test',
358
+ });
359
+ wfResult.durationMs = Date.now() - start;
360
+ wfResult.steps = (result.steps || []).map(s => ({
361
+ id: s.id,
362
+ tool: s.tool,
363
+ skipped: s.skipped || false,
364
+ error: s.error || null,
365
+ durationMs: s.durationMs,
366
+ }));
367
+
368
+ // Run assertions
369
+ if (mapping.assertions.includes('noErrors')) {
370
+ const errorSteps = (result.steps || []).filter(s => s.error);
371
+ if (errorSteps.length > 0) {
372
+ wfResult.assertions.push({
373
+ pass: false,
374
+ name: 'noErrors',
375
+ message: `${errorSteps.length} step(s) errored: ${errorSteps.map(s => `${s.id}: ${s.error}`).join('; ')}`,
376
+ });
377
+ wfResult.status = 'failed';
378
+ } else {
379
+ wfResult.assertions.push({ pass: true, name: 'noErrors', message: 'All steps error-free' });
380
+ }
381
+ }
382
+
383
+ if (mapping.assertions.includes('stepsCompleted')) {
384
+ const completedSteps = (result.steps || []).filter(s => !s.skipped && !s.error);
385
+ if (completedSteps.length === 0) {
386
+ wfResult.assertions.push({ pass: false, name: 'stepsCompleted', message: 'No steps completed' });
387
+ wfResult.status = 'failed';
388
+ } else {
389
+ wfResult.assertions.push({ pass: true, name: 'stepsCompleted', message: `${completedSteps.length} steps completed` });
390
+ }
391
+ }
392
+
393
+ if (mapping.assertions.includes('nonEmptyOutput')) {
394
+ const output = result.output || {};
395
+ const hasContent = Object.values(output).some(v =>
396
+ v && (typeof v === 'string' ? v.length > 0 : Array.isArray(v) ? v.length > 0 : true)
397
+ );
398
+ if (!hasContent) {
399
+ wfResult.assertions.push({ pass: false, name: 'nonEmptyOutput', message: 'Output is empty' });
400
+ wfResult.status = 'failed';
401
+ } else {
402
+ wfResult.assertions.push({ pass: true, name: 'nonEmptyOutput', message: 'Output has content' });
403
+ }
404
+ }
405
+
406
+ // Check expected sources if the query has sampleResults
407
+ if (testQuery.sampleResults && testQuery.sampleResults.length > 0 && result.output) {
408
+ const outputStr = JSON.stringify(result.output).toLowerCase();
409
+ const expectedSource = testQuery.sampleResults[0].source.toLowerCase();
410
+ const baseName = expectedSource.replace('.md', '');
411
+ const found = outputStr.includes(expectedSource) || outputStr.includes(baseName);
412
+ wfResult.assertions.push({
413
+ pass: found,
414
+ name: 'expectedSource',
415
+ message: found
416
+ ? `Found expected source: ${testQuery.sampleResults[0].source}`
417
+ : `Expected source "${testQuery.sampleResults[0].source}" not found in output (sources: ${
418
+ (result.output.sources || []).map(s => s.source || s.filename || 'unknown').join(', ') || 'none'
419
+ })`,
420
+ });
421
+ // Source matching is a soft signal — don't fail the whole test, just warn
422
+ if (!found) wfResult.assertions[wfResult.assertions.length - 1].warn = true;
423
+ }
424
+
425
+ } catch (err) {
426
+ wfResult.durationMs = Date.now() - start;
427
+ wfResult.status = 'failed';
428
+ wfResult.errors.push(err.message);
429
+ }
430
+
431
+ results.workflows.push(wfResult);
432
+ results.summary.total++;
433
+ if (wfResult.status === 'passed') results.summary.passed++;
434
+ else if (wfResult.status === 'failed') results.summary.failed++;
435
+ else results.summary.skipped++;
436
+ }
437
+
438
+ // Step 4: Teardown
439
+ if (teardown) {
440
+ onProgress({ phase: 'teardown', message: `Dropping ${testCollectionName}...` });
441
+ try {
442
+ const { connectAndClose } = require('./mongo');
443
+ await connectAndClose(domain.dbName || 'vai_integration_test', testCollectionName, async (col) => {
444
+ await col.drop();
445
+ });
446
+ } catch (err) {
447
+ onProgress({ phase: 'teardown', message: `Teardown failed: ${err.message}` });
448
+ }
449
+ }
450
+
451
+ return results;
452
+ }
453
+
454
+ module.exports = {
455
+ WORKFLOW_DOMAIN_MAP,
456
+ seedCollection,
457
+ checkVectorIndex,
458
+ runIntegrationTests,
459
+ };
@@ -149,7 +149,8 @@ function validatePackage(packagePath, pkg) {
149
149
 
150
150
  if (pkg.vai.minVaiVersion) {
151
151
  try {
152
- const { version: currentVersion } = require('../../package.json');
152
+ const { getVersion } = require('./banner');
153
+ const currentVersion = getVersion();
153
154
  if (compareVersions(pkg.vai.minVaiVersion, currentVersion) > 0) {
154
155
  warnings.push(`Requires vai >= ${pkg.vai.minVaiVersion} (you have ${currentVersion})`);
155
156
  }
@@ -1161,6 +1161,19 @@ async function executeHttp(inputs) {
1161
1161
  };
1162
1162
  }
1163
1163
 
1164
+ /**
1165
+ * Resolve db and collection from inputs → defaults → project → vai config.
1166
+ */
1167
+ function resolveDbAndCollection(inputs, defaults) {
1168
+ const { loadProject } = require('./project');
1169
+ const { getConfigValue } = require('./config');
1170
+ const { config: proj } = loadProject();
1171
+ return {
1172
+ db: inputs.db || defaults.db || proj.db || process.env.VAI_DEFAULT_DB || getConfigValue('defaultDb'),
1173
+ collection: inputs.collection || defaults.collection || proj.collection || process.env.VAI_DEFAULT_COLLECTION || getConfigValue('defaultCollection'),
1174
+ };
1175
+ }
1176
+
1164
1177
  /**
1165
1178
  * Execute a MongoDB aggregation pipeline step.
1166
1179
  *
@@ -1170,11 +1183,8 @@ async function executeHttp(inputs) {
1170
1183
  */
1171
1184
  async function executeAggregate(inputs, defaults) {
1172
1185
  const { getMongoCollection } = require('./mongo');
1173
- const { loadProject } = require('./project');
1174
- const { config: proj } = loadProject();
1175
1186
 
1176
- const db = inputs.db || defaults.db || proj.db;
1177
- const collection = inputs.collection || defaults.collection || proj.collection;
1187
+ const { db, collection } = resolveDbAndCollection(inputs, defaults);
1178
1188
  const pipeline = inputs.pipeline;
1179
1189
  const allowWrites = inputs.allowWrites || false;
1180
1190
 
@@ -1217,11 +1227,8 @@ async function executeAggregate(inputs, defaults) {
1217
1227
  async function executeQuery(inputs, defaults) {
1218
1228
  const { generateEmbeddings, apiRequest } = require('./api');
1219
1229
  const { getMongoCollection } = require('./mongo');
1220
- const { loadProject } = require('./project');
1221
- const { config: proj } = loadProject();
1222
1230
 
1223
- const db = inputs.db || defaults.db || proj.db;
1224
- const collection = inputs.collection || defaults.collection || proj.collection;
1231
+ const { db, collection } = resolveDbAndCollection(inputs, defaults);
1225
1232
  const model = inputs.model || defaults.model;
1226
1233
  const query = inputs.query;
1227
1234
  const limit = inputs.limit || 10;
@@ -1381,11 +1388,8 @@ async function executeIngest(inputs, defaults) {
1381
1388
  const { generateEmbeddings } = require('./api');
1382
1389
  const { getMongoCollection } = require('./mongo');
1383
1390
  const { chunk } = require('./chunker');
1384
- const { loadProject } = require('./project');
1385
- const { config: proj } = loadProject();
1386
1391
 
1387
- const db = inputs.db || defaults.db || proj.db;
1388
- const collection = inputs.collection || defaults.collection || proj.collection;
1392
+ const { db, collection } = resolveDbAndCollection(inputs, defaults);
1389
1393
  const text = inputs.text;
1390
1394
  const source = inputs.source || 'workflow-ingest';
1391
1395
  const model = inputs.model || defaults.model;
@@ -1464,10 +1468,8 @@ async function executeIngest(inputs, defaults) {
1464
1468
  */
1465
1469
  async function executeCollections(inputs, defaults) {
1466
1470
  const { introspectCollections } = require('./workflow-utils');
1467
- const { loadProject } = require('./project');
1468
- const { config: proj } = loadProject();
1469
1471
 
1470
- const db = inputs.db || defaults.db || proj.db;
1472
+ const { db } = resolveDbAndCollection(inputs, defaults);
1471
1473
  if (!db) throw new Error('collections: database not specified');
1472
1474
 
1473
1475
  const collections = await introspectCollections(db);
package/src/mcp/server.js CHANGED
@@ -12,7 +12,8 @@ const { registerWorkspaceTools } = require('./tools/workspace');
12
12
  const { registerCodeSearchTools } = require('./tools/code-search');
13
13
  const { registerAuthoringTools } = require('./tools/authoring');
14
14
 
15
- const VERSION = require('../../package.json').version;
15
+ const { getVersion } = require('../lib/banner');
16
+ const VERSION = getVersion();
16
17
 
17
18
  /**
18
19
  * Create and configure the MCP server with all tools registered.
@@ -11,7 +11,8 @@ const { introspectCollections } = require('../../lib/workflow-utils');
11
11
  */
12
12
  async function handleVaiCollections(input) {
13
13
  const { config: proj } = loadProject();
14
- const dbName = input.db || proj.db;
14
+ const { getConfigValue } = require('../../lib/config');
15
+ const dbName = input.db || proj.db || process.env.VAI_DEFAULT_DB || getConfigValue('defaultDb');
15
16
  if (!dbName) throw new Error('No database specified. Pass db parameter or configure via vai init.');
16
17
 
17
18
  const collections = await introspectCollections(dbName);
@@ -7915,6 +7915,10 @@ Reranking models rescore initial search results to improve relevance ordering.</
7915
7915
  <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-config"/></svg>
7916
7916
  <span>General</span>
7917
7917
  </button>
7918
+ <button class="settings-nav-item" data-settings-section="database">
7919
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-database"/></svg>
7920
+ <span>Database</span>
7921
+ </button>
7918
7922
  <button class="settings-nav-item" data-settings-section="appearance">
7919
7923
  <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-palette"/></svg>
7920
7924
  <span>Appearance</span>
@@ -8018,6 +8022,47 @@ Reranking models rescore initial search results to improve relevance ordering.</
8018
8022
  </div>
8019
8023
  </div>
8020
8024
 
8025
+ <!-- ── Database ── -->
8026
+ <div class="settings-panel" id="settings-database">
8027
+ <div class="settings-panel-header">
8028
+ <h3 class="settings-panel-title">Database</h3>
8029
+ <p class="settings-panel-subtitle">MongoDB Atlas connection and default targets</p>
8030
+ </div>
8031
+ <div class="settings-section">
8032
+ <div class="settings-row">
8033
+ <div class="settings-label">
8034
+ <span class="settings-label-text">MongoDB URI <span class="settings-origin" data-origin-key="mongodbUri"></span></span>
8035
+ <span class="settings-label-hint">Connection string for MongoDB Atlas · <a href="https://cloud.mongodb.com" target="_blank" class="settings-key-link"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>Atlas Console</a></span>
8036
+ </div>
8037
+ <div class="settings-control" style="min-width:260px;">
8038
+ <div class="settings-api-field">
8039
+ <input type="password" id="settingsMongoUri" placeholder="mongodb+srv://user:pass@cluster.mongodb.net/" autocomplete="off" spellcheck="false">
8040
+ <button type="button" id="settingsMongoUriToggle" title="Show/hide"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg></button>
8041
+ <button type="button" id="settingsMongoUriSave" class="save-btn" title="Save">Save</button>
8042
+ </div>
8043
+ </div>
8044
+ </div>
8045
+ <div class="settings-row">
8046
+ <div class="settings-label">
8047
+ <span class="settings-label-text">Default Database <span class="settings-origin" data-origin-key="db"></span></span>
8048
+ <span class="settings-label-hint">Used by workflows and commands when no database is specified</span>
8049
+ </div>
8050
+ <div class="settings-control">
8051
+ <input type="text" class="settings-input" id="settingsDefaultDb" placeholder="e.g. my_knowledge_base">
8052
+ </div>
8053
+ </div>
8054
+ <div class="settings-row">
8055
+ <div class="settings-label">
8056
+ <span class="settings-label-text">Default Collection <span class="settings-origin" data-origin-key="collection"></span></span>
8057
+ <span class="settings-label-hint">Used by workflows and commands when no collection is specified</span>
8058
+ </div>
8059
+ <div class="settings-control">
8060
+ <input type="text" class="settings-input" id="settingsDefaultCollection" placeholder="e.g. documents">
8061
+ </div>
8062
+ </div>
8063
+ </div>
8064
+ </div>
8065
+
8021
8066
  <!-- ── Appearance ── -->
8022
8067
  <div class="settings-panel" id="settings-appearance">
8023
8068
  <div class="settings-panel-header">
@@ -11845,6 +11890,100 @@ function initSettings() {
11845
11890
  });
11846
11891
  }
11847
11892
 
11893
+ // ── Database settings (read/write ~/.vai/config.json via /api/settings) ──
11894
+ const dbSettingsFields = {
11895
+ 'settingsMongoUri': 'mongodb-uri',
11896
+ 'settingsDefaultDb': 'default-db',
11897
+ 'settingsDefaultCollection': 'default-collection',
11898
+ };
11899
+
11900
+ // Load current values from ~/.vai/config.json
11901
+ fetch('/api/settings').then(r => r.json()).then(settings => {
11902
+ for (const [elemId, cliKey] of Object.entries(dbSettingsFields)) {
11903
+ const el = document.getElementById(elemId);
11904
+ if (el && settings[cliKey] && settings[cliKey].isSet) {
11905
+ // Don't populate masked secret values into editable fields
11906
+ if (settings[cliKey].isSecret) {
11907
+ el.value = settings[cliKey].value || '';
11908
+ el.dataset.isMasked = 'true';
11909
+ } else {
11910
+ el.value = settings[cliKey].value || '';
11911
+ }
11912
+ }
11913
+ }
11914
+ }).catch(() => {});
11915
+
11916
+ // Save non-secret fields on change (default-db, default-collection)
11917
+ ['settingsDefaultDb', 'settingsDefaultCollection'].forEach(elemId => {
11918
+ const el = document.getElementById(elemId);
11919
+ if (el) {
11920
+ let debounce;
11921
+ el.addEventListener('input', () => {
11922
+ clearTimeout(debounce);
11923
+ debounce = setTimeout(() => {
11924
+ const cliKey = dbSettingsFields[elemId];
11925
+ fetch('/api/settings', {
11926
+ method: 'PUT',
11927
+ headers: { 'Content-Type': 'application/json' },
11928
+ body: JSON.stringify({ [cliKey]: el.value || null }),
11929
+ }).then(() => flashSaved()).catch(() => {});
11930
+ }, 600);
11931
+ });
11932
+ }
11933
+ });
11934
+
11935
+ // MongoDB URI — save button (secret field, same pattern as API key)
11936
+ const mongoUriInput = document.getElementById('settingsMongoUri');
11937
+ const mongoUriToggle = document.getElementById('settingsMongoUriToggle');
11938
+ const mongoUriSave = document.getElementById('settingsMongoUriSave');
11939
+ if (mongoUriInput && mongoUriSave) {
11940
+ let mongoUriVisible = false;
11941
+ if (mongoUriToggle) {
11942
+ mongoUriToggle.addEventListener('click', async () => {
11943
+ if (!mongoUriVisible) {
11944
+ // Fetch the real unmasked value
11945
+ try {
11946
+ const resp = await fetch('/api/settings/reveal/mongodb-uri');
11947
+ const data = await resp.json();
11948
+ if (data.value) {
11949
+ mongoUriInput.value = data.value;
11950
+ delete mongoUriInput.dataset.isMasked;
11951
+ }
11952
+ } catch {}
11953
+ mongoUriInput.type = 'text';
11954
+ mongoUriVisible = true;
11955
+ } else {
11956
+ mongoUriInput.type = 'password';
11957
+ mongoUriVisible = false;
11958
+ }
11959
+ });
11960
+ }
11961
+ mongoUriSave.addEventListener('click', () => {
11962
+ // Don't save masked values back
11963
+ if (mongoUriInput.dataset.isMasked === 'true') return;
11964
+ const val = mongoUriInput.value.trim();
11965
+ fetch('/api/settings', {
11966
+ method: 'PUT',
11967
+ headers: { 'Content-Type': 'application/json' },
11968
+ body: JSON.stringify({ 'mongodb-uri': val || null }),
11969
+ }).then(() => {
11970
+ flashSaved();
11971
+ if (val) {
11972
+ mongoUriInput.value = '';
11973
+ mongoUriInput.type = 'password';
11974
+ mongoUriVisible = false;
11975
+ mongoUriInput.placeholder = val.slice(0, 20) + '...';
11976
+ }
11977
+ }).catch(() => {});
11978
+ });
11979
+ mongoUriInput.addEventListener('input', () => {
11980
+ delete mongoUriInput.dataset.isMasked;
11981
+ });
11982
+ mongoUriInput.addEventListener('keydown', (e) => {
11983
+ if (e.key === 'Enter') mongoUriSave.click();
11984
+ });
11985
+ }
11986
+
11848
11987
  // Set up settings sub-navigation
11849
11988
  setupSettingsNav();
11850
11989