voyageai-cli 1.20.6 → 1.22.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 (42) hide show
  1. package/CHANGELOG.md +142 -26
  2. package/README.md +130 -2
  3. package/package.json +3 -2
  4. package/src/cli.js +10 -0
  5. package/src/commands/bug.js +249 -0
  6. package/src/commands/eval.js +420 -10
  7. package/src/commands/generate.js +220 -0
  8. package/src/commands/playground.js +93 -0
  9. package/src/commands/purge.js +271 -0
  10. package/src/commands/refresh.js +322 -0
  11. package/src/commands/scaffold.js +217 -0
  12. package/src/lib/codegen.js +339 -0
  13. package/src/lib/explanations.js +155 -0
  14. package/src/lib/scaffold-structure.js +114 -0
  15. package/src/lib/templates/nextjs/README.md.tpl +106 -0
  16. package/src/lib/templates/nextjs/env.example.tpl +8 -0
  17. package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
  18. package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
  19. package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
  20. package/src/lib/templates/nextjs/package.json.tpl +33 -0
  21. package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
  22. package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
  23. package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
  24. package/src/lib/templates/nextjs/theme.js.tpl +84 -0
  25. package/src/lib/templates/python/README.md.tpl +145 -0
  26. package/src/lib/templates/python/app.py.tpl +221 -0
  27. package/src/lib/templates/python/chunker.py.tpl +127 -0
  28. package/src/lib/templates/python/env.example.tpl +12 -0
  29. package/src/lib/templates/python/mongo_client.py.tpl +125 -0
  30. package/src/lib/templates/python/requirements.txt.tpl +10 -0
  31. package/src/lib/templates/python/voyage_client.py.tpl +124 -0
  32. package/src/lib/templates/vanilla/README.md.tpl +156 -0
  33. package/src/lib/templates/vanilla/client.js.tpl +103 -0
  34. package/src/lib/templates/vanilla/connection.js.tpl +126 -0
  35. package/src/lib/templates/vanilla/env.example.tpl +11 -0
  36. package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
  37. package/src/lib/templates/vanilla/package.json.tpl +31 -0
  38. package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
  39. package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
  40. package/src/lib/templates/vanilla/server.js.tpl +81 -0
  41. package/src/lib/zip.js +130 -0
  42. package/src/playground/index.html +708 -3
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const path = require('path');
4
5
  const { getDefaultModel, DEFAULT_RERANK_MODEL, MODEL_CATALOG } = require('../lib/catalog');
5
6
  const { generateEmbeddings, apiRequest } = require('../lib/api');
6
7
  const { getMongoCollection } = require('../lib/mongo');
@@ -8,6 +9,105 @@ const { loadProject } = require('../lib/project');
8
9
  const { computeMetrics, aggregateMetrics } = require('../lib/metrics');
9
10
  const ui = require('../lib/ui');
10
11
 
12
+ /**
13
+ * Save evaluation results to a JSON file.
14
+ * @param {string} filePath - Output path
15
+ * @param {object} results - Results object to save
16
+ */
17
+ function saveResults(filePath, results) {
18
+ const output = {
19
+ ...results,
20
+ savedAt: new Date().toISOString(),
21
+ vaiVersion: require('../../package.json').version,
22
+ };
23
+ fs.writeFileSync(filePath, JSON.stringify(output, null, 2), 'utf8');
24
+ }
25
+
26
+ /**
27
+ * Load baseline results from a JSON file.
28
+ * @param {string} filePath - Input path
29
+ * @returns {object} Loaded results
30
+ */
31
+ function loadBaseline(filePath) {
32
+ if (!fs.existsSync(filePath)) {
33
+ throw new Error(`Baseline file not found: ${filePath}`);
34
+ }
35
+ const content = fs.readFileSync(filePath, 'utf8');
36
+ return JSON.parse(content);
37
+ }
38
+
39
+ /**
40
+ * Compute deltas between current and baseline results.
41
+ * @param {object} current - Current aggregated metrics
42
+ * @param {object} baseline - Baseline aggregated metrics
43
+ * @returns {object} Deltas with direction indicators
44
+ */
45
+ function computeDeltas(current, baseline) {
46
+ const deltas = {};
47
+ for (const key of Object.keys(current)) {
48
+ if (baseline[key] !== undefined) {
49
+ const diff = current[key] - baseline[key];
50
+ const pctChange = baseline[key] !== 0
51
+ ? ((diff / baseline[key]) * 100).toFixed(1)
52
+ : (diff > 0 ? '+∞' : diff < 0 ? '-∞' : '0');
53
+ deltas[key] = {
54
+ current: current[key],
55
+ baseline: baseline[key],
56
+ diff,
57
+ pctChange,
58
+ improved: diff > 0.001,
59
+ regressed: diff < -0.001,
60
+ };
61
+ }
62
+ }
63
+ return deltas;
64
+ }
65
+
66
+ /**
67
+ * Print comparison between current and baseline results.
68
+ * @param {object} deltas - Delta object from computeDeltas
69
+ */
70
+ function printBaselineComparison(deltas) {
71
+ console.log('');
72
+ console.log(ui.bold('Comparison with baseline:'));
73
+ console.log('');
74
+
75
+ const metricKeys = Object.keys(deltas);
76
+ const maxKeyLen = Math.max(...metricKeys.map(k => k.length));
77
+
78
+ for (const key of metricKeys) {
79
+ const d = deltas[key];
80
+ const label = key.toUpperCase().padEnd(maxKeyLen + 1);
81
+ const currentStr = d.current.toFixed(4);
82
+ const baselineStr = d.baseline.toFixed(4);
83
+
84
+ let diffStr;
85
+ if (d.improved) {
86
+ diffStr = ui.green(`+${d.diff.toFixed(4)} (${d.pctChange}%)`);
87
+ } else if (d.regressed) {
88
+ diffStr = ui.red(`${d.diff.toFixed(4)} (${d.pctChange}%)`);
89
+ } else {
90
+ diffStr = ui.dim(`${d.diff >= 0 ? '+' : ''}${d.diff.toFixed(4)} (${d.pctChange}%)`);
91
+ }
92
+
93
+ console.log(` ${label} ${currentStr} vs ${baselineStr} ${diffStr}`);
94
+ }
95
+
96
+ // Summary
97
+ const improved = Object.values(deltas).filter(d => d.improved).length;
98
+ const regressed = Object.values(deltas).filter(d => d.regressed).length;
99
+ const unchanged = metricKeys.length - improved - regressed;
100
+
101
+ console.log('');
102
+ if (improved > regressed) {
103
+ console.log(ui.success(` Overall: ${improved} improved, ${regressed} regressed, ${unchanged} unchanged`));
104
+ } else if (regressed > improved) {
105
+ console.log(ui.warn(` Overall: ${improved} improved, ${regressed} regressed, ${unchanged} unchanged`));
106
+ } else {
107
+ console.log(ui.dim(` Overall: ${improved} improved, ${regressed} regressed, ${unchanged} unchanged`));
108
+ }
109
+ }
110
+
11
111
  /**
12
112
  * Load a test set from a JSONL file.
13
113
  *
@@ -61,9 +161,14 @@ function loadTestSet(filePath, mode = 'retrieval') {
61
161
  * @param {import('commander').Command} program
62
162
  */
63
163
  function registerEval(program) {
64
- program
164
+ const evalCmd = program
65
165
  .command('eval')
66
- .description('Evaluate retrieval & reranking quality — MRR, NDCG, Recall on your data')
166
+ .description('Evaluate retrieval & reranking quality — MRR, NDCG, Recall on your data');
167
+
168
+ // Register compare subcommand
169
+ registerEvalCompare(evalCmd);
170
+
171
+ evalCmd
67
172
  .requiredOption('--test-set <path>', 'JSONL file with queries and expected results')
68
173
  .option('--mode <mode>', 'Evaluation mode: "retrieval" (default) or "rerank"', 'retrieval')
69
174
  .option('--db <database>', 'Database name (retrieval mode)')
@@ -82,6 +187,8 @@ function registerEval(program) {
82
187
  .option('--text-field <name>', 'Document text field', 'text')
83
188
  .option('--id-field <name>', 'Document ID field for matching (default: _id)', '_id')
84
189
  .option('--compare <configs>', 'Compare configs: "model1,model2" or "rerank,no-rerank"')
190
+ .option('--save <path>', 'Save results to JSON file for later comparison')
191
+ .option('--baseline <path>', 'Compare against baseline results from previous run')
85
192
  .option('--json', 'Machine-readable JSON output')
86
193
  .option('-q, --quiet', 'Suppress non-essential output')
87
194
  .action(async (opts) => {
@@ -241,14 +348,43 @@ function registerEval(program) {
241
348
  const sorted = [...perQueryResults].sort((a, b) => a.metrics.mrr - b.metrics.mrr);
242
349
  const worstQueries = sorted.slice(0, Math.min(3, sorted.length));
243
350
 
351
+ // Build results object for saving/comparison
352
+ const resultsObj = {
353
+ mode: 'retrieval',
354
+ config: { model, rerank: doRerank, rerankModel: doRerank ? rerankModel : null, db, collection, kValues },
355
+ summary: aggregated,
356
+ tokens: { embed: totalEmbedTokens, rerank: totalRerankTokens },
357
+ queries: perQueryResults.length,
358
+ perQuery: perQueryResults,
359
+ };
360
+
361
+ // Save results if --save specified
362
+ if (opts.save) {
363
+ saveResults(opts.save, resultsObj);
364
+ if (verbose) {
365
+ console.log(ui.success(`Results saved to ${opts.save}`));
366
+ console.log('');
367
+ }
368
+ }
369
+
370
+ // Load and compare with baseline if --baseline specified
371
+ let baseline = null;
372
+ let deltas = null;
373
+ if (opts.baseline) {
374
+ try {
375
+ baseline = loadBaseline(opts.baseline);
376
+ deltas = computeDeltas(aggregated, baseline.summary);
377
+ } catch (err) {
378
+ console.error(ui.warn(`Could not load baseline: ${err.message}`));
379
+ }
380
+ }
381
+
244
382
  if (opts.json) {
245
- console.log(JSON.stringify({
246
- config: { model, rerank: doRerank, rerankModel: doRerank ? rerankModel : null, db, collection, kValues },
247
- summary: aggregated,
248
- tokens: { embed: totalEmbedTokens, rerank: totalRerankTokens },
249
- queries: perQueryResults.length,
250
- perQuery: perQueryResults,
251
- }, null, 2));
383
+ if (deltas) {
384
+ resultsObj.baseline = { path: opts.baseline, savedAt: baseline.savedAt };
385
+ resultsObj.deltas = deltas;
386
+ }
387
+ console.log(JSON.stringify(resultsObj, null, 2));
252
388
  return;
253
389
  }
254
390
 
@@ -285,6 +421,11 @@ function registerEval(program) {
285
421
  console.log('');
286
422
  console.log(ui.dim(` ${testSet.length} queries evaluated | Tokens: embed ${totalEmbedTokens}${totalRerankTokens ? `, rerank ${totalRerankTokens}` : ''}`));
287
423
 
424
+ // Print baseline comparison if available
425
+ if (deltas) {
426
+ printBaselineComparison(deltas);
427
+ }
428
+
288
429
  // Suggestions
289
430
  const mrr = aggregated.mrr;
290
431
  const recall5 = aggregated['r@5'];
@@ -618,4 +759,273 @@ function renderBar(value, width) {
618
759
  return '█'.repeat(filled) + '░'.repeat(empty);
619
760
  }
620
761
 
621
- module.exports = { registerEval };
762
+ /**
763
+ * Register the eval compare subcommand.
764
+ * Compares multiple configurations side-by-side on the same test set.
765
+ *
766
+ * @param {import('commander').Command} evalCmd - The eval command to add compare to
767
+ */
768
+ function registerEvalCompare(evalCmd) {
769
+ evalCmd
770
+ .command('compare')
771
+ .description('Compare multiple configurations on the same test set')
772
+ .requiredOption('--test-set <path>', 'JSONL file with queries and expected results')
773
+ .requiredOption('--configs <paths>', 'Comma-separated paths to config JSON files')
774
+ .option('--mode <mode>', 'Evaluation mode: "retrieval" (default) or "rerank"', 'retrieval')
775
+ .option('-k, --k-values <values>', 'Comma-separated K values for @K metrics', '1,3,5,10')
776
+ .option('--save <path>', 'Save comparison results to JSON file')
777
+ .option('--json', 'Machine-readable JSON output')
778
+ .option('-q, --quiet', 'Suppress non-essential output')
779
+ .action(async (opts) => {
780
+ try {
781
+ const configPaths = opts.configs.split(',').map(p => p.trim());
782
+ const kValues = opts.kValues.split(',').map(v => parseInt(v.trim(), 10)).filter(v => !isNaN(v));
783
+ const verbose = !opts.json && !opts.quiet;
784
+
785
+ // Load config files
786
+ const configs = [];
787
+ for (const configPath of configPaths) {
788
+ if (!fs.existsSync(configPath)) {
789
+ console.error(ui.error(`Config file not found: ${configPath}`));
790
+ process.exit(1);
791
+ }
792
+ const content = fs.readFileSync(configPath, 'utf8');
793
+ const config = JSON.parse(content);
794
+ config._path = configPath;
795
+ configs.push(config);
796
+ }
797
+
798
+ // Load test set
799
+ let testSet;
800
+ try {
801
+ testSet = loadTestSet(opts.testSet, opts.mode);
802
+ } catch (err) {
803
+ console.error(ui.error(`Failed to load test set: ${err.message}`));
804
+ process.exit(1);
805
+ }
806
+
807
+ if (testSet.length === 0) {
808
+ console.error(ui.error('Test set is empty.'));
809
+ process.exit(1);
810
+ }
811
+
812
+ if (verbose) {
813
+ console.log('');
814
+ console.log(ui.bold('📊 Configuration Comparison'));
815
+ console.log(ui.dim(` Test set: ${testSet.length} queries`));
816
+ console.log(ui.dim(` Configs: ${configs.map(c => c.name || c._path).join(', ')}`));
817
+ console.log(ui.dim(` Mode: ${opts.mode}`));
818
+ console.log('');
819
+ }
820
+
821
+ // Run eval for each config
822
+ const results = [];
823
+
824
+ for (const config of configs) {
825
+ const configName = config.name || path.basename(config._path, '.json');
826
+
827
+ if (verbose) {
828
+ console.log(ui.dim(` Evaluating: ${configName}...`));
829
+ }
830
+
831
+ if (opts.mode === 'rerank') {
832
+ // Rerank mode
833
+ const model = config.model || config.rerankModel || DEFAULT_RERANK_MODEL;
834
+ const topK = config.topK;
835
+
836
+ const perQueryResults = [];
837
+ let totalTokens = 0;
838
+
839
+ for (const testCase of testSet) {
840
+ const rerankResult = await apiRequest('/rerank', {
841
+ query: testCase.query,
842
+ documents: testCase.documents,
843
+ model,
844
+ ...(topK ? { top_k: topK } : {}),
845
+ });
846
+ totalTokens += rerankResult.usage?.total_tokens || 0;
847
+
848
+ const relevantIdSet = new Set(testCase.relevant.map(idx => `doc_${idx}`));
849
+ const rerankedItems = rerankResult.data || [];
850
+ const retrievedIds = rerankedItems.map(item => `doc_${item.index}`);
851
+ const metrics = computeMetrics(retrievedIds, [...relevantIdSet], kValues);
852
+
853
+ perQueryResults.push({ metrics });
854
+ }
855
+
856
+ const aggregated = aggregateMetrics(perQueryResults.map(r => r.metrics));
857
+ results.push({
858
+ name: configName,
859
+ config,
860
+ summary: aggregated,
861
+ tokens: totalTokens,
862
+ queries: testSet.length,
863
+ });
864
+
865
+ } else {
866
+ // Retrieval mode
867
+ const { config: proj } = loadProject();
868
+ const model = config.model || proj.model || getDefaultModel();
869
+ const db = config.db || proj.db;
870
+ const collection = config.collection || proj.collection;
871
+ const index = config.index || proj.index || 'vector_index';
872
+ const field = config.field || proj.field || 'embedding';
873
+ const doRerank = config.rerank !== false;
874
+ const rerankModel = config.rerankModel || DEFAULT_RERANK_MODEL;
875
+ const dimensions = config.dimensions || proj.dimensions;
876
+ const limit = config.limit || 20;
877
+
878
+ if (!db || !collection) {
879
+ console.error(ui.error(`Config ${configName} missing db/collection.`));
880
+ process.exit(1);
881
+ }
882
+
883
+ const { client, collection: coll } = await getMongoCollection(db, collection);
884
+ const perQueryResults = [];
885
+ let totalEmbedTokens = 0;
886
+ let totalRerankTokens = 0;
887
+
888
+ try {
889
+ for (const testCase of testSet) {
890
+ const embedOpts = { model, inputType: 'query' };
891
+ if (dimensions) embedOpts.dimensions = dimensions;
892
+ const embedResult = await generateEmbeddings([testCase.query], embedOpts);
893
+ const queryVector = embedResult.data[0].embedding;
894
+ totalEmbedTokens += embedResult.usage?.total_tokens || 0;
895
+
896
+ const numCandidates = Math.min(limit * 15, 10000);
897
+ const pipeline = [
898
+ { $vectorSearch: { index, path: field, queryVector, numCandidates, limit } },
899
+ { $addFields: { _vsScore: { $meta: 'vectorSearchScore' } } },
900
+ ];
901
+
902
+ let searchResults = await coll.aggregate(pipeline).toArray();
903
+
904
+ if (doRerank && searchResults.length > 1) {
905
+ const documents = searchResults.map(doc => String(doc.text || doc));
906
+ const rerankResult = await apiRequest('/rerank', {
907
+ query: testCase.query,
908
+ documents,
909
+ model: rerankModel,
910
+ });
911
+ totalRerankTokens += rerankResult.usage?.total_tokens || 0;
912
+ searchResults = (rerankResult.data || []).map(item => searchResults[item.index]);
913
+ }
914
+
915
+ const retrievedIds = searchResults.map(doc => String(doc._id));
916
+ const metrics = computeMetrics(retrievedIds, testCase.relevant, kValues);
917
+ perQueryResults.push({ metrics });
918
+ }
919
+ } finally {
920
+ await client.close();
921
+ }
922
+
923
+ const aggregated = aggregateMetrics(perQueryResults.map(r => r.metrics));
924
+ results.push({
925
+ name: configName,
926
+ config,
927
+ summary: aggregated,
928
+ tokens: { embed: totalEmbedTokens, rerank: totalRerankTokens },
929
+ queries: testSet.length,
930
+ });
931
+ }
932
+ }
933
+
934
+ // Save if requested
935
+ if (opts.save) {
936
+ const output = {
937
+ mode: opts.mode,
938
+ testSet: opts.testSet,
939
+ kValues,
940
+ configs: results,
941
+ comparedAt: new Date().toISOString(),
942
+ vaiVersion: require('../../package.json').version,
943
+ };
944
+ fs.writeFileSync(opts.save, JSON.stringify(output, null, 2), 'utf8');
945
+ if (verbose) {
946
+ console.log(ui.success(`Comparison saved to ${opts.save}`));
947
+ }
948
+ }
949
+
950
+ // JSON output
951
+ if (opts.json) {
952
+ console.log(JSON.stringify({
953
+ mode: opts.mode,
954
+ testSet: opts.testSet,
955
+ kValues,
956
+ configs: results,
957
+ }, null, 2));
958
+ return;
959
+ }
960
+
961
+ // Pretty output - comparison table
962
+ console.log('');
963
+ console.log(ui.bold('Configuration Comparison'));
964
+ console.log('');
965
+
966
+ const keyMetrics = ['mrr', 'ndcg@5', 'ndcg@10', 'r@5', 'r@10'];
967
+ const availableMetrics = keyMetrics.filter(k => results[0].summary[k] !== undefined);
968
+
969
+ // Find best per metric
970
+ const bestPerMetric = {};
971
+ for (const m of availableMetrics) {
972
+ bestPerMetric[m] = Math.max(...results.map(r => r.summary[m]));
973
+ }
974
+
975
+ // Header
976
+ const nameColW = Math.max(20, ...results.map(r => r.name.length + 2));
977
+ const header = ` ${'Config'.padEnd(nameColW)} ${availableMetrics.map(m => m.toUpperCase().padStart(10)).join('')}`;
978
+ console.log(ui.dim(header));
979
+ console.log(ui.dim(' ' + '─'.repeat(header.length - 2)));
980
+
981
+ // Rows
982
+ for (const result of results) {
983
+ const cols = availableMetrics.map(m => {
984
+ const val = result.summary[m];
985
+ const str = val.toFixed(4);
986
+ return val === bestPerMetric[m] ? ui.green(str.padStart(10)) : str.padStart(10);
987
+ }).join('');
988
+ console.log(` ${result.name.padEnd(nameColW)} ${cols}`);
989
+ }
990
+
991
+ console.log('');
992
+
993
+ // Visual comparison for key metrics
994
+ for (const m of ['ndcg@5', 'mrr']) {
995
+ if (!results[0].summary[m]) continue;
996
+ console.log(ui.bold(` ${m.toUpperCase()}`));
997
+ for (const result of results) {
998
+ const val = result.summary[m];
999
+ const bar = renderBar(val, 30);
1000
+ const color = val === bestPerMetric[m] ? ui.green(val.toFixed(4)) : ui.cyan(val.toFixed(4));
1001
+ console.log(` ${result.name.padEnd(nameColW - 2)} ${bar} ${color}`);
1002
+ }
1003
+ console.log('');
1004
+ }
1005
+
1006
+ // Winner summary
1007
+ const scores = results.map(r => ({
1008
+ name: r.name,
1009
+ score: availableMetrics.reduce((sum, m) => sum + (r.summary[m] === bestPerMetric[m] ? 1 : 0), 0),
1010
+ }));
1011
+ scores.sort((a, b) => b.score - a.score);
1012
+
1013
+ if (scores[0].score > scores[1]?.score) {
1014
+ console.log(ui.success(` Winner: ${scores[0].name} (best in ${scores[0].score}/${availableMetrics.length} metrics)`));
1015
+ } else {
1016
+ console.log(ui.dim(` Tie between configs - consider other factors (cost, latency)`));
1017
+ }
1018
+
1019
+ console.log('');
1020
+ console.log(ui.dim(` ${testSet.length} queries × ${configs.length} configs evaluated`));
1021
+ console.log('');
1022
+
1023
+ } catch (err) {
1024
+ console.error(ui.error(err.message));
1025
+ if (process.env.DEBUG) console.error(err.stack);
1026
+ process.exit(1);
1027
+ }
1028
+ });
1029
+ }
1030
+
1031
+ module.exports = { registerEval, registerEvalCompare };
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { loadProject } = require('../lib/project');
6
+ const { renderTemplate, buildContext, listTemplates, listTargets } = require('../lib/codegen');
7
+ const ui = require('../lib/ui');
8
+
9
+ /**
10
+ * Component to template mapping for each target.
11
+ */
12
+ const COMPONENT_MAP = {
13
+ vanilla: {
14
+ client: 'client.js',
15
+ connection: 'connection.js',
16
+ retrieval: 'retrieval.js',
17
+ ingest: 'ingest.js',
18
+ 'search-api': 'search-api.js',
19
+ },
20
+ nextjs: {
21
+ client: 'lib-voyage.js',
22
+ connection: 'lib-mongo.js',
23
+ retrieval: 'route-search.js', // Route includes retrieval logic
24
+ ingest: 'route-ingest.js',
25
+ 'search-api': 'route-search.js',
26
+ 'search-page': 'page-search.jsx',
27
+ theme: 'theme.js',
28
+ layout: 'layout.jsx',
29
+ },
30
+ python: {
31
+ client: 'voyage_client.py',
32
+ connection: 'mongo_client.py',
33
+ retrieval: 'app.py', // App includes retrieval routes
34
+ ingest: 'chunker.py',
35
+ 'search-api': 'app.py',
36
+ },
37
+ };
38
+
39
+ /**
40
+ * Suggested output filenames for each component.
41
+ */
42
+ const OUTPUT_NAMES = {
43
+ vanilla: {
44
+ client: 'lib/voyage.js',
45
+ connection: 'lib/mongodb.js',
46
+ retrieval: 'lib/retrieval.js',
47
+ ingest: 'lib/ingest.js',
48
+ 'search-api': 'lib/search-api.js',
49
+ },
50
+ nextjs: {
51
+ client: 'lib/voyage.js',
52
+ connection: 'lib/mongodb.js',
53
+ retrieval: 'app/api/search/route.js',
54
+ ingest: 'app/api/ingest/route.js',
55
+ 'search-api': 'app/api/search/route.js',
56
+ 'search-page': 'app/search/page.jsx',
57
+ theme: 'lib/theme.js',
58
+ layout: 'app/layout.jsx',
59
+ },
60
+ python: {
61
+ client: 'voyage_client.py',
62
+ connection: 'mongo_client.py',
63
+ retrieval: 'app.py',
64
+ ingest: 'chunker.py',
65
+ 'search-api': 'app.py',
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Auto-detect target from project files.
71
+ */
72
+ function detectTarget() {
73
+ const cwd = process.cwd();
74
+
75
+ // Check for Next.js
76
+ if (
77
+ fs.existsSync(path.join(cwd, 'next.config.js')) ||
78
+ fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
79
+ fs.existsSync(path.join(cwd, 'next.config.ts'))
80
+ ) {
81
+ return 'nextjs';
82
+ }
83
+
84
+ // Check for Python
85
+ if (
86
+ fs.existsSync(path.join(cwd, 'requirements.txt')) ||
87
+ fs.existsSync(path.join(cwd, 'pyproject.toml')) ||
88
+ fs.existsSync(path.join(cwd, 'setup.py'))
89
+ ) {
90
+ return 'python';
91
+ }
92
+
93
+ // Default to vanilla Node.js
94
+ return 'vanilla';
95
+ }
96
+
97
+ /**
98
+ * Get available components for a target.
99
+ */
100
+ function getComponents(target) {
101
+ return Object.keys(COMPONENT_MAP[target] || {});
102
+ }
103
+
104
+ /**
105
+ * Register the generate command.
106
+ * @param {import('commander').Command} program
107
+ */
108
+ function registerGenerate(program) {
109
+ program
110
+ .command('generate [component]')
111
+ .alias('gen')
112
+ .description('Generate code snippets for RAG applications')
113
+ .option('-t, --target <target>', 'Target framework: vanilla, nextjs, python')
114
+ .option('-m, --model <model>', 'Override embedding model')
115
+ .option('--db <database>', 'Override database name')
116
+ .option('--collection <name>', 'Override collection name')
117
+ .option('--field <name>', 'Override embedding field name')
118
+ .option('--index <name>', 'Override vector index name')
119
+ .option('-d, --dimensions <n>', 'Override dimensions', parseInt)
120
+ .option('--no-rerank', 'Omit reranking from generated code')
121
+ .option('--rerank-model <model>', 'Rerank model to use')
122
+ .option('--json', 'Machine-readable output with filename')
123
+ .option('-l, --list', 'List available components')
124
+ .option('-q, --quiet', 'Suppress hints and metadata')
125
+ .action(async (component, opts) => {
126
+ try {
127
+ // Determine target
128
+ const target = opts.target || detectTarget();
129
+
130
+ // Validate target
131
+ const validTargets = ['vanilla', 'nextjs', 'python'];
132
+ if (!validTargets.includes(target)) {
133
+ console.error(ui.error(`Invalid target: ${target}`));
134
+ console.error(ui.dim(` Valid targets: ${validTargets.join(', ')}`));
135
+ process.exit(1);
136
+ }
137
+
138
+ // List components
139
+ if (opts.list || !component) {
140
+ console.log('');
141
+ console.log(ui.bold(`Available components for ${target}:`));
142
+ console.log('');
143
+
144
+ const components = getComponents(target);
145
+ for (const comp of components) {
146
+ const outputName = OUTPUT_NAMES[target][comp];
147
+ console.log(` ${ui.cyan(comp.padEnd(15))} → ${ui.dim(outputName)}`);
148
+ }
149
+
150
+ console.log('');
151
+ console.log(ui.dim('Usage: vai generate <component> [--target <target>]'));
152
+ console.log(ui.dim(' vai generate retrieval > lib/retrieval.js'));
153
+ console.log('');
154
+ return;
155
+ }
156
+
157
+ // Validate component
158
+ const components = getComponents(target);
159
+ if (!components.includes(component)) {
160
+ console.error(ui.error(`Unknown component: ${component}`));
161
+ console.error(ui.dim(` Available: ${components.join(', ')}`));
162
+ process.exit(1);
163
+ }
164
+
165
+ // Load project config
166
+ let project = {};
167
+ try {
168
+ project = loadProject();
169
+ } catch (e) {
170
+ // No .vai.json, use defaults
171
+ if (!opts.quiet) {
172
+ console.error(ui.warn('No .vai.json found, using defaults'));
173
+ }
174
+ }
175
+
176
+ // Build context with overrides
177
+ const context = buildContext(project, {
178
+ model: opts.model,
179
+ db: opts.db,
180
+ collection: opts.collection,
181
+ field: opts.field,
182
+ index: opts.index,
183
+ dimensions: opts.dimensions,
184
+ rerank: opts.rerank,
185
+ rerankModel: opts.rerankModel,
186
+ projectName: path.basename(process.cwd()),
187
+ });
188
+
189
+ // Get template name (strip the output extension, keep for template lookup)
190
+ const templateFile = COMPONENT_MAP[target][component];
191
+ const templateName = templateFile.replace(/\.(js|jsx|py)$/, '');
192
+
193
+ // Render template
194
+ const output = renderTemplate(target, templateName, context);
195
+
196
+ // Output
197
+ if (opts.json) {
198
+ const filename = OUTPUT_NAMES[target][component];
199
+ console.log(JSON.stringify({ filename, content: output }, null, 2));
200
+ } else {
201
+ // Output to stdout for piping
202
+ console.log(output);
203
+
204
+ // Hints on stderr
205
+ if (!opts.quiet) {
206
+ const filename = OUTPUT_NAMES[target][component];
207
+ console.error('');
208
+ console.error(ui.dim(`# Generated ${component} for ${target}`));
209
+ console.error(ui.dim(`# Suggested: vai generate ${component} > ${filename}`));
210
+ }
211
+ }
212
+ } catch (err) {
213
+ console.error(ui.error(err.message));
214
+ if (process.env.DEBUG) console.error(err.stack);
215
+ process.exit(1);
216
+ }
217
+ });
218
+ }
219
+
220
+ module.exports = { registerGenerate, detectTarget, getComponents };