@toothfairyai/cli 1.2.0 → 1.4.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.
package/bin/toothfairy.js CHANGED
@@ -7,6 +7,7 @@ const { table } = require('table');
7
7
  require('dotenv').config();
8
8
  const path = require('path');
9
9
 
10
+ const fs = require('fs');
10
11
  const ToothFairyAPI = require('../src/api');
11
12
  const {
12
13
  loadConfig,
@@ -17,6 +18,128 @@ const {
17
18
  validateDocumentConfiguration,
18
19
  } = require('../src/config');
19
20
 
21
+ /**
22
+ * Determines the best way to handle a local file based on its type and size.
23
+ * Text files under a certain size are read as text content.
24
+ * Binary files or large text files should be uploaded.
25
+ *
26
+ * @param {string} filePath - Path to the local file
27
+ * @returns {Object} - { strategy: 'text'|'upload', content?: string, mimeType: string, size: number }
28
+ */
29
+ function analyzeLocalFile(filePath) {
30
+ const absolutePath = path.resolve(filePath);
31
+
32
+ if (!fs.existsSync(absolutePath)) {
33
+ throw new Error(`File not found: ${filePath}`);
34
+ }
35
+
36
+ const stats = fs.statSync(absolutePath);
37
+ const ext = path.extname(filePath).toLowerCase().slice(1);
38
+ const sizeInMB = stats.size / (1024 * 1024);
39
+
40
+ // Text-based file extensions that can be read as content
41
+ const textExtensions = [
42
+ 'txt', 'md', 'markdown', 'json', 'jsonl', 'yaml', 'yml',
43
+ 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'c', 'cpp', 'h', 'hpp',
44
+ 'cs', 'rb', 'go', 'rs', 'php', 'swift', 'kt', 'scala',
45
+ 'html', 'htm', 'css', 'scss', 'sass', 'less',
46
+ 'xml', 'svg', 'sql', 'sh', 'bash', 'zsh', 'ps1',
47
+ 'env', 'ini', 'cfg', 'conf', 'toml', 'properties',
48
+ 'csv', 'log', 'gitignore', 'dockerignore', 'editorconfig'
49
+ ];
50
+
51
+ // Binary file extensions that must be uploaded
52
+ const binaryExtensions = [
53
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
54
+ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'ico', 'tiff',
55
+ 'mp3', 'wav', 'aac', 'ogg', 'flac', 'm4a',
56
+ 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv',
57
+ 'zip', 'tar', 'gz', 'rar', '7z',
58
+ 'exe', 'dll', 'so', 'dylib',
59
+ 'woff', 'woff2', 'ttf', 'otf', 'eot'
60
+ ];
61
+
62
+ const isTextFile = textExtensions.includes(ext) || ext === '';
63
+ const isBinaryFile = binaryExtensions.includes(ext);
64
+
65
+ // Max size for reading as text (500KB)
66
+ const maxTextSize = 0.5;
67
+
68
+ // Determine strategy
69
+ let strategy;
70
+ let content = null;
71
+
72
+ if (isBinaryFile) {
73
+ strategy = 'upload';
74
+ } else if (isTextFile && sizeInMB <= maxTextSize) {
75
+ strategy = 'text';
76
+ content = fs.readFileSync(absolutePath, 'utf-8');
77
+ } else if (sizeInMB > 15) {
78
+ throw new Error(`File size (${sizeInMB.toFixed(2)}MB) exceeds 15MB limit`);
79
+ } else {
80
+ // Large text file or unknown extension - upload it
81
+ strategy = 'upload';
82
+ }
83
+
84
+ return {
85
+ strategy,
86
+ content,
87
+ absolutePath,
88
+ fileName: path.basename(filePath),
89
+ extension: ext,
90
+ size: stats.size,
91
+ sizeInMB
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Processes local files and prepares them for sending to an agent.
97
+ * Returns message text with file contents embedded and/or attachments for upload.
98
+ *
99
+ * @param {string[]} localFiles - Array of local file paths
100
+ * @param {string} originalMessage - The original message from the user
101
+ * @param {Object} existingAttachments - Existing attachment object
102
+ * @returns {Object} - { message: string, attachments: Object }
103
+ */
104
+ function processLocalFiles(localFiles, originalMessage, existingAttachments = {}) {
105
+ const textContents = [];
106
+ const attachments = { ...existingAttachments };
107
+
108
+ for (const filePath of localFiles) {
109
+ const analysis = analyzeLocalFile(filePath);
110
+
111
+ if (analysis.strategy === 'text') {
112
+ // Add file content as text
113
+ textContents.push({
114
+ fileName: analysis.fileName,
115
+ content: analysis.content
116
+ });
117
+ } else {
118
+ // Add to attachments for upload
119
+ if (!attachments.files) {
120
+ attachments.files = [];
121
+ }
122
+ attachments.files.push(analysis.absolutePath);
123
+ }
124
+ }
125
+
126
+ // Build enhanced message with text file contents
127
+ let enhancedMessage = originalMessage;
128
+
129
+ if (textContents.length > 0) {
130
+ const fileSection = textContents.map(file =>
131
+ `\n\n--- File: ${file.fileName} ---\n${file.content}\n--- End of ${file.fileName} ---`
132
+ ).join('');
133
+
134
+ enhancedMessage = `${originalMessage}${fileSection}`;
135
+ }
136
+
137
+ return {
138
+ message: enhancedMessage,
139
+ attachments
140
+ };
141
+ }
142
+
20
143
  const program = new Command();
21
144
 
22
145
  program
@@ -48,6 +171,7 @@ program
48
171
  .option('--api-key <key>', 'API key')
49
172
  .option('--workspace-id <id>', 'Workspace ID')
50
173
  .option('--user-id <id>', 'User ID')
174
+ .option('--default-agent-id <id>', 'Default agent ID for send/chat commands')
51
175
  .option('--config-path <path>', 'Path to save config file')
52
176
  .action(async (options) => {
53
177
  try {
@@ -57,7 +181,7 @@ program
57
181
  existingConfig = loadConfig(options.configPath);
58
182
  } catch (error) {
59
183
  // No existing config, use empty defaults
60
- existingConfig = new ToothFairyConfig('', '', '', '', '', '');
184
+ existingConfig = new ToothFairyConfig('', '', '', '', '', '', '');
61
185
  }
62
186
 
63
187
  // Merge provided options with existing config
@@ -71,7 +195,8 @@ program
71
195
  'https://ais.toothfairyai.com',
72
196
  options.apiKey || existingConfig.apiKey || '',
73
197
  options.workspaceId || existingConfig.workspaceId || '',
74
- options.userId || existingConfig.userId || ''
198
+ options.userId || existingConfig.userId || '',
199
+ options.defaultAgentId || existingConfig.defaultAgentId || ''
75
200
  );
76
201
 
77
202
  // Validate that at least API key and workspace ID are set
@@ -109,7 +234,7 @@ program
109
234
  .command('send')
110
235
  .description('Send a message to a ToothFairyAI agent')
111
236
  .argument('<message>', 'message to send')
112
- .requiredOption('--agent-id <id>', 'Agent ID to send message to')
237
+ .option('--agent-id <id>', 'Agent ID to send message to (uses default if configured)')
113
238
  .option('--chat-id <id>', 'Chat ID to use existing conversation (optional)')
114
239
  .option('--phone-number <number>', 'Phone number for SMS channel')
115
240
  .option('--customer-id <id>', 'Customer ID')
@@ -119,6 +244,7 @@ program
119
244
  .option('--audio <path>', 'Path to audio file (wav, mp3, aac, ogg, flac)')
120
245
  .option('--video <path>', 'Path to video file (mp4, avi, mov, wmv, flv, webm)')
121
246
  .option('--file <path...>', 'Path to document files (max 5)')
247
+ .option('--local-file <path...>', 'Path to local files to include (text files embedded in message, binary files uploaded)')
122
248
  .option('-o, --output <format>', 'Output format (json|text)', 'text')
123
249
  .option('-v, --verbose', 'Show detailed response information')
124
250
  .action(async (message, options, command) => {
@@ -127,6 +253,15 @@ program
127
253
  const config = loadConfig(globalOptions.config);
128
254
  validateConfiguration(config);
129
255
 
256
+ // Use provided agent-id or fall back to default from config
257
+ const agentId = options.agentId || config.defaultAgentId;
258
+ if (!agentId) {
259
+ console.error(chalk.red('Error: --agent-id is required'));
260
+ console.error(chalk.yellow('Either provide --agent-id or set a default with:'));
261
+ console.error(chalk.dim(' tf configure --default-agent-id YOUR_AGENT_ID'));
262
+ process.exit(1);
263
+ }
264
+
130
265
  const api = new ToothFairyAPI(
131
266
  config.baseUrl,
132
267
  config.aiUrl,
@@ -148,7 +283,7 @@ program
148
283
  }
149
284
 
150
285
  // Process file attachments if provided
151
- const attachments = {};
286
+ let attachments = {};
152
287
  if (options.image) {
153
288
  attachments.images = [options.image];
154
289
  }
@@ -162,11 +297,28 @@ program
162
297
  attachments.files = Array.isArray(options.file) ? options.file : [options.file];
163
298
  }
164
299
 
300
+ // Process local files - text files are embedded in message, binary files are uploaded
301
+ let finalMessage = message;
302
+ if (options.localFile) {
303
+ const localFiles = Array.isArray(options.localFile) ? options.localFile : [options.localFile];
304
+ const processed = processLocalFiles(localFiles, message, attachments);
305
+ finalMessage = processed.message;
306
+ attachments = processed.attachments;
307
+
308
+ if (globalOptions.verbose || options.verbose) {
309
+ console.log(chalk.dim(`Processing ${localFiles.length} local file(s)...`));
310
+ for (const filePath of localFiles) {
311
+ const analysis = analyzeLocalFile(filePath);
312
+ console.log(chalk.dim(` - ${analysis.fileName}: ${analysis.strategy} (${analysis.sizeInMB.toFixed(2)}MB)`));
313
+ }
314
+ }
315
+ }
316
+
165
317
  const spinner = ora('Sending message to agent...').start();
166
318
 
167
319
  const response = await api.sendMessageToAgent(
168
- message,
169
- options.agentId,
320
+ finalMessage,
321
+ agentId,
170
322
  options.phoneNumber,
171
323
  options.customerId,
172
324
  options.providerId,
@@ -238,7 +390,7 @@ program
238
390
  'Send a message to a ToothFairyAI agent with real-time streaming response'
239
391
  )
240
392
  .argument('<message>', 'message to send')
241
- .requiredOption('--agent-id <id>', 'Agent ID to send message to')
393
+ .option('--agent-id <id>', 'Agent ID to send message to (uses default if configured)')
242
394
  .option('--chat-id <id>', 'Chat ID to use existing conversation (optional)')
243
395
  .option('--phone-number <number>', 'Phone number for SMS channel')
244
396
  .option('--customer-id <id>', 'Customer ID')
@@ -248,6 +400,7 @@ program
248
400
  .option('--audio <path>', 'Path to audio file (WAV, max 1)')
249
401
  .option('--video <path>', 'Path to video file (MP4, max 1)')
250
402
  .option('--file <path>', 'Path to file (max 5, can be used multiple times)', (value, previous) => previous.concat([value]), [])
403
+ .option('--local-file <path...>', 'Path to local files to include (text files embedded in message, binary files uploaded)')
251
404
  .option('-o, --output <format>', 'Output format (json|text)', 'text')
252
405
  .option('-v, --verbose', 'Show detailed streaming information')
253
406
  .option('--show-progress', 'Show agent processing status updates')
@@ -277,6 +430,15 @@ program
277
430
  const config = loadConfig(globalOptions.config);
278
431
  validateConfiguration(config);
279
432
 
433
+ // Use provided agent-id or fall back to default from config
434
+ const agentId = options.agentId || config.defaultAgentId;
435
+ if (!agentId) {
436
+ console.error(chalk.red('Error: --agent-id is required'));
437
+ console.error(chalk.yellow('Either provide --agent-id or set a default with:'));
438
+ console.error(chalk.dim(' tf configure --default-agent-id YOUR_AGENT_ID'));
439
+ process.exit(1);
440
+ }
441
+
280
442
  const api = new ToothFairyAPI(
281
443
  config.baseUrl,
282
444
  config.aiUrl,
@@ -298,7 +460,7 @@ program
298
460
  }
299
461
 
300
462
  // Process file attachments if provided
301
- const attachments = {};
463
+ let attachments = {};
302
464
  if (options.image) {
303
465
  attachments.images = [options.image];
304
466
  }
@@ -312,18 +474,34 @@ program
312
474
  attachments.files = Array.isArray(options.file) ? options.file : [options.file];
313
475
  }
314
476
 
477
+ // Process local files - text files are embedded in message, binary files are uploaded
478
+ let finalMessage = message;
479
+ if (options.localFile) {
480
+ const localFiles = Array.isArray(options.localFile) ? options.localFile : [options.localFile];
481
+ const processed = processLocalFiles(localFiles, message, attachments);
482
+ finalMessage = processed.message;
483
+ attachments = processed.attachments;
484
+
485
+ if (globalOptions.verbose || options.verbose) {
486
+ console.log(chalk.dim(`Processing ${localFiles.length} local file(s)...`));
487
+ for (const filePath of localFiles) {
488
+ const analysis = analyzeLocalFile(filePath);
489
+ console.log(chalk.dim(` - ${analysis.fileName}: ${analysis.strategy} (${analysis.sizeInMB.toFixed(2)}MB)`));
490
+ }
491
+ }
492
+ }
493
+
315
494
  console.log(
316
- chalk.cyan(`Streaming message to agent ${options.agentId}...`)
495
+ chalk.cyan(`Streaming message to agent ${agentId}...`)
317
496
  );
318
- console.log(chalk.dim(`Message: ${message}`));
497
+ console.log(chalk.dim(`Message: ${options.localFile ? '(with local files) ' : ''}${message}`));
319
498
  console.log();
320
499
 
321
500
  // Initialize variables for streaming
322
- let currentText = '';
323
501
  let finalResponse = null;
324
502
  let processingStatus = null;
325
- let lastUpdateTime = 0;
326
503
  let hasStartedResponse = false;
504
+ let hasCompletedResponse = false;
327
505
 
328
506
  const mapStateWithLabel = (state) => {
329
507
  switch (state) {
@@ -377,8 +555,8 @@ program
377
555
  const allEvents = [];
378
556
 
379
557
  await api.sendMessageToAgentStream(
380
- message,
381
- options.agentId,
558
+ finalMessage,
559
+ agentId,
382
560
  options.phoneNumber,
383
561
  options.customerId,
384
562
  options.providerId,
@@ -396,7 +574,8 @@ program
396
574
  },
397
575
  attachments,
398
576
  options.showProgress,
399
- options.chatId
577
+ options.chatId,
578
+ true // rawStream - enable chunk-by-chunk streaming
400
579
  );
401
580
 
402
581
  console.log(JSON.stringify(allEvents, null, 2));
@@ -404,42 +583,10 @@ program
404
583
  // Text mode: show live streaming
405
584
  let currentSpinner = null;
406
585
 
407
- // Function to update display with status filtering
408
- const updateDisplay = (text, agentStatus, type = 'response') => {
409
- if (currentSpinner) {
410
- currentSpinner.stop();
411
- currentSpinner = null;
412
- }
413
-
414
- // Only show text when agent is actually replying
415
- if (text && type === 'response' && agentStatus === 'replying') {
416
- const trimmedText = text.trim();
417
- const now = Date.now();
418
-
419
- // Only update if the text is actually different and enough time has passed
420
- // This prevents rapid duplicate updates
421
- if (
422
- trimmedText !== currentText &&
423
- trimmedText?.length > currentText?.length &&
424
- now - lastUpdateTime > 50
425
- ) {
426
- if (!hasStartedResponse) {
427
- // First time showing response - print header on new line
428
- console.log(chalk.green('🧚 Responding'));
429
- hasStartedResponse = true;
430
- }
431
- // Stream new text content on new line
432
- console.log(trimmedText);
433
- currentText = trimmedText;
434
- lastUpdateTime = now;
435
- }
436
- }
437
- };
438
-
439
586
  try {
440
587
  await api.sendMessageToAgentStream(
441
- message,
442
- options.agentId,
588
+ finalMessage,
589
+ agentId,
443
590
  options.phoneNumber,
444
591
  options.customerId,
445
592
  options.providerId,
@@ -480,19 +627,71 @@ program
480
627
  return;
481
628
  }
482
629
 
483
- // Handle message events
630
+ // Handle streaming data events (tokens from raw_stream mode)
631
+ if (eventType === 'data' && eventData.text) {
632
+ // Stop spinner when text starts streaming
633
+ if (currentSpinner) {
634
+ currentSpinner.stop();
635
+ currentSpinner = null;
636
+ }
637
+
638
+ if (!hasStartedResponse) {
639
+ console.log(chalk.green('🧚 Responding'));
640
+ hasStartedResponse = true;
641
+ }
642
+
643
+ // Stream chunk directly to output
644
+ process.stdout.write(eventData.text);
645
+ }
646
+
647
+ // Handle progress events (from raw_stream mode)
648
+ if (eventType === 'progress') {
649
+ const status = eventData.processing_status || eventData.metadata_parsed?.agent_processing_status;
650
+ if (status && status !== processingStatus) {
651
+ processingStatus = status;
652
+ if (options.showProgress) {
653
+ const statusMsg = mapStateWithLabel(processingStatus);
654
+ if (currentSpinner) {
655
+ currentSpinner.stop();
656
+ }
657
+ currentSpinner = ora(chalk.yellow(statusMsg)).start();
658
+ }
659
+ }
660
+ }
661
+
662
+ // Handle chat_created events
663
+ if (eventType === 'chat_created' && eventData.chatId) {
664
+ if (options.verbose) {
665
+ console.log(chalk.dim(`\n📝 Chat created: ${eventData.chatId}`));
666
+ }
667
+ }
668
+
669
+ // Handle complete events
670
+ if (eventType === 'complete') {
671
+ finalResponse = eventData;
672
+ if (currentSpinner) {
673
+ currentSpinner.stop();
674
+ currentSpinner = null;
675
+ }
676
+ }
677
+
678
+ // Handle message events (legacy format / additional metadata)
484
679
  if (eventData.type === 'message') {
485
680
  let metadata = {};
486
681
  let agentStatus = null;
487
682
 
488
683
  // Parse metadata if available
489
684
  if (eventData.metadata) {
490
- try {
491
- metadata = JSON.parse(eventData.metadata);
492
- agentStatus = metadata.agent_processing_status;
493
- } catch (e) {
494
- // Metadata parsing failed, continue without it
685
+ if (typeof eventData.metadata === 'object') {
686
+ metadata = eventData.metadata;
687
+ } else {
688
+ try {
689
+ metadata = JSON.parse(eventData.metadata);
690
+ } catch (e) {
691
+ // Metadata parsing failed, continue without it
692
+ }
495
693
  }
694
+ agentStatus = metadata.agent_processing_status;
496
695
  }
497
696
 
498
697
  // Handle status changes
@@ -507,22 +706,15 @@ program
507
706
  }
508
707
  }
509
708
 
510
- // Handle progressive text streaming
511
- if (eventData.text && agentStatus === 'replying') {
512
- updateDisplay(eventData.text, agentStatus, 'response');
513
- }
514
-
515
709
  // Handle fulfilled status (final response)
516
- if (eventData.status === 'fulfilled') {
710
+ if (eventData.status === 'fulfilled' && !hasCompletedResponse) {
517
711
  finalResponse = eventData;
518
- if (currentText) {
519
- // Clean final display with magic wand emoji
520
- if (currentSpinner) {
521
- currentSpinner.stop();
522
- currentSpinner = null;
523
- }
524
- console.log(chalk.blue('🪄 Response complete'));
712
+ hasCompletedResponse = true;
713
+ if (currentSpinner) {
714
+ currentSpinner.stop();
715
+ currentSpinner = null;
525
716
  }
717
+ console.log(chalk.blue('\n🪄 Response complete'));
526
718
  }
527
719
 
528
720
  // Handle additional metadata events (images, files, callback metadata)
@@ -530,14 +722,12 @@ program
530
722
  eventData.images !== undefined ||
531
723
  eventData.files !== undefined
532
724
  ) {
533
- // These are attachment events - could show notification if needed
534
725
  if (options.verbose) {
535
726
  console.log(chalk.dim('\n📎 Attachments processed'));
536
727
  }
537
728
  }
538
729
 
539
730
  if (eventData.callbackMetadata) {
540
- // Function execution metadata
541
731
  if (options.verbose) {
542
732
  console.log(
543
733
  chalk.dim('\n⚙️ Function execution metadata received')
@@ -558,7 +748,8 @@ program
558
748
  },
559
749
  attachments,
560
750
  options.showProgress,
561
- options.chatId
751
+ options.chatId,
752
+ true // rawStream - enable chunk-by-chunk streaming
562
753
  );
563
754
 
564
755
  // Clean up spinner after streaming completes
@@ -610,7 +801,7 @@ program
610
801
  console.log('─'.repeat(50));
611
802
  }
612
803
 
613
- if (!currentText) {
804
+ if (!hasStartedResponse) {
614
805
  console.log(chalk.yellow('No text response received from agent.'));
615
806
  }
616
807
  } catch (streamError) {
@@ -626,6 +817,378 @@ program
626
817
  }
627
818
  });
628
819
 
820
+ // Interactive Chat command
821
+ program
822
+ .command('chat')
823
+ .description('Start an interactive chat session with a ToothFairyAI agent (use @ to reference local files)')
824
+ .option('--agent-id <id>', 'Agent ID to chat with (uses default if configured)')
825
+ .option('--chat-id <id>', 'Resume an existing chat conversation')
826
+ .option('-v, --verbose', 'Show detailed information')
827
+ .option('--show-progress', 'Show agent processing status updates')
828
+ .action(async (options, command) => {
829
+ const readline = require('readline');
830
+ const glob = require('glob');
831
+
832
+ try {
833
+ const globalOptions = command.parent.opts();
834
+ const config = loadConfig(globalOptions.config);
835
+ validateConfiguration(config);
836
+
837
+ // Use provided agent-id or fall back to default from config
838
+ const agentId = options.agentId || config.defaultAgentId;
839
+ if (!agentId) {
840
+ console.error(chalk.red('Error: --agent-id is required'));
841
+ console.error(chalk.yellow('Either provide --agent-id or set a default with:'));
842
+ console.error(chalk.dim(' tf configure --default-agent-id YOUR_AGENT_ID'));
843
+ process.exit(1);
844
+ }
845
+
846
+ const api = new ToothFairyAPI(
847
+ config.baseUrl,
848
+ config.aiUrl,
849
+ config.aiStreamUrl,
850
+ config.apiKey,
851
+ config.workspaceId,
852
+ globalOptions.verbose || options.verbose
853
+ );
854
+
855
+ // Track current chat ID for conversation continuity
856
+ let currentChatId = options.chatId || null;
857
+ const workingDir = process.cwd();
858
+
859
+ // Helper to get files matching a pattern
860
+ const getMatchingFiles = (pattern) => {
861
+ try {
862
+ // Remove @ prefix if present
863
+ const cleanPattern = pattern.startsWith('@') ? pattern.slice(1) : pattern;
864
+
865
+ // If it's a directory, list its contents
866
+ if (fs.existsSync(cleanPattern) && fs.statSync(cleanPattern).isDirectory()) {
867
+ return fs.readdirSync(cleanPattern).map(f => path.join(cleanPattern, f));
868
+ }
869
+
870
+ // Try glob pattern
871
+ const matches = glob.sync(cleanPattern, {
872
+ cwd: workingDir,
873
+ nodir: false,
874
+ dot: false
875
+ });
876
+ return matches;
877
+ } catch (e) {
878
+ return [];
879
+ }
880
+ };
881
+
882
+ // Helper to list files for autocomplete
883
+ const listFilesForCompletion = (partial) => {
884
+ const cleanPartial = partial.startsWith('@') ? partial.slice(1) : partial;
885
+ const dir = path.dirname(cleanPartial) || '.';
886
+ const prefix = path.basename(cleanPartial);
887
+
888
+ try {
889
+ let searchDir = dir === '.' ? workingDir : path.resolve(workingDir, dir);
890
+ if (!fs.existsSync(searchDir)) {
891
+ searchDir = workingDir;
892
+ }
893
+
894
+ const files = fs.readdirSync(searchDir);
895
+ const matches = files
896
+ .filter(f => f.startsWith(prefix) || prefix === '')
897
+ .map(f => {
898
+ const fullPath = path.join(dir === '.' ? '' : dir, f);
899
+ const isDir = fs.statSync(path.join(searchDir, f)).isDirectory();
900
+ return isDir ? fullPath + '/' : fullPath;
901
+ });
902
+ return matches;
903
+ } catch (e) {
904
+ return [];
905
+ }
906
+ };
907
+
908
+ // Parse @ references from message
909
+ const parseFileReferences = (message) => {
910
+ const fileRefs = [];
911
+ // Match @path/to/file or @"path with spaces/file" or @./relative/path
912
+ const regex = /@(?:"([^"]+)"|([^\s@]+))/g;
913
+ let match;
914
+
915
+ while ((match = regex.exec(message)) !== null) {
916
+ const filePath = match[1] || match[2];
917
+ fileRefs.push(filePath);
918
+ }
919
+
920
+ return fileRefs;
921
+ };
922
+
923
+ // Remove @ references from message for cleaner prompt
924
+ const cleanMessage = (message) => {
925
+ return message.replace(/@(?:"[^"]+"|[^\s@]+)/g, '').trim();
926
+ };
927
+
928
+ // Print welcome banner
929
+ console.log(chalk.cyan.bold('\n🧚 ToothFairy Interactive Chat'));
930
+ console.log(chalk.dim('─'.repeat(50)));
931
+ console.log(chalk.white(`Agent: ${agentId}`));
932
+ console.log(chalk.white(`Working directory: ${workingDir}`));
933
+ if (currentChatId) {
934
+ console.log(chalk.white(`Resuming chat: ${currentChatId}`));
935
+ }
936
+ console.log(chalk.dim('─'.repeat(50)));
937
+ console.log(chalk.yellow('\nTips:'));
938
+ console.log(chalk.dim(' • Use @filename to include a file in your message'));
939
+ console.log(chalk.dim(' • Use @path/to/file or @./relative/path for paths'));
940
+ console.log(chalk.dim(' • Use @"path with spaces/file.txt" for paths with spaces'));
941
+ console.log(chalk.dim(' • Use @*.js or @src/**/*.ts for glob patterns'));
942
+ console.log(chalk.dim(' • Type /files to list files in current directory'));
943
+ console.log(chalk.dim(' • Type /cd <path> to change directory'));
944
+ console.log(chalk.dim(' • Type /chat to see current chat ID'));
945
+ console.log(chalk.dim(' • Type /clear to start a new chat'));
946
+ console.log(chalk.dim(' • Type /exit or Ctrl+C to quit'));
947
+ console.log(chalk.dim('─'.repeat(50)));
948
+ console.log();
949
+
950
+ // Create readline interface with custom completer
951
+ const rl = readline.createInterface({
952
+ input: process.stdin,
953
+ output: process.stdout,
954
+ completer: (line) => {
955
+ // Check if we're completing a file reference
956
+ const atMatch = line.match(/@([^\s]*)$/);
957
+ if (atMatch) {
958
+ const partial = atMatch[1];
959
+ const completions = listFilesForCompletion(partial);
960
+ const hits = completions.map(c => '@' + c);
961
+ return [hits.length ? hits : [], line];
962
+ }
963
+ return [[], line];
964
+ },
965
+ prompt: chalk.green('You: ')
966
+ });
967
+
968
+ rl.prompt();
969
+
970
+ rl.on('line', async (input) => {
971
+ const trimmedInput = input.trim();
972
+
973
+ if (!trimmedInput) {
974
+ rl.prompt();
975
+ return;
976
+ }
977
+
978
+ // Handle special commands
979
+ if (trimmedInput.startsWith('/')) {
980
+ const [cmd, ...args] = trimmedInput.slice(1).split(' ');
981
+
982
+ switch (cmd.toLowerCase()) {
983
+ case 'exit':
984
+ case 'quit':
985
+ case 'q':
986
+ console.log(chalk.cyan('\nGoodbye! 👋'));
987
+ rl.close();
988
+ process.exit(0);
989
+ break;
990
+
991
+ case 'files':
992
+ case 'ls': {
993
+ const targetDir = args[0] || '.';
994
+ try {
995
+ const files = fs.readdirSync(path.resolve(workingDir, targetDir));
996
+ console.log(chalk.cyan(`\nFiles in ${targetDir}:`));
997
+ files.forEach(f => {
998
+ const fullPath = path.join(workingDir, targetDir, f);
999
+ const isDir = fs.statSync(fullPath).isDirectory();
1000
+ console.log(chalk.dim(` ${isDir ? '📁' : '📄'} ${f}${isDir ? '/' : ''}`));
1001
+ });
1002
+ console.log();
1003
+ } catch (e) {
1004
+ console.log(chalk.red(`Cannot list directory: ${e.message}`));
1005
+ }
1006
+ break;
1007
+ }
1008
+
1009
+ case 'cd': {
1010
+ if (args[0]) {
1011
+ const newDir = path.resolve(workingDir, args[0]);
1012
+ if (fs.existsSync(newDir) && fs.statSync(newDir).isDirectory()) {
1013
+ process.chdir(newDir);
1014
+ console.log(chalk.cyan(`\nChanged directory to: ${newDir}\n`));
1015
+ } else {
1016
+ console.log(chalk.red(`Directory not found: ${args[0]}`));
1017
+ }
1018
+ } else {
1019
+ console.log(chalk.yellow('Usage: /cd <path>'));
1020
+ }
1021
+ break;
1022
+ }
1023
+
1024
+ case 'chat':
1025
+ console.log(chalk.cyan(`\nCurrent chat ID: ${currentChatId || '(new chat)'}\n`));
1026
+ break;
1027
+
1028
+ case 'clear':
1029
+ case 'new':
1030
+ currentChatId = null;
1031
+ console.log(chalk.cyan('\nStarting new chat session.\n'));
1032
+ break;
1033
+
1034
+ case 'help':
1035
+ console.log(chalk.yellow('\nCommands:'));
1036
+ console.log(chalk.dim(' /files [path] - List files in directory'));
1037
+ console.log(chalk.dim(' /cd <path> - Change directory'));
1038
+ console.log(chalk.dim(' /chat - Show current chat ID'));
1039
+ console.log(chalk.dim(' /clear - Start new chat'));
1040
+ console.log(chalk.dim(' /exit - Quit'));
1041
+ console.log();
1042
+ break;
1043
+
1044
+ default:
1045
+ console.log(chalk.yellow(`Unknown command: /${cmd}. Type /help for available commands.`));
1046
+ }
1047
+
1048
+ rl.prompt();
1049
+ return;
1050
+ }
1051
+
1052
+ // Parse file references from the message
1053
+ const fileRefs = parseFileReferences(trimmedInput);
1054
+ let messageText = trimmedInput;
1055
+ let attachments = {};
1056
+
1057
+ // Process file references
1058
+ if (fileRefs.length > 0) {
1059
+ const allFiles = [];
1060
+
1061
+ for (const ref of fileRefs) {
1062
+ const matches = getMatchingFiles(ref);
1063
+ if (matches.length === 0) {
1064
+ // Check if it's a direct file path
1065
+ const directPath = path.resolve(workingDir, ref);
1066
+ if (fs.existsSync(directPath)) {
1067
+ allFiles.push(directPath);
1068
+ } else {
1069
+ console.log(chalk.yellow(`Warning: No files found matching @${ref}`));
1070
+ }
1071
+ } else {
1072
+ allFiles.push(...matches.map(m => path.resolve(workingDir, m)));
1073
+ }
1074
+ }
1075
+
1076
+ if (allFiles.length > 0) {
1077
+ // Filter out directories
1078
+ const validFiles = allFiles.filter(f => {
1079
+ try {
1080
+ return fs.existsSync(f) && fs.statSync(f).isFile();
1081
+ } catch {
1082
+ return false;
1083
+ }
1084
+ });
1085
+
1086
+ if (validFiles.length > 0) {
1087
+ console.log(chalk.dim(`\nIncluding ${validFiles.length} file(s):`));
1088
+ validFiles.forEach(f => console.log(chalk.dim(` 📄 ${path.relative(workingDir, f)}`)));
1089
+ console.log();
1090
+
1091
+ // Process the files
1092
+ const cleanedMessage = cleanMessage(messageText);
1093
+ const processed = processLocalFiles(validFiles, cleanedMessage || 'Please review these files:', attachments);
1094
+ messageText = processed.message;
1095
+ attachments = processed.attachments;
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ // Send message to agent with streaming
1101
+ console.log(chalk.blue('\n🧚 Agent: '));
1102
+
1103
+ let processingStatus = null;
1104
+ let currentSpinner = null;
1105
+
1106
+ try {
1107
+ await api.sendMessageToAgentStream(
1108
+ messageText,
1109
+ agentId,
1110
+ null, // phoneNumber
1111
+ null, // customerId
1112
+ null, // providerId
1113
+ {}, // customerInfo
1114
+ (eventType, eventData) => {
1115
+ // Handle chat creation event
1116
+ if (eventData.type === 'chat_created' || eventData.event === 'chat_created') {
1117
+ if (eventData.chatId) {
1118
+ currentChatId = eventData.chatId;
1119
+ }
1120
+ }
1121
+
1122
+ // Update chat ID from response
1123
+ if (eventData.chatId && !currentChatId) {
1124
+ currentChatId = eventData.chatId;
1125
+ }
1126
+
1127
+ // Handle status events
1128
+ if (eventData.status === 'connected' && options.showProgress) {
1129
+ console.log(chalk.dim('Connected...'));
1130
+ }
1131
+
1132
+ // Handle progress events
1133
+ if (eventType === 'progress' && options.showProgress) {
1134
+ const status = eventData.processing_status || eventData.status;
1135
+ if (status && status !== processingStatus) {
1136
+ processingStatus = status;
1137
+ if (currentSpinner) currentSpinner.stop();
1138
+ currentSpinner = ora(chalk.dim(status)).start();
1139
+ }
1140
+ }
1141
+
1142
+ // Handle streaming text (raw_stream tokens come as 'data' events with chunk in text)
1143
+ if (eventType === 'data' && eventData.text) {
1144
+ if (currentSpinner) {
1145
+ currentSpinner.stop();
1146
+ currentSpinner = null;
1147
+ }
1148
+ // Print chunk directly (raw_stream sends individual chunks, not cumulative)
1149
+ process.stdout.write(eventData.text);
1150
+ }
1151
+
1152
+ // Handle completion
1153
+ if (eventData.status === 'fulfilled' || eventType === 'complete') {
1154
+ if (currentSpinner) {
1155
+ currentSpinner.stop();
1156
+ currentSpinner = null;
1157
+ }
1158
+ }
1159
+
1160
+ // Handle errors
1161
+ if (eventType === 'error') {
1162
+ if (currentSpinner) currentSpinner.stop();
1163
+ console.log(chalk.red(`\nError: ${eventData.message || 'Unknown error'}`));
1164
+ }
1165
+ },
1166
+ attachments,
1167
+ options.showProgress,
1168
+ currentChatId,
1169
+ true // rawStream - enable chunk-by-chunk streaming
1170
+ );
1171
+
1172
+ console.log('\n');
1173
+ } catch (error) {
1174
+ if (currentSpinner) currentSpinner.stop();
1175
+ console.log(chalk.red(`\nError: ${error.message}\n`));
1176
+ }
1177
+
1178
+ rl.prompt();
1179
+ });
1180
+
1181
+ rl.on('close', () => {
1182
+ console.log(chalk.cyan('\nGoodbye! 👋\n'));
1183
+ process.exit(0);
1184
+ });
1185
+
1186
+ } catch (error) {
1187
+ console.error(chalk.red(`Error starting chat: ${error.message}`));
1188
+ process.exit(1);
1189
+ }
1190
+ });
1191
+
629
1192
  // Search command
630
1193
  program
631
1194
  .command('search')
@@ -963,6 +1526,7 @@ program
963
1526
  ],
964
1527
  ['Workspace ID', config.workspaceId],
965
1528
  ['User ID', config.userId ? config.userId : 'Not set'],
1529
+ ['Default Agent ID', config.defaultAgentId ? config.defaultAgentId : 'Not set'],
966
1530
  ];
967
1531
 
968
1532
  console.log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toothfairyai/cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Command-line interface for ToothFairyAI API",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -37,18 +37,20 @@
37
37
  },
38
38
  "homepage": "https://gitea.toothfairyai.com/ToothFairyAI/tooth-fairy-website/toothfairy-cli#readme",
39
39
  "dependencies": {
40
- "commander": "^9.4.1",
41
40
  "axios": "^1.4.0",
42
41
  "chalk": "^4.1.2",
42
+ "commander": "^9.4.1",
43
43
  "dotenv": "^16.0.3",
44
+ "eventsource": "^2.0.2",
45
+ "glob": "^10.3.0",
44
46
  "js-yaml": "^4.1.0",
45
47
  "ora": "^5.4.1",
46
- "table": "^6.8.1",
47
- "eventsource": "^2.0.2"
48
+ "table": "^6.8.1"
48
49
  },
49
50
  "devDependencies": {
50
51
  "eslint": "^8.32.0",
51
- "jest": "^29.3.1"
52
+ "jest": "^29.3.1",
53
+ "typescript": "^5.9.3"
52
54
  },
53
55
  "engines": {
54
56
  "node": ">=14.0.0"
package/src/api.js CHANGED
@@ -367,6 +367,8 @@ class ToothFairyAPI {
367
367
  * @param {Function} onEvent - Callback function called for each event
368
368
  * @param {Object} attachments - File attachments (images, audios, videos, files)
369
369
  * @param {boolean} showProgress - Show all progress events from SSE endpoint (default: false)
370
+ * @param {string|null} chatId - Existing chat ID to continue conversation (default: null, creates new chat)
371
+ * @param {boolean} rawStream - Enable raw streaming mode for chunk-by-chunk output (default: false)
370
372
  * @returns {Promise<void>} - Promise resolves when streaming is complete
371
373
  *
372
374
  * Event Types Explained:
@@ -399,7 +401,8 @@ class ToothFairyAPI {
399
401
  onEvent,
400
402
  attachments = {},
401
403
  showProgress = false,
402
- chatId = null
404
+ chatId = null,
405
+ rawStream = false
403
406
  ) {
404
407
  try {
405
408
  // Use defaults for optional parameters
@@ -463,6 +466,7 @@ class ToothFairyAPI {
463
466
  chatid: chatId,
464
467
  messages: [messageData],
465
468
  agentid: agentId,
469
+ raw_stream: rawStream,
466
470
  };
467
471
 
468
472
  // Stream the agent response using the dedicated streaming URL
@@ -511,7 +515,14 @@ class ToothFairyAPI {
511
515
  }
512
516
 
513
517
  // Standard event processing (always executed for backward compatibility)
514
- if (eventData.status) {
518
+ // Handle raw_stream token events (streaming text chunks)
519
+ if (eventData.type === 'token' && eventData.chunk !== undefined) {
520
+ // Token streaming event - emit as 'data' with text field for compatibility
521
+ onEvent('data', {
522
+ ...eventData,
523
+ text: eventData.chunk,
524
+ });
525
+ } else if (eventData.status) {
515
526
  if (eventData.status === 'connected') {
516
527
  onEvent('status', eventData);
517
528
  } else if (eventData.status === 'complete') {
@@ -520,10 +531,15 @@ class ToothFairyAPI {
520
531
  // Parse metadata to understand what's happening
521
532
  let metadata = {};
522
533
  if (eventData.metadata) {
523
- try {
524
- metadata = JSON.parse(eventData.metadata);
525
- } catch (e) {
526
- metadata = { raw_metadata: eventData.metadata };
534
+ // metadata can be an object or a JSON string
535
+ if (typeof eventData.metadata === 'object') {
536
+ metadata = eventData.metadata;
537
+ } else {
538
+ try {
539
+ metadata = JSON.parse(eventData.metadata);
540
+ } catch (e) {
541
+ metadata = { raw_metadata: eventData.metadata };
542
+ }
527
543
  }
528
544
  }
529
545
 
@@ -550,8 +566,17 @@ class ToothFairyAPI {
550
566
  eventData.text &&
551
567
  eventData.type === 'message'
552
568
  ) {
553
- // This is streaming text data
569
+ // This is streaming text data (non-raw_stream mode)
554
570
  onEvent('data', eventData);
571
+ } else if (
572
+ eventData.type === 'message' &&
573
+ eventData.chat_created === true
574
+ ) {
575
+ // Chat creation event from raw_stream mode
576
+ onEvent('chat_created', {
577
+ ...eventData,
578
+ chatId: eventData.chatid,
579
+ });
555
580
  } else if (
556
581
  eventData.type === 'message' &&
557
582
  eventData.images !== undefined
@@ -568,7 +593,7 @@ class ToothFairyAPI {
568
593
  eventData.type === 'chat_created' ||
569
594
  eventData.event === 'chat_created'
570
595
  ) {
571
- // Chat creation event
596
+ // Chat creation event (legacy format)
572
597
  onEvent('chat_created', eventData);
573
598
  } else {
574
599
  // Generic event data
@@ -660,6 +685,7 @@ class ToothFairyAPI {
660
685
  customerId: customerId,
661
686
  providerId: providerId,
662
687
  customerInfo: customerInfo,
688
+ raw_stream: rawStream,
663
689
  };
664
690
 
665
691
  // Stream the agent response using the dedicated streaming URL
@@ -708,7 +734,14 @@ class ToothFairyAPI {
708
734
  }
709
735
 
710
736
  // Standard event processing (always executed for backward compatibility)
711
- if (eventData.status) {
737
+ // Handle raw_stream token events (streaming text chunks)
738
+ if (eventData.type === 'token' && eventData.chunk !== undefined) {
739
+ // Token streaming event - emit as 'data' with text field for compatibility
740
+ onEvent('data', {
741
+ ...eventData,
742
+ text: eventData.chunk,
743
+ });
744
+ } else if (eventData.status) {
712
745
  if (eventData.status === 'connected') {
713
746
  onEvent('status', eventData);
714
747
  } else if (eventData.status === 'complete') {
@@ -717,10 +750,15 @@ class ToothFairyAPI {
717
750
  // Parse metadata to understand what's happening
718
751
  let metadata = {};
719
752
  if (eventData.metadata) {
720
- try {
721
- metadata = JSON.parse(eventData.metadata);
722
- } catch (e) {
723
- metadata = { raw_metadata: eventData.metadata };
753
+ // metadata can be an object or a JSON string
754
+ if (typeof eventData.metadata === 'object') {
755
+ metadata = eventData.metadata;
756
+ } else {
757
+ try {
758
+ metadata = JSON.parse(eventData.metadata);
759
+ } catch (e) {
760
+ metadata = { raw_metadata: eventData.metadata };
761
+ }
724
762
  }
725
763
  }
726
764
 
@@ -747,8 +785,17 @@ class ToothFairyAPI {
747
785
  eventData.text &&
748
786
  eventData.type === 'message'
749
787
  ) {
750
- // This is streaming text data
788
+ // This is streaming text data (non-raw_stream mode)
751
789
  onEvent('data', eventData);
790
+ } else if (
791
+ eventData.type === 'message' &&
792
+ eventData.chat_created === true
793
+ ) {
794
+ // Chat creation event from raw_stream mode
795
+ onEvent('chat_created', {
796
+ ...eventData,
797
+ chatId: eventData.chatid,
798
+ });
752
799
  } else if (
753
800
  eventData.type === 'message' &&
754
801
  eventData.images !== undefined
@@ -765,7 +812,7 @@ class ToothFairyAPI {
765
812
  eventData.type === 'chat_created' ||
766
813
  eventData.event === 'chat_created'
767
814
  ) {
768
- // Chat creation event
815
+ // Chat creation event (legacy format)
769
816
  onEvent('chat_created', eventData);
770
817
  } else {
771
818
  // Generic event data
package/src/config.js CHANGED
@@ -4,13 +4,14 @@ const os = require('os');
4
4
  const yaml = require('js-yaml');
5
5
 
6
6
  class ToothFairyConfig {
7
- constructor(baseUrl, aiUrl, aiStreamUrl, apiKey, workspaceId, userId) {
7
+ constructor(baseUrl, aiUrl, aiStreamUrl, apiKey, workspaceId, userId, defaultAgentId = '') {
8
8
  this.baseUrl = baseUrl;
9
9
  this.aiUrl = aiUrl;
10
10
  this.aiStreamUrl = aiStreamUrl;
11
11
  this.apiKey = apiKey;
12
12
  this.workspaceId = workspaceId;
13
13
  this.userId = userId;
14
+ this.defaultAgentId = defaultAgentId;
14
15
  }
15
16
 
16
17
  static fromEnv() {
@@ -20,7 +21,8 @@ class ToothFairyConfig {
20
21
  process.env.TF_AI_STREAM_URL || 'https://ais.toothfairyai.com',
21
22
  process.env.TF_API_KEY || '',
22
23
  process.env.TF_WORKSPACE_ID || '',
23
- process.env.TF_USER_ID || ''
24
+ process.env.TF_USER_ID || '',
25
+ process.env.TF_DEFAULT_AGENT_ID || ''
24
26
  );
25
27
  }
26
28
 
@@ -44,7 +46,8 @@ class ToothFairyConfig {
44
46
  data.ai_stream_url || 'https://ais.toothfairyai.com',
45
47
  data.api_key || '',
46
48
  data.workspace_id || '',
47
- data.user_id || ''
49
+ data.user_id || '',
50
+ data.default_agent_id || ''
48
51
  );
49
52
  }
50
53
 
@@ -64,6 +67,7 @@ class ToothFairyConfig {
64
67
  api_key: this.apiKey,
65
68
  workspace_id: this.workspaceId,
66
69
  user_id: this.userId,
70
+ default_agent_id: this.defaultAgentId,
67
71
  };
68
72
  }
69
73
  }