@toothfairyai/cli 1.1.5 ā 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/README.md +352 -123
- package/bin/toothfairy.js +636 -72
- package/package.json +7 -5
- package/src/api.js +62 -15
- package/src/config.js +7 -3
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
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
|
-
|
|
491
|
-
metadata =
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 (!
|
|
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(
|