voyageai-cli 1.24.0 → 1.26.1

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +2 -0
  3. package/src/commands/about.js +1 -1
  4. package/src/commands/bug.js +1 -1
  5. package/src/commands/chat.js +281 -78
  6. package/src/commands/playground.js +73 -19
  7. package/src/commands/scaffold.js +23 -1
  8. package/src/commands/workflow.js +336 -0
  9. package/src/lib/chat.js +170 -4
  10. package/src/lib/explanations.js +53 -0
  11. package/src/lib/llm.js +304 -2
  12. package/src/lib/mongo.js +6 -6
  13. package/src/lib/prompt.js +60 -1
  14. package/src/lib/scaffold-structure.js +8 -9
  15. package/src/lib/telemetry.js +1 -1
  16. package/src/lib/template-engine.js +240 -0
  17. package/src/lib/templates/nextjs/README.md.tpl +78 -55
  18. package/src/lib/templates/nextjs/favicon.svg.tpl +11 -0
  19. package/src/lib/templates/nextjs/footer.jsx.tpl +49 -0
  20. package/src/lib/templates/nextjs/layout.jsx.tpl +16 -10
  21. package/src/lib/templates/nextjs/lib-mongo.js.tpl +5 -5
  22. package/src/lib/templates/nextjs/lib-voyage.js.tpl +13 -8
  23. package/src/lib/templates/nextjs/navbar.jsx.tpl +98 -0
  24. package/src/lib/templates/nextjs/page-home.jsx.tpl +201 -0
  25. package/src/lib/templates/nextjs/page-search.jsx.tpl +184 -82
  26. package/src/lib/templates/nextjs/theme-registry.jsx.tpl +51 -0
  27. package/src/lib/templates/nextjs/theme.js.tpl +138 -65
  28. package/src/lib/templates/nextjs/vai-logo-256.png +0 -0
  29. package/src/lib/tool-registry.js +194 -0
  30. package/src/lib/workflow-utils.js +65 -0
  31. package/src/lib/workflow.js +1259 -0
  32. package/src/mcp/tools/embedding.js +55 -43
  33. package/src/mcp/tools/ingest.js +74 -67
  34. package/src/mcp/tools/management.js +54 -101
  35. package/src/mcp/tools/retrieval.js +181 -163
  36. package/src/mcp/tools/utility.js +171 -153
  37. package/src/playground/icons/dark/128.png +0 -0
  38. package/src/playground/icons/dark/16.png +0 -0
  39. package/src/playground/icons/dark/256.png +0 -0
  40. package/src/playground/icons/dark/32.png +0 -0
  41. package/src/playground/icons/dark/64.png +0 -0
  42. package/src/playground/icons/light/128.png +0 -0
  43. package/src/playground/icons/light/16.png +0 -0
  44. package/src/playground/icons/light/256.png +0 -0
  45. package/src/playground/icons/light/32.png +0 -0
  46. package/src/playground/icons/light/64.png +0 -0
  47. package/src/playground/icons/watermark.png +0 -0
  48. package/src/playground/index.html +633 -83
  49. package/src/workflows/consistency-check.json +64 -0
  50. package/src/workflows/cost-analysis.json +69 -0
  51. package/src/workflows/multi-collection-search.json +80 -0
  52. package/src/workflows/research-and-summarize.json +46 -0
  53. package/src/workflows/smart-ingest.json +63 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.24.0",
3
+ "version": "1.26.1",
4
4
  "description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
5
5
  "bin": {
6
6
  "vai": "./src/cli.js"
package/src/cli.js CHANGED
@@ -37,6 +37,7 @@ const { register: registerQuickstart } = require('./commands/quickstart');
37
37
  const { registerBug } = require('./commands/bug');
38
38
  const { registerChat } = require('./commands/chat');
39
39
  const { registerMcpServer } = require('./commands/mcp-server');
40
+ const { registerWorkflow } = require('./commands/workflow');
40
41
  const { showBanner, showQuickStart, getVersion } = require('./lib/banner');
41
42
 
42
43
  const version = getVersion();
@@ -78,6 +79,7 @@ registerQuickstart(program);
78
79
  registerBug(program);
79
80
  registerChat(program);
80
81
  registerMcpServer(program);
82
+ registerWorkflow(program);
81
83
 
82
84
  // Append disclaimer to all help output
83
85
  program.addHelpText('after', `
@@ -20,7 +20,7 @@ function registerAbout(program) {
20
20
  name: 'Michael Lynn',
21
21
  role: 'Principal Staff Developer Advocate, MongoDB',
22
22
  github: 'https://github.com/mrlynn',
23
- vai_website: 'https://vai.mlynn.org',
23
+ vai_website: 'https://vaicli.com',
24
24
  website: 'https://mlynn.org',
25
25
  },
26
26
  links: {
@@ -15,7 +15,7 @@ function getVersion() {
15
15
  }
16
16
 
17
17
  const GITHUB_ISSUES_URL = 'https://github.com/mrlynn/voyageai-cli/issues/new';
18
- const BUG_API_URL = 'https://vai.mlynn.org/api/bugs';
18
+ const BUG_API_URL = 'https://vaicli.com/api/bugs';
19
19
 
20
20
  /**
21
21
  * Generate a GitHub issue URL with pre-filled template
@@ -3,7 +3,7 @@
3
3
  const readline = require('readline');
4
4
  const { createLLMProvider, resolveLLMConfig } = require('../lib/llm');
5
5
  const { ChatHistory } = require('../lib/history');
6
- const { chatTurn } = require('../lib/chat');
6
+ const { chatTurn, agentChatTurn } = require('../lib/chat');
7
7
  const { loadProject } = require('../lib/project');
8
8
  const { getMongoCollection } = require('../lib/mongo');
9
9
  const { setConfigValue } = require('../lib/config');
@@ -29,6 +29,7 @@ function registerChat(program) {
29
29
  .option('--llm-model <name>', 'Specific LLM model to use')
30
30
  .option('--llm-api-key <key>', 'LLM API key')
31
31
  .option('--llm-base-url <url>', 'LLM API base URL (Ollama)')
32
+ .option('--mode <mode>', 'Chat mode: pipeline (fixed RAG) or agent (tool-calling)', 'pipeline')
32
33
  .option('--max-context-docs <n>', 'Max retrieved documents for context', (v) => parseInt(v, 10), 5)
33
34
  .option('--max-turns <n>', 'Max conversation turns before truncation', (v) => parseInt(v, 10), 20)
34
35
  .option('--no-history', 'Disable MongoDB persistence (in-memory only)')
@@ -63,13 +64,19 @@ async function runChat(opts) {
63
64
  const doStream = opts.stream !== false;
64
65
  const systemPrompt = opts.systemPrompt || chatConf.systemPrompt;
65
66
 
66
- // Validate DB + collection
67
- if (!db || !collection) {
68
- console.error(ui.error('Database and collection required.'));
67
+ // Resolve mode
68
+ const mode = opts.mode || chatConf.mode || 'pipeline';
69
+ const isAgent = mode === 'agent';
70
+
71
+ // Validate DB + collection (required for pipeline, recommended for agent)
72
+ if (!isAgent && (!db || !collection)) {
73
+ console.error(ui.error('Database and collection required for pipeline mode.'));
69
74
  console.error('');
70
75
  console.error(' Use --db and --collection, or configure .vai.json:');
71
76
  console.error(' vai init');
72
77
  console.error('');
78
+ console.error(' Or use --mode agent to let the LLM discover collections.');
79
+ console.error('');
73
80
  process.exit(1);
74
81
  }
75
82
 
@@ -131,8 +138,17 @@ async function runChat(opts) {
131
138
 
132
139
  const llm = createLLMProvider(opts);
133
140
 
134
- // Preflight: verify the RAG pipeline is ready
135
- if (!opts.json) {
141
+ // Check tool support for agent mode
142
+ if (isAgent && !llm.supportsTools) {
143
+ if (!opts.quiet && !opts.json) {
144
+ console.log(ui.warn(`LLM provider "${llm.name}" does not support tool calling. Falling back to pipeline mode.`));
145
+ }
146
+ // Fall through to pipeline mode
147
+ return runChat({ ...opts, mode: 'pipeline' });
148
+ }
149
+
150
+ // Preflight: verify the RAG pipeline is ready (pipeline mode only)
151
+ if (!isAgent && !opts.json) {
136
152
  const { runPreflight, formatPreflight, waitForIndex } = require('../lib/preflight');
137
153
  const { checks, ready } = await runPreflight({
138
154
  db, collection,
@@ -184,7 +200,7 @@ async function runChat(opts) {
184
200
  try {
185
201
  const historyCollection = chatConf.historyCollection ||
186
202
  process.env.VAI_CHAT_HISTORY || 'vai_chat_history';
187
- const { client, collection: coll } = await getMongoCollection(db, historyCollection);
203
+ const { client, collection: coll } = await getMongoCollection(db || 'vai', historyCollection);
188
204
  historyMongo = { client, collection: coll };
189
205
  await ChatHistory.ensureIndexes(coll);
190
206
  } catch {
@@ -207,12 +223,22 @@ async function runChat(opts) {
207
223
  }
208
224
  }
209
225
 
226
+ // Track tool calls from last agent response (for /tools and /export-workflow)
227
+ let lastToolCalls = [];
228
+
210
229
  // Print header
211
230
  if (!opts.quiet && !opts.json) {
212
231
  console.log('');
213
232
  console.log(`${pc.bold('vai chat')} v${getVersion()}`);
214
233
  console.log(ui.label('Provider', `${llmConfig.provider} (${llmConfig.model})`));
215
- console.log(ui.label('Knowledge base', `${db}.${collection}`));
234
+ if (isAgent) {
235
+ console.log(ui.label('Mode', 'agent (tool-calling)'));
236
+ if (db) console.log(ui.label('Default DB', db));
237
+ if (collection) console.log(ui.label('Default collection', collection));
238
+ } else {
239
+ console.log(ui.label('Mode', 'pipeline (fixed RAG)'));
240
+ console.log(ui.label('Knowledge base', `${db}.${collection}`));
241
+ }
216
242
  console.log(ui.label('Session', pc.dim(history.sessionId)));
217
243
  console.log(pc.dim('Type /help for commands, /quit to exit.'));
218
244
  console.log('');
@@ -237,7 +263,10 @@ async function runChat(opts) {
237
263
 
238
264
  // Handle slash commands
239
265
  if (input.startsWith('/')) {
240
- const handled = await handleSlashCommand(input, { history, opts, db, collection, llm, rl, historyMongo });
266
+ const handled = await handleSlashCommand(input, {
267
+ history, opts, db, collection, llm, rl, historyMongo,
268
+ isAgent, lastToolCalls,
269
+ });
241
270
  if (handled === 'quit') {
242
271
  await cleanup(historyMongo);
243
272
  process.exit(0);
@@ -248,70 +277,15 @@ async function runChat(opts) {
248
277
 
249
278
  // Execute chat turn
250
279
  try {
251
- let turnNum = Math.floor(history.turns.length / 2) + 1;
252
-
253
- if (opts.json) {
254
- // JSON mode — collect everything then output
255
- let fullResponse = '';
256
- let sources = [];
257
- let metadata = {};
258
-
259
- for await (const event of chatTurn({
260
- query: input, db, collection, llm, history,
261
- opts: { maxDocs, rerank: doRerank, stream: false, systemPrompt, textField, filter: opts.filter },
262
- })) {
263
- if (event.type === 'chunk') fullResponse += event.data;
264
- if (event.type === 'done') {
265
- sources = event.data.sources;
266
- metadata = event.data.metadata;
267
- }
268
- }
269
-
270
- console.log(JSON.stringify({
271
- sessionId: history.sessionId,
272
- turn: turnNum,
273
- query: input,
274
- response: fullResponse,
275
- sources,
276
- metadata,
277
- }));
280
+ if (isAgent) {
281
+ lastToolCalls = await handleAgentTurn(input, {
282
+ llm, history, opts, db, collection, systemPrompt, chatConf,
283
+ });
278
284
  } else {
279
- // Interactive mode — stream output
280
- let retrievalShown = false;
281
-
282
- for await (const event of chatTurn({
283
- query: input, db, collection, llm, history,
284
- opts: { maxDocs, rerank: doRerank, stream: doStream, systemPrompt, textField, filter: opts.filter },
285
- })) {
286
- if (event.type === 'retrieval' && !opts.quiet) {
287
- const { docs, timeMs } = event.data;
288
- if (!retrievalShown) {
289
- console.log(pc.dim(` [${docs.length} docs retrieved in ${timeMs}ms]`));
290
- console.log('');
291
- retrievalShown = true;
292
- }
293
- }
294
-
295
- if (event.type === 'chunk') {
296
- process.stdout.write(event.data);
297
- }
298
-
299
- if (event.type === 'done') {
300
- console.log(''); // End the streamed response line
301
-
302
- // Show sources
303
- const { sources, metadata } = event.data;
304
- if (sources.length > 0 && chatConf.showSources !== false) {
305
- console.log('');
306
- console.log(pc.dim('Sources:'));
307
- for (let i = 0; i < sources.length; i++) {
308
- const s = sources[i];
309
- console.log(pc.dim(` [${i + 1}] ${s.source} (relevance: ${s.score?.toFixed(2) || 'N/A'})`));
310
- }
311
- }
312
- console.log('');
313
- }
314
- }
285
+ await handlePipelineTurn(input, {
286
+ db, collection, llm, history, opts,
287
+ maxDocs, doRerank, doStream, systemPrompt, textField, chatConf,
288
+ });
315
289
  }
316
290
  } catch (err) {
317
291
  console.error('');
@@ -335,12 +309,160 @@ async function runChat(opts) {
335
309
  });
336
310
  }
337
311
 
312
+ /**
313
+ * Handle a single pipeline mode turn.
314
+ */
315
+ async function handlePipelineTurn(input, ctx) {
316
+ const { db, collection, llm, history, opts, maxDocs, doRerank, doStream, systemPrompt, textField, chatConf } = ctx;
317
+ const turnNum = Math.floor(history.turns.length / 2) + 1;
318
+
319
+ if (opts.json) {
320
+ // JSON mode — collect everything then output
321
+ let fullResponse = '';
322
+ let sources = [];
323
+ let metadata = {};
324
+
325
+ for await (const event of chatTurn({
326
+ query: input, db, collection, llm, history,
327
+ opts: { maxDocs, rerank: doRerank, stream: false, systemPrompt, textField, filter: opts.filter },
328
+ })) {
329
+ if (event.type === 'chunk') fullResponse += event.data;
330
+ if (event.type === 'done') {
331
+ sources = event.data.sources;
332
+ metadata = event.data.metadata;
333
+ }
334
+ }
335
+
336
+ console.log(JSON.stringify({
337
+ sessionId: history.sessionId,
338
+ turn: turnNum,
339
+ query: input,
340
+ response: fullResponse,
341
+ sources,
342
+ metadata,
343
+ }));
344
+ } else {
345
+ // Interactive mode — stream output
346
+ let retrievalShown = false;
347
+
348
+ for await (const event of chatTurn({
349
+ query: input, db, collection, llm, history,
350
+ opts: { maxDocs, rerank: doRerank, stream: doStream, systemPrompt, textField, filter: opts.filter },
351
+ })) {
352
+ if (event.type === 'retrieval' && !opts.quiet) {
353
+ const { docs, timeMs } = event.data;
354
+ if (!retrievalShown) {
355
+ console.log(pc.dim(` [${docs.length} docs retrieved in ${timeMs}ms]`));
356
+ console.log('');
357
+ retrievalShown = true;
358
+ }
359
+ }
360
+
361
+ if (event.type === 'chunk') {
362
+ process.stdout.write(event.data);
363
+ }
364
+
365
+ if (event.type === 'done') {
366
+ console.log(''); // End the streamed response line
367
+
368
+ // Show sources
369
+ const { sources, metadata } = event.data;
370
+ if (sources.length > 0 && chatConf.showSources !== false) {
371
+ console.log('');
372
+ console.log(pc.dim('Sources:'));
373
+ for (let i = 0; i < sources.length; i++) {
374
+ const s = sources[i];
375
+ console.log(pc.dim(` [${i + 1}] ${s.source} (relevance: ${s.score?.toFixed(2) || 'N/A'})`));
376
+ }
377
+ }
378
+ console.log('');
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Handle a single agent mode turn.
386
+ * @returns {Array} Tool calls from this turn (for /tools and /export-workflow)
387
+ */
388
+ async function handleAgentTurn(input, ctx) {
389
+ const { llm, history, opts, db, collection, systemPrompt, chatConf } = ctx;
390
+ const showToolCalls = chatConf.showToolCalls !== undefined ? chatConf.showToolCalls : true;
391
+ const toolCalls = [];
392
+
393
+ if (opts.json) {
394
+ // JSON mode — collect everything then output
395
+ let fullResponse = '';
396
+ let metadata = {};
397
+
398
+ for await (const event of agentChatTurn({
399
+ query: input, llm, history,
400
+ opts: { systemPrompt, db, collection },
401
+ })) {
402
+ if (event.type === 'tool_call') {
403
+ toolCalls.push(event.data);
404
+ }
405
+ if (event.type === 'chunk') fullResponse += event.data;
406
+ if (event.type === 'done') {
407
+ metadata = event.data.metadata;
408
+ }
409
+ }
410
+
411
+ console.log(JSON.stringify({
412
+ sessionId: history.sessionId,
413
+ query: input,
414
+ response: fullResponse,
415
+ toolCalls,
416
+ metadata,
417
+ }));
418
+ } else {
419
+ // Interactive mode
420
+ for await (const event of agentChatTurn({
421
+ query: input, llm, history,
422
+ opts: { systemPrompt, db, collection },
423
+ })) {
424
+ if (event.type === 'tool_call') {
425
+ toolCalls.push(event.data);
426
+ if (showToolCalls) {
427
+ const { name, timeMs, error } = event.data;
428
+ if (error) {
429
+ console.log(pc.dim(` [tool] ${name} ${pc.red('failed')} (${timeMs}ms): ${error}`));
430
+ } else if (showToolCalls === 'verbose') {
431
+ console.log(pc.dim(` [tool] ${name} (${timeMs}ms)`));
432
+ const result = event.data.result;
433
+ if (result) {
434
+ const preview = JSON.stringify(result).substring(0, 200);
435
+ console.log(pc.dim(` ${preview}${JSON.stringify(result).length > 200 ? '...' : ''}`));
436
+ }
437
+ } else {
438
+ console.log(pc.dim(` [tool] ${name} (${timeMs}ms)`));
439
+ }
440
+ }
441
+ }
442
+
443
+ if (event.type === 'chunk') {
444
+ if (toolCalls.length > 0 && !opts.quiet) {
445
+ console.log(''); // Visual separator after tool calls
446
+ }
447
+ process.stdout.write(event.data);
448
+ }
449
+
450
+ if (event.type === 'done') {
451
+ console.log(''); // End the response line
452
+ console.log('');
453
+ }
454
+ }
455
+ }
456
+
457
+ return toolCalls;
458
+ }
459
+
338
460
  /**
339
461
  * Handle slash commands within the REPL.
340
462
  * @returns {'quit'|true|false} - 'quit' to exit, true if handled, false if unknown
341
463
  */
342
464
  async function handleSlashCommand(input, ctx) {
343
- const { history, opts, db, collection, llm, rl } = ctx;
465
+ const { history, opts, db, collection, llm, rl, isAgent, lastToolCalls } = ctx;
344
466
  const parts = input.split(/\s+/);
345
467
  const cmd = parts[0].toLowerCase();
346
468
 
@@ -360,6 +482,10 @@ async function handleSlashCommand(input, ctx) {
360
482
  console.log(' /clear Clear conversation history');
361
483
  console.log(' /model Show or switch LLM model (/model <name>)');
362
484
  console.log(' /export Export conversation (markdown or json)');
485
+ if (isAgent) {
486
+ console.log(' /tools Show tool calls from last response');
487
+ console.log(' /export-workflow Export last tool sequence as workflow');
488
+ }
363
489
  console.log(' /help Show this help');
364
490
  console.log(' /quit Exit chat');
365
491
  console.log('');
@@ -382,15 +508,20 @@ async function handleSlashCommand(input, ctx) {
382
508
  case '/session':
383
509
  console.log(` Session: ${history.sessionId}`);
384
510
  console.log(` Turns: ${Math.floor(history.turns.length / 2)}`);
511
+ if (isAgent) {
512
+ console.log(` Mode: agent (tool-calling)`);
513
+ } else {
514
+ console.log(` Mode: pipeline (fixed RAG)`);
515
+ }
385
516
  return true;
386
517
 
387
518
  case '/context': {
388
- const ctx = history.getLastContext();
389
- if (!ctx) {
519
+ const lastCtx = history.getLastContext();
520
+ if (!lastCtx) {
390
521
  console.log(pc.dim(' No context available yet.'));
391
522
  } else {
392
523
  console.log('');
393
- for (const doc of ctx) {
524
+ for (const doc of lastCtx) {
394
525
  console.log(pc.bold(` [${doc.source}]`));
395
526
  const preview = (doc.text || '').substring(0, 300);
396
527
  console.log(` ${preview}${doc.text?.length > 300 ? '...' : ''}`);
@@ -413,7 +544,7 @@ async function handleSlashCommand(input, ctx) {
413
544
  } else {
414
545
  console.log('');
415
546
  for (const s of sessions) {
416
- const active = s.sessionId === history.sessionId ? pc.green(' current') : '';
547
+ const active = s.sessionId === history.sessionId ? pc.green(' <- current') : '';
417
548
  const date = s.lastActivity ? new Date(s.lastActivity).toLocaleString() : 'unknown';
418
549
  const preview = (s.firstMessage || '').substring(0, 60);
419
550
  console.log(` ${pc.bold(s.sessionId.slice(0, 8))} ${pc.dim(date)} ${s.turnCount} turns${active}`);
@@ -448,7 +579,7 @@ async function handleSlashCommand(input, ctx) {
448
579
  console.log('');
449
580
  console.log(` Available models:`);
450
581
  for (const m of models) {
451
- const current = m.id === llm.model ? pc.green(' current') : '';
582
+ const current = m.id === llm.model ? pc.green(' <- current') : '';
452
583
  let info = m.name || m.id;
453
584
  if (m.size) info += pc.dim(` (${m.size})`);
454
585
  if (m.parameterSize) info += pc.dim(` [${m.parameterSize}]`);
@@ -479,6 +610,78 @@ async function handleSlashCommand(input, ctx) {
479
610
  return true;
480
611
  }
481
612
 
613
+ case '/tools': {
614
+ if (!isAgent) {
615
+ console.log(pc.dim(' /tools is only available in agent mode (--mode agent).'));
616
+ return true;
617
+ }
618
+ if (!lastToolCalls || lastToolCalls.length === 0) {
619
+ console.log(pc.dim(' No tool calls from the last response.'));
620
+ return true;
621
+ }
622
+ console.log('');
623
+ console.log(pc.bold(` Tool calls (${lastToolCalls.length}):`));
624
+ console.log('');
625
+ for (let i = 0; i < lastToolCalls.length; i++) {
626
+ const tc = lastToolCalls[i];
627
+ const status = tc.error ? pc.red('FAILED') : pc.green('OK');
628
+ console.log(` ${i + 1}. ${pc.bold(tc.name)} [${status}] (${tc.timeMs}ms)`);
629
+
630
+ // Show args
631
+ const argKeys = Object.keys(tc.args || {});
632
+ if (argKeys.length > 0) {
633
+ const argStr = argKeys.map(k => `${k}=${JSON.stringify(tc.args[k])}`).join(', ');
634
+ const preview = argStr.substring(0, 120);
635
+ console.log(pc.dim(` Args: ${preview}${argStr.length > 120 ? '...' : ''}`));
636
+ }
637
+
638
+ // Show result summary
639
+ if (tc.error) {
640
+ console.log(pc.dim(` Error: ${tc.error}`));
641
+ } else if (tc.result) {
642
+ const resultStr = JSON.stringify(tc.result);
643
+ const preview = resultStr.substring(0, 120);
644
+ console.log(pc.dim(` Result: ${preview}${resultStr.length > 120 ? '...' : ''}`));
645
+ }
646
+ console.log('');
647
+ }
648
+ return true;
649
+ }
650
+
651
+ case '/export-workflow': {
652
+ if (!isAgent) {
653
+ console.log(pc.dim(' /export-workflow is only available in agent mode (--mode agent).'));
654
+ return true;
655
+ }
656
+ if (!lastToolCalls || lastToolCalls.length === 0) {
657
+ console.log(pc.dim(' No tool calls to export. Ask a question first.'));
658
+ return true;
659
+ }
660
+
661
+ const workflow = {
662
+ name: `agent-workflow-${Date.now()}`,
663
+ description: 'Workflow exported from vai chat agent session',
664
+ version: '1.0.0',
665
+ steps: lastToolCalls.map((tc, i) => ({
666
+ id: `step_${i + 1}`,
667
+ tool: tc.name,
668
+ args: tc.args,
669
+ description: `Step ${i + 1}: ${tc.name}`,
670
+ })),
671
+ metadata: {
672
+ exportedAt: new Date().toISOString(),
673
+ sessionId: history.sessionId,
674
+ llmProvider: llm.name,
675
+ llmModel: llm.model,
676
+ },
677
+ };
678
+
679
+ const filename = `agent-workflow-${history.sessionId.slice(0, 8)}.vai-workflow.json`;
680
+ fs.writeFileSync(filename, JSON.stringify(workflow, null, 2) + '\n');
681
+ console.log(ui.success(`Exported ${lastToolCalls.length} tool calls to ${filename}`));
682
+ return true;
683
+ }
684
+
482
685
  default:
483
686
  console.log(pc.dim(` Unknown command: ${cmd}. Type /help for available commands.`));
484
687
  return true;
@@ -85,6 +85,23 @@ function createPlaygroundServer() {
85
85
  return;
86
86
  }
87
87
 
88
+ // Serve watermark image
89
+ if (req.method === 'GET' && req.url === '/icons/watermark.png') {
90
+ const wmPath = path.join(__dirname, '..', 'playground', 'icons', 'watermark.png');
91
+ if (fs.existsSync(wmPath)) {
92
+ const data = fs.readFileSync(wmPath);
93
+ res.writeHead(200, {
94
+ 'Content-Type': 'image/png',
95
+ 'Cache-Control': 'public, max-age=86400',
96
+ });
97
+ res.end(data);
98
+ } else {
99
+ res.writeHead(404);
100
+ res.end('Watermark not found');
101
+ }
102
+ return;
103
+ }
104
+
88
105
  // Serve icon assets: /icons/{dark|light}/{size}.png
89
106
  const iconMatch = req.url.match(/^\/icons\/(dark|light)\/(\d+)\.png$/);
90
107
  if (req.method === 'GET' && iconMatch) {
@@ -193,6 +210,20 @@ function createPlaygroundServer() {
193
210
  }
194
211
  }
195
212
 
213
+ // Add binary files
214
+ if (structure.binaryFiles) {
215
+ for (const file of structure.binaryFiles) {
216
+ const srcPath = path.join(__dirname, '..', 'lib', 'templates', target, file.source);
217
+ if (fs.existsSync(srcPath)) {
218
+ files.push({
219
+ name: `${projectName}/${file.output}`,
220
+ content: fs.readFileSync(srcPath),
221
+ binary: true,
222
+ });
223
+ }
224
+ }
225
+ }
226
+
196
227
  // Create ZIP
197
228
  const zipBuffer = createZip(files);
198
229
 
@@ -246,6 +277,7 @@ function createPlaygroundServer() {
246
277
  db: proj.db || null,
247
278
  collection: proj.collection || null,
248
279
  chat: proj.chat || {},
280
+ mode: proj.chat?.mode || 'pipeline',
249
281
  }));
250
282
  return;
251
283
  }
@@ -333,6 +365,7 @@ function createPlaygroundServer() {
333
365
  if (parsed.maxDocs !== undefined) proj.chat.maxContextDocs = parsed.maxDocs;
334
366
  if (parsed.rerank !== undefined) proj.chat.rerank = parsed.rerank;
335
367
  if (parsed.systemPrompt !== undefined) proj.chat.systemPrompt = parsed.systemPrompt;
368
+ if (parsed.mode !== undefined) proj.chat.mode = parsed.mode;
336
369
 
337
370
  try {
338
371
  saveProject(proj, filePath || undefined);
@@ -370,20 +403,23 @@ function createPlaygroundServer() {
370
403
 
371
404
  // API: Chat message (streaming SSE)
372
405
  if (req.url === '/api/chat/message') {
373
- const { query, db, collection, provider, model, maxDocs, rerank, systemPrompt } = parsed;
406
+ const { query, db, collection, provider, model, maxDocs, rerank, systemPrompt, mode } = parsed;
407
+ const isAgent = mode === 'agent';
408
+
374
409
  if (!query) {
375
410
  res.writeHead(400, { 'Content-Type': 'application/json' });
376
411
  res.end(JSON.stringify({ error: 'query is required' }));
377
412
  return;
378
413
  }
379
- if (!db || !collection) {
414
+ // Pipeline mode requires db + collection; agent mode they're optional
415
+ if (!isAgent && (!db || !collection)) {
380
416
  res.writeHead(400, { 'Content-Type': 'application/json' });
381
- res.end(JSON.stringify({ error: 'db and collection are required' }));
417
+ res.end(JSON.stringify({ error: 'db and collection are required for pipeline mode' }));
382
418
  return;
383
419
  }
384
420
 
385
421
  const { createLLMProvider } = require('../lib/llm');
386
- const { chatTurn } = require('../lib/chat');
422
+ const { chatTurn, agentChatTurn } = require('../lib/chat');
387
423
  const { ChatHistory } = require('../lib/history');
388
424
 
389
425
  let llm;
@@ -416,21 +452,39 @@ function createPlaygroundServer() {
416
452
  });
417
453
 
418
454
  try {
419
- for await (const event of chatTurn({
420
- query, db, collection, llm, history,
421
- opts: {
422
- maxDocs: maxDocs || 5,
423
- rerank: rerank !== false,
424
- stream: true,
425
- systemPrompt,
426
- },
427
- })) {
428
- if (event.type === 'retrieval') {
429
- res.write(`event: retrieval\ndata: ${JSON.stringify(event.data)}\n\n`);
430
- } else if (event.type === 'chunk') {
431
- res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
432
- } else if (event.type === 'done') {
433
- res.write(`event: done\ndata: ${JSON.stringify(event.data)}\n\n`);
455
+ if (isAgent) {
456
+ // Agent mode: LLM decides which tools to call
457
+ for await (const event of agentChatTurn({
458
+ query, llm, history,
459
+ opts: { systemPrompt, db: db || undefined, collection: collection || undefined },
460
+ })) {
461
+ if (event.type === 'tool_call') {
462
+ const { name, args, timeMs, error } = event.data;
463
+ res.write(`event: tool_call\ndata: ${JSON.stringify({ name, args, timeMs, error })}\n\n`);
464
+ } else if (event.type === 'chunk') {
465
+ res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
466
+ } else if (event.type === 'done') {
467
+ res.write(`event: done\ndata: ${JSON.stringify(event.data)}\n\n`);
468
+ }
469
+ }
470
+ } else {
471
+ // Pipeline mode: fixed RAG retrieval
472
+ for await (const event of chatTurn({
473
+ query, db, collection, llm, history,
474
+ opts: {
475
+ maxDocs: maxDocs || 5,
476
+ rerank: rerank !== false,
477
+ stream: true,
478
+ systemPrompt,
479
+ },
480
+ })) {
481
+ if (event.type === 'retrieval') {
482
+ res.write(`event: retrieval\ndata: ${JSON.stringify(event.data)}\n\n`);
483
+ } else if (event.type === 'chunk') {
484
+ res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
485
+ } else if (event.type === 'done') {
486
+ res.write(`event: done\ndata: ${JSON.stringify(event.data)}\n\n`);
487
+ }
434
488
  }
435
489
  }
436
490
  } catch (err) {