voyageai-cli 1.22.0 → 1.23.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.
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const { generateEmbeddings } = require('../../lib/api');
4
+ const { cosineSimilarity } = require('../../lib/math');
5
+
6
+ /**
7
+ * Register embedding tools: vai_embed, vai_similarity
8
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
9
+ * @param {object} schemas
10
+ */
11
+ function registerEmbeddingTools(server, schemas) {
12
+ // vai_embed — embed text and return the vector
13
+ server.tool(
14
+ 'vai_embed',
15
+ 'Embed text using a Voyage AI model and return the vector representation. Use when you need the raw embedding vector for custom similarity logic, storing in another system, or debugging.',
16
+ schemas.embedSchema,
17
+ async (input) => {
18
+ const embedOpts = { model: input.model, inputType: input.inputType };
19
+ if (input.dimensions) embedOpts.dimensions = input.dimensions;
20
+
21
+ const result = await generateEmbeddings([input.text], embedOpts);
22
+ const vector = result.data[0].embedding;
23
+
24
+ const structured = {
25
+ text: input.text.slice(0, 100) + (input.text.length > 100 ? '...' : ''),
26
+ model: input.model,
27
+ vector,
28
+ dimensions: vector.length,
29
+ inputType: input.inputType,
30
+ };
31
+
32
+ return {
33
+ structuredContent: structured,
34
+ content: [{ type: 'text', text: `Embedded text (${vector.length} dimensions, model: ${input.model}, type: ${input.inputType}). Vector: [${vector.slice(0, 5).map(v => v.toFixed(4)).join(', ')}, ... ${vector.length - 5} more]` }],
35
+ };
36
+ }
37
+ );
38
+
39
+ // vai_similarity — compare two texts
40
+ server.tool(
41
+ 'vai_similarity',
42
+ 'Compare two texts semantically by embedding both and computing cosine similarity. Returns a score from -1 (opposite) to 1 (identical). Use for duplicate detection, relevance checking, or topic comparison.',
43
+ schemas.similaritySchema,
44
+ async (input) => {
45
+ const result = await generateEmbeddings([input.text1, input.text2], {
46
+ model: input.model,
47
+ inputType: 'document',
48
+ });
49
+
50
+ const vec1 = result.data[0].embedding;
51
+ const vec2 = result.data[1].embedding;
52
+ const similarity = cosineSimilarity(vec1, vec2);
53
+
54
+ return {
55
+ structuredContent: {
56
+ text1: input.text1.slice(0, 100) + (input.text1.length > 100 ? '...' : ''),
57
+ text2: input.text2.slice(0, 100) + (input.text2.length > 100 ? '...' : ''),
58
+ similarity,
59
+ model: input.model,
60
+ },
61
+ content: [{ type: 'text', text: `Similarity: ${similarity.toFixed(4)} (model: ${input.model})\nText 1: "${input.text1.slice(0, 80)}..."\nText 2: "${input.text2.slice(0, 80)}..."` }],
62
+ };
63
+ }
64
+ );
65
+ }
66
+
67
+ module.exports = { registerEmbeddingTools };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const { chunk } = require('../../lib/chunker');
4
+ const { generateEmbeddings } = require('../../lib/api');
5
+ const { getMongoCollection } = require('../../lib/mongo');
6
+ const { loadProject } = require('../../lib/project');
7
+ const { getDefaultModel } = require('../../lib/catalog');
8
+
9
+ /**
10
+ * Register the vai_ingest tool (write operation).
11
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
12
+ * @param {object} schemas
13
+ */
14
+ function registerIngestTool(server, schemas) {
15
+ server.tool(
16
+ 'vai_ingest',
17
+ 'Add a document to a collection: chunks the text, embeds each chunk with Voyage AI, and stores them in MongoDB Atlas. Use when the user provides new content to add to the knowledge base.',
18
+ schemas.ingestSchema,
19
+ async (input) => {
20
+ const { config: proj } = loadProject();
21
+ const db = input.db || proj.db;
22
+ const collName = input.collection || proj.collection;
23
+ if (!db) throw new Error('No database specified. Pass db parameter or configure via vai init.');
24
+ if (!collName) throw new Error('No collection specified. Pass collection parameter or configure via vai init.');
25
+
26
+ const model = input.model || proj.model || getDefaultModel();
27
+ const start = Date.now();
28
+
29
+ // Step 1: Chunk the text
30
+ const chunks = chunk(input.text, {
31
+ strategy: input.chunkStrategy,
32
+ size: input.chunkSize,
33
+ });
34
+
35
+ if (chunks.length === 0) {
36
+ return {
37
+ structuredContent: { source: input.source || 'unknown', chunksCreated: 0, collection: collName },
38
+ content: [{ type: 'text', text: 'No chunks produced — text may be too short or empty.' }],
39
+ };
40
+ }
41
+
42
+ // Step 2: Embed all chunks
43
+ const embedResult = await generateEmbeddings(chunks, {
44
+ model,
45
+ inputType: 'document',
46
+ });
47
+
48
+ // Step 3: Store in MongoDB
49
+ const { client, collection: coll } = await getMongoCollection(db, collName);
50
+ try {
51
+ const docs = chunks.map((text, i) => ({
52
+ text,
53
+ embedding: embedResult.data[i].embedding,
54
+ source: input.source || 'mcp-ingest',
55
+ metadata: {
56
+ ...(input.metadata || {}),
57
+ ingestedAt: new Date().toISOString(),
58
+ chunkIndex: i,
59
+ totalChunks: chunks.length,
60
+ model,
61
+ chunkStrategy: input.chunkStrategy,
62
+ },
63
+ }));
64
+
65
+ await coll.insertMany(docs);
66
+ const timeMs = Date.now() - start;
67
+
68
+ const structured = {
69
+ source: input.source || 'mcp-ingest',
70
+ chunksCreated: chunks.length,
71
+ collection: collName,
72
+ database: db,
73
+ model,
74
+ timeMs,
75
+ metadata: input.metadata || {},
76
+ };
77
+
78
+ return {
79
+ structuredContent: structured,
80
+ content: [{ type: 'text', text: `Ingested "${input.source || 'document'}" into ${db}.${collName}: ${chunks.length} chunks embedded with ${model} (${timeMs}ms)` }],
81
+ };
82
+ } finally {
83
+ await client.close();
84
+ }
85
+ }
86
+ );
87
+ }
88
+
89
+ module.exports = { registerIngestTool };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const { MODEL_CATALOG } = require('../../lib/catalog');
4
+ const { loadProject } = require('../../lib/project');
5
+ const { requireMongoUri } = require('../../lib/mongo');
6
+
7
+ /**
8
+ * Introspect MongoDB collections — list collections with vector index info.
9
+ * @param {string} dbName
10
+ * @returns {Promise<Array<{ name: string, documentCount: number, hasVectorIndex: boolean, embeddingField?: string, dimensions?: number }>>}
11
+ */
12
+ async function introspectCollections(dbName) {
13
+ const { MongoClient } = require('mongodb');
14
+ const uri = requireMongoUri();
15
+ const client = new MongoClient(uri);
16
+ await client.connect();
17
+
18
+ try {
19
+ const db = client.db(dbName);
20
+ const collections = await db.listCollections().toArray();
21
+ const results = [];
22
+
23
+ for (const collInfo of collections) {
24
+ if (collInfo.name.startsWith('system.')) continue;
25
+ const coll = db.collection(collInfo.name);
26
+ const documentCount = await coll.estimatedDocumentCount();
27
+
28
+ let hasVectorIndex = false;
29
+ let embeddingField;
30
+ let dimensions;
31
+
32
+ try {
33
+ const indexes = await coll.listSearchIndexes().toArray();
34
+ for (const idx of indexes) {
35
+ // Atlas Search index definitions vary; look for vector type
36
+ const fields = idx.latestDefinition?.fields || [];
37
+ for (const f of fields) {
38
+ if (f.type === 'vector') {
39
+ hasVectorIndex = true;
40
+ embeddingField = f.path;
41
+ dimensions = f.numDimensions;
42
+ break;
43
+ }
44
+ }
45
+ if (hasVectorIndex) break;
46
+ }
47
+ } catch {
48
+ // listSearchIndexes may not be available on non-Atlas deployments
49
+ }
50
+
51
+ results.push({
52
+ name: collInfo.name,
53
+ documentCount,
54
+ hasVectorIndex,
55
+ ...(embeddingField && { embeddingField }),
56
+ ...(dimensions && { dimensions }),
57
+ });
58
+ }
59
+
60
+ return results;
61
+ } finally {
62
+ await client.close();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Register management tools: vai_collections, vai_models
68
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
69
+ * @param {object} schemas
70
+ */
71
+ function registerManagementTools(server, schemas) {
72
+ // vai_collections — list collections with vector index info
73
+ server.tool(
74
+ 'vai_collections',
75
+ 'List available MongoDB collections with document counts and vector index information. Use at the start of a task to discover which knowledge bases exist, or when the user mentions a topic and you need to find the right collection.',
76
+ schemas.collectionsSchema,
77
+ async (input) => {
78
+ const { config: proj } = loadProject();
79
+ const dbName = input.db || proj.db;
80
+ if (!dbName) throw new Error('No database specified. Pass db parameter or configure via vai init.');
81
+
82
+ const collections = await introspectCollections(dbName);
83
+
84
+ return {
85
+ structuredContent: { database: dbName, collections },
86
+ content: [{
87
+ type: 'text',
88
+ text: `Database: ${dbName}\n\n${collections.map(c =>
89
+ `• ${c.name} — ${c.documentCount} docs${c.hasVectorIndex ? ` ✓ vector index (${c.embeddingField}, ${c.dimensions}d)` : ''}`
90
+ ).join('\n')}`,
91
+ }],
92
+ };
93
+ }
94
+ );
95
+
96
+ // vai_models — list Voyage AI models
97
+ server.tool(
98
+ 'vai_models',
99
+ 'List available Voyage AI models with capabilities, benchmarks, and pricing. Use when selecting a model for embedding or reranking, or when the user asks about model tradeoffs.',
100
+ schemas.modelsSchema,
101
+ async (input) => {
102
+ let models = MODEL_CATALOG.filter(m => !m.legacy && !m.unreleased);
103
+
104
+ if (input.category !== 'all') {
105
+ models = models.filter(m => m.type === input.category);
106
+ }
107
+
108
+ const mapped = models.map(m => ({
109
+ id: m.name,
110
+ name: m.name,
111
+ type: m.type,
112
+ dimensions: m.dimensions,
113
+ maxTokens: m.maxTokens,
114
+ pricePerMToken: m.pricePerMToken,
115
+ ...(m.architecture && { architecture: m.architecture }),
116
+ ...(m.sharedSpace && { sharedSpace: m.sharedSpace }),
117
+ }));
118
+
119
+ return {
120
+ structuredContent: { category: input.category, models: mapped },
121
+ content: [{
122
+ type: 'text',
123
+ text: `Available ${input.category === 'all' ? '' : input.category + ' '}models:\n\n${mapped.map(m =>
124
+ `• ${m.name} (${m.type}) — ${m.dimensions}d, $${m.pricePerMToken}/M tokens`
125
+ ).join('\n')}`,
126
+ }],
127
+ };
128
+ }
129
+ );
130
+ }
131
+
132
+ module.exports = { registerManagementTools, introspectCollections };
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const { generateEmbeddings, apiRequest } = require('../../lib/api');
4
+ const { getMongoCollection } = require('../../lib/mongo');
5
+ const { getDefaultModel, DEFAULT_RERANK_MODEL } = require('../../lib/catalog');
6
+ const { loadProject } = require('../../lib/project');
7
+
8
+ /**
9
+ * Resolve db/collection from tool input, falling back to project config.
10
+ * @param {object} input
11
+ * @returns {{ db: string, collection: string }}
12
+ */
13
+ function resolveDbCollection(input) {
14
+ const { config: proj } = loadProject();
15
+ const db = input.db || proj.db;
16
+ const collection = input.collection || proj.collection;
17
+ if (!db) throw new Error('No database specified. Pass db parameter or configure via vai init.');
18
+ if (!collection) throw new Error('No collection specified. Pass collection parameter or configure via vai init.');
19
+ return { db, collection };
20
+ }
21
+
22
+ /**
23
+ * Register retrieval tools: vai_query, vai_search, vai_rerank
24
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
25
+ * @param {object} schemas
26
+ */
27
+ function registerRetrievalTools(server, schemas) {
28
+ // vai_query — full RAG query: embed → vector search → rerank
29
+ server.tool(
30
+ 'vai_query',
31
+ 'Full RAG query: embeds the question with Voyage AI, runs vector search against MongoDB Atlas, and reranks results. Use this when you need to answer a question using the knowledge base.',
32
+ schemas.querySchema,
33
+ async (input) => {
34
+ const { db, collection: collName } = resolveDbCollection(input);
35
+ const { config: proj } = loadProject();
36
+ const model = input.model || proj.model || getDefaultModel();
37
+ const index = proj.index || 'vector_index';
38
+ const field = proj.field || 'embedding';
39
+ const dimensions = proj.dimensions;
40
+ const limit = input.limit;
41
+ const candidateLimit = Math.min(limit * 4, 20);
42
+ const start = Date.now();
43
+
44
+ // Step 1: Embed query
45
+ const embedOpts = { model, inputType: 'query' };
46
+ if (dimensions) embedOpts.dimensions = dimensions;
47
+ const embedResult = await generateEmbeddings([input.query], embedOpts);
48
+ const queryVector = embedResult.data[0].embedding;
49
+
50
+ // Step 2: Vector search
51
+ const { client, collection: coll } = await getMongoCollection(db, collName);
52
+ try {
53
+ const vectorSearchStage = {
54
+ index,
55
+ path: field,
56
+ queryVector,
57
+ numCandidates: Math.min(candidateLimit * 15, 10000),
58
+ limit: candidateLimit,
59
+ };
60
+ if (input.filter) vectorSearchStage.filter = input.filter;
61
+
62
+ const searchResults = await coll.aggregate([
63
+ { $vectorSearch: vectorSearchStage },
64
+ { $addFields: { _vsScore: { $meta: 'vectorSearchScore' } } },
65
+ ]).toArray();
66
+
67
+ if (searchResults.length === 0) {
68
+ return {
69
+ structuredContent: { query: input.query, results: [], metadata: { collection: collName, model, reranked: false, retrievalTimeMs: Date.now() - start, resultCount: 0 } },
70
+ content: [{ type: 'text', text: `No results found for "${input.query}" in ${db}.${collName}` }],
71
+ };
72
+ }
73
+
74
+ // Step 3: Rerank (optional)
75
+ let finalResults;
76
+ let reranked = false;
77
+
78
+ if (input.rerank && searchResults.length > 1) {
79
+ const documents = searchResults.map(doc => doc.text || JSON.stringify(doc));
80
+ const rerankResult = await apiRequest('/rerank', {
81
+ query: input.query,
82
+ documents,
83
+ model: DEFAULT_RERANK_MODEL,
84
+ top_k: limit,
85
+ });
86
+ reranked = true;
87
+ finalResults = (rerankResult.data || []).map(item => {
88
+ const doc = searchResults[item.index];
89
+ return {
90
+ source: doc.metadata?.source || doc.source || 'unknown',
91
+ content: doc.text || '',
92
+ score: doc._vsScore,
93
+ rerankedScore: item.relevance_score,
94
+ metadata: doc.metadata || {},
95
+ };
96
+ });
97
+ } else {
98
+ finalResults = searchResults.slice(0, limit).map(doc => ({
99
+ source: doc.metadata?.source || doc.source || 'unknown',
100
+ content: doc.text || '',
101
+ score: doc._vsScore,
102
+ metadata: doc.metadata || {},
103
+ }));
104
+ }
105
+
106
+ const retrievalTimeMs = Date.now() - start;
107
+ const structured = {
108
+ query: input.query,
109
+ results: finalResults,
110
+ metadata: { collection: collName, model, reranked, retrievalTimeMs, resultCount: finalResults.length },
111
+ };
112
+
113
+ const textLines = finalResults.map((r, i) =>
114
+ `[${i + 1}] ${r.source} (score: ${(r.rerankedScore || r.score || 0).toFixed(3)})\n${r.content.slice(0, 500)}`
115
+ );
116
+
117
+ return {
118
+ structuredContent: structured,
119
+ content: [{ type: 'text', text: `Found ${finalResults.length} results for "${input.query}" (${retrievalTimeMs}ms):\n\n${textLines.join('\n\n')}` }],
120
+ };
121
+ } finally {
122
+ await client.close();
123
+ }
124
+ }
125
+ );
126
+
127
+ // vai_search — raw vector similarity search (no reranking)
128
+ server.tool(
129
+ 'vai_search',
130
+ 'Raw vector similarity search without reranking. Faster than vai_query but results are ordered by vector distance only. Use for exploratory searches or when you plan to rerank separately.',
131
+ schemas.searchSchema,
132
+ async (input) => {
133
+ const { db, collection: collName } = resolveDbCollection(input);
134
+ const { config: proj } = loadProject();
135
+ const model = input.model || proj.model || getDefaultModel();
136
+ const index = proj.index || 'vector_index';
137
+ const field = proj.field || 'embedding';
138
+ const dimensions = proj.dimensions;
139
+ const start = Date.now();
140
+
141
+ const embedOpts = { model, inputType: 'query' };
142
+ if (dimensions) embedOpts.dimensions = dimensions;
143
+ const embedResult = await generateEmbeddings([input.query], embedOpts);
144
+ const queryVector = embedResult.data[0].embedding;
145
+
146
+ const { client, collection: coll } = await getMongoCollection(db, collName);
147
+ try {
148
+ const vectorSearchStage = {
149
+ index,
150
+ path: field,
151
+ queryVector,
152
+ numCandidates: Math.min(input.limit * 15, 10000),
153
+ limit: input.limit,
154
+ };
155
+ if (input.filter) vectorSearchStage.filter = input.filter;
156
+
157
+ const results = await coll.aggregate([
158
+ { $vectorSearch: vectorSearchStage },
159
+ { $addFields: { _vsScore: { $meta: 'vectorSearchScore' } } },
160
+ ]).toArray();
161
+
162
+ const mapped = results.map(doc => ({
163
+ source: doc.metadata?.source || doc.source || 'unknown',
164
+ content: doc.text || '',
165
+ score: doc._vsScore,
166
+ metadata: doc.metadata || {},
167
+ }));
168
+
169
+ const retrievalTimeMs = Date.now() - start;
170
+
171
+ return {
172
+ structuredContent: { query: input.query, results: mapped, metadata: { collection: collName, model, retrievalTimeMs, resultCount: mapped.length } },
173
+ content: [{ type: 'text', text: `Found ${mapped.length} results for "${input.query}" (${retrievalTimeMs}ms):\n\n${mapped.map((r, i) => `[${i + 1}] ${r.source} (${(r.score || 0).toFixed(3)})\n${r.content.slice(0, 500)}`).join('\n\n')}` }],
174
+ };
175
+ } finally {
176
+ await client.close();
177
+ }
178
+ }
179
+ );
180
+
181
+ // vai_rerank — standalone reranking
182
+ server.tool(
183
+ 'vai_rerank',
184
+ 'Rerank documents against a query using Voyage AI reranker. Takes a query and candidate documents, returns them reordered by relevance. Use when you have documents from another source and want to order them by relevance.',
185
+ schemas.rerankSchema,
186
+ async (input) => {
187
+ const start = Date.now();
188
+ const result = await apiRequest('/rerank', {
189
+ query: input.query,
190
+ documents: input.documents,
191
+ model: input.model,
192
+ top_k: input.documents.length,
193
+ });
194
+
195
+ const ranked = (result.data || []).map(item => ({
196
+ index: item.index,
197
+ relevanceScore: item.relevance_score,
198
+ document: input.documents[item.index].slice(0, 200) + (input.documents[item.index].length > 200 ? '...' : ''),
199
+ }));
200
+
201
+ return {
202
+ structuredContent: { query: input.query, results: ranked, model: input.model, timeMs: Date.now() - start },
203
+ content: [{ type: 'text', text: `Reranked ${input.documents.length} documents:\n\n${ranked.map((r, i) => `[${i + 1}] Score: ${r.relevanceScore.toFixed(3)} — ${r.document}`).join('\n')}` }],
204
+ };
205
+ }
206
+ );
207
+ }
208
+
209
+ module.exports = { registerRetrievalTools };