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.
@@ -1151,6 +1151,175 @@ const concepts = {
1151
1151
  ],
1152
1152
  },
1153
1153
 
1154
+ 'auto-embedding': {
1155
+ title: 'MongoDB Auto-Embedding',
1156
+ summary: 'Automatic vector embedding generation in Atlas Vector Search',
1157
+ content: [
1158
+ `${pc.bold('What is Auto-Embedding?')}`,
1159
+ `${pc.cyan('Auto-Embedding')} is a MongoDB Atlas Vector Search feature (currently in Preview)`,
1160
+ `that automatically generates vector embeddings for text fields using Voyage AI`,
1161
+ `models — no embedding code required.`,
1162
+ ``,
1163
+ `${pc.bold('How it works:')}`,
1164
+ ` ${pc.dim('1.')} Configure your vector search index with ${pc.cyan('autoEmbed')} type`,
1165
+ ` ${pc.dim('2.')} Specify which text field to embed and which Voyage AI model to use`,
1166
+ ` ${pc.dim('3.')} MongoDB automatically generates embeddings when documents are inserted/updated`,
1167
+ ` ${pc.dim('4.')} At query time, pass natural language text — MongoDB embeds it automatically`,
1168
+ ``,
1169
+ `${pc.bold('Supported models:')}`,
1170
+ ` ${pc.cyan('voyage-4-lite')} — High-volume, cost-sensitive applications`,
1171
+ ` ${pc.cyan('voyage-4')} — Balanced performance (recommended)`,
1172
+ ` ${pc.cyan('voyage-4-large')} — Maximum accuracy for complex relationships`,
1173
+ ` ${pc.cyan('voyage-code-3')} — Code search and technical documentation`,
1174
+ ``,
1175
+ `${pc.bold('Index definition example:')}`,
1176
+ ` ${pc.dim('{')}`,
1177
+ ` ${pc.dim('"mappings": {')}`,
1178
+ ` ${pc.dim('"fields": {')}`,
1179
+ ` ${pc.cyan('"summary"')}: ${pc.dim('{')}`,
1180
+ ` ${pc.dim('"type": "')}${pc.cyan('autoEmbed')}${pc.dim('",')}`,
1181
+ ` ${pc.dim('"model": "voyage-4"')}`,
1182
+ ` ${pc.dim('}')}`,
1183
+ ` ${pc.dim('}')}`,
1184
+ ` ${pc.dim('}')}`,
1185
+ ` ${pc.dim('}')}`,
1186
+ ``,
1187
+ `${pc.bold('Query syntax:')} Use ${pc.cyan('query.text')} in $vectorSearch instead of ${pc.cyan('queryVector')}:`,
1188
+ ` ${pc.dim('$vectorSearch: {')}`,
1189
+ ` ${pc.dim('index: "myIndex",')}`,
1190
+ ` ${pc.dim('path: "summary",')}`,
1191
+ ` ${pc.cyan('query: { text: "properties near amusement parks" }')},`,
1192
+ ` ${pc.dim('numCandidates: 100,')}`,
1193
+ ` ${pc.dim('limit: 10')}`,
1194
+ ` ${pc.dim('}')}`,
1195
+ ``,
1196
+ `${pc.bold('API keys:')}`,
1197
+ `Auto-Embedding uses Voyage AI API keys configured during mongot deployment.`,
1198
+ `Best practice: use separate keys for indexing vs. querying to avoid rate limit`,
1199
+ `conflicts. Keys can be created from Atlas (AI Models section) or Voyage AI directly.`,
1200
+ ``,
1201
+ `${pc.bold('Current limitations (Preview):')}`,
1202
+ ` ${pc.dim('•')} ${pc.yellow('Not yet available')} on Atlas clusters (only self-managed Community Edition)`,
1203
+ ` ${pc.dim('•')} Not available on local Atlas deployments via Atlas CLI`,
1204
+ ` ${pc.dim('•')} Not available on MongoDB Enterprise Edition`,
1205
+ ` ${pc.dim('•')} Available via Docker, tarball, package manager, or Kubernetes with 8.2+ CE`,
1206
+ ``,
1207
+ `${pc.bold('When to use Auto-Embedding:')}`,
1208
+ ` ${pc.dim('•')} Simple use cases where you want zero embedding code`,
1209
+ ` ${pc.dim('•')} Single-field text embedding scenarios`,
1210
+ ` ${pc.dim('•')} When your data changes frequently and you want automatic sync`,
1211
+ ` ${pc.dim('•')} Self-managed MongoDB deployments`,
1212
+ ``,
1213
+ `${pc.bold('When to use vai (manual embedding) instead:')}`,
1214
+ ` ${pc.dim('•')} Atlas clusters (auto-embedding not yet available)`,
1215
+ ` ${pc.dim('•')} Custom chunking strategies needed`,
1216
+ ` ${pc.dim('•')} Multi-field or multi-collection embeddings`,
1217
+ ` ${pc.dim('•')} Reranking pipelines (auto-embedding doesn't include reranking)`,
1218
+ ` ${pc.dim('•')} Quantization (int8/binary) for storage optimization`,
1219
+ ` ${pc.dim('•')} Multimodal embeddings (images + text)`,
1220
+ ].join('\n'),
1221
+ links: [
1222
+ 'https://www.mongodb.com/docs/atlas/atlas-vector-search/crud-embeddings/create-embeddings-automatic/',
1223
+ 'https://www.mongodb.com/docs/voyageai/management/api-keys/',
1224
+ ],
1225
+ tryIt: [
1226
+ 'vai explain vai-vs-auto-embedding',
1227
+ 'vai explain vector-search',
1228
+ 'vai models --type embedding',
1229
+ ],
1230
+ },
1231
+
1232
+ 'vai-vs-auto-embedding': {
1233
+ title: 'VAI vs Auto-Embedding — When to Use Each',
1234
+ summary: 'Choosing between manual embedding pipelines and MongoDB auto-embedding',
1235
+ content: [
1236
+ `Both ${pc.cyan('vai')} (manual embedding) and ${pc.cyan('MongoDB Auto-Embedding')} use the same`,
1237
+ `Voyage AI models, but they serve different use cases and deployment scenarios.`,
1238
+ ``,
1239
+ `${pc.bold('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
1240
+ ``,
1241
+ `${pc.bold(pc.cyan('VAI (Manual Embedding Pipeline)'))}`,
1242
+ ``,
1243
+ `You embed text explicitly using ${pc.cyan('vai embed')}, ${pc.cyan('vai pipeline')}, or ${pc.cyan('vai store')},`,
1244
+ `then store the vectors in any database. Full control over every step.`,
1245
+ ``,
1246
+ `${pc.bold('Use vai when:')}`,
1247
+ ` ${pc.green('✓')} Using ${pc.cyan('MongoDB Atlas clusters')} (auto-embedding not available yet)`,
1248
+ ` ${pc.green('✓')} Using ${pc.cyan('any vector database')} (Pinecone, Weaviate, Qdrant, etc.)`,
1249
+ ` ${pc.green('✓')} You need ${pc.cyan('custom chunking')} (sentence, paragraph, semantic, sliding window)`,
1250
+ ` ${pc.green('✓')} You need ${pc.cyan('reranking')} (vai supports two-stage retrieval pipelines)`,
1251
+ ` ${pc.green('✓')} You want ${pc.cyan('quantization')} (int8, binary) for storage optimization`,
1252
+ ` ${pc.green('✓')} You need ${pc.cyan('multimodal embeddings')} (images + text)`,
1253
+ ` ${pc.green('✓')} You need ${pc.cyan('flexible dimensions')} (256, 512, 1024, 2048)`,
1254
+ ` ${pc.green('✓')} You want to ${pc.cyan('mix models')} (embed docs with -large, query with -lite)`,
1255
+ ` ${pc.green('✓')} You need ${pc.cyan('batch processing')} with custom concurrency/rate limiting`,
1256
+ ` ${pc.green('✓')} You're building ${pc.cyan('RAG pipelines')} with custom retrieval logic`,
1257
+ ``,
1258
+ `${pc.dim('Workflow:')}`,
1259
+ ` ${pc.cyan('vai chunk')} → ${pc.cyan('vai embed')} → ${pc.cyan('vai store')} → ${pc.cyan('vai search')} → ${pc.cyan('vai rerank')}`,
1260
+ ``,
1261
+ `${pc.bold('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
1262
+ ``,
1263
+ `${pc.bold(pc.cyan('MongoDB Auto-Embedding'))}`,
1264
+ ``,
1265
+ `MongoDB automatically generates embeddings when you insert/update documents.`,
1266
+ `No embedding code needed — just configure your index and insert data.`,
1267
+ ``,
1268
+ `${pc.bold('Use Auto-Embedding when:')}`,
1269
+ ` ${pc.green('✓')} Using ${pc.cyan('self-managed MongoDB Community Edition')} (8.2+)`,
1270
+ ` ${pc.green('✓')} You want ${pc.cyan('zero embedding code')} — simplest possible setup`,
1271
+ ` ${pc.green('✓')} You're embedding a ${pc.cyan('single text field')} per collection`,
1272
+ ` ${pc.green('✓')} Your data ${pc.cyan('changes frequently')} and you want automatic sync`,
1273
+ ` ${pc.green('✓')} You don't need reranking, quantization, or multimodal`,
1274
+ ` ${pc.green('✓')} Standard chunking is sufficient (or you pre-chunk your data)`,
1275
+ ``,
1276
+ `${pc.dim('Workflow:')}`,
1277
+ ` ${pc.cyan('db.collection.insertOne({text: "..."})')} → embeddings auto-generated`,
1278
+ ` ${pc.cyan('$vectorSearch: {query: {text: "..."}}')} → query auto-embedded`,
1279
+ ``,
1280
+ `${pc.bold('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
1281
+ ``,
1282
+ `${pc.bold('FEATURE COMPARISON')}`,
1283
+ ``,
1284
+ `${pc.dim('Feature vai Auto-Embedding')}`,
1285
+ `${pc.dim('─────────────────────────────────────────────────────────────────')}`,
1286
+ `Atlas clusters ${pc.green('Yes')} ${pc.yellow('Not yet')}`,
1287
+ `Self-managed CE 8.2+ ${pc.green('Yes')} ${pc.green('Yes')}`,
1288
+ `Other vector DBs ${pc.green('Yes')} ${pc.dim('No')}`,
1289
+ `Custom chunking ${pc.green('Yes')} ${pc.dim('No')}`,
1290
+ `Reranking ${pc.green('Yes')} ${pc.dim('No')}`,
1291
+ `Quantization ${pc.green('Yes')} ${pc.dim('No')}`,
1292
+ `Multimodal ${pc.green('Yes')} ${pc.dim('No')}`,
1293
+ `Flexible dimensions ${pc.green('Yes')} ${pc.dim('No')}`,
1294
+ `Mix query/doc models ${pc.green('Yes')} ${pc.dim('No')}`,
1295
+ `Auto-sync on update ${pc.dim('Manual')} ${pc.green('Yes')}`,
1296
+ `Zero code setup ${pc.dim('No')} ${pc.green('Yes')}`,
1297
+ ``,
1298
+ `${pc.bold('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}`,
1299
+ ``,
1300
+ `${pc.bold('RECOMMENDATION')}`,
1301
+ ``,
1302
+ `${pc.dim('•')} For ${pc.cyan('Atlas users')}: Use vai — auto-embedding isn't available yet`,
1303
+ `${pc.dim('•')} For ${pc.cyan('production RAG')}: Use vai — you'll want reranking and custom chunking`,
1304
+ `${pc.dim('•')} For ${pc.cyan('quick prototypes')} on self-managed CE: Auto-embedding is faster to set up`,
1305
+ `${pc.dim('•')} For ${pc.cyan('complex pipelines')}: vai gives you full control over every step`,
1306
+ ``,
1307
+ `${pc.bold('Migration path:')} Start with auto-embedding for simplicity, then migrate to`,
1308
+ `vai when you need advanced features. The models are the same — your embeddings`,
1309
+ `will be compatible.`,
1310
+ ].join('\n'),
1311
+ links: [
1312
+ 'https://www.mongodb.com/docs/atlas/atlas-vector-search/crud-embeddings/create-embeddings-automatic/',
1313
+ 'https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/',
1314
+ ],
1315
+ tryIt: [
1316
+ 'vai explain auto-embedding',
1317
+ 'vai pipeline --help',
1318
+ 'vai chunk --help',
1319
+ 'vai rerank --help',
1320
+ ],
1321
+ },
1322
+
1154
1323
  'eval-comparison': {
1155
1324
  title: 'Evaluation Comparison — vai eval compare',
1156
1325
  summary: 'Compare configurations and track quality over time',
@@ -1199,6 +1368,72 @@ const concepts = {
1199
1368
  'vai eval compare --test-set test.jsonl --configs baseline.json,experiment.json',
1200
1369
  ],
1201
1370
  },
1371
+ chat: {
1372
+ title: 'RAG Chat',
1373
+ summary: 'How vai chat works — retrieval-augmented conversational AI',
1374
+ content: [
1375
+ `${pc.cyan('vai chat')} adds a conversational layer on top of your existing RAG pipeline.`,
1376
+ `It connects your embedded documents to an LLM for grounded Q&A with citations.`,
1377
+ ``,
1378
+ `${pc.bold('The Two-Stage Pipeline:')}`,
1379
+ ``,
1380
+ ` ┌─────────────────────────────────────────────────────┐`,
1381
+ ` │ ${pc.cyan('STAGE 1: RETRIEVAL')} (Voyage AI + MongoDB) │`,
1382
+ ` │ │`,
1383
+ ` │ Your question → Voyage AI creates an embedding │`,
1384
+ ` │ → MongoDB Atlas finds similar document chunks │`,
1385
+ ` │ → Voyage AI reranks for better relevance │`,
1386
+ ` │ │`,
1387
+ ` │ Output: Top 5 relevant text chunks │`,
1388
+ ` ├─────────────────────────────────────────────────────┤`,
1389
+ ` │ ${pc.cyan('STAGE 2: GENERATION')} (Your chosen LLM) │`,
1390
+ ` │ │`,
1391
+ ` │ Those text chunks + your question → sent to LLM │`,
1392
+ ` │ → LLM reads the context and writes an answer │`,
1393
+ ` │ → Response streamed back with citations │`,
1394
+ ` │ │`,
1395
+ ` │ Output: Conversational answer │`,
1396
+ ` └─────────────────────────────────────────────────────┘`,
1397
+ ``,
1398
+ `${pc.bold('Key insight:')} The LLM ${pc.cyan('never sees embedding vectors')}. It receives`,
1399
+ `plain text — the retrieved document chunks — and produces plain text.`,
1400
+ `Voyage AI finds the right documents; the LLM reads them and writes`,
1401
+ `an answer. They are completely independent systems.`,
1402
+ ``,
1403
+ `${pc.bold('What goes where:')}`,
1404
+ ``,
1405
+ ` ${pc.dim('Voyage AI API')} ← your question text, document chunks for reranking`,
1406
+ ` ${pc.dim('Your MongoDB')} ← embedded documents, chat history, session data`,
1407
+ ` ${pc.dim('LLM Provider')} ← system prompt, retrieved chunks, question, history`,
1408
+ ` ${pc.dim(' (Ollama)')} ← fully local, nothing leaves your machine`,
1409
+ ``,
1410
+ `${pc.bold('Supported LLM Providers:')}`,
1411
+ ``,
1412
+ ` ${pc.cyan('anthropic')} Claude (API key required)`,
1413
+ ` ${pc.cyan('openai')} GPT-4o and others (API key required)`,
1414
+ ` ${pc.cyan('ollama')} Fully local, free (requires Ollama installed)`,
1415
+ ``,
1416
+ `${pc.bold('Conversation History:')}`,
1417
+ `Previous turns are included so follow-up questions work naturally.`,
1418
+ `History is stored in your MongoDB (collection: ${pc.dim('vai_chat_history')}).`,
1419
+ `Sessions can be resumed with: ${pc.cyan('vai chat --session <id>')}`,
1420
+ ``,
1421
+ `${pc.bold('Slash Commands (inside chat):')}`,
1422
+ ` ${pc.cyan('/sources')} Show sources from last response`,
1423
+ ` ${pc.cyan('/context')} Show retrieved document chunks`,
1424
+ ` ${pc.cyan('/history')} List recent chat sessions`,
1425
+ ` ${pc.cyan('/session')} Show current session ID`,
1426
+ ` ${pc.cyan('/export')} Export to Markdown or JSON`,
1427
+ ` ${pc.cyan('/clear')} Clear conversation`,
1428
+ ` ${pc.cyan('/model')} Show or switch LLM model`,
1429
+ ].join('\n'),
1430
+ links: ['https://github.com/mrlynn/voyageai-cli#chat'],
1431
+ tryIt: [
1432
+ 'vai config set llm-provider anthropic',
1433
+ 'vai config set llm-api-key YOUR_KEY',
1434
+ 'vai chat --db myapp --collection knowledge',
1435
+ ],
1436
+ },
1202
1437
  };
1203
1438
 
1204
1439
  /**
@@ -1320,6 +1555,23 @@ const aliases = {
1320
1555
  'save-results': 'eval-comparison',
1321
1556
  'a-b-test': 'eval-comparison',
1322
1557
  regression: 'eval-comparison',
1558
+ // Auto-embedding aliases
1559
+ 'auto-embedding': 'auto-embedding',
1560
+ 'auto-embed': 'auto-embedding',
1561
+ autoembed: 'auto-embedding',
1562
+ 'autoEmbed': 'auto-embedding',
1563
+ 'automatic-embedding': 'auto-embedding',
1564
+ 'automatic-embeddings': 'auto-embedding',
1565
+ 'atlas-auto-embed': 'auto-embedding',
1566
+ 'mongodb-auto-embedding': 'auto-embedding',
1567
+ 'zero-code': 'auto-embedding',
1568
+ // VAI vs Auto-embedding aliases
1569
+ 'vai-vs-auto-embedding': 'vai-vs-auto-embedding',
1570
+ 'vai-vs-autoembedding': 'vai-vs-auto-embedding',
1571
+ 'manual-vs-auto': 'vai-vs-auto-embedding',
1572
+ 'auto-vs-manual': 'vai-vs-auto-embedding',
1573
+ 'which-approach': 'vai-vs-auto-embedding',
1574
+ 'embedding-approach': 'vai-vs-auto-embedding',
1323
1575
  // Provider comparison aliases
1324
1576
  'provider-comparison': 'provider-comparison',
1325
1577
  providers: 'provider-comparison',
@@ -1338,6 +1590,14 @@ const aliases = {
1338
1590
  'vs-anthropic': 'provider-comparison',
1339
1591
  competitors: 'provider-comparison',
1340
1592
  alternatives: 'provider-comparison',
1593
+ // Chat aliases
1594
+ chat: 'chat',
1595
+ 'vai-chat': 'chat',
1596
+ 'rag-chat': 'chat',
1597
+ conversation: 'chat',
1598
+ conversational: 'chat',
1599
+ 'chat-history': 'chat',
1600
+ llm: 'chat',
1341
1601
  };
1342
1602
 
1343
1603
  /**
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * Chat History Manager
7
+ *
8
+ * Manages conversation sessions with in-memory storage
9
+ * and optional MongoDB persistence.
10
+ */
11
+
12
+ /**
13
+ * Generate a new session ID.
14
+ * @returns {string}
15
+ */
16
+ function generateSessionId() {
17
+ return crypto.randomUUID();
18
+ }
19
+
20
+ /**
21
+ * In-memory history store for a single session.
22
+ */
23
+ class ChatHistory {
24
+ /**
25
+ * @param {object} [opts]
26
+ * @param {string} [opts.sessionId] - Resume an existing session
27
+ * @param {number} [opts.maxTurns] - Max turns to keep (default 20)
28
+ * @param {object} [opts.mongo] - { client, collection } for persistence
29
+ */
30
+ constructor(opts = {}) {
31
+ this.sessionId = opts.sessionId || generateSessionId();
32
+ this.maxTurns = opts.maxTurns || 20;
33
+ this.turns = []; // Array of { role, content, context?, metadata?, timestamp }
34
+ this._mongo = opts.mongo || null;
35
+ }
36
+
37
+ /**
38
+ * Load existing session from MongoDB.
39
+ * @returns {Promise<boolean>} true if session was found and loaded
40
+ */
41
+ async load() {
42
+ if (!this._mongo) return false;
43
+
44
+ try {
45
+ const docs = await this._mongo.collection
46
+ .find({ sessionId: this.sessionId })
47
+ .sort({ timestamp: 1 })
48
+ .limit(this.maxTurns * 2) // user + assistant turns
49
+ .toArray();
50
+
51
+ if (docs.length === 0) return false;
52
+
53
+ this.turns = docs.map(d => ({
54
+ role: d.role,
55
+ content: d.content,
56
+ context: d.context || undefined,
57
+ metadata: d.metadata || undefined,
58
+ timestamp: d.timestamp,
59
+ }));
60
+
61
+ return true;
62
+ } catch {
63
+ // Persistence failure is non-fatal
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Add a turn to history and optionally persist.
70
+ * @param {object} turn - { role, content, context?, metadata? }
71
+ */
72
+ async addTurn(turn) {
73
+ const entry = {
74
+ ...turn,
75
+ timestamp: new Date(),
76
+ };
77
+
78
+ this.turns.push(entry);
79
+
80
+ // Trim to maxTurns (keep pairs)
81
+ const maxEntries = this.maxTurns * 2;
82
+ if (this.turns.length > maxEntries) {
83
+ this.turns = this.turns.slice(-maxEntries);
84
+ }
85
+
86
+ // Persist to MongoDB if available
87
+ if (this._mongo) {
88
+ try {
89
+ await this._mongo.collection.insertOne({
90
+ sessionId: this.sessionId,
91
+ ...entry,
92
+ });
93
+ } catch {
94
+ // Persistence failure is non-fatal — chat continues in-memory
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Get conversation history as message array for the LLM.
101
+ * Returns only role + content (no metadata).
102
+ * @returns {Array<{role: string, content: string}>}
103
+ */
104
+ getMessages() {
105
+ return this.turns.map(t => ({ role: t.role, content: t.content }));
106
+ }
107
+
108
+ /**
109
+ * Get the last assistant turn's context docs.
110
+ * @returns {Array|null}
111
+ */
112
+ getLastContext() {
113
+ for (let i = this.turns.length - 1; i >= 0; i--) {
114
+ if (this.turns[i].role === 'assistant' && this.turns[i].context) {
115
+ return this.turns[i].context;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Get the last assistant turn's sources formatted for display.
123
+ * @returns {Array<{source: string, score: number}>|null}
124
+ */
125
+ getLastSources() {
126
+ const ctx = this.getLastContext();
127
+ if (!ctx) return null;
128
+ return ctx.map(d => ({
129
+ source: d.source || d.metadata?.source || 'unknown',
130
+ score: d.score,
131
+ }));
132
+ }
133
+
134
+ /**
135
+ * Clear conversation history (keep session ID).
136
+ */
137
+ clear() {
138
+ this.turns = [];
139
+ }
140
+
141
+ /**
142
+ * Get conversation history trimmed to fit a token budget.
143
+ * Uses ~4 chars per token estimate. Prioritizes recent turns.
144
+ * @param {number} [maxTokens=8000] - Token budget for history
145
+ * @returns {Array<{role: string, content: string}>}
146
+ */
147
+ getMessagesWithBudget(maxTokens = 8000) {
148
+ const messages = this.getMessages();
149
+ if (messages.length === 0) return [];
150
+
151
+ let totalChars = 0;
152
+ const maxChars = maxTokens * 4;
153
+ const result = [];
154
+
155
+ // Work backwards from most recent
156
+ for (let i = messages.length - 1; i >= 0; i--) {
157
+ const charCount = messages[i].content.length;
158
+ if (totalChars + charCount > maxChars && result.length > 0) break;
159
+ result.unshift(messages[i]);
160
+ totalChars += charCount;
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * Export conversation to markdown.
168
+ * @returns {string}
169
+ */
170
+ exportMarkdown() {
171
+ const lines = [
172
+ `# Chat Session: ${this.sessionId}`,
173
+ `_Exported: ${new Date().toISOString()}_`,
174
+ '',
175
+ ];
176
+
177
+ for (const turn of this.turns) {
178
+ if (turn.role === 'user') {
179
+ lines.push(`**You:** ${turn.content}`);
180
+ } else if (turn.role === 'assistant') {
181
+ lines.push(`**Assistant:** ${turn.content}`);
182
+ if (turn.context && turn.context.length > 0) {
183
+ lines.push('');
184
+ lines.push('Sources:');
185
+ for (const doc of turn.context) {
186
+ const src = doc.source || doc.metadata?.source || 'unknown';
187
+ lines.push(`- ${src} (${doc.score?.toFixed(2) || 'N/A'})`);
188
+ }
189
+ }
190
+ }
191
+ lines.push('');
192
+ }
193
+
194
+ return lines.join('\n');
195
+ }
196
+
197
+ /**
198
+ * Export conversation to JSON.
199
+ * @returns {object}
200
+ */
201
+ exportJSON() {
202
+ return {
203
+ sessionId: this.sessionId,
204
+ exportedAt: new Date().toISOString(),
205
+ turns: this.turns,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Ensure MongoDB indexes exist for chat history.
211
+ * Called once on first persist.
212
+ * @param {import('mongodb').Collection} collection
213
+ */
214
+ static async ensureIndexes(collection) {
215
+ try {
216
+ await collection.createIndex(
217
+ { sessionId: 1, timestamp: 1 },
218
+ { background: true }
219
+ );
220
+ } catch {
221
+ // Index creation failure is non-fatal
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * List recent chat sessions from MongoDB.
228
+ * @param {import('mongodb').Collection} collection
229
+ * @param {number} [limit=10]
230
+ * @returns {Promise<Array<{sessionId: string, firstMessage: string, lastActivity: Date, turnCount: number}>>}
231
+ */
232
+ async function listSessions(collection, limit = 10) {
233
+ const pipeline = [
234
+ {
235
+ $group: {
236
+ _id: '$sessionId',
237
+ firstMessage: { $first: '$content' },
238
+ firstRole: { $first: '$role' },
239
+ lastActivity: { $max: '$timestamp' },
240
+ turnCount: { $sum: 1 },
241
+ },
242
+ },
243
+ { $sort: { lastActivity: -1 } },
244
+ { $limit: limit },
245
+ ];
246
+
247
+ const sessions = await collection.aggregate(pipeline).toArray();
248
+ return sessions.map(s => ({
249
+ sessionId: s._id,
250
+ firstMessage: s.firstRole === 'user' ? s.firstMessage : '(continued)',
251
+ lastActivity: s.lastActivity,
252
+ turnCount: s.turnCount,
253
+ }));
254
+ }
255
+
256
+ module.exports = {
257
+ generateSessionId,
258
+ ChatHistory,
259
+ listSessions,
260
+ };