voyageai-cli 1.20.5 → 1.21.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 (44) hide show
  1. package/CHANGELOG.md +142 -26
  2. package/README.md +153 -2
  3. package/package.json +1 -1
  4. package/src/cli.js +12 -0
  5. package/src/commands/about.js +39 -3
  6. package/src/commands/doctor.js +325 -0
  7. package/src/commands/eval.js +420 -10
  8. package/src/commands/generate.js +220 -0
  9. package/src/commands/playground.js +93 -0
  10. package/src/commands/purge.js +271 -0
  11. package/src/commands/quickstart.js +203 -0
  12. package/src/commands/refresh.js +322 -0
  13. package/src/commands/scaffold.js +217 -0
  14. package/src/lib/codegen.js +313 -0
  15. package/src/lib/explanations.js +163 -1
  16. package/src/lib/scaffold-structure.js +114 -0
  17. package/src/lib/templates/nextjs/README.md.tpl +106 -0
  18. package/src/lib/templates/nextjs/env.example.tpl +8 -0
  19. package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
  20. package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
  21. package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
  22. package/src/lib/templates/nextjs/package.json.tpl +33 -0
  23. package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
  24. package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
  25. package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
  26. package/src/lib/templates/nextjs/theme.js.tpl +84 -0
  27. package/src/lib/templates/python/README.md.tpl +145 -0
  28. package/src/lib/templates/python/app.py.tpl +221 -0
  29. package/src/lib/templates/python/chunker.py.tpl +127 -0
  30. package/src/lib/templates/python/env.example.tpl +12 -0
  31. package/src/lib/templates/python/mongo_client.py.tpl +125 -0
  32. package/src/lib/templates/python/requirements.txt.tpl +10 -0
  33. package/src/lib/templates/python/voyage_client.py.tpl +124 -0
  34. package/src/lib/templates/vanilla/README.md.tpl +156 -0
  35. package/src/lib/templates/vanilla/client.js.tpl +103 -0
  36. package/src/lib/templates/vanilla/connection.js.tpl +126 -0
  37. package/src/lib/templates/vanilla/env.example.tpl +11 -0
  38. package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
  39. package/src/lib/templates/vanilla/package.json.tpl +31 -0
  40. package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
  41. package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
  42. package/src/lib/templates/vanilla/server.js.tpl +81 -0
  43. package/src/lib/zip.js +130 -0
  44. package/src/playground/index.html +772 -33
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ const p = require('@clack/prompts');
4
+ const { loadProject, saveProject } = require('../lib/project');
5
+ const { connect, close } = require('../lib/mongo');
6
+ const { generateEmbeddings } = require('../lib/api');
7
+ const { chunkText } = require('../lib/chunker');
8
+ const ui = require('../lib/ui');
9
+
10
+ /**
11
+ * Process documents in batches.
12
+ */
13
+ async function processBatch(docs, embedder, options) {
14
+ const texts = docs.map(d => d.text);
15
+ const embeddings = await embedder(texts);
16
+
17
+ return docs.map((doc, i) => ({
18
+ ...doc,
19
+ [options.field]: embeddings[i],
20
+ _model: options.model,
21
+ _embeddedAt: new Date(),
22
+ }));
23
+ }
24
+
25
+ /**
26
+ * Re-chunk a document's text.
27
+ */
28
+ function rechunkDocument(doc, options) {
29
+ const text = doc.text || doc.content || '';
30
+ if (!text) return [doc];
31
+
32
+ const chunks = chunkText(text, {
33
+ strategy: options.strategy || 'recursive',
34
+ chunkSize: options.chunkSize || 512,
35
+ overlap: options.overlap || 50,
36
+ });
37
+
38
+ return chunks.map((chunk, i) => ({
39
+ ...doc,
40
+ text: chunk.text,
41
+ _chunkIndex: i,
42
+ _chunkCount: chunks.length,
43
+ metadata: {
44
+ ...doc.metadata,
45
+ chunkIndex: i,
46
+ chunkCount: chunks.length,
47
+ originalId: doc._id?.toString(),
48
+ },
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Execute the refresh command.
54
+ */
55
+ async function refresh(options = {}) {
56
+ const quiet = options.quiet || options.json;
57
+
58
+ // Load project config
59
+ const project = loadProject();
60
+ const db = options.db || project.db || process.env.VAI_DB || 'vai';
61
+ const collectionName = options.collection || project.collection || process.env.VAI_COLLECTION || 'embeddings';
62
+ const field = options.field || project.field || 'embedding';
63
+ const model = options.model || project.model || 'voyage-3.5-lite';
64
+ const dimensions = options.dimensions || project.dimensions;
65
+ const batchSize = options.batchSize || 25;
66
+
67
+ if (!quiet) {
68
+ p.intro(ui.title('vai refresh'));
69
+ }
70
+
71
+ let client;
72
+ try {
73
+ // Connect to MongoDB
74
+ if (!quiet) {
75
+ p.log.step(`Connecting to database: ${db}`);
76
+ }
77
+ client = await connect(db);
78
+ const collection = client.db(db).collection(collectionName);
79
+
80
+ // Build filter
81
+ let filter = {};
82
+ if (options.filter) {
83
+ try {
84
+ filter = JSON.parse(options.filter);
85
+ } catch (err) {
86
+ throw new Error(`Invalid JSON filter: ${err.message}`);
87
+ }
88
+ }
89
+
90
+ // Count documents
91
+ const totalCount = await collection.countDocuments(filter);
92
+
93
+ if (totalCount === 0) {
94
+ if (options.json) {
95
+ console.log(JSON.stringify({ success: true, count: 0, message: 'No documents to refresh' }));
96
+ } else {
97
+ p.log.success('No documents to refresh.');
98
+ p.outro('Nothing to do.');
99
+ }
100
+ return { success: true, count: 0 };
101
+ }
102
+
103
+ // Show plan
104
+ const rechunkLabel = options.rechunk ? ` (re-chunking with ${options.strategy || 'recursive'})` : '';
105
+ const dimLabel = dimensions ? ` @ ${dimensions}d` : '';
106
+
107
+ if (options.json && options.dryRun) {
108
+ console.log(JSON.stringify({
109
+ dryRun: true,
110
+ count: totalCount,
111
+ model,
112
+ dimensions: dimensions || 'default',
113
+ rechunk: !!options.rechunk,
114
+ }));
115
+ return { success: true, dryRun: true, count: totalCount };
116
+ }
117
+
118
+ if (!quiet) {
119
+ p.log.info(`Found ${totalCount} document${totalCount === 1 ? '' : 's'} to refresh`);
120
+ p.log.info(`Target model: ${model}${dimLabel}${rechunkLabel}`);
121
+ }
122
+
123
+ // Dry run - stop here
124
+ if (options.dryRun) {
125
+ if (!quiet) {
126
+ p.log.info('Dry run - no documents modified.');
127
+ p.outro(`Would refresh ${totalCount} document${totalCount === 1 ? '' : 's'}.`);
128
+ }
129
+ return { success: true, dryRun: true, count: totalCount };
130
+ }
131
+
132
+ // Confirm unless --force
133
+ if (!options.force && !options.json) {
134
+ const confirmed = await p.confirm({
135
+ message: `Re-embed ${totalCount} document${totalCount === 1 ? '' : 's'}? This will update the embeddings in-place.`,
136
+ initialValue: true,
137
+ });
138
+
139
+ if (p.isCancel(confirmed) || !confirmed) {
140
+ p.log.info('Refresh cancelled.');
141
+ p.outro('No documents modified.');
142
+ return { success: false, cancelled: true };
143
+ }
144
+ }
145
+
146
+ // Create embedder function
147
+ const embedder = async (texts) => {
148
+ const result = await generateEmbeddings(texts, {
149
+ model,
150
+ dimensions,
151
+ inputType: 'document',
152
+ });
153
+ return result.embeddings;
154
+ };
155
+
156
+ // Process documents
157
+ let processed = 0;
158
+ let errors = 0;
159
+ const cursor = collection.find(filter);
160
+ let batch = [];
161
+
162
+ const spinner = !quiet ? p.spinner() : null;
163
+ if (spinner) spinner.start('Processing documents...');
164
+
165
+ while (await cursor.hasNext()) {
166
+ const doc = await cursor.next();
167
+
168
+ if (options.rechunk) {
169
+ // Re-chunk the document
170
+ const chunks = rechunkDocument(doc, options);
171
+ batch.push(...chunks);
172
+ } else {
173
+ batch.push(doc);
174
+ }
175
+
176
+ // Process when batch is full
177
+ if (batch.length >= batchSize) {
178
+ try {
179
+ const updated = await processBatch(batch, embedder, { field, model });
180
+
181
+ // Replace documents in database
182
+ for (const updatedDoc of updated) {
183
+ if (options.rechunk && updatedDoc.metadata?.originalId) {
184
+ // For rechunked docs, insert new and delete original later
185
+ await collection.insertOne(updatedDoc);
186
+ } else {
187
+ // Update in place
188
+ await collection.updateOne(
189
+ { _id: updatedDoc._id },
190
+ { $set: { [field]: updatedDoc[field], _model: model, _embeddedAt: new Date() } }
191
+ );
192
+ }
193
+ }
194
+
195
+ processed += batch.length;
196
+ if (spinner) spinner.message(`Processed ${processed}/${totalCount} documents...`);
197
+ } catch (err) {
198
+ errors += batch.length;
199
+ if (!quiet) {
200
+ p.log.warn(`Batch error: ${err.message}`);
201
+ }
202
+ }
203
+ batch = [];
204
+ }
205
+ }
206
+
207
+ // Process remaining batch
208
+ if (batch.length > 0) {
209
+ try {
210
+ const updated = await processBatch(batch, embedder, { field, model });
211
+
212
+ for (const updatedDoc of updated) {
213
+ if (options.rechunk && updatedDoc.metadata?.originalId) {
214
+ await collection.insertOne(updatedDoc);
215
+ } else {
216
+ await collection.updateOne(
217
+ { _id: updatedDoc._id },
218
+ { $set: { [field]: updatedDoc[field], _model: model, _embeddedAt: new Date() } }
219
+ );
220
+ }
221
+ }
222
+
223
+ processed += batch.length;
224
+ } catch (err) {
225
+ errors += batch.length;
226
+ if (!quiet) {
227
+ p.log.warn(`Batch error: ${err.message}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ // If rechunking, delete original documents
233
+ if (options.rechunk) {
234
+ const originalIds = await collection.distinct('metadata.originalId', filter);
235
+ if (originalIds.length > 0) {
236
+ // Convert string IDs back to ObjectIds for deletion
237
+ const { ObjectId } = require('mongodb');
238
+ const objectIds = originalIds
239
+ .filter(id => id)
240
+ .map(id => {
241
+ try { return new ObjectId(id); } catch { return null; }
242
+ })
243
+ .filter(id => id);
244
+
245
+ if (objectIds.length > 0) {
246
+ await collection.deleteMany({ _id: { $in: objectIds } });
247
+ }
248
+ }
249
+ }
250
+
251
+ if (spinner) spinner.stop('Processing complete.');
252
+
253
+ // Update project config if model/dimensions changed
254
+ const configUpdated = (model !== project.model) || (dimensions && dimensions !== project.dimensions);
255
+ if (configUpdated && !options.json) {
256
+ try {
257
+ saveProject({
258
+ ...project,
259
+ model,
260
+ ...(dimensions && { dimensions }),
261
+ });
262
+ if (!quiet) {
263
+ p.log.info('Updated .vai.json with new model/dimensions.');
264
+ }
265
+ } catch {
266
+ // Ignore save errors
267
+ }
268
+ }
269
+
270
+ if (options.json) {
271
+ console.log(JSON.stringify({ success: true, processed, errors }));
272
+ } else {
273
+ if (errors > 0) {
274
+ p.log.warn(`Refreshed ${processed} documents with ${errors} errors.`);
275
+ } else {
276
+ p.log.success(`Refreshed ${processed} document${processed === 1 ? '' : 's'}.`);
277
+ }
278
+ p.outro('Refresh complete.');
279
+ }
280
+
281
+ return { success: true, processed, errors };
282
+
283
+ } catch (err) {
284
+ if (options.json) {
285
+ console.log(JSON.stringify({ error: err.message }));
286
+ } else {
287
+ p.log.error(`Refresh failed: ${err.message}`);
288
+ }
289
+ return { success: false, error: err.message };
290
+ } finally {
291
+ if (client) {
292
+ await close();
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Register the refresh command with Commander.
299
+ */
300
+ function register(program) {
301
+ program
302
+ .command('refresh')
303
+ .description('Re-embed documents with a new model, dimensions, or chunk settings')
304
+ .option('--db <database>', 'Database name')
305
+ .option('--collection <name>', 'Collection name')
306
+ .option('--field <name>', 'Embedding field name')
307
+ .option('-m, --model <model>', 'New embedding model')
308
+ .option('-d, --dimensions <n>', 'New dimensions', parseInt)
309
+ .option('--rechunk', 'Re-chunk text before re-embedding')
310
+ .option('-s, --strategy <strategy>', 'Chunk strategy (with --rechunk)')
311
+ .option('-c, --chunk-size <n>', 'Chunk size (with --rechunk)', parseInt)
312
+ .option('--overlap <n>', 'Chunk overlap (with --rechunk)', parseInt)
313
+ .option('--batch-size <n>', 'Texts per API call (default: 25)', parseInt)
314
+ .option('--filter <json>', 'Only refresh matching documents (JSON)')
315
+ .option('--force', 'Skip confirmation prompt')
316
+ .option('--dry-run', 'Show plan without executing')
317
+ .option('--json', 'Machine-readable output')
318
+ .option('-q, --quiet', 'Suppress non-essential output')
319
+ .action(refresh);
320
+ }
321
+
322
+ module.exports = { register, refresh };
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const p = require('@clack/prompts');
6
+ const { loadProject } = require('../lib/project');
7
+ const { renderTemplate, buildContext, listTemplates } = require('../lib/codegen');
8
+ const { PROJECT_STRUCTURE } = require('../lib/scaffold-structure');
9
+ const ui = require('../lib/ui');
10
+
11
+ /**
12
+ * Create a directory if it doesn't exist.
13
+ */
14
+ function ensureDir(dirPath) {
15
+ if (!fs.existsSync(dirPath)) {
16
+ fs.mkdirSync(dirPath, { recursive: true });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Write a file, creating parent directories as needed.
22
+ */
23
+ function writeFile(filePath, content) {
24
+ ensureDir(path.dirname(filePath));
25
+ fs.writeFileSync(filePath, content, 'utf8');
26
+ }
27
+
28
+ /**
29
+ * Check if a directory exists and is not empty.
30
+ */
31
+ function directoryExists(dirPath) {
32
+ if (!fs.existsSync(dirPath)) return false;
33
+ const files = fs.readdirSync(dirPath);
34
+ return files.length > 0;
35
+ }
36
+
37
+ /**
38
+ * Register the scaffold command.
39
+ * @param {import('commander').Command} program
40
+ */
41
+ function registerScaffold(program) {
42
+ program
43
+ .command('scaffold <name>')
44
+ .description('Create a complete starter project')
45
+ .option('-t, --target <target>', 'Target framework: vanilla, nextjs, python', 'vanilla')
46
+ .option('-m, --model <model>', 'Override embedding model')
47
+ .option('--db <database>', 'Override database name')
48
+ .option('--collection <name>', 'Override collection name')
49
+ .option('--field <name>', 'Override embedding field name')
50
+ .option('--index <name>', 'Override vector index name')
51
+ .option('-d, --dimensions <n>', 'Override dimensions', parseInt)
52
+ .option('--no-rerank', 'Omit reranking from generated code')
53
+ .option('--rerank-model <model>', 'Rerank model to use')
54
+ .option('--force', 'Overwrite existing directory')
55
+ .option('--json', 'Output file manifest as JSON (no file creation)')
56
+ .option('--dry-run', 'Show what would be created without writing')
57
+ .option('-q, --quiet', 'Suppress non-essential output')
58
+ .action(async (name, opts) => {
59
+ try {
60
+ const target = opts.target;
61
+ const projectDir = path.resolve(process.cwd(), name);
62
+
63
+ // Validate target
64
+ if (!PROJECT_STRUCTURE[target]) {
65
+ console.error(ui.error(`Invalid target: ${target}`));
66
+ console.error(ui.dim(` Valid targets: ${Object.keys(PROJECT_STRUCTURE).join(', ')}`));
67
+ process.exit(1);
68
+ }
69
+
70
+ const structure = PROJECT_STRUCTURE[target];
71
+
72
+ // Check if directory exists
73
+ if (directoryExists(projectDir) && !opts.force && !opts.dryRun && !opts.json) {
74
+ console.error(ui.error(`Directory already exists: ${name}`));
75
+ console.error(ui.dim(' Use --force to overwrite.'));
76
+ process.exit(1);
77
+ }
78
+
79
+ // Load project config
80
+ let project = {};
81
+ try {
82
+ project = loadProject();
83
+ } catch (e) {
84
+ // No .vai.json, use defaults
85
+ if (!opts.quiet && !opts.json) {
86
+ console.error(ui.warn('No .vai.json found, using defaults'));
87
+ }
88
+ }
89
+
90
+ // Build context with overrides
91
+ const context = buildContext(project, {
92
+ model: opts.model,
93
+ db: opts.db,
94
+ collection: opts.collection,
95
+ field: opts.field,
96
+ index: opts.index,
97
+ dimensions: opts.dimensions,
98
+ rerank: opts.rerank,
99
+ rerankModel: opts.rerankModel,
100
+ projectName: name,
101
+ });
102
+
103
+ // Build file manifest
104
+ const manifest = [];
105
+
106
+ // Render template files
107
+ for (const file of structure.files) {
108
+ const content = renderTemplate(target, file.template, context);
109
+ manifest.push({
110
+ path: file.output,
111
+ fullPath: path.join(projectDir, file.output),
112
+ source: `${target}/${file.template}`,
113
+ size: content.length,
114
+ content,
115
+ });
116
+ }
117
+
118
+ // Add extra static files
119
+ if (structure.extraFiles) {
120
+ for (const file of structure.extraFiles) {
121
+ const content = typeof file.content === 'function'
122
+ ? file.content(context)
123
+ : file.content;
124
+ manifest.push({
125
+ path: file.output,
126
+ fullPath: path.join(projectDir, file.output),
127
+ source: 'static',
128
+ size: content.length,
129
+ content,
130
+ });
131
+ }
132
+ }
133
+
134
+ // JSON output mode
135
+ if (opts.json) {
136
+ const output = {
137
+ name,
138
+ target,
139
+ directory: projectDir,
140
+ files: manifest.map(f => ({
141
+ path: f.path,
142
+ size: f.size,
143
+ source: f.source,
144
+ })),
145
+ config: context,
146
+ };
147
+ console.log(JSON.stringify(output, null, 2));
148
+ return;
149
+ }
150
+
151
+ // Dry run mode
152
+ if (opts.dryRun) {
153
+ console.log('');
154
+ console.log(ui.bold(`Would create: ${name}/ (${structure.description})`));
155
+ console.log('');
156
+ for (const file of manifest) {
157
+ console.log(` ${ui.cyan('+')} ${file.path} ${ui.dim(`(${file.size} bytes)`)}`);
158
+ }
159
+ console.log('');
160
+ console.log(ui.dim(`Total: ${manifest.length} files`));
161
+ return;
162
+ }
163
+
164
+ // Create project directory
165
+ if (!opts.quiet) {
166
+ console.log('');
167
+ console.log(ui.bold(`Creating ${name}/ (${structure.description})`));
168
+ console.log('');
169
+ }
170
+
171
+ ensureDir(projectDir);
172
+
173
+ // Write all files
174
+ for (const file of manifest) {
175
+ writeFile(file.fullPath, file.content);
176
+ if (!opts.quiet) {
177
+ console.log(` ${ui.cyan('✓')} ${file.path}`);
178
+ }
179
+ }
180
+
181
+ // Success message with next steps
182
+ if (!opts.quiet) {
183
+ console.log('');
184
+ console.log(ui.success(`Created ${manifest.length} files in ${name}/`));
185
+ console.log('');
186
+
187
+ // Use clack's note for next steps
188
+ const steps = [
189
+ `cd ${name}`,
190
+ `cp .env.example .env`,
191
+ `# Edit .env with your API keys`,
192
+ structure.postInstall,
193
+ structure.startCommand,
194
+ ];
195
+
196
+ p.note(steps.join('\n'), 'Next steps');
197
+
198
+ console.log('');
199
+ console.log(ui.dim('Configuration:'));
200
+ console.log(ui.dim(` Model: ${context.model}`));
201
+ console.log(ui.dim(` Database: ${context.db}.${context.collection}`));
202
+ console.log(ui.dim(` Dimensions: ${context.dimensions}`));
203
+ if (context.rerank) {
204
+ console.log(ui.dim(` Reranking: ${context.rerankModel}`));
205
+ }
206
+ console.log('');
207
+ }
208
+
209
+ } catch (err) {
210
+ console.error(ui.error(err.message));
211
+ if (process.env.DEBUG) console.error(err.stack);
212
+ process.exit(1);
213
+ }
214
+ });
215
+ }
216
+
217
+ module.exports = { registerScaffold, PROJECT_STRUCTURE };