@operor/core 0.1.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/src/Operor.ts ADDED
@@ -0,0 +1,2972 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import yaml from 'js-yaml';
5
+ import { Agent } from './Agent.js';
6
+ import { InMemoryStore } from './InMemoryStore.js';
7
+ import { KeywordIntentClassifier } from './KeywordIntentClassifier.js';
8
+ import { GuardrailEngine } from './Guardrails.js';
9
+ import { buildSystemPrompt } from './AgentLoader.js';
10
+ import { AgentVersionStore } from './AgentVersionStore.js';
11
+ import type { VersionSnapshot } from './AgentVersionStore.js';
12
+ import type {
13
+ OperorConfig,
14
+ MessageProvider,
15
+ Skill,
16
+ IncomingMessage,
17
+ Customer,
18
+ Intent,
19
+ ConversationContext,
20
+ AgentConfig,
21
+ ConversationMessage,
22
+ MemoryStore,
23
+ IntentClassifier,
24
+ OutgoingMessage,
25
+ } from './types.js';
26
+
27
+ /**
28
+ * Truncate text at the last sentence boundary within maxLen.
29
+ * Falls back to last whitespace if no sentence-ending punctuation is found.
30
+ */
31
+ function truncateAtSentence(text: string, maxLen: number): string {
32
+ if (text.length <= maxLen) return text;
33
+
34
+ const truncated = text.slice(0, maxLen);
35
+
36
+ // Try to find the last sentence-ending punctuation (. ! ? or Hebrew ׃)
37
+ const sentenceEnd = Math.max(
38
+ truncated.lastIndexOf('. '),
39
+ truncated.lastIndexOf('! '),
40
+ truncated.lastIndexOf('? '),
41
+ truncated.lastIndexOf('.\n'),
42
+ truncated.lastIndexOf('!\n'),
43
+ truncated.lastIndexOf('?\n'),
44
+ );
45
+
46
+ if (sentenceEnd > maxLen * 0.5) {
47
+ return truncated.slice(0, sentenceEnd + 1).trimEnd();
48
+ }
49
+
50
+ // Fall back to last whitespace
51
+ const lastSpace = truncated.lastIndexOf(' ');
52
+ if (lastSpace > maxLen * 0.5) {
53
+ return truncated.slice(0, lastSpace).trimEnd();
54
+ }
55
+
56
+ // Last resort: hard cut (no "..." appended)
57
+ return truncated.trimEnd();
58
+ }
59
+
60
+ /**
61
+ * Split text into chunks that fit within maxLen, breaking at paragraph
62
+ * boundaries (\n\n). Falls back to sentence splitting for oversized paragraphs.
63
+ */
64
+ function splitIntoParagraphChunks(text: string, maxLen: number): string[] {
65
+ if (text.length <= maxLen) return [text];
66
+
67
+ const paragraphs = text.split(/\n\n+/);
68
+ const chunks: string[] = [];
69
+ let current = '';
70
+
71
+ for (const para of paragraphs) {
72
+ const candidate = current ? current + '\n\n' + para : para;
73
+
74
+ if (candidate.length <= maxLen) {
75
+ current = candidate;
76
+ continue;
77
+ }
78
+
79
+ // Adding this paragraph would exceed maxLen
80
+ if (current) {
81
+ chunks.push(current.trimEnd());
82
+ current = '';
83
+ }
84
+
85
+ // If the paragraph itself fits, start a new chunk with it
86
+ if (para.length <= maxLen) {
87
+ current = para;
88
+ continue;
89
+ }
90
+
91
+ // Single paragraph exceeds maxLen — split at sentence boundaries
92
+ let remaining = para;
93
+ while (remaining.length > maxLen) {
94
+ const piece = truncateAtSentence(remaining, maxLen);
95
+ chunks.push(piece.trimEnd());
96
+ remaining = remaining.slice(piece.length).trimStart();
97
+ }
98
+ if (remaining) {
99
+ current = remaining;
100
+ }
101
+ }
102
+
103
+ if (current) {
104
+ chunks.push(current.trimEnd());
105
+ }
106
+
107
+ return chunks.filter(Boolean);
108
+ }
109
+
110
+ interface PendingEdit {
111
+ agentName: string;
112
+ field: 'instructions' | 'identity' | 'soul';
113
+ section?: string;
114
+ content: string[];
115
+ createdAt: number;
116
+ state: 'capturing' | 'confirming';
117
+ newContent?: string;
118
+ }
119
+
120
+ interface PendingQuickstart {
121
+ step: 'question' | 'answer';
122
+ question?: string;
123
+ createdAt: number;
124
+ }
125
+
126
+ interface PendingSkillAdd {
127
+ skillName: string;
128
+ envVars: Record<string, string>;
129
+ pendingKeys: string[];
130
+ currentKey: string | null;
131
+ catalogEntry: any;
132
+ createdAt: number;
133
+ }
134
+
135
+ export class Operor extends EventEmitter {
136
+ private config: OperorConfig;
137
+ private providers: Map<string, MessageProvider> = new Map();
138
+ private agents: Map<string, Agent> = new Map();
139
+ private skills: Map<string, Skill> = new Map();
140
+ private memory: MemoryStore;
141
+ private intentClassifier: IntentClassifier;
142
+ private isRunning: boolean = false;
143
+ private messageBatches: Map<string, IncomingMessage[]> = new Map();
144
+ private batchTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
145
+ private batchWindowMs: number = 2000;
146
+ private autoAddAssistantMessages: boolean = true;
147
+ private pendingEdits: Map<string, PendingEdit> = new Map();
148
+ private pendingQuickstarts: Map<string, PendingQuickstart> = new Map();
149
+ private pendingSkillAdds: Map<string, PendingSkillAdd> = new Map();
150
+ private pendingFaqReplacements: Map<string, { question: string; answer: string; replaceId: string; expiresAt: number }> = new Map();
151
+ private versionStore: AgentVersionStore;
152
+
153
+ constructor(config: OperorConfig = {}) {
154
+ super();
155
+ this.config = config;
156
+ this.memory = config.memory || new InMemoryStore();
157
+ this.intentClassifier = config.intentClassifier || new KeywordIntentClassifier();
158
+ this.batchWindowMs = config.batchWindowMs ?? 2000;
159
+ this.autoAddAssistantMessages = config.autoAddAssistantMessages ?? true;
160
+ this.versionStore = new AgentVersionStore(config.agentsDir);
161
+ this.log('Operor initialized');
162
+
163
+ // Wire analytics collector (non-blocking)
164
+ if (config.analyticsCollector) {
165
+ config.analyticsCollector.attach(this);
166
+ this.log('Analytics collector attached');
167
+ }
168
+
169
+ // Wire copilot tracker (non-blocking)
170
+ if (config.copilotTracker) {
171
+ this.on('message:processed', (event: any) => {
172
+ config.copilotTracker!.maybeTrack({
173
+ query: event.message?.text,
174
+ channel: event.message?.channel || event.message?.provider,
175
+ customerPhone: event.message?.from,
176
+ response: event.response,
177
+ }).catch((err: any) => {
178
+ if (config.debug) console.warn('[Copilot] Tracking error:', err?.message);
179
+ });
180
+ });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Register a message provider (WhatsApp, Instagram, etc.)
186
+ */
187
+ async addProvider(provider: MessageProvider): Promise<void> {
188
+ this.log(`Adding provider: ${provider.name}`);
189
+
190
+ // Listen for incoming messages from this provider
191
+ provider.on('message', (message: IncomingMessage) => {
192
+ this.handleIncomingMessage(message).catch((error) => {
193
+ this.log(`Error handling message: ${error}`);
194
+ console.error('❌ Error in handleIncomingMessage:', error);
195
+ this.emit('error', { error, message });
196
+ });
197
+ });
198
+
199
+ this.providers.set(provider.name, provider);
200
+ this.log(`Provider added: ${provider.name}`);
201
+ }
202
+
203
+ /**
204
+ * Create and register an agent
205
+ */
206
+ createAgent(config: AgentConfig): Agent {
207
+ this.log(`Creating agent: ${config.name}`);
208
+ const agent = new Agent(config);
209
+ this.agents.set(agent.id, agent);
210
+ this.log(`Agent created: ${config.name} (${agent.id})`);
211
+ return agent;
212
+ }
213
+
214
+ /**
215
+ * Add a skill (MCP server, etc.)
216
+ */
217
+ async addSkill(skill: Skill): Promise<void> {
218
+ this.log(`Adding skill: ${skill.name}`);
219
+ await skill.initialize();
220
+ this.skills.set(skill.name, skill);
221
+ this.log(`Skill added: ${skill.name}`);
222
+ }
223
+
224
+ async removeSkill(name: string): Promise<void> {
225
+ const skill = this.skills.get(name);
226
+ if (skill) {
227
+ await skill.close();
228
+ this.skills.delete(name);
229
+ this.log(`Skill removed: ${name}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Start Operor
235
+ */
236
+ async start(): Promise<void> {
237
+ if (this.isRunning) {
238
+ throw new Error('Operor is already running');
239
+ }
240
+
241
+ this.log('Starting Operor...');
242
+
243
+ // Initialize memory store
244
+ await this.memory.initialize();
245
+
246
+ // Connect all providers
247
+ const providerConnections = Array.from(this.providers.values()).map((p) =>
248
+ p.connect()
249
+ );
250
+
251
+ await Promise.all(providerConnections);
252
+
253
+ this.isRunning = true;
254
+ this.log('✅ Operor started successfully');
255
+ this.emit('started');
256
+ }
257
+
258
+ /**
259
+ * Stop Operor
260
+ */
261
+ async stop(): Promise<void> {
262
+ if (!this.isRunning) return;
263
+
264
+ this.log('Stopping Operor...');
265
+
266
+ // Clear pending batch timers
267
+ for (const timer of this.batchTimers.values()) {
268
+ clearTimeout(timer);
269
+ }
270
+ this.batchTimers.clear();
271
+ this.messageBatches.clear();
272
+
273
+ // Disconnect all providers
274
+ const providerDisconnections = Array.from(this.providers.values()).map(
275
+ (p) => p.disconnect()
276
+ );
277
+
278
+ await Promise.all(providerDisconnections);
279
+
280
+ // Detach analytics collector
281
+ if (this.config.analyticsCollector?.detach) {
282
+ this.config.analyticsCollector.detach(this);
283
+ }
284
+
285
+ // Close memory store
286
+ await this.memory.close();
287
+
288
+ this.isRunning = false;
289
+ this.log('Operor stopped');
290
+ this.emit('stopped');
291
+ }
292
+
293
+ /**
294
+ * Handle incoming message from any provider
295
+ */
296
+ private async handleIncomingMessage(
297
+ message: IncomingMessage
298
+ ): Promise<void> {
299
+ this.log(`📱 Incoming message from ${message.from}: "${message.text}"`);
300
+
301
+ // Emit message event
302
+ this.emit('message:received', { message });
303
+
304
+ // Add to batch (include groupJid to prevent cross-group batching)
305
+ const groupJid = message.metadata?.groupJid;
306
+ const batchKey = groupJid
307
+ ? `${message.provider}:${message.from}:${groupJid}`
308
+ : `${message.provider}:${message.from}`;
309
+ if (!this.messageBatches.has(batchKey)) {
310
+ this.messageBatches.set(batchKey, []);
311
+ }
312
+ this.messageBatches.get(batchKey)!.push(message);
313
+
314
+ // Clear existing timer
315
+ const existingTimer = this.batchTimers.get(batchKey);
316
+ if (existingTimer) {
317
+ clearTimeout(existingTimer);
318
+ }
319
+
320
+ // Set new timer to process batch
321
+ const timer = setTimeout(() => {
322
+ this.processBatch(batchKey).catch((error) => {
323
+ this.log(`Error processing batch: ${error}`);
324
+ console.error('❌ Error in processBatch:', error);
325
+ });
326
+ }, this.batchWindowMs);
327
+
328
+ this.batchTimers.set(batchKey, timer);
329
+ }
330
+
331
+ /**
332
+ * Process a batch of messages from the same sender
333
+ */
334
+ private async processBatch(batchKey: string): Promise<void> {
335
+ const messages = this.messageBatches.get(batchKey);
336
+ if (!messages || messages.length === 0) return;
337
+
338
+ // Clear batch and timer
339
+ this.messageBatches.delete(batchKey);
340
+ this.batchTimers.delete(batchKey);
341
+
342
+ // Combine messages into one
343
+ const firstMessage = messages[0];
344
+ const combinedText = messages.map((m) => m.text).join('\n');
345
+ const combinedMessage: IncomingMessage = {
346
+ ...firstMessage,
347
+ text: combinedText,
348
+ };
349
+
350
+ if (messages.length > 1) {
351
+ this.log(`📦 Batched ${messages.length} messages: "${combinedText}"`);
352
+ }
353
+
354
+ // Pending quickstart interceptor — capture messages during /quickstart flow
355
+ if (this.config.trainingMode?.enabled) {
356
+ const pendingQs = this.pendingQuickstarts.get(combinedMessage.from);
357
+ if (pendingQs) {
358
+ const handled = await this.handlePendingQuickstart(combinedMessage, pendingQs);
359
+ if (handled) return;
360
+ }
361
+ }
362
+
363
+ // Pending skill add interceptor — capture env var values during /skill add flow
364
+ const pendingSkill = this.pendingSkillAdds.get(combinedMessage.from);
365
+ if (pendingSkill) {
366
+ const handled = await this.handlePendingSkillAdd(combinedMessage, pendingSkill);
367
+ if (handled) return;
368
+ }
369
+
370
+ // Pending edit interceptor — capture messages before training commands
371
+ if (this.config.trainingMode?.enabled) {
372
+ const pendingEdit = this.pendingEdits.get(combinedMessage.from);
373
+ if (pendingEdit) {
374
+ const handled = await this.handlePendingEdit(combinedMessage, pendingEdit);
375
+ if (handled) return;
376
+ }
377
+ }
378
+
379
+ // Pending FAQ replacement interceptor — handle "yes"/"no" for dedup prompt
380
+ if (this.config.trainingMode?.enabled) {
381
+ const faqKey = `faq_replace_${combinedMessage.from}`;
382
+ const pending = this.pendingFaqReplacements.get(faqKey);
383
+ if (pending && Date.now() < pending.expiresAt) {
384
+ const text = combinedMessage.text.trim().toLowerCase();
385
+ const kb = this.config.kb!;
386
+ const provider = this.providers.values().next().value;
387
+ const reply = async (t: string) => { if (provider) await provider.sendMessage(combinedMessage.from, t); };
388
+
389
+ this.pendingFaqReplacements.delete(faqKey);
390
+ if (text === 'yes') {
391
+ await kb.ingestFaq(pending.question, pending.answer, { forceReplace: true, replaceId: pending.replaceId });
392
+ await reply(`FAQ replaced:\nQ: ${pending.question}\nA: ${pending.answer}`);
393
+ } else {
394
+ await kb.ingestFaq(pending.question, pending.answer, { forceReplace: true });
395
+ await reply(`Both FAQs kept. New FAQ added:\nQ: ${pending.question}\nA: ${pending.answer}`);
396
+ }
397
+ return;
398
+ } else if (pending) {
399
+ this.pendingFaqReplacements.delete(faqKey); // expired
400
+ }
401
+ }
402
+
403
+ // Training mode interceptor — check before normal processing
404
+ if (this.config.trainingMode?.enabled) {
405
+ const senderPhone = combinedMessage.from;
406
+ // Normalize comparison: strip leading '+' from both sides so
407
+ // whitelist entry '+85253332683' matches from value '85253332683' and vice versa
408
+ const normalizePhone = (p: string) => p.replace(/^\+/, '');
409
+ const normalizedSender = normalizePhone(senderPhone);
410
+ const isWhitelisted = this.config.trainingMode.whitelist.some(
411
+ (w) => normalizePhone(w) === normalizedSender
412
+ );
413
+ if (isWhitelisted) {
414
+ const handled = await this.handleTrainingCommand(combinedMessage);
415
+ if (handled) return;
416
+ // Block unrecognized /commands from reaching the LLM (prevents hallucinated responses)
417
+ if (combinedMessage.text.trim().startsWith('/')) {
418
+ await this.sendMessage(combinedMessage.from, combinedMessage.provider, {
419
+ to: combinedMessage.from,
420
+ text: `Unknown command: ${combinedMessage.text.trim().split(/\s+/)[0]}\nType /help for available commands.`,
421
+ });
422
+ return;
423
+ }
424
+ }
425
+ }
426
+
427
+ try {
428
+ // Send typing indicator while processing
429
+ const provider = this.providers.get(combinedMessage.provider);
430
+ if (provider?.sendTypingIndicator) {
431
+ await provider.sendTypingIndicator(combinedMessage.from).catch(() => {});
432
+ }
433
+
434
+ // 1. Get or create customer
435
+ const customer = await this.getOrCreateCustomer(combinedMessage);
436
+
437
+ // 2. Load brief global history for intent classification (no agentId)
438
+ const globalHistory = await this.memory.getHistory(customer.id, 5);
439
+
440
+ // 3. Classify intent
441
+ const intentStart = Date.now();
442
+ const intent = await this.intentClassifier.classify(
443
+ combinedMessage.text,
444
+ Array.from(this.agents.values()).map(a => a.config),
445
+ globalHistory
446
+ );
447
+ this.log(`🧠 Intent: ${intent.intent} (confidence: ${intent.confidence}) in ${((Date.now() - intentStart) / 1000).toFixed(1)}s`);
448
+
449
+ // 4. Select appropriate agent (filtered by channel, sorted by priority)
450
+ const agent = this.selectAgent(intent, combinedMessage);
451
+
452
+ if (!agent) {
453
+ this.log('⚠️ No agent matched the intent');
454
+ await this.sendMessage(combinedMessage.from, combinedMessage.provider, {
455
+ to: combinedMessage.from,
456
+ text: "Sorry, I couldn't understand that. Could you rephrase?",
457
+ });
458
+ return;
459
+ }
460
+
461
+ this.log(`🤖 Agent selected: ${agent.name}`);
462
+
463
+ // 4b. Check input guardrails
464
+ const guardrails = agent.getConfig().guardrails;
465
+ if (guardrails) {
466
+ const guardrailEngine = new GuardrailEngine();
467
+ const inputCheck = guardrailEngine.checkInput(combinedMessage.text, guardrails);
468
+ if (!inputCheck.allowed) {
469
+ this.log(`🛡️ Guardrail blocked input: ${inputCheck.reason}`);
470
+ this.emit('guardrail:blocked', { message: combinedMessage, reason: inputCheck.reason, escalate: !!inputCheck.escalate });
471
+ const replyText = inputCheck.escalate
472
+ ? 'Let me connect you with a human agent for this.'
473
+ : "I'm not able to help with that topic. Is there something else I can assist you with?";
474
+ await this.sendMessage(combinedMessage.from, combinedMessage.provider, {
475
+ to: combinedMessage.from,
476
+ text: replyText,
477
+ });
478
+ return;
479
+ }
480
+ }
481
+
482
+ // 5. Load full agent-scoped history for processing context
483
+ const history = await this.memory.getHistory(customer.id, 50, agent.name);
484
+
485
+ // 6. Build context
486
+ const context: ConversationContext = {
487
+ customer,
488
+ history,
489
+ currentMessage: combinedMessage,
490
+ intent,
491
+ };
492
+
493
+ // 7. Process with agent
494
+ const response = await agent.process(context);
495
+
496
+ this.log(`✉️ Response generated in ${response.duration}ms`);
497
+
498
+ // 7b. Check output guardrails
499
+ const fullResponseText = response.text;
500
+ if (guardrails) {
501
+ const guardrailEngine = new GuardrailEngine();
502
+
503
+ // Handle maxResponseLength — split into multiple messages at paragraph boundaries
504
+ if (guardrails.maxResponseLength && response.text.length > guardrails.maxResponseLength) {
505
+ const chunks = splitIntoParagraphChunks(response.text, guardrails.maxResponseLength);
506
+ if (chunks.length > 1) {
507
+ this.log(`📨 Splitting long response (${response.text.length}/${guardrails.maxResponseLength}) into ${chunks.length} messages`);
508
+ // Send all chunks except the last one now
509
+ for (let i = 0; i < chunks.length - 1; i++) {
510
+ await this.sendMessage(combinedMessage.from, combinedMessage.provider, {
511
+ to: combinedMessage.from,
512
+ text: chunks[i],
513
+ });
514
+ }
515
+ // The last chunk goes through the normal send path (with any media attachment)
516
+ response.text = chunks[chunks.length - 1];
517
+ } else {
518
+ response.text = chunks[0];
519
+ }
520
+ }
521
+
522
+ // Check other guardrails (blocked topics, etc.) — exclude length from this check
523
+ const { maxResponseLength: _mrl, ...otherGuardrails } = guardrails;
524
+ const hasOtherGuardrails = otherGuardrails.blockedTopics?.length || otherGuardrails.escalationTriggers?.length;
525
+ if (hasOtherGuardrails) {
526
+ const outputCheck = guardrailEngine.checkOutput(response.text, otherGuardrails);
527
+ if (!outputCheck.allowed) {
528
+ this.log(`🛡️ Guardrail blocked output: ${outputCheck.reason}`);
529
+ this.emit('guardrail:output_blocked', { message: combinedMessage, reason: outputCheck.reason });
530
+ response.text = 'I apologize, but I\'m unable to provide that information.';
531
+ }
532
+ }
533
+ }
534
+
535
+ // Log tool calls
536
+ if (response.toolCalls) {
537
+ for (const toolCall of response.toolCalls) {
538
+ this.log(
539
+ `🔧 Tool: ${toolCall.name}(${JSON.stringify(toolCall.params)})`
540
+ );
541
+ if (toolCall.success) {
542
+ this.log(` ✓ Success (${toolCall.duration}ms)`);
543
+ } else {
544
+ this.log(` ✗ Failed: ${toolCall.error}`);
545
+ }
546
+ }
547
+ }
548
+
549
+ // 8. Send response
550
+ await this.sendMessage(combinedMessage.from, combinedMessage.provider, {
551
+ to: combinedMessage.from,
552
+ text: response.text,
553
+ ...(response.mediaBuffer && {
554
+ mediaBuffer: response.mediaBuffer,
555
+ mediaFileName: response.mediaFileName,
556
+ mediaMimeType: response.mediaMimeType,
557
+ }),
558
+ });
559
+
560
+ // Log what was actually sent
561
+ if (response.text) {
562
+ this.log(`📤 Sent: "${response.text.substring(0, 100)}${response.text.length > 100 ? '...' : ''}"`);
563
+ }
564
+
565
+ // 9. Update conversation history (add all batched messages)
566
+ for (const msg of messages) {
567
+ await this.memory.addMessage(customer.id, {
568
+ role: 'user',
569
+ content: msg.text,
570
+ timestamp: msg.timestamp,
571
+ }, agent.name);
572
+ }
573
+
574
+ // 10. Auto-add assistant message to history if enabled
575
+ if (this.autoAddAssistantMessages) {
576
+ await this.memory.addMessage(customer.id, {
577
+ role: 'assistant',
578
+ content: fullResponseText,
579
+ timestamp: Date.now(),
580
+ toolCalls: response.toolCalls,
581
+ }, agent.name);
582
+ }
583
+
584
+ // 11. Emit complete event
585
+ this.emit('message:processed', {
586
+ message: combinedMessage,
587
+ response,
588
+ agent: agent.name,
589
+ intent,
590
+ customer,
591
+ toolCalls: response.toolCalls,
592
+ duration: response.duration,
593
+ cost: response.cost,
594
+ });
595
+ } catch (error) {
596
+ this.log(`❌ Error processing message: ${error}`);
597
+ this.emit('error', { error, message: combinedMessage });
598
+
599
+ // Send error message to user
600
+ await this.sendMessage(combinedMessage.from, combinedMessage.provider, {
601
+ to: combinedMessage.from,
602
+ text: 'Sorry, something went wrong. Please try again.',
603
+ });
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Get or create customer from message
609
+ */
610
+ private async getOrCreateCustomer(
611
+ message: IncomingMessage
612
+ ): Promise<Customer> {
613
+ let customer = await this.memory.getCustomerByPhone(message.from);
614
+
615
+ if (!customer) {
616
+ customer = {
617
+ id: `customer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
618
+ phone: message.from,
619
+ name: message.from,
620
+ firstInteraction: new Date(),
621
+ lastInteraction: new Date(),
622
+ };
623
+
624
+ if (message.channel === 'whatsapp') {
625
+ customer.whatsappId = message.from;
626
+ }
627
+
628
+ await this.memory.upsertCustomer(customer);
629
+ this.log(`👤 New customer: ${customer.id}`);
630
+ } else {
631
+ customer.lastInteraction = new Date();
632
+ await this.memory.upsertCustomer(customer);
633
+ }
634
+
635
+ return customer;
636
+ }
637
+
638
+ /**
639
+ * Handle training commands from whitelisted users.
640
+ * Returns true if the message was a training command, false otherwise.
641
+ */
642
+ private async handleTrainingCommand(message: IncomingMessage): Promise<boolean> {
643
+ const text = message.text.trim();
644
+
645
+ // Must start with /
646
+ if (!text.startsWith('/')) return false;
647
+
648
+ const parts = text.split(/\s+/);
649
+ let command = parts[0].toLowerCase();
650
+ const args = parts.slice(1);
651
+
652
+ // Normalize aliases before validation
653
+ const commandAliases: Record<string, string> = { '/skill': '/skills' };
654
+ if (commandAliases[command]) command = commandAliases[command];
655
+
656
+ const validCommands = ['/teach', '/faq', '/kb', '/test', '/status', '/stats', '/help', '/learn-url', '/learn-site', '/learn-file', '/rebuild', '/review', '/export', '/import', '/instructions', '/identity', '/soul', '/config', '/done', '/cancel', '/whitelist', '/quickstart', '/skills', '/model', '/create-skill', '/update-skill', '/clear-history'];
657
+ if (!validCommands.includes(command)) return false;
658
+
659
+ this.log(`🎓 Training command: ${command} ${args.join(' ')}`);
660
+
661
+ // Emit training event for external handlers
662
+ this.emit('training:command', { command, args, message });
663
+
664
+ const reply = async (text: string) => {
665
+ await this.sendMessage(message.from, message.provider, {
666
+ to: message.from,
667
+ text,
668
+ });
669
+ };
670
+
671
+ const kb = this.config.kb;
672
+
673
+ if (command === '/help') {
674
+ const showAll = args[0] === 'all';
675
+
676
+ if (showAll) {
677
+ const helpLines = [
678
+ 'Training commands:',
679
+ ' /teach <question> | <answer> — Add FAQ entry',
680
+ ' /learn-url <url> — Ingest a webpage into KB',
681
+ ' /learn-site <url> — Crawl and ingest a website',
682
+ ' /learn-file — Ingest an attached file (send with caption)',
683
+ ' /faq list — List all FAQ entries',
684
+ ' /faq search <query> — Search FAQ entries',
685
+ ' /faq delete <id> — Delete an FAQ entry',
686
+ ' /kb list [page] — List ingested documents (URLs, files)',
687
+ ' /export — Export KB bundle as YAML file',
688
+ ' /import — Import KB bundle from YAML file (send with caption)',
689
+ ' /import <url> — Import KB bundle from a URL',
690
+ ' /test <message> — Test KB response',
691
+ ' /rebuild — Rebuild all KB embeddings (after switching provider)',
692
+ ' /status — Show KB stats and bot status',
693
+ ' /stats — 7-day performance summary',
694
+ ' /stats today — Today\'s performance',
695
+ ' /stats kb — KB-specific analytics',
696
+ ' /help — Show quick help',
697
+ ' /help all — Show this full help message',
698
+ ];
699
+ helpLines.push('',
700
+ 'Agent editor commands:',
701
+ ' /instructions — View instructions summary',
702
+ ' /instructions view [section] — View full or section',
703
+ ' /instructions edit [section] — Edit instructions',
704
+ ' /instructions history — Version history',
705
+ ' /instructions rollback [n] — Revert to version n',
706
+ ' /identity — View identity',
707
+ ' /identity edit — Edit identity',
708
+ ' /soul — View soul',
709
+ ' /soul edit — Edit soul',
710
+ ' /config show — View agent config',
711
+ ' /config set <key> <value> — Set config field',
712
+ ' /config add <key> <value> — Add to array field',
713
+ ' /config remove <key> <value> — Remove from array field',
714
+ ' /done — Finish editing (in edit mode)',
715
+ ' /cancel — Cancel editing',
716
+ );
717
+ if (this.config.copilotHandler) {
718
+ helpLines.push(' /review — Review unanswered questions (Copilot)');
719
+ helpLines.push(' /review help — See all review commands');
720
+ }
721
+ helpLines.push('',
722
+ 'Admin commands:',
723
+ ' /whitelist — View whitelisted phone numbers',
724
+ ' /whitelist add <phone> — Add a phone number',
725
+ ' /whitelist remove <phone> — Remove a phone number',
726
+ ' /clear-history — Clear your conversation history',
727
+ );
728
+ helpLines.push('',
729
+ 'Skills:',
730
+ ' /skills — List registered skills and tools',
731
+ ' /skills info <name> — Show skill details',
732
+ ' /skills catalog [category] — Browse available skills',
733
+ ' /skills add <name> — Install a skill from the catalog',
734
+ ' /skills remove <name> — Remove a skill',
735
+ ' /create-skill <name> | <instructions> — Create a prompt skill',
736
+ ' /update-skill <name> | <instructions> — Update a prompt skill',
737
+ );
738
+ helpLines.push('',
739
+ 'Model management:',
740
+ ' /model — Show current LLM provider and model',
741
+ ' /model list — List available models per provider',
742
+ ' /model set <provider/model> — Switch LLM model',
743
+ ' /model key <provider> <key> — Add API key for a provider',
744
+ );
745
+ await reply(helpLines.join('\n'));
746
+ } else {
747
+ const quickLines = [
748
+ 'Quick start:',
749
+ ' /teach <question> | <answer> — Add FAQ entry',
750
+ ' /test <message> — Test how your bot responds',
751
+ ' /status — Show bot status and KB stats',
752
+ ' /learn-url <url> — Ingest a webpage',
753
+ ' /learn-file — Ingest an attached document',
754
+ ' /skills — List registered skills and tools',
755
+ ' /skills catalog — Browse available skills',
756
+ ' /quickstart — Guided first FAQ setup',
757
+ ' /help all — See all commands',
758
+ '',
759
+ 'Example: /teach What are your hours? | We\'re open 9am-5pm Mon-Fri',
760
+ ];
761
+ await reply(quickLines.join('\n'));
762
+ }
763
+ return true;
764
+ }
765
+
766
+ // /quickstart command — guided 3-step FAQ teaching
767
+ if (command === '/quickstart') {
768
+ if (!kb) {
769
+ await reply('Knowledge Base is not enabled. Set KB_ENABLED=true to use /quickstart.');
770
+ return true;
771
+ }
772
+ this.pendingQuickstarts.set(message.from, {
773
+ step: 'question',
774
+ createdAt: Date.now(),
775
+ });
776
+ await reply('Let\'s teach your agent its first FAQ!\n\nStep 1/3: What question do your customers ask?\n\n(Send the question, or /cancel to abort)');
777
+ return true;
778
+ }
779
+
780
+ // /whitelist command — does NOT require KB
781
+ if (command === '/whitelist') {
782
+ await this.handleWhitelistCommand(message, args, reply);
783
+ return true;
784
+ }
785
+
786
+ // /skills command — does NOT require KB
787
+ if (command === '/skills') {
788
+ await this.handleSkillsCommand(message, args, reply);
789
+ return true;
790
+ }
791
+
792
+ // /create-skill and /update-skill commands — do NOT require KB
793
+ if (command === '/create-skill') {
794
+ await this.handleCreateSkillCommand(message, args, reply);
795
+ return true;
796
+ }
797
+ if (command === '/update-skill') {
798
+ await this.handleUpdateSkillCommand(message, args, reply);
799
+ return true;
800
+ }
801
+
802
+ // Instruction editor commands — do NOT require KB
803
+ if (command === '/instructions' || command === '/identity' || command === '/soul' || command === '/config' || command === '/done' || command === '/cancel') {
804
+ try {
805
+ if (command === '/instructions') {
806
+ await this.handleInstructionsCommand(message, args, reply);
807
+ } else if (command === '/identity') {
808
+ await this.handleFieldViewOrEdit('identity', message, args, reply);
809
+ } else if (command === '/soul') {
810
+ await this.handleFieldViewOrEdit('soul', message, args, reply);
811
+ } else if (command === '/config') {
812
+ await this.handleConfigCommand(message, args, reply);
813
+ } else {
814
+ await reply('No active edit session. Use /instructions edit, /identity edit, or /soul edit to start.');
815
+ }
816
+ } catch (error: any) {
817
+ this.log(`Training command error: ${error.message}`);
818
+ await reply(`Error: ${error.message}`);
819
+ }
820
+ return true;
821
+ }
822
+
823
+ // /model command — does NOT require KB
824
+ if (command === '/model') {
825
+ try {
826
+ await this.handleModelCommand(args, reply);
827
+ } catch (error: any) {
828
+ this.log(`Training command error: ${error.message}`);
829
+ await reply(`Error: ${error.message}`);
830
+ }
831
+ return true;
832
+ }
833
+
834
+ // /clear-history command — does NOT require KB
835
+ if (command === '/clear-history') {
836
+ if (!this.memory?.clearHistory) {
837
+ await reply('Clear history is not supported by the current memory store.');
838
+ return true;
839
+ }
840
+ try {
841
+ const customer = await this.memory.getCustomerByPhone?.(message.from);
842
+ if (!customer) {
843
+ await reply('No conversation history found for your number.');
844
+ return true;
845
+ }
846
+ const { deletedCount } = await this.memory.clearHistory(customer.id);
847
+ await reply(`Cleared ${deletedCount} message(s) from your conversation history.`);
848
+ } catch (error: any) {
849
+ this.log(`Training command error: ${error.message}`);
850
+ await reply(`Error clearing history: ${error.message}`);
851
+ }
852
+ return true;
853
+ }
854
+
855
+ // All other commands require KB
856
+ if (!kb) {
857
+ await reply('Knowledge Base is not enabled. Set KB_ENABLED=true and configure embedding settings to use training commands.');
858
+ return true;
859
+ }
860
+
861
+ // Copilot /review command — delegate to copilot handler
862
+ if (command === '/review') {
863
+ if (this.config.copilotHandler) {
864
+ await this.config.copilotHandler.handleCommand(command, args.join(' '), message.from, reply);
865
+ } else {
866
+ await reply('Training Copilot is not available. If COPILOT_ENABLED=true is set, check startup logs for initialization errors.');
867
+ }
868
+ return true;
869
+ }
870
+
871
+ try {
872
+ if (command === '/teach') {
873
+ const raw = args.join(' ');
874
+ const sepIndex = raw.indexOf('|');
875
+ if (sepIndex === -1) {
876
+ await reply('Usage: /teach <question> | <answer>');
877
+ return true;
878
+ }
879
+ // Strip optional surrounding quotes so `/teach "q" | "a"` works the same as `/teach q | a`
880
+ const stripQuotes = (s: string) => s.replace(/^["']|["']$/g, '');
881
+ const question = stripQuotes(raw.slice(0, sepIndex).trim());
882
+ const answer = stripQuotes(raw.slice(sepIndex + 1).trim());
883
+ if (!question || !answer) {
884
+ await reply('Usage: /teach <question> | <answer>');
885
+ return true;
886
+ }
887
+ const doc = await kb.ingestFaq(question, answer);
888
+
889
+ // FAQ dedup: if similar FAQ exists, prompt admin to confirm replacement
890
+ if (doc.existingMatch) {
891
+ const match = doc.existingMatch;
892
+ // Store pending replacement info for this conversation
893
+ const pendingKey = `faq_replace_${msg.from}`;
894
+ this.pendingFaqReplacements.set(pendingKey, {
895
+ question,
896
+ answer,
897
+ replaceId: match.id,
898
+ expiresAt: Date.now() + 120_000, // 2 min TTL
899
+ });
900
+ await reply(
901
+ `Similar FAQ already exists (${(match.score * 100).toFixed(0)}% match):\n` +
902
+ `Q: ${match.question}\n` +
903
+ `A: ${match.answer}\n\n` +
904
+ `Reply "yes" to replace it with your new answer, or "no" to keep both.`
905
+ );
906
+ return true;
907
+ }
908
+
909
+ await reply(`FAQ added (${doc.id.slice(0, 8)}...):\nQ: ${question}\nA: ${answer}`);
910
+ // Warn if answer uses "you/your" which may sound wrong from the bot's perspective
911
+ const perspectivePattern = /\b(your name|you are\s+\w+|you work|you're\s+\w+)\b/i;
912
+ if (perspectivePattern.test(answer)) {
913
+ await reply(
914
+ '⚠️ Heads up: This answer uses "you/your" which may sound odd coming from the bot. '
915
+ + 'Consider rephrasing (e.g., "my name is..." instead of "your name is...").'
916
+ );
917
+ }
918
+ }
919
+
920
+ else if (command === '/faq') {
921
+ const subcommand = args[0]?.toLowerCase();
922
+
923
+ if (subcommand === 'list') {
924
+ const docs = await kb.listDocuments();
925
+ const faqs = docs.filter(d => d.sourceType === 'faq');
926
+ if (faqs.length === 0) {
927
+ await reply('No FAQ entries found.');
928
+ } else {
929
+ const lines = faqs.map(d =>
930
+ `[${d.id.slice(0, 8)}] ${d.title || d.content.slice(0, 60)}`
931
+ );
932
+ await reply(`FAQ entries (${faqs.length}):\n${lines.join('\n')}`);
933
+ }
934
+ }
935
+
936
+ else if (subcommand === 'search') {
937
+ const query = args.slice(1).join(' ');
938
+ if (!query) {
939
+ await reply('Usage: /faq search <query>');
940
+ return true;
941
+ }
942
+ const result = await kb.retrieve(query);
943
+ if (result.results.length === 0) {
944
+ await reply(`No results for "${query}".`);
945
+ } else {
946
+ const lines = result.results.slice(0, 5).map((r, i) =>
947
+ `${i + 1}. [${r.score.toFixed(2)}] ${r.document.title || r.chunk.content.slice(0, 60)}`
948
+ );
949
+ await reply(`Search results for "${query}":\n${lines.join('\n')}`);
950
+ }
951
+ }
952
+
953
+ else if (subcommand === 'delete') {
954
+ const id = args[1];
955
+ if (!id) {
956
+ await reply('Usage: /faq delete <id>');
957
+ return true;
958
+ }
959
+ // Support partial ID matching
960
+ const docs = await kb.listDocuments();
961
+ const match = docs.find(d => d.id === id || d.id.startsWith(id));
962
+ if (!match) {
963
+ await reply(`No document found with ID starting with "${id}".`);
964
+ } else {
965
+ await kb.deleteDocument(match.id);
966
+ await reply(`Deleted: ${match.title || match.id.slice(0, 8)}`);
967
+ }
968
+ }
969
+
970
+ else {
971
+ await reply('Usage: /faq list | /faq search <query> | /faq delete <id>');
972
+ }
973
+ }
974
+
975
+ else if (command === '/kb') {
976
+ const subcommand = args[0]?.toLowerCase();
977
+
978
+ if (subcommand === 'list') {
979
+ const page = parseInt(args[1]) || 1;
980
+ const PAGE_SIZE = 10;
981
+ const docs = await kb.listDocuments();
982
+ const nonFaq = docs.filter(d => d.sourceType !== 'faq');
983
+
984
+ if (nonFaq.length === 0) {
985
+ await reply('No KB documents found. Use /learn-url, /learn-site, or /learn-file to add content.');
986
+ } else {
987
+ const totalPages = Math.ceil(nonFaq.length / PAGE_SIZE);
988
+ const safePage = Math.max(1, Math.min(page, totalPages));
989
+ const slice = nonFaq.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
990
+
991
+ const lines = slice.map(d => {
992
+ const label = d.title || d.sourceUrl || d.fileName || d.content.slice(0, 60);
993
+ const truncLabel = label.length > 60 ? label.slice(0, 57) + '...' : label;
994
+ const source = d.sourceUrl
995
+ ? `\n ${d.sourceUrl.length > 60 ? d.sourceUrl.slice(0, 57) + '...' : d.sourceUrl}`
996
+ : d.fileName ? `\n ${d.fileName}` : '';
997
+ return `[${d.id.slice(0, 8)}] ${d.sourceType} — ${truncLabel}${source}`;
998
+ });
999
+
1000
+ let header = `KB Documents (${nonFaq.length} total`;
1001
+ if (totalPages > 1) header += `, page ${safePage}/${totalPages}`;
1002
+ header += '):';
1003
+
1004
+ let msg = `${header}\n${lines.join('\n')}`;
1005
+ if (totalPages > 1 && safePage < totalPages) {
1006
+ msg += `\n\nSend /kb list ${safePage + 1} for next page.`;
1007
+ }
1008
+ await reply(msg);
1009
+ }
1010
+ } else {
1011
+ await reply('Unknown /kb command. Available: /kb list');
1012
+ }
1013
+ }
1014
+
1015
+ else if (command === '/test') {
1016
+ const query = args.join(' ');
1017
+ if (!query) {
1018
+ await reply('Usage: /test <message>');
1019
+ return true;
1020
+ }
1021
+ const result = await kb.retrieve(query);
1022
+ if (result.results.length === 0) {
1023
+ await reply(`No KB match for: "${query}"`);
1024
+ } else {
1025
+ const top = result.results[0];
1026
+ const matchType = result.isFaqMatch ? 'FAQ direct match' : 'KB search';
1027
+ await reply(`Test: "${query}"\nMatch type: ${matchType}\nScore: ${top.score.toFixed(2)}\nContent: ${top.chunk.content.slice(0, 200)}`);
1028
+ }
1029
+ }
1030
+
1031
+ else if (command === '/stats') {
1032
+ const analyticsStore = this.getAnalyticsStore();
1033
+ if (!analyticsStore) {
1034
+ await reply('Analytics is not enabled. Set ANALYTICS_ENABLED=true to track metrics.');
1035
+ return true;
1036
+ }
1037
+
1038
+ const subcommand = args[0]?.toLowerCase();
1039
+
1040
+ if (subcommand === 'kb') {
1041
+ // KB-specific analytics
1042
+ const now = Date.now();
1043
+ const range = { from: now - 7 * 24 * 60 * 60 * 1000, to: now };
1044
+ const kbStats = await analyticsStore.getKbStats(range);
1045
+ const total = kbStats.totalQueries;
1046
+
1047
+ const lines = [
1048
+ '📚 *KB Analytics (last 7 days)*',
1049
+ '',
1050
+ `🔍 ${total} total queries`,
1051
+ ];
1052
+
1053
+ if (total > 0) {
1054
+ const faqPct = kbStats.faqHitRate.toFixed(0);
1055
+ const nonFaqCount = total - kbStats.faqHits;
1056
+ const hybridPct = ((nonFaqCount / total) * 100).toFixed(0);
1057
+ const missCount = kbStats.topMisses?.reduce((s: number, m: any) => s + m.count, 0) || 0;
1058
+ const noMatchPct = ((missCount / total) * 100).toFixed(0);
1059
+
1060
+ lines.push(`⚡ FAQ fast-path: ${faqPct}%`);
1061
+ lines.push(`🔄 Hybrid search: ${hybridPct}%`);
1062
+ lines.push(`❌ No match: ${noMatchPct}%`);
1063
+ lines.push(`📊 Avg top score: ${kbStats.avgTopScore.toFixed(2)}`);
1064
+ }
1065
+
1066
+ if (kbStats.topFaqHits && kbStats.topFaqHits.length > 0) {
1067
+ lines.push('');
1068
+ lines.push('🎯 *Top FAQ hits:*');
1069
+ for (const hit of kbStats.topFaqHits.slice(0, 5)) {
1070
+ lines.push(` ${hit.count}x — ${hit.intent}`);
1071
+ }
1072
+ }
1073
+
1074
+ if (kbStats.topMisses && kbStats.topMisses.length > 0) {
1075
+ lines.push('');
1076
+ lines.push('❓ *Top misses:*');
1077
+ for (const miss of kbStats.topMisses.slice(0, 5)) {
1078
+ lines.push(` ${miss.count}x [${miss.avgScore.toFixed(2)}] — ${miss.intent}`);
1079
+ }
1080
+ }
1081
+
1082
+ await reply(lines.join('\n'));
1083
+ } else {
1084
+ // General stats — /stats or /stats today
1085
+ const isToday = subcommand === 'today';
1086
+ const now = Date.now();
1087
+ const range = isToday
1088
+ ? { from: new Date(new Date().setHours(0, 0, 0, 0)).getTime(), to: now }
1089
+ : { from: now - 7 * 24 * 60 * 60 * 1000, to: now };
1090
+
1091
+ const summary = await analyticsStore.getSummary(range);
1092
+ const label = isToday ? 'today' : 'last 7 days';
1093
+
1094
+ const noAnswerCount = Math.round((summary.noAnswerPct / 100) * summary.totalMessages);
1095
+ const avgPerDay = Math.round(summary.avgPerDay);
1096
+
1097
+ const lines = [
1098
+ `📊 *Bot Performance (${label})*`,
1099
+ '',
1100
+ `💬 ${summary.totalMessages} messages from ${summary.uniqueCustomers} customers`,
1101
+ ];
1102
+
1103
+ if (!isToday && summary.totalMessages > 0 && summary.peakDay) {
1104
+ const peakLabel = this.formatDayName(summary.peakDay);
1105
+ lines.push(`📈 Avg ${avgPerDay}/day — busiest: ${peakLabel}`);
1106
+ }
1107
+
1108
+ lines.push('');
1109
+ lines.push(`✅ ${summary.kbAnsweredPct.toFixed(0)}% answered by KB`);
1110
+ lines.push(`🤖 ${summary.llmFallbackPct.toFixed(0)}% needed LLM`);
1111
+ lines.push(`❌ ${summary.noAnswerPct.toFixed(0)}% couldn't answer (${noAnswerCount} msgs)`);
1112
+ lines.push('');
1113
+ lines.push(`⚡ Avg response: ${(summary.avgResponseTime / 1000).toFixed(1)}s`);
1114
+ lines.push(`🎯 FAQ hit rate: ${summary.faqHitRate.toFixed(0)}%`);
1115
+ lines.push('');
1116
+ lines.push(`👥 ${summary.newCustomers} new customers${isToday ? ' today' : ' this week'}`);
1117
+ lines.push(`🔄 ${summary.returningCustomers} returning customers`);
1118
+
1119
+ if (summary.pendingReviews > 0) {
1120
+ lines.push('');
1121
+ lines.push(`📋 ${summary.pendingReviews} questions pending review`);
1122
+ lines.push('→ /review to start teaching');
1123
+ }
1124
+
1125
+ await reply(lines.join('\n'));
1126
+ }
1127
+ }
1128
+
1129
+ else if (command === '/status') {
1130
+ const stats = await kb.getStats();
1131
+ const statusLines = [
1132
+ '📦 *Knowledge Base Status:*',
1133
+ ` Documents: ${stats.documentCount}`,
1134
+ ` Chunks: ${stats.chunkCount}`,
1135
+ ` Embedding dimensions: ${stats.embeddingDimensions}`,
1136
+ ` DB size: ${(stats.dbSizeBytes / 1024).toFixed(1)} KB`,
1137
+ ];
1138
+
1139
+ // Append bot usage summary from analytics if available
1140
+ const analyticsStore = this.getAnalyticsStore();
1141
+ if (analyticsStore) {
1142
+ try {
1143
+ const summary = await analyticsStore.getSummary();
1144
+ statusLines.push('');
1145
+ statusLines.push('🤖 *Bot Status:*');
1146
+ statusLines.push(` Total messages processed: ${summary.totalMessages}`);
1147
+ statusLines.push(` Unique customers: ${summary.uniqueCustomers}`);
1148
+ if (summary.pendingReviews > 0) {
1149
+ statusLines.push(` Pending reviews: ${summary.pendingReviews}`);
1150
+ }
1151
+ const uptime = process.uptime();
1152
+ const uptimeStr = uptime >= 86400
1153
+ ? `${Math.floor(uptime / 86400)}d ${Math.floor((uptime % 86400) / 3600)}h`
1154
+ : uptime >= 3600
1155
+ ? `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`
1156
+ : `${Math.floor(uptime / 60)}m`;
1157
+ statusLines.push(` Uptime: ${uptimeStr}`);
1158
+ } catch {
1159
+ // Analytics query failed — skip silently
1160
+ }
1161
+ }
1162
+
1163
+ await reply(statusLines.join('\n'));
1164
+ }
1165
+
1166
+ else if (command === '/learn-url') {
1167
+ const url = args[0];
1168
+ if (!url) {
1169
+ await reply('Usage: /learn-url <url>');
1170
+ return true;
1171
+ }
1172
+ if (!kb.ingestUrl) {
1173
+ await reply('URL ingestion is not available. Ensure the KB runtime supports ingestUrl.');
1174
+ return true;
1175
+ }
1176
+ try {
1177
+ new URL(url);
1178
+ } catch {
1179
+ await reply(`Invalid URL: ${url}`);
1180
+ return true;
1181
+ }
1182
+ await reply(`Ingesting URL: ${url}...`);
1183
+ const result = await kb.ingestUrl(url);
1184
+ const countLabel = (result as any).faqCount ? `${(result as any).faqCount} Q&A pairs` : `${result.chunks} chunks`;
1185
+ await reply(`Done! Added "${result.title || url}" (${countLabel}).`);
1186
+ }
1187
+
1188
+ else if (command === '/learn-site') {
1189
+ const url = args[0];
1190
+ if (!url) {
1191
+ await reply('Usage: /learn-site <url>');
1192
+ return true;
1193
+ }
1194
+ if (!kb.ingestSite) {
1195
+ await reply('Site ingestion is not available. Ensure the KB runtime supports ingestSite.');
1196
+ return true;
1197
+ }
1198
+ try {
1199
+ new URL(url);
1200
+ } catch {
1201
+ await reply(`Invalid URL: ${url}`);
1202
+ return true;
1203
+ }
1204
+ await reply(`Crawling site: ${url} (this may take a while)...`);
1205
+ let lastProgressUpdate = 0;
1206
+ const result = await kb.ingestSite(url, {
1207
+ maxDepth: 2,
1208
+ maxPages: 50,
1209
+ onProgress: (crawled, total, pageUrl) => {
1210
+ // Send progress update every 5 pages
1211
+ if (crawled - lastProgressUpdate >= 5) {
1212
+ lastProgressUpdate = crawled;
1213
+ const shortUrl = pageUrl.length > 60 ? pageUrl.slice(0, 57) + '...' : pageUrl;
1214
+ reply(`Crawling page ${crawled}/${total}: ${shortUrl}`);
1215
+ }
1216
+ },
1217
+ });
1218
+ await reply(`Done! Crawled ${result.documents} pages (${result.chunks} total chunks).`);
1219
+ }
1220
+
1221
+ else if (command === '/learn-file') {
1222
+ if (!kb.ingestFile) {
1223
+ await reply('File ingestion is not available. Ensure the KB runtime supports ingestFile.');
1224
+ return true;
1225
+ }
1226
+ if (!message.mediaBuffer || !message.mediaFileName) {
1227
+ await reply('No file attached. Send a document (PDF, DOCX, TXT, CSV, etc.) with /learn-file as the caption.');
1228
+ return true;
1229
+ }
1230
+ await reply(`Ingesting file: ${message.mediaFileName}...`);
1231
+ const result = await kb.ingestFile(message.mediaBuffer, message.mediaFileName);
1232
+ await reply(`Done! Added "${result.title || message.mediaFileName}" (${result.chunks} chunks).`);
1233
+ }
1234
+
1235
+ else if (command === '/rebuild') {
1236
+ if (!kb.rebuild) {
1237
+ await reply('Rebuild is not available. Ensure the KB runtime supports rebuild.');
1238
+ return true;
1239
+ }
1240
+ await reply('Rebuilding KB embeddings... This may take a while.');
1241
+ const result = await kb.rebuild();
1242
+ await reply([
1243
+ 'Rebuild complete!',
1244
+ ` Documents: ${result.documentsRebuilt}`,
1245
+ ` Chunks: ${result.chunksRebuilt}`,
1246
+ ` Old dimensions: ${result.oldDimensions}`,
1247
+ ` New dimensions: ${result.newDimensions}`,
1248
+ ].join('\n'));
1249
+ }
1250
+
1251
+ else if (command === '/export') {
1252
+ await this.handleExportCommand(message, args);
1253
+ }
1254
+
1255
+ else if (command === '/import') {
1256
+ await this.handleImportCommand(message, args);
1257
+ }
1258
+
1259
+ } catch (error: any) {
1260
+ this.log(`Training command error: ${error.message}`);
1261
+ await reply(`Error: ${error.message}`);
1262
+ }
1263
+
1264
+ return true;
1265
+ }
1266
+
1267
+ /**
1268
+ * Handle /export command — generates YAML bundle and sends as file attachment
1269
+ */
1270
+ private async handleExportCommand(message: IncomingMessage, args: string[]): Promise<void> {
1271
+ const kb = this.config.kb!;
1272
+
1273
+ const reply = async (text: string) => {
1274
+ await this.sendMessage(message.from, message.provider, {
1275
+ to: message.from,
1276
+ text,
1277
+ });
1278
+ };
1279
+
1280
+ const replyWithFile = async (text: string, buffer: Buffer, fileName: string, mimeType: string) => {
1281
+ await this.sendMessage(message.from, message.provider, {
1282
+ to: message.from,
1283
+ text,
1284
+ mediaBuffer: buffer,
1285
+ mediaFileName: fileName,
1286
+ mediaMimeType: mimeType,
1287
+ });
1288
+ };
1289
+
1290
+ try {
1291
+ await reply('Generating export bundle...');
1292
+
1293
+ // Gather KB data
1294
+ const docs = await kb.listDocuments();
1295
+ const faqs = docs.filter(d => d.sourceType === 'faq');
1296
+ const nonFaqDocs = docs.filter(d => d.sourceType !== 'faq');
1297
+
1298
+ // Extract FAQ question/answer pairs
1299
+ const faqEntries = faqs.map(d => {
1300
+ const content = d.content;
1301
+ const aIndex = content.indexOf('\nA: ');
1302
+ if (aIndex !== -1 && content.startsWith('Q: ')) {
1303
+ return {
1304
+ question: content.slice(3, aIndex).trim(),
1305
+ answer: content.slice(aIndex + 4).trim(),
1306
+ };
1307
+ }
1308
+ // Fallback: use title as question, full content as answer
1309
+ return { question: (d.title || content).trim(), answer: content.trim() };
1310
+ });
1311
+
1312
+ // Extract document references (no full content — too large)
1313
+ const documentRefs = nonFaqDocs.map(d => {
1314
+ const ref: Record<string, any> = {
1315
+ title: d.title || undefined,
1316
+ sourceType: d.sourceType,
1317
+ };
1318
+ if (d.sourceUrl) ref.sourceUrl = d.sourceUrl;
1319
+ if (d.fileName) ref.fileName = d.fileName;
1320
+ return ref;
1321
+ });
1322
+
1323
+ // Get first agent's config for metadata
1324
+ const agentList = Array.from(this.agents.values());
1325
+ const firstAgent = agentList[0];
1326
+ const agentConfig = firstAgent?.getConfig();
1327
+
1328
+ // Build the export bundle
1329
+ const bundle: Record<string, any> = {
1330
+ version: 1,
1331
+ exported_at: new Date().toISOString(),
1332
+ };
1333
+
1334
+ if (agentConfig) {
1335
+ const agentSection: Record<string, any> = { name: agentConfig.name };
1336
+ if (agentConfig.purpose) agentSection.purpose = agentConfig.purpose;
1337
+ if (agentConfig.triggers?.length) agentSection.triggers = agentConfig.triggers;
1338
+ if (agentConfig.channels?.length) agentSection.channels = agentConfig.channels;
1339
+ if (agentConfig.skills?.length) agentSection.skills = agentConfig.skills;
1340
+ if (agentConfig.knowledgeBase != null) agentSection.knowledgeBase = agentConfig.knowledgeBase;
1341
+ if (agentConfig.priority != null) agentSection.priority = agentConfig.priority;
1342
+ if (agentConfig.escalateTo) agentSection.escalateTo = agentConfig.escalateTo;
1343
+ if (agentConfig._rawInstructions) agentSection.instructions = agentConfig._rawInstructions;
1344
+ if (agentConfig._rawIdentity) agentSection.identity = agentConfig._rawIdentity;
1345
+ if (agentConfig._rawSoul) agentSection.soul = agentConfig._rawSoul;
1346
+ bundle.agent = agentSection;
1347
+
1348
+ if (agentConfig.guardrails) {
1349
+ const g: Record<string, any> = {};
1350
+ if (agentConfig.guardrails.blockedTopics?.length) g.blockedTopics = agentConfig.guardrails.blockedTopics;
1351
+ if (agentConfig.guardrails.systemRules?.length) g.systemRules = agentConfig.guardrails.systemRules;
1352
+ if (agentConfig.guardrails.escalationTriggers?.length) g.escalationTriggers = agentConfig.guardrails.escalationTriggers;
1353
+ if (agentConfig.guardrails.maxResponseLength) g.maxResponseLength = agentConfig.guardrails.maxResponseLength;
1354
+ if (Object.keys(g).length > 0) bundle.guardrails = g;
1355
+ }
1356
+ }
1357
+
1358
+ bundle.faqs = faqEntries;
1359
+ if (documentRefs.length > 0) {
1360
+ bundle.documents = documentRefs;
1361
+ }
1362
+
1363
+ // Include prompt skills from registered skills
1364
+ const promptSkills = Array.from(this.skills.values())
1365
+ .filter((s) => typeof (s as any).getContent === 'function')
1366
+ .map((s) => {
1367
+ const cfg = (s as any).getConfig?.() || {};
1368
+ const entry: Record<string, any> = { name: s.name, content: (s as any).getContent() };
1369
+ if (cfg.summary) entry.summary = cfg.summary;
1370
+ if (cfg.tags?.length) entry.tags = cfg.tags;
1371
+ if (cfg.author) entry.author = cfg.author;
1372
+ return entry;
1373
+ });
1374
+ if (promptSkills.length > 0) {
1375
+ bundle.promptSkills = promptSkills;
1376
+ }
1377
+
1378
+ const yamlStr = '# operor-export-v1\n' + yaml.dump(bundle, { lineWidth: -1, noRefs: true });
1379
+ const yamlBuffer = Buffer.from(yamlStr, 'utf-8');
1380
+ const fileName = `operor-export-${new Date().toISOString().slice(0, 10)}.yaml`;
1381
+
1382
+ // Check if /export gist was requested
1383
+ if (args[0]?.toLowerCase() === 'gist') {
1384
+ const token = process.env.GITHUB_GIST_TOKEN;
1385
+ if (!token) {
1386
+ await reply('GitHub token not configured. Set GITHUB_GIST_TOKEN env var to use /export gist.');
1387
+ return;
1388
+ }
1389
+ try {
1390
+ const res = await fetch('https://api.github.com/gists', {
1391
+ method: 'POST',
1392
+ headers: {
1393
+ 'Authorization': `token ${token}`,
1394
+ 'Content-Type': 'application/json',
1395
+ 'Accept': 'application/vnd.github.v3+json',
1396
+ },
1397
+ body: JSON.stringify({
1398
+ description: `Operor Export - ${new Date().toISOString().slice(0, 10)}`,
1399
+ public: false,
1400
+ files: { [fileName]: { content: yamlStr } },
1401
+ }),
1402
+ });
1403
+ if (!res.ok) {
1404
+ const errText = await res.text();
1405
+ await reply(`GitHub Gist creation failed (${res.status}): ${errText.slice(0, 200)}`);
1406
+ return;
1407
+ }
1408
+ const gist = await res.json() as { html_url: string; files: Record<string, { raw_url: string }> };
1409
+ const rawUrl = gist.files[fileName]?.raw_url || gist.html_url;
1410
+ await reply([
1411
+ `Export published to GitHub Gist!`,
1412
+ `${faqEntries.length} FAQs, ${documentRefs.length} document references.`,
1413
+ ``,
1414
+ `View: ${gist.html_url}`,
1415
+ `Import URL: ${rawUrl}`,
1416
+ ``,
1417
+ `Share this URL with others: /import ${rawUrl}`,
1418
+ ].join('\n'));
1419
+ } catch (err: any) {
1420
+ await reply(`Error creating GitHub Gist: ${err.message}`);
1421
+ }
1422
+ return;
1423
+ }
1424
+
1425
+ // Send as file attachment
1426
+ await replyWithFile(
1427
+ `Export complete! ${faqEntries.length} FAQs, ${documentRefs.length} document references.`,
1428
+ yamlBuffer,
1429
+ fileName,
1430
+ 'text/yaml',
1431
+ );
1432
+ } catch (error: any) {
1433
+ this.log(`Export error: ${error.message}`);
1434
+ await reply(`Error: ${error.message}`);
1435
+ }
1436
+ }
1437
+
1438
+ /**
1439
+ * Handle /import command — imports YAML bundle from file attachment or URL
1440
+ */
1441
+ private async handleImportCommand(message: IncomingMessage, args: string[]): Promise<void> {
1442
+ const kb = this.config.kb!;
1443
+
1444
+ const reply = async (text: string) => {
1445
+ await this.sendMessage(message.from, message.provider, {
1446
+ to: message.from,
1447
+ text,
1448
+ });
1449
+ };
1450
+
1451
+ try {
1452
+ let yamlStr: string | null = null;
1453
+
1454
+ // Option 1: File attachment
1455
+ if (message.mediaBuffer && message.mediaFileName) {
1456
+ yamlStr = message.mediaBuffer.toString('utf-8');
1457
+ }
1458
+ // Option 2: URL argument
1459
+ else if (args[0]) {
1460
+ const url = args[0];
1461
+ try {
1462
+ new URL(url);
1463
+ } catch {
1464
+ await reply(`Invalid URL: ${url}`);
1465
+ return;
1466
+ }
1467
+ await reply(`Fetching bundle from ${url}...`);
1468
+ const res = await fetch(url);
1469
+ if (!res.ok) {
1470
+ await reply(`Failed to fetch URL (${res.status}): ${res.statusText}`);
1471
+ return;
1472
+ }
1473
+ yamlStr = await res.text();
1474
+ }
1475
+ // No input provided
1476
+ else {
1477
+ await reply('Usage: /import <url> — or send a YAML file with /import as caption.');
1478
+ return;
1479
+ }
1480
+
1481
+ // Parse and validate YAML
1482
+ let bundle: any;
1483
+ try {
1484
+ bundle = yaml.load(yamlStr);
1485
+ } catch (err: any) {
1486
+ await reply(`Invalid YAML: ${err.message}`);
1487
+ return;
1488
+ }
1489
+
1490
+ if (!bundle || typeof bundle !== 'object') {
1491
+ await reply('Invalid bundle: expected a YAML object.');
1492
+ return;
1493
+ }
1494
+
1495
+ if (bundle.version !== 1) {
1496
+ await reply(`Unsupported bundle version: ${bundle.version}. Expected version 1.`);
1497
+ return;
1498
+ }
1499
+
1500
+ const faqs = bundle.faqs;
1501
+ if (!Array.isArray(faqs)) {
1502
+ await reply('Invalid bundle: missing or invalid "faqs" array.');
1503
+ return;
1504
+ }
1505
+
1506
+ // Import FAQs
1507
+ await reply(`Importing ${faqs.length} FAQs...`);
1508
+ let imported = 0;
1509
+ let skipped = 0;
1510
+ for (const faq of faqs) {
1511
+ const question = typeof faq.question === 'string' ? faq.question.trim() : '';
1512
+ const answer = typeof faq.answer === 'string' ? faq.answer.trim() : '';
1513
+ if (question && answer) {
1514
+ await kb.ingestFaq(question, answer);
1515
+ imported++;
1516
+ } else {
1517
+ skipped++;
1518
+ }
1519
+ }
1520
+
1521
+ // Summarize document references
1522
+ const docRefs = Array.isArray(bundle.documents) ? bundle.documents : [];
1523
+ const urlRefs = docRefs.filter((d: any) => d.sourceType === 'url' && d.sourceUrl);
1524
+
1525
+ const lines = [`Import complete! ${imported} FAQs imported.`];
1526
+ if (skipped > 0) lines.push(`${skipped} FAQs skipped (missing question or answer).`);
1527
+ if (docRefs.length > 0) {
1528
+ lines.push(`${docRefs.length} document reference(s) found:`);
1529
+ for (const ref of docRefs) {
1530
+ if (ref.sourceUrl) {
1531
+ lines.push(` - ${ref.title || ref.sourceUrl} (${ref.sourceType})`);
1532
+ } else if (ref.fileName) {
1533
+ lines.push(` - ${ref.fileName} (${ref.sourceType})`);
1534
+ }
1535
+ }
1536
+ if (urlRefs.length > 0) {
1537
+ lines.push(`\nTo re-ingest URLs, send /learn-url for each.`);
1538
+ }
1539
+ }
1540
+
1541
+ // Import agent instructions if present
1542
+ const bundleAgent = bundle.agent;
1543
+ if (bundleAgent && (bundleAgent.instructions || bundleAgent.identity || bundleAgent.soul)) {
1544
+ const agentsDir = this.config.agentsDir;
1545
+ const agentName = bundleAgent.name || 'support';
1546
+
1547
+ if (agentsDir) {
1548
+ // Write agent files to disk
1549
+ const agentDir = path.join(agentsDir, agentName);
1550
+ await fs.mkdir(agentDir, { recursive: true });
1551
+
1552
+ // Reconstruct INSTRUCTIONS.md with YAML frontmatter
1553
+ if (bundleAgent.instructions) {
1554
+ const frontmatter: Record<string, any> = { name: agentName };
1555
+ if (bundleAgent.purpose) frontmatter.purpose = bundleAgent.purpose;
1556
+ if (bundleAgent.triggers?.length) frontmatter.triggers = bundleAgent.triggers;
1557
+ if (bundleAgent.channels?.length) frontmatter.channels = bundleAgent.channels;
1558
+ if (bundleAgent.skills?.length) frontmatter.skills = bundleAgent.skills;
1559
+ if (bundleAgent.knowledgeBase != null) frontmatter.knowledgeBase = bundleAgent.knowledgeBase;
1560
+ if (bundleAgent.priority != null) frontmatter.priority = bundleAgent.priority;
1561
+ if (bundleAgent.escalateTo) frontmatter.escalateTo = bundleAgent.escalateTo;
1562
+ if (bundle.guardrails) frontmatter.guardrails = bundle.guardrails;
1563
+
1564
+ const yamlFrontmatter = yaml.dump(frontmatter, { lineWidth: -1, noRefs: true }).trim();
1565
+ const instructionsContent = `---\n${yamlFrontmatter}\n---\n\n${bundleAgent.instructions.trim()}\n`;
1566
+ await fs.writeFile(path.join(agentDir, 'INSTRUCTIONS.md'), instructionsContent, 'utf-8');
1567
+ }
1568
+
1569
+ if (bundleAgent.identity) {
1570
+ await fs.writeFile(path.join(agentDir, 'IDENTITY.md'), bundleAgent.identity.trim() + '\n', 'utf-8');
1571
+ }
1572
+
1573
+ if (bundleAgent.soul) {
1574
+ await fs.writeFile(path.join(agentDir, 'SOUL.md'), bundleAgent.soul.trim() + '\n', 'utf-8');
1575
+ }
1576
+
1577
+ lines.push(`Agent instructions written to ${agentDir}.`);
1578
+ } else {
1579
+ // No agentsDir — update the in-memory agent's config
1580
+ const agentList = Array.from(this.agents.values());
1581
+ const targetAgent = agentList.find(a => a.getConfig().name === agentName) || agentList[0];
1582
+ if (targetAgent) {
1583
+ // Mutate the agent's config directly — getConfig() returns a shallow copy,
1584
+ // so we must use the public config property to persist changes.
1585
+ if (bundleAgent.instructions) targetAgent.config._rawInstructions = bundleAgent.instructions;
1586
+ if (bundleAgent.identity) targetAgent.config._rawIdentity = bundleAgent.identity;
1587
+ if (bundleAgent.soul) targetAgent.config._rawSoul = bundleAgent.soul;
1588
+ lines.push('Agent instructions applied in-memory.');
1589
+ }
1590
+ }
1591
+ }
1592
+
1593
+ // Import prompt skills if present
1594
+ if (Array.isArray(bundle.promptSkills) && bundle.promptSkills.length > 0) {
1595
+ const mod = this.config.skillsModule;
1596
+ if (mod) {
1597
+ try {
1598
+ const skillsConfig = mod.loadSkillsConfig();
1599
+ let added = 0;
1600
+ for (const ps of bundle.promptSkills) {
1601
+ if (!ps.name || !ps.content) continue;
1602
+ if (skillsConfig.skills.find((s: any) => s.name === ps.name)) continue;
1603
+ skillsConfig.skills.push({ type: 'prompt', name: ps.name, content: ps.content, summary: ps.summary, tags: ps.tags, author: ps.author, enabled: true });
1604
+ added++;
1605
+ }
1606
+ if (added > 0) {
1607
+ mod.saveSkillsConfig(skillsConfig);
1608
+ lines.push(`${added} prompt skill(s) imported to mcp.json.`);
1609
+ }
1610
+ } catch (err: any) {
1611
+ lines.push(`Warning: failed to import prompt skills: ${err.message}`);
1612
+ }
1613
+ }
1614
+ }
1615
+
1616
+ await reply(lines.join('\n'));
1617
+ } catch (error: any) {
1618
+ this.log(`Import error: ${error.message}`);
1619
+ await reply(`Error: ${error.message}`);
1620
+ }
1621
+ }
1622
+
1623
+ /**
1624
+ * Handle messages when sender has an active edit session.
1625
+ */
1626
+ private async handlePendingEdit(message: IncomingMessage, edit: PendingEdit): Promise<boolean> {
1627
+ const text = message.text.trim();
1628
+ const reply = async (t: string) => {
1629
+ await this.sendMessage(message.from, message.provider, { to: message.from, text: t });
1630
+ };
1631
+
1632
+ // Check TTL (2 minutes)
1633
+ if (Date.now() - edit.createdAt > 120_000) {
1634
+ this.pendingEdits.delete(message.from);
1635
+ await reply('Edit session expired (2 min timeout). Start again with /instructions edit, /identity edit, or /soul edit.');
1636
+ return true;
1637
+ }
1638
+
1639
+ if (edit.state === 'confirming') {
1640
+ if (text.toLowerCase() === 'yes') {
1641
+ await this.applyEdit(message.from, edit, reply);
1642
+ } else {
1643
+ this.pendingEdits.delete(message.from);
1644
+ await reply('Edit cancelled.');
1645
+ }
1646
+ return true;
1647
+ }
1648
+
1649
+ // State: capturing
1650
+ if (text === '/cancel') {
1651
+ this.pendingEdits.delete(message.from);
1652
+ await reply('Edit cancelled.');
1653
+ return true;
1654
+ }
1655
+
1656
+ if (text === '/done') {
1657
+ const newContent = edit.content.join('\n');
1658
+ const agent = this.getFirstAgent();
1659
+ if (!agent) { await reply('No agent found.'); return true; }
1660
+
1661
+ const fieldMap = { instructions: '_rawInstructions', identity: '_rawIdentity', soul: '_rawSoul' } as const;
1662
+ const current = (agent.config as any)[fieldMap[edit.field]] || '(empty)';
1663
+
1664
+ let currentDisplay = current;
1665
+ let newDisplay = newContent;
1666
+ if (edit.field === 'instructions' && edit.section) {
1667
+ currentDisplay = this.extractSection(current, edit.section) || '(section not found)';
1668
+ newDisplay = newContent;
1669
+ }
1670
+
1671
+ // Truncate for display
1672
+ const maxDisplay = 1500;
1673
+ if (currentDisplay.length > maxDisplay) currentDisplay = currentDisplay.slice(0, maxDisplay) + '\n...';
1674
+ if (newDisplay.length > maxDisplay) newDisplay = newDisplay.slice(0, maxDisplay) + '\n...';
1675
+
1676
+ edit.state = 'confirming';
1677
+ await reply(`*CURRENT ${edit.field}${edit.section ? ` (${edit.section})` : ''}:*\n${currentDisplay}\n\n*NEW:*\n${newDisplay}\n\nSend "yes" to apply or anything else to cancel.`);
1678
+ return true;
1679
+ }
1680
+
1681
+ // Capture content
1682
+ edit.content.push(text);
1683
+ return true;
1684
+ }
1685
+
1686
+ /**
1687
+ * Apply a confirmed edit.
1688
+ */
1689
+ private async applyEdit(sender: string, edit: PendingEdit, reply: (t: string) => Promise<void>): Promise<void> {
1690
+ this.pendingEdits.delete(sender);
1691
+ const agent = this.getFirstAgent();
1692
+ if (!agent) { await reply('No agent found.'); return; }
1693
+
1694
+ const newContent = edit.content.join('\n');
1695
+ const fieldMap = { instructions: '_rawInstructions', identity: '_rawIdentity', soul: '_rawSoul' } as const;
1696
+ const rawKey = fieldMap[edit.field];
1697
+
1698
+ // Save version before editing
1699
+ await this.versionStore.saveVersion(edit.agentName, {
1700
+ timestamp: new Date().toISOString(),
1701
+ instructions: agent.config._rawInstructions,
1702
+ identity: agent.config._rawIdentity,
1703
+ soul: agent.config._rawSoul,
1704
+ frontmatter: this.extractFrontmatter(agent.config),
1705
+ editedBy: sender,
1706
+ changeDescription: `Edit ${edit.field}${edit.section ? ` section "${edit.section}"` : ''}`,
1707
+ });
1708
+
1709
+ // Apply edit
1710
+ if (edit.field === 'instructions' && edit.section) {
1711
+ const current = agent.config._rawInstructions || '';
1712
+ agent.config._rawInstructions = this.replaceSection(current, edit.section, newContent);
1713
+ } else {
1714
+ (agent.config as any)[rawKey] = newContent;
1715
+ }
1716
+
1717
+ // Rebuild system prompt
1718
+ this.rebuildAgentSystemPrompt(agent);
1719
+
1720
+ // Write to disk if agentsDir configured
1721
+ await this.writeAgentFiles(edit.agentName, agent);
1722
+
1723
+ await reply(`${edit.field} updated successfully.`);
1724
+ }
1725
+
1726
+ /**
1727
+ * Handle messages when sender has an active /quickstart session.
1728
+ */
1729
+ private async handlePendingQuickstart(message: IncomingMessage, qs: PendingQuickstart): Promise<boolean> {
1730
+ const text = message.text.trim();
1731
+ const reply = async (t: string) => {
1732
+ await this.sendMessage(message.from, message.provider, { to: message.from, text: t });
1733
+ };
1734
+
1735
+ // Check TTL (2 minutes)
1736
+ if (Date.now() - qs.createdAt > 120_000) {
1737
+ this.pendingQuickstarts.delete(message.from);
1738
+ await reply('Quickstart session expired (2 min timeout). Send /quickstart to try again.');
1739
+ return true;
1740
+ }
1741
+
1742
+ if (text === '/cancel') {
1743
+ this.pendingQuickstarts.delete(message.from);
1744
+ await reply('Quickstart cancelled.');
1745
+ return true;
1746
+ }
1747
+
1748
+ const kb = this.config.kb;
1749
+ if (!kb) {
1750
+ this.pendingQuickstarts.delete(message.from);
1751
+ await reply('Knowledge Base is not available.');
1752
+ return true;
1753
+ }
1754
+
1755
+ if (qs.step === 'question') {
1756
+ qs.question = text;
1757
+ qs.step = 'answer';
1758
+ qs.createdAt = Date.now(); // reset TTL
1759
+ await reply(`Got it!\n\nStep 2/3: What should the agent answer?\n\nQ: "${text}"\n\n(Send the answer, or /cancel to abort)`);
1760
+ return true;
1761
+ }
1762
+
1763
+ if (qs.step === 'answer') {
1764
+ const question = qs.question!;
1765
+ const answer = text;
1766
+ this.pendingQuickstarts.delete(message.from);
1767
+
1768
+ // Step 3: auto-teach + auto-test
1769
+ try {
1770
+ const doc = await kb.ingestFaq(question, answer);
1771
+ const result = await kb.retrieve(question);
1772
+ const top = result.results[0];
1773
+ const score = top ? top.score.toFixed(2) : 'N/A';
1774
+ const matchType = result.isFaqMatch ? 'FAQ direct match' : 'KB search';
1775
+
1776
+ await reply([
1777
+ 'Step 3/3: Teaching & testing...',
1778
+ '',
1779
+ `FAQ added (${doc.id.slice(0, 8)}...)`,
1780
+ ` Q: ${question}`,
1781
+ ` A: ${answer}`,
1782
+ '',
1783
+ `Test result: ${matchType} (score: ${score})`,
1784
+ '',
1785
+ 'Your agent just learned its first answer! Try sending the question as a regular message to see it in action.',
1786
+ '',
1787
+ 'Next steps:',
1788
+ ' /teach <question> | <answer> — add more FAQs',
1789
+ ' /test <message> — test KB responses',
1790
+ ' /help — see all commands',
1791
+ ].join('\n'));
1792
+ } catch (error: any) {
1793
+ await reply(`Error during quickstart: ${error.message}`);
1794
+ }
1795
+ return true;
1796
+ }
1797
+
1798
+ return false;
1799
+ }
1800
+
1801
+ /**
1802
+ * Add assistant response to conversation history (public API for manual use)
1803
+ */
1804
+ async addAssistantMessage(customerId: string, text: string, toolCalls?: any[]): Promise<void> {
1805
+ await this.memory.addMessage(customerId, {
1806
+ role: 'assistant',
1807
+ content: text,
1808
+ timestamp: Date.now(),
1809
+ toolCalls,
1810
+ });
1811
+ }
1812
+
1813
+ private async handleInstructionsCommand(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
1814
+ const agent = this.getFirstAgent();
1815
+ if (!agent) { await reply('No agent found.'); return; }
1816
+ const sub = args[0]?.toLowerCase();
1817
+ const raw = agent.config._rawInstructions || '';
1818
+
1819
+ if (sub === 'edit') {
1820
+ const section = args.slice(1).join(' ') || undefined;
1821
+ this.pendingEdits.set(message.from, {
1822
+ agentName: agent.config.name, field: 'instructions', section,
1823
+ content: [], createdAt: Date.now(), state: 'capturing',
1824
+ });
1825
+ const label = section ? `section "${section}"` : 'full instructions';
1826
+ await reply(`Editing ${label}. Send your new content, then /done to apply or /cancel to abort. (2 min timeout)`);
1827
+ return;
1828
+ }
1829
+
1830
+ if (sub === 'view') {
1831
+ const section = args.slice(1).join(' ') || undefined;
1832
+ let content = section ? (this.extractSection(raw, section) || `Section "${section}" not found.`) : raw;
1833
+ if (!content.trim()) { await reply('Instructions are empty.'); return; }
1834
+ // Paginate at 2500 chars
1835
+ if (content.length > 2500) {
1836
+ await reply(content.slice(0, 2500) + '\n\n(truncated — send /instructions view again for full)');
1837
+ } else {
1838
+ await reply(content);
1839
+ }
1840
+ return;
1841
+ }
1842
+
1843
+ if (sub === 'history') {
1844
+ const history = await this.versionStore.getHistory(agent.config.name, 10);
1845
+ if (history.length === 0) { await reply('No version history.'); return; }
1846
+ const lines = history.map((v, i) =>
1847
+ `${i}. ${v.timestamp} — ${v.changeDescription || 'no description'}${v.editedBy ? ` (by ${v.editedBy})` : ''}`
1848
+ );
1849
+ await reply(`Version history:\n${lines.join('\n')}`);
1850
+ return;
1851
+ }
1852
+
1853
+ if (sub === 'rollback') {
1854
+ const n = args[1] != null ? parseInt(args[1]) : 0;
1855
+ const version = await this.versionStore.getVersion(agent.config.name, n);
1856
+ if (!version) { await reply(`Version ${n} not found.`); return; }
1857
+
1858
+ // Save current as new version before rollback
1859
+ await this.versionStore.saveVersion(agent.config.name, {
1860
+ timestamp: new Date().toISOString(),
1861
+ instructions: agent.config._rawInstructions,
1862
+ identity: agent.config._rawIdentity,
1863
+ soul: agent.config._rawSoul,
1864
+ frontmatter: this.extractFrontmatter(agent.config),
1865
+ editedBy: message.from,
1866
+ changeDescription: `Rollback to version ${n}`,
1867
+ });
1868
+
1869
+ if (version.instructions !== undefined) agent.config._rawInstructions = version.instructions;
1870
+ if (version.identity !== undefined) agent.config._rawIdentity = version.identity;
1871
+ if (version.soul !== undefined) agent.config._rawSoul = version.soul;
1872
+ this.rebuildAgentSystemPrompt(agent);
1873
+ await this.writeAgentFiles(agent.config.name, agent);
1874
+ await reply(`Rolled back to version ${n} (${version.timestamp}).`);
1875
+ return;
1876
+ }
1877
+
1878
+ // Default: summary — show sections list
1879
+ if (!raw.trim()) { await reply('Instructions are empty.'); return; }
1880
+ const sections = this.parseSections(raw);
1881
+ if (sections.length === 0) {
1882
+ await reply(`Instructions (${raw.length} chars):\n${raw.slice(0, 500)}${raw.length > 500 ? '\n...' : ''}`);
1883
+ } else {
1884
+ const lines = sections.map(s => ` ${s.heading} (${s.content.length} chars)`);
1885
+ await reply(`Instructions sections:\n${lines.join('\n')}\n\nUse /instructions view [section] to see details.`);
1886
+ }
1887
+ }
1888
+
1889
+ private async handleFieldViewOrEdit(
1890
+ field: 'identity' | 'soul', message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>,
1891
+ ): Promise<void> {
1892
+ const agent = this.getFirstAgent();
1893
+ if (!agent) { await reply('No agent found.'); return; }
1894
+ const rawKey = field === 'identity' ? '_rawIdentity' : '_rawSoul';
1895
+ const current = (agent.config as any)[rawKey] || '';
1896
+
1897
+ if (args[0]?.toLowerCase() === 'edit') {
1898
+ this.pendingEdits.set(message.from, {
1899
+ agentName: agent.config.name, field, content: [], createdAt: Date.now(), state: 'capturing',
1900
+ });
1901
+ await reply(`Editing ${field}. Send your new content, then /done to apply or /cancel to abort. (2 min timeout)`);
1902
+ return;
1903
+ }
1904
+
1905
+ // View
1906
+ if (!current.trim()) { await reply(`${field} is empty.`); return; }
1907
+ if (current.length > 2500) {
1908
+ await reply(current.slice(0, 2500) + '\n\n(truncated)');
1909
+ } else {
1910
+ await reply(current);
1911
+ }
1912
+ }
1913
+
1914
+ private async handleConfigCommand(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
1915
+ const agent = this.getFirstAgent();
1916
+ if (!agent) { await reply('No agent found.'); return; }
1917
+ const sub = args[0]?.toLowerCase();
1918
+
1919
+ if (sub === 'show' || !sub) {
1920
+ const c = agent.config;
1921
+ const lines = [
1922
+ `*Agent Config: ${c.name}*`,
1923
+ ` purpose: ${c.purpose || '(not set)'}`,
1924
+ ` triggers: ${c.triggers?.join(', ') || '(none)'}`,
1925
+ ` channels: ${c.channels?.join(', ') || '(all)'}`,
1926
+ ` skills: ${c.skills?.join(', ') || '(none)'}`,
1927
+ ` knowledgeBase: ${c.knowledgeBase ?? false}`,
1928
+ ` priority: ${c.priority ?? 0}`,
1929
+ ` escalateTo: ${c.escalateTo || '(not set)'}`,
1930
+ ];
1931
+ if (c.guardrails) {
1932
+ lines.push(` maxResponseLength: ${c.guardrails.maxResponseLength ?? '(not set)'}`);
1933
+ lines.push(` blockedTopics: ${c.guardrails.blockedTopics?.join(', ') || '(none)'}`);
1934
+ lines.push(` escalationTriggers: ${c.guardrails.escalationTriggers?.join(', ') || '(none)'}`);
1935
+ lines.push(` systemRules: ${c.guardrails.systemRules?.length || 0} rules`);
1936
+ }
1937
+ await reply(lines.join('\n'));
1938
+ return;
1939
+ }
1940
+
1941
+ if (sub === 'set') {
1942
+ const key = args[1];
1943
+ const value = args.slice(2).join(' ');
1944
+ if (!key || !value) { await reply('Usage: /config set <key> <value>'); return; }
1945
+ await this.configSet(agent, key, value, message.from, reply);
1946
+ return;
1947
+ }
1948
+
1949
+ if (sub === 'add') {
1950
+ const key = args[1];
1951
+ const value = args.slice(2).join(' ');
1952
+ if (!key || !value) { await reply('Usage: /config add <key> <value>'); return; }
1953
+ await this.configArrayOp(agent, 'add', key, value, message.from, reply);
1954
+ return;
1955
+ }
1956
+
1957
+ if (sub === 'remove') {
1958
+ const key = args[1];
1959
+ const value = args.slice(2).join(' ');
1960
+ if (!key || !value) { await reply('Usage: /config remove <key> <value>'); return; }
1961
+ await this.configArrayOp(agent, 'remove', key, value, message.from, reply);
1962
+ return;
1963
+ }
1964
+
1965
+ await reply('Usage: /config show | /config set <key> <value> | /config add <key> <value> | /config remove <key> <value>');
1966
+ }
1967
+
1968
+ private async handleModelCommand(args: string[], reply: (t: string) => Promise<void>): Promise<void> {
1969
+ const llm = this.config.llmProvider;
1970
+ if (!llm || !llm.getConfig) {
1971
+ await reply('LLM provider not available. Configure LLM_PROVIDER and LLM_API_KEY first.');
1972
+ return;
1973
+ }
1974
+
1975
+ const sub = args[0]?.toLowerCase();
1976
+
1977
+ // /model or /model show — display current model
1978
+ if (!sub || sub === 'show') {
1979
+ const cfg = llm.getConfig();
1980
+ const key = cfg.apiKey;
1981
+ const masked = key ? key.slice(0, 6) + '...' + key.slice(-4) : '(not set)';
1982
+ const lines = [
1983
+ '*Current LLM Config*',
1984
+ ` provider: ${cfg.provider}`,
1985
+ ` model: ${cfg.model || '(default)'}`,
1986
+ ` apiKey: ${masked}`,
1987
+ ];
1988
+ await reply(lines.join('\n'));
1989
+ return;
1990
+ }
1991
+
1992
+ // /model list — show available models
1993
+ if (sub === 'list') {
1994
+ if (!llm.getModelCatalog) {
1995
+ await reply('Model catalog not available.');
1996
+ return;
1997
+ }
1998
+ const { catalog, defaults } = llm.getModelCatalog();
1999
+ const cfg = llm.getConfig();
2000
+ const lines = ['*Available Models*', ''];
2001
+ for (const [provider, models] of Object.entries(catalog)) {
2002
+ const isCurrent = provider === cfg.provider;
2003
+ const hasKey = isCurrent || !!llm.getApiKey?.(provider);
2004
+ const marker = isCurrent ? ' (active)' : hasKey ? ' (key set)' : '';
2005
+ lines.push(`*${provider}*${marker}`);
2006
+ for (const m of models) {
2007
+ const isDefault = m === (defaults[provider] || '');
2008
+ const isActive = isCurrent && (cfg.model === m || (!cfg.model && isDefault));
2009
+ const prefix = isActive ? ' → ' : ' ';
2010
+ lines.push(`${prefix}${m}${isDefault ? ' (default)' : ''}`);
2011
+ }
2012
+ lines.push('');
2013
+ }
2014
+ await reply(lines.join('\n'));
2015
+ return;
2016
+ }
2017
+
2018
+ // /model set <provider/model> or /model set <model>
2019
+ if (sub === 'set') {
2020
+ const value = args.slice(1).join(' ').trim();
2021
+ if (!value) {
2022
+ await reply('Usage: /model set <provider/model> or /model set <model>');
2023
+ return;
2024
+ }
2025
+
2026
+ let provider: string | undefined;
2027
+ let model: string;
2028
+
2029
+ if (value.includes('/')) {
2030
+ const slashIdx = value.indexOf('/');
2031
+ provider = value.slice(0, slashIdx);
2032
+ model = value.slice(slashIdx + 1);
2033
+ } else {
2034
+ model = value;
2035
+ }
2036
+
2037
+ const cfg = llm.getConfig();
2038
+ const targetProvider = provider || cfg.provider;
2039
+
2040
+ // Validate provider
2041
+ const validProviders = ['openai', 'anthropic', 'google', 'groq', 'ollama'];
2042
+ if (!validProviders.includes(targetProvider)) {
2043
+ await reply(`Unknown provider: ${targetProvider}\nValid: ${validProviders.join(', ')}`);
2044
+ return;
2045
+ }
2046
+
2047
+ // Check API key availability when switching providers
2048
+ if (provider && provider !== cfg.provider) {
2049
+ const key = llm.getApiKey?.(provider);
2050
+ if (!key) {
2051
+ await reply(`No API key for ${provider}. Use /model key ${provider} <key> first.`);
2052
+ return;
2053
+ }
2054
+ llm.setConfig({ provider: provider as any, model, apiKey: key });
2055
+ } else {
2056
+ llm.setConfig({ model });
2057
+ }
2058
+
2059
+ // Persist via memory store
2060
+ const memory = this.config.memory;
2061
+ if (memory?.setSetting) {
2062
+ await memory.setSetting('llm_provider', targetProvider);
2063
+ await memory.setSetting('llm_model', model);
2064
+ }
2065
+
2066
+ await reply(`Model switched to *${targetProvider}/${model}*`);
2067
+ return;
2068
+ }
2069
+
2070
+ // /model key <provider> <key> — store API key
2071
+ if (sub === 'key') {
2072
+ const provider = args[1]?.toLowerCase();
2073
+ const key = args[2];
2074
+ if (!provider || !key) {
2075
+ await reply('Usage: /model key <provider> <api-key>');
2076
+ return;
2077
+ }
2078
+ const validProviders = ['openai', 'anthropic', 'google', 'groq', 'ollama'];
2079
+ if (!validProviders.includes(provider)) {
2080
+ await reply(`Unknown provider: ${provider}\nValid: ${validProviders.join(', ')}`);
2081
+ return;
2082
+ }
2083
+ llm.setApiKey(provider, key);
2084
+
2085
+ // Persist
2086
+ const memory = this.config.memory;
2087
+ if (memory?.setSetting) {
2088
+ await memory.setSetting(`llm_apikey_${provider}`, key);
2089
+ }
2090
+
2091
+ const masked = key.slice(0, 6) + '...' + key.slice(-4);
2092
+ await reply(`API key for *${provider}* saved: ${masked}`);
2093
+ return;
2094
+ }
2095
+
2096
+ await reply('Usage: /model | /model list | /model set <provider/model> | /model key <provider> <key>');
2097
+ }
2098
+
2099
+ /**
2100
+ * Select agent based on intent.
2101
+ * Sorts by priority (higher first), then specificity (fewer triggers = more specific).
2102
+ * Agents with triggers: ['*'] are treated as fallback (checked last).
2103
+ */
2104
+ private selectAgent(intent: Intent, message: IncomingMessage): Agent | null {
2105
+ // Filter by channel first
2106
+ const channelFiltered = Array.from(this.agents.values()).filter((agent) => {
2107
+ const cfg = agent.getConfig();
2108
+ if (cfg.channels && cfg.channels.length > 0) {
2109
+ return cfg.channels.includes(message.channel) || cfg.channels.includes(message.provider);
2110
+ }
2111
+ return true;
2112
+ });
2113
+
2114
+ // Separate wildcard fallback agents from specific agents
2115
+ const specific: Agent[] = [];
2116
+ const fallback: Agent[] = [];
2117
+
2118
+ for (const agent of channelFiltered) {
2119
+ const cfg = agent.getConfig();
2120
+ const triggers = cfg.triggers || [];
2121
+ if (triggers.length === 1 && triggers[0] === '*') {
2122
+ fallback.push(agent);
2123
+ } else {
2124
+ specific.push(agent);
2125
+ }
2126
+ }
2127
+
2128
+ // Sort by priority (higher first), then specificity (fewer triggers = more specific)
2129
+ const sortByPriority = (a: Agent, b: Agent) => {
2130
+ const pa = a.getConfig().priority ?? 0;
2131
+ const pb = b.getConfig().priority ?? 0;
2132
+ if (pb !== pa) return pb - pa;
2133
+ const ta = a.getConfig().triggers?.length ?? 0;
2134
+ const tb = b.getConfig().triggers?.length ?? 0;
2135
+ return ta - tb;
2136
+ };
2137
+
2138
+ specific.sort(sortByPriority);
2139
+ fallback.sort(sortByPriority);
2140
+
2141
+ // Try specific agents first
2142
+ for (const agent of specific) {
2143
+ if (agent.matchesIntent(intent.intent)) {
2144
+ return agent;
2145
+ }
2146
+ }
2147
+
2148
+ // Fall back to wildcard agents
2149
+ for (const agent of fallback) {
2150
+ return agent;
2151
+ }
2152
+
2153
+ return null;
2154
+ }
2155
+
2156
+ /**
2157
+ * Send message via provider
2158
+ */
2159
+ private async sendMessage(
2160
+ to: string,
2161
+ providerName: string,
2162
+ message: OutgoingMessage
2163
+ ): Promise<void> {
2164
+ const provider = this.providers.get(providerName);
2165
+ if (!provider) {
2166
+ throw new Error(`Provider not found: ${providerName}`);
2167
+ }
2168
+
2169
+ await provider.sendMessage(to, message);
2170
+ }
2171
+
2172
+ /**
2173
+ * Get all agents
2174
+ */
2175
+ getAgents(): Agent[] {
2176
+ return Array.from(this.agents.values());
2177
+ }
2178
+
2179
+ /**
2180
+ * Get all skills
2181
+ */
2182
+ getSkills(): Skill[] {
2183
+ return Array.from(this.skills.values());
2184
+ }
2185
+
2186
+ /**
2187
+ * Check if running
2188
+ */
2189
+ isActive(): boolean {
2190
+ return this.isRunning;
2191
+ }
2192
+
2193
+ /**
2194
+ * Get the analytics store (if configured) for querying metrics
2195
+ */
2196
+ getAnalyticsStore(): any {
2197
+ return this.config.analyticsStore ?? null;
2198
+ }
2199
+
2200
+ private async configSet(agent: Agent, key: string, value: string, sender: string, reply: (t: string) => Promise<void>): Promise<void> {
2201
+ const scalarKeys: Record<string, (v: string) => any> = {
2202
+ purpose: (v) => v,
2203
+ maxResponseLength: (v) => parseInt(v),
2204
+ priority: (v) => parseInt(v),
2205
+ escalateTo: (v) => v,
2206
+ knowledgeBase: (v) => v === 'true',
2207
+ };
2208
+ const parser = scalarKeys[key];
2209
+ if (!parser) { await reply(`Unknown config key "${key}". Valid: ${Object.keys(scalarKeys).join(', ')}`); return; }
2210
+
2211
+ await this.versionStore.saveVersion(agent.config.name, {
2212
+ timestamp: new Date().toISOString(),
2213
+ instructions: agent.config._rawInstructions,
2214
+ identity: agent.config._rawIdentity,
2215
+ soul: agent.config._rawSoul,
2216
+ frontmatter: this.extractFrontmatter(agent.config),
2217
+ editedBy: sender,
2218
+ changeDescription: `Set ${key} = ${value}`,
2219
+ });
2220
+
2221
+ const parsed = parser(value);
2222
+ if (key === 'maxResponseLength') {
2223
+ agent.config.guardrails = agent.config.guardrails || {};
2224
+ agent.config.guardrails.maxResponseLength = parsed;
2225
+ } else {
2226
+ (agent.config as any)[key] = parsed;
2227
+ }
2228
+
2229
+ await this.writeAgentFrontmatter(agent.config.name, agent);
2230
+ await reply(`Config updated: ${key} = ${value}`);
2231
+ }
2232
+
2233
+ private async configArrayOp(agent: Agent, op: 'add' | 'remove', key: string, value: string, sender: string, reply: (t: string) => Promise<void>): Promise<void> {
2234
+ const arrayKeys: Record<string, { get: () => string[], set: (v: string[]) => void }> = {
2235
+ triggers: { get: () => agent.config.triggers || [], set: (v) => { agent.config.triggers = v; } },
2236
+ channels: { get: () => agent.config.channels || [], set: (v) => { agent.config.channels = v; } },
2237
+ skills: { get: () => agent.config.skills || [], set: (v) => { agent.config.skills = v; } },
2238
+ blockedTopics: {
2239
+ get: () => agent.config.guardrails?.blockedTopics || [],
2240
+ set: (v) => { agent.config.guardrails = agent.config.guardrails || {}; agent.config.guardrails.blockedTopics = v; },
2241
+ },
2242
+ escalationTriggers: {
2243
+ get: () => agent.config.guardrails?.escalationTriggers || [],
2244
+ set: (v) => { agent.config.guardrails = agent.config.guardrails || {}; agent.config.guardrails.escalationTriggers = v; },
2245
+ },
2246
+ systemRules: {
2247
+ get: () => agent.config.guardrails?.systemRules || [],
2248
+ set: (v) => { agent.config.guardrails = agent.config.guardrails || {}; agent.config.guardrails.systemRules = v; },
2249
+ },
2250
+ };
2251
+ const accessor = arrayKeys[key];
2252
+ if (!accessor) { await reply(`Unknown array key "${key}". Valid: ${Object.keys(arrayKeys).join(', ')}`); return; }
2253
+
2254
+ await this.versionStore.saveVersion(agent.config.name, {
2255
+ timestamp: new Date().toISOString(),
2256
+ instructions: agent.config._rawInstructions,
2257
+ identity: agent.config._rawIdentity,
2258
+ soul: agent.config._rawSoul,
2259
+ frontmatter: this.extractFrontmatter(agent.config),
2260
+ editedBy: sender,
2261
+ changeDescription: `${op} ${key}: ${value}`,
2262
+ });
2263
+
2264
+ const arr = accessor.get();
2265
+ if (op === 'add') {
2266
+ if (!arr.includes(value)) arr.push(value);
2267
+ accessor.set(arr);
2268
+ } else {
2269
+ accessor.set(arr.filter(v => v !== value));
2270
+ }
2271
+
2272
+ // Rebuild system prompt if systemRules changed
2273
+ if (key === 'systemRules') this.rebuildAgentSystemPrompt(agent);
2274
+
2275
+ await this.writeAgentFrontmatter(agent.config.name, agent);
2276
+ await reply(`Config updated: ${op} "${value}" ${op === 'add' ? 'to' : 'from'} ${key}`);
2277
+ }
2278
+
2279
+ private async handleWhitelistCommand(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2280
+ const whitelist = this.config.trainingMode?.whitelist;
2281
+ if (!whitelist) {
2282
+ await reply('Training mode whitelist is not configured.');
2283
+ return;
2284
+ }
2285
+
2286
+ const normalizePhone = (p: string) => p.replace(/[\s\-+]/g, '');
2287
+ const formatPhone = (p: string) => p.startsWith('+') ? p : `+${p}`;
2288
+ const sub = args[0]?.toLowerCase();
2289
+
2290
+ if (!sub || sub === 'list') {
2291
+ if (whitelist.length === 0) {
2292
+ await reply('Whitelist is empty.');
2293
+ } else {
2294
+ const lines = whitelist.map(p => ` ${formatPhone(p)}`);
2295
+ await reply(`Whitelisted numbers (${whitelist.length}):\n${lines.join('\n')}`);
2296
+ }
2297
+ return;
2298
+ }
2299
+
2300
+ if (sub === 'add') {
2301
+ const phone = args.slice(1).join('').trim();
2302
+ if (!phone) {
2303
+ await reply('Usage: /whitelist add <phone>');
2304
+ return;
2305
+ }
2306
+ const normalized = normalizePhone(phone);
2307
+ const alreadyExists = whitelist.some(w => normalizePhone(w) === normalized);
2308
+ if (alreadyExists) {
2309
+ await reply(`${formatPhone(phone)} is already whitelisted.`);
2310
+ return;
2311
+ }
2312
+ whitelist.push(phone.startsWith('+') ? phone : `+${phone}`);
2313
+ await this.persistWhitelist();
2314
+ await reply(`Added ${formatPhone(phone)} to whitelist.`);
2315
+ return;
2316
+ }
2317
+
2318
+ if (sub === 'remove') {
2319
+ const phone = args.slice(1).join('').trim();
2320
+ if (!phone) {
2321
+ await reply('Usage: /whitelist remove <phone>');
2322
+ return;
2323
+ }
2324
+ const normalized = normalizePhone(phone);
2325
+ const senderNormalized = normalizePhone(message.from);
2326
+ if (normalized === senderNormalized) {
2327
+ await reply('You cannot remove yourself from the whitelist.');
2328
+ return;
2329
+ }
2330
+ const idx = whitelist.findIndex(w => normalizePhone(w) === normalized);
2331
+ if (idx === -1) {
2332
+ await reply(`${formatPhone(phone)} is not in the whitelist.`);
2333
+ return;
2334
+ }
2335
+ whitelist.splice(idx, 1);
2336
+ await this.persistWhitelist();
2337
+ await reply(`Removed ${formatPhone(phone)} from whitelist.`);
2338
+ return;
2339
+ }
2340
+
2341
+ await reply('Usage: /whitelist | /whitelist add <phone> | /whitelist remove <phone>');
2342
+ }
2343
+
2344
+ private async handleSkillsCommand(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2345
+ const skills = Array.from(this.skills.values());
2346
+ const sub = args[0]?.toLowerCase();
2347
+
2348
+ if (sub === 'info') {
2349
+ const name = args.slice(1).join(' ').trim();
2350
+ if (!name) {
2351
+ await reply('Usage: /skills info <name>');
2352
+ return;
2353
+ }
2354
+ const skill = this.skills.get(name);
2355
+ if (!skill) {
2356
+ const available = skills.map(s => s.name).join(', ') || 'none';
2357
+ await reply(`Skill "${name}" not found. Available: ${available}`);
2358
+ return;
2359
+ }
2360
+ const isPrompt = typeof (skill as any).getContent === 'function';
2361
+ if (isPrompt) {
2362
+ const cfg = (skill as any).getConfig?.() || {};
2363
+ const content = (skill as any).getContent() || '';
2364
+ const preview = content.length > 300 ? content.slice(0, 300) + '...' : content;
2365
+ const lines = [
2366
+ `📝 *Prompt Skill: ${skill.name}*`,
2367
+ ` Status: ${skill.isReady() ? 'ready' : 'not ready'}`,
2368
+ ` Type: prompt`,
2369
+ ];
2370
+ if (cfg.summary) lines.push(` Summary: ${cfg.summary}`);
2371
+ if (cfg.tags?.length) lines.push(` Tags: ${cfg.tags.join(', ')}`);
2372
+ if (cfg.author) lines.push(` Author: ${cfg.author}`);
2373
+ lines.push('', ' Content:', ` ${preview}`);
2374
+ await reply(lines.join('\n'));
2375
+ } else {
2376
+ const tools = Object.values(skill.tools);
2377
+ const lines = [
2378
+ `🔧 *Skill: ${skill.name}*`,
2379
+ ` Status: ${skill.isReady() ? 'ready' : 'not ready'}`,
2380
+ ` Tools: ${tools.length}`,
2381
+ ];
2382
+ for (const tool of tools) {
2383
+ lines.push('');
2384
+ lines.push(` *${tool.name}*`);
2385
+ if (tool.description) lines.push(` ${tool.description}`);
2386
+ const params = Object.entries(tool.parameters);
2387
+ if (params.length > 0) {
2388
+ lines.push(' Parameters:');
2389
+ for (const [pName, pDef] of params) {
2390
+ const p = pDef as Record<string, any>;
2391
+ const req = p.required ? ' (required)' : '';
2392
+ const type = p.type ? ` [${p.type}]` : '';
2393
+ lines.push(` ${pName}${type}${req}${p.description ? ' — ' + p.description : ''}`);
2394
+ }
2395
+ }
2396
+ }
2397
+ await reply(lines.join('\n'));
2398
+ }
2399
+ return;
2400
+ }
2401
+
2402
+ if (sub === 'catalog' || sub === 'browse') {
2403
+ await this.handleSkillCatalog(args.slice(1), reply);
2404
+ return;
2405
+ }
2406
+
2407
+ if (sub === 'add') {
2408
+ await this.handleSkillAdd(message, args.slice(1), reply);
2409
+ return;
2410
+ }
2411
+
2412
+ if (sub === 'remove') {
2413
+ await this.handleSkillRemove(args.slice(1), reply);
2414
+ return;
2415
+ }
2416
+
2417
+ // Default: list all skills
2418
+ if (skills.length === 0) {
2419
+ await reply('No skills registered. Use /skills catalog to browse available skills, or /create-skill to create a prompt skill.');
2420
+ return;
2421
+ }
2422
+ const lines = [`🔧 *Registered Skills (${skills.length}):*`];
2423
+ for (const skill of skills) {
2424
+ const status = skill.isReady() ? '✅' : '⏳';
2425
+ const isPrompt = typeof (skill as any).getContent === 'function';
2426
+ if (isPrompt) {
2427
+ lines.push(` ${status} 📝 ${skill.name} — prompt skill`);
2428
+ } else {
2429
+ const toolNames = Object.keys(skill.tools);
2430
+ lines.push(` ${status} ${skill.name} — ${toolNames.length} tool${toolNames.length !== 1 ? 's' : ''}: ${toolNames.join(', ')}`);
2431
+ }
2432
+ }
2433
+ lines.push('');
2434
+ lines.push('Use /skills info <name> for details, /skills catalog to browse, /skills add <name> to install, /create-skill to create a prompt skill.');
2435
+ await reply(lines.join('\n'));
2436
+ }
2437
+
2438
+ private async handleCreateSkillCommand(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2439
+ // Usage: /create-skill <name> | <content>
2440
+ const raw = args.join(' ');
2441
+ const pipeIdx = raw.indexOf('|');
2442
+ if (pipeIdx === -1 || !raw.trim()) {
2443
+ await reply('Usage: /create-skill <name> | <instructions>\n\nExample: /create-skill sentiment-monitor | Monitor customer sentiment and flag negative interactions for review.');
2444
+ return;
2445
+ }
2446
+
2447
+ const name = raw.slice(0, pipeIdx).trim();
2448
+ const content = raw.slice(pipeIdx + 1).trim();
2449
+ if (!name || !content) {
2450
+ await reply('Both name and content are required.\nUsage: /create-skill <name> | <instructions>');
2451
+ return;
2452
+ }
2453
+
2454
+ const mod = this.config.skillsModule;
2455
+ if (!mod) {
2456
+ await reply('Skills module not available. Make sure @operor/skills is installed.');
2457
+ return;
2458
+ }
2459
+
2460
+ try {
2461
+ const config = mod.loadSkillsConfig();
2462
+ const exists = config.skills.find((s: any) => s.name === name);
2463
+ if (exists) {
2464
+ await reply(`A skill named "${name}" already exists. Remove it first or choose a different name.`);
2465
+ return;
2466
+ }
2467
+
2468
+ const promptSkillConfig = { type: 'prompt' as const, name, content, enabled: true };
2469
+ config.skills.push(promptSkillConfig);
2470
+ mod.saveSkillsConfig(config);
2471
+
2472
+ // Auto-add skill to all agents' skills lists so it activates on hot-reload
2473
+ const agents = Array.from(this.agents.values());
2474
+ for (const agent of agents) {
2475
+ const cfg = agent.getConfig();
2476
+ if (cfg.skills && !cfg.skills.includes(name)) {
2477
+ cfg.skills.push(name);
2478
+ agent.config.skills = cfg.skills;
2479
+ }
2480
+ }
2481
+
2482
+ // Persist to INSTRUCTIONS.md frontmatter and save version snapshot
2483
+ for (const agent of agents) {
2484
+ await this.versionStore.saveVersion(agent.config.name, {
2485
+ timestamp: new Date().toISOString(),
2486
+ instructions: agent.config._rawInstructions,
2487
+ identity: agent.config._rawIdentity,
2488
+ soul: agent.config._rawSoul,
2489
+ frontmatter: this.extractFrontmatter(agent.config),
2490
+ editedBy: message.from,
2491
+ changeDescription: `add skills: ${name}`,
2492
+ });
2493
+ await this.writeAgentFrontmatter(agent.config.name, agent);
2494
+ }
2495
+
2496
+ await reply(`📝 Prompt skill "${name}" created and added to your agent(s).\nSaving to mcp.json triggers hot-reload — skill is now active.`);
2497
+ } catch (err: any) {
2498
+ await reply(`Failed to create skill: ${err.message}`);
2499
+ }
2500
+ }
2501
+
2502
+ private async handleUpdateSkillCommand(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2503
+ // Usage: /update-skill <name> | <content>
2504
+ const raw = args.join(' ');
2505
+ const pipeIdx = raw.indexOf('|');
2506
+ if (pipeIdx === -1 || !raw.trim()) {
2507
+ await reply('Usage: /update-skill <name> | <instructions>\n\nExample: /update-skill sentiment-monitor | Updated instructions for monitoring sentiment.');
2508
+ return;
2509
+ }
2510
+
2511
+ const name = raw.slice(0, pipeIdx).trim();
2512
+ const content = raw.slice(pipeIdx + 1).trim();
2513
+ if (!name || !content) {
2514
+ await reply('Both name and content are required.\nUsage: /update-skill <name> | <instructions>');
2515
+ return;
2516
+ }
2517
+
2518
+ const mod = this.config.skillsModule;
2519
+ if (!mod) {
2520
+ await reply('Skills module not available. Make sure @operor/skills is installed.');
2521
+ return;
2522
+ }
2523
+
2524
+ try {
2525
+ const config = mod.loadSkillsConfig();
2526
+ const existing = config.skills.find((s: any) => s.name === name);
2527
+ if (!existing) {
2528
+ await reply(`Skill "${name}" not found. Use /skills to list registered skills.`);
2529
+ return;
2530
+ }
2531
+ if (existing.type !== 'prompt') {
2532
+ await reply(`Skill "${name}" is not a prompt skill (type: ${existing.type}). Only prompt skills can be updated with this command.`);
2533
+ return;
2534
+ }
2535
+
2536
+ existing.content = content;
2537
+ mod.saveSkillsConfig(config);
2538
+
2539
+ // Save version snapshot
2540
+ const agents = Array.from(this.agents.values());
2541
+ for (const agent of agents) {
2542
+ await this.versionStore.saveVersion(agent.config.name, {
2543
+ timestamp: new Date().toISOString(),
2544
+ instructions: agent.config._rawInstructions,
2545
+ identity: agent.config._rawIdentity,
2546
+ soul: agent.config._rawSoul,
2547
+ frontmatter: this.extractFrontmatter(agent.config),
2548
+ editedBy: message.from,
2549
+ changeDescription: `update skill: ${name}`,
2550
+ });
2551
+ }
2552
+
2553
+ await reply(`📝 Prompt skill "${name}" updated.\nSaving to mcp.json triggers hot-reload — changes are now active.`);
2554
+ } catch (err: any) {
2555
+ await reply(`Failed to update skill: ${err.message}`);
2556
+ }
2557
+ }
2558
+
2559
+ private async handleSkillCatalog(args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2560
+ const mod = this.config.skillsModule;
2561
+ if (!mod) {
2562
+ await reply('Skill catalog is not available. Make sure @operor/skills is installed.');
2563
+ return;
2564
+ }
2565
+ const catalog = mod.loadSkillCatalog();
2566
+
2567
+ const filter = args[0]?.toLowerCase();
2568
+ const validCategories = ['commerce', 'payments', 'crm', 'support', 'marketing', 'search', 'communication', 'productivity'];
2569
+
2570
+ let entries = catalog.skills as any[];
2571
+ let categoryFilter: string | undefined;
2572
+ if (filter) {
2573
+ if (validCategories.includes(filter)) {
2574
+ categoryFilter = filter;
2575
+ entries = entries.filter((s: any) => s.category === filter);
2576
+ } else {
2577
+ // Text search fallback across name, description, category
2578
+ entries = entries.filter((s: any) =>
2579
+ s.name?.toLowerCase().includes(filter) ||
2580
+ s.description?.toLowerCase().includes(filter) ||
2581
+ s.category?.toLowerCase().includes(filter) ||
2582
+ s.displayName?.toLowerCase().includes(filter)
2583
+ );
2584
+ }
2585
+ }
2586
+
2587
+ if (entries.length === 0) {
2588
+ await reply(filter ? `No skills found matching "${filter}".` : 'Skill catalog is empty.');
2589
+ return;
2590
+ }
2591
+
2592
+ // Group by category
2593
+ const grouped: Record<string, any[]> = {};
2594
+ for (const entry of entries) {
2595
+ const cat = entry.category || 'other';
2596
+ if (!grouped[cat]) grouped[cat] = [];
2597
+ grouped[cat].push(entry);
2598
+ }
2599
+
2600
+ const lines = [`📦 *Skill Catalog (${entries.length} skills):*`];
2601
+ for (const [cat, catSkills] of Object.entries(grouped)) {
2602
+ lines.push('');
2603
+ lines.push(`*${cat.charAt(0).toUpperCase() + cat.slice(1)}:*`);
2604
+ for (const s of catSkills) {
2605
+ const installed = this.skills.has(s.name) ? ' ✅' : '';
2606
+ const badge = s.maturity === 'official' ? ' 🏷️' : '';
2607
+ const typeTag = s.type === 'prompt' ? ' 📝' : '';
2608
+ lines.push(` ${s.displayName || s.name}${badge}${typeTag}${installed} — ${s.description}`);
2609
+ lines.push(` → /skills add ${s.name}`);
2610
+ }
2611
+ }
2612
+ lines.push('');
2613
+ if (!categoryFilter) {
2614
+ lines.push(`Filter by category: /skills catalog <${validCategories.join('|')}>`);
2615
+ }
2616
+ await reply(lines.join('\n'));
2617
+ }
2618
+
2619
+ private async handleSkillAdd(message: IncomingMessage, args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2620
+ const name = args[0]?.trim();
2621
+ if (!name) {
2622
+ await reply('Usage: /skills add <name>\n\nBrowse available skills with /skills catalog');
2623
+ return;
2624
+ }
2625
+
2626
+ // Check if already installed (runtime or mcp.json)
2627
+ if (this.skills.has(name)) {
2628
+ await reply(`Skill "${name}" is already installed and active.`);
2629
+ return;
2630
+ }
2631
+
2632
+ // Look up in catalog
2633
+ const mod = this.config.skillsModule;
2634
+ if (!mod) {
2635
+ await reply('Skill catalog is not available. Make sure @operor/skills is installed.');
2636
+ return;
2637
+ }
2638
+
2639
+ // Also check mcp.json for already-configured skills
2640
+ const root = this.config.projectRoot;
2641
+ const existingConfig = mod.loadSkillsConfig(root);
2642
+ if (existingConfig.skills.some((s: any) => s.name === name)) {
2643
+ await reply(`Skill "${name}" already exists in mcp.json. Restart to activate.`);
2644
+ return;
2645
+ }
2646
+ const catalog = mod.loadSkillCatalog();
2647
+ const entry = mod.findSkillInCatalog(catalog, name);
2648
+ if (!entry) {
2649
+ // Suggest similar skills
2650
+ const allNames = catalog.skills.map((s: any) => s.name);
2651
+ const suggestions = allNames.filter((n: string) =>
2652
+ n.includes(name) || name.includes(n)
2653
+ );
2654
+ const msg = suggestions.length > 0
2655
+ ? `Skill "${name}" not found. Did you mean: ${suggestions.join(', ')}?`
2656
+ : `Skill "${name}" not found in catalog. Use /skills catalog to browse available skills.`;
2657
+ await reply(msg);
2658
+ return;
2659
+ }
2660
+
2661
+ // Prompt skills install directly — no env vars or tools
2662
+ if (entry.type === 'prompt') {
2663
+ await reply(`📝 *Adding prompt skill: ${entry.displayName}*\n ${entry.description}\n\nInstalling...`);
2664
+ await this.installSkillFromCatalog(entry, {}, reply);
2665
+ return;
2666
+ }
2667
+
2668
+ // Show skill info
2669
+ const envKeys = Object.keys(entry.envVars || {});
2670
+ const requiredKeys = envKeys.filter((k: string) => entry.envVars[k].required);
2671
+
2672
+ const lines = [
2673
+ `📦 *Adding: ${entry.displayName}*`,
2674
+ ` ${entry.description}`,
2675
+ ` Category: ${entry.category} | Maturity: ${entry.maturity}`,
2676
+ ` Tools: ${(entry.tools || []).map((t: any) => t.name).join(', ')}`,
2677
+ ];
2678
+
2679
+ if (entry.docsUrl) {
2680
+ lines.push(` Docs: ${entry.docsUrl}`);
2681
+ }
2682
+
2683
+ if (requiredKeys.length === 0) {
2684
+ // No env vars needed — install directly
2685
+ await reply(lines.join('\n') + '\n\nNo API keys required. Installing...');
2686
+ await this.installSkillFromCatalog(entry, {}, reply);
2687
+ return;
2688
+ }
2689
+
2690
+ // Start env var capture flow
2691
+ lines.push('');
2692
+ lines.push('*Required API keys:*');
2693
+ for (const key of requiredKeys) {
2694
+ const spec = entry.envVars[key];
2695
+ lines.push(` ${key} — ${spec.description}${spec.placeholder ? ` (e.g. ${spec.placeholder})` : ''}`);
2696
+ }
2697
+ lines.push('');
2698
+ lines.push(`Please send the value for *${requiredKeys[0]}*:`);
2699
+ lines.push('(Send "cancel" to abort)');
2700
+
2701
+ this.pendingSkillAdds.set(message.from, {
2702
+ skillName: name,
2703
+ envVars: {},
2704
+ pendingKeys: [...requiredKeys],
2705
+ currentKey: requiredKeys[0],
2706
+ catalogEntry: entry,
2707
+ createdAt: Date.now(),
2708
+ });
2709
+
2710
+ await reply(lines.join('\n'));
2711
+ }
2712
+
2713
+ private async handlePendingSkillAdd(message: IncomingMessage, pending: PendingSkillAdd): Promise<boolean> {
2714
+ const reply = async (text: string) => {
2715
+ await this.sendMessage(message.from, message.provider, { to: message.from, text });
2716
+ };
2717
+
2718
+ // Timeout after 5 minutes
2719
+ if (Date.now() - pending.createdAt > 5 * 60 * 1000) {
2720
+ this.pendingSkillAdds.delete(message.from);
2721
+ await reply('Skill add session expired (5 min timeout). Send /skills add <name> to try again.');
2722
+ return true;
2723
+ }
2724
+
2725
+ const text = message.text.trim();
2726
+
2727
+ if (text.toLowerCase() === 'cancel' || text.toLowerCase() === '/cancel') {
2728
+ this.pendingSkillAdds.delete(message.from);
2729
+ await reply('Skill installation cancelled.');
2730
+ return true;
2731
+ }
2732
+
2733
+ if (!pending.currentKey) {
2734
+ this.pendingSkillAdds.delete(message.from);
2735
+ return false;
2736
+ }
2737
+
2738
+ // Store the value for the current key
2739
+ pending.envVars[pending.currentKey] = text;
2740
+ pending.pendingKeys.shift();
2741
+
2742
+ if (pending.pendingKeys.length > 0) {
2743
+ // Ask for next key
2744
+ pending.currentKey = pending.pendingKeys[0];
2745
+ const spec = pending.catalogEntry.envVars[pending.currentKey];
2746
+ await reply(`Got it. Now send the value for *${pending.currentKey}*:${spec?.placeholder ? ` (e.g. ${spec.placeholder})` : ''}`);
2747
+ return true;
2748
+ }
2749
+
2750
+ // All keys collected — install
2751
+ this.pendingSkillAdds.delete(message.from);
2752
+ await reply('All keys received. Installing skill...');
2753
+ await this.installSkillFromCatalog(pending.catalogEntry, pending.envVars, reply);
2754
+ return true;
2755
+ }
2756
+
2757
+ private async installSkillFromCatalog(entry: any, envVars: Record<string, string>, reply: (t: string) => Promise<void>): Promise<void> {
2758
+ try {
2759
+ const mod = this.config.skillsModule;
2760
+ if (!mod) { await reply('Skill catalog is not available.'); return; }
2761
+ const config = mod.catalogEntryToConfig(entry);
2762
+
2763
+ // Merge actual env var values into the config
2764
+ if (config.env && Object.keys(envVars).length > 0) {
2765
+ for (const [key, value] of Object.entries(envVars)) {
2766
+ config.env[key] = value;
2767
+ }
2768
+ }
2769
+
2770
+ // Save to mcp.json
2771
+ const root = this.config.projectRoot;
2772
+ const skillsConfig = mod.loadSkillsConfig(root);
2773
+ const existingIdx = skillsConfig.skills.findIndex((s: any) => s.name === config.name);
2774
+ if (existingIdx >= 0) {
2775
+ skillsConfig.skills[existingIdx] = config;
2776
+ } else {
2777
+ skillsConfig.skills.push(config);
2778
+ }
2779
+ mod.saveSkillsConfig(skillsConfig, root);
2780
+
2781
+ // Add skill to all agents' skills lists and persist to INSTRUCTIONS.md
2782
+ const agents = Array.from(this.agents.values());
2783
+ for (const agent of agents) {
2784
+ const cfg = agent.getConfig();
2785
+ if (cfg.skills && !cfg.skills.includes(config.name)) {
2786
+ cfg.skills.push(config.name);
2787
+ agent.config.skills = cfg.skills;
2788
+ }
2789
+ await this.writeAgentFrontmatter(agent.config.name, agent);
2790
+ }
2791
+
2792
+ await reply(`✅ *${entry.displayName}* added to mcp.json and enabled.\n\nRestart the agent to activate the skill, or use /reload if available.`);
2793
+ } catch (err: any) {
2794
+ await reply(`Failed to install skill: ${err.message}`);
2795
+ }
2796
+ }
2797
+
2798
+ private async handleSkillRemove(args: string[], reply: (t: string) => Promise<void>): Promise<void> {
2799
+ const name = args[0]?.trim();
2800
+ if (!name) {
2801
+ await reply('Usage: /skills remove <name>');
2802
+ return;
2803
+ }
2804
+
2805
+ try {
2806
+ const mod = this.config.skillsModule;
2807
+ if (!mod) { await reply('Skill catalog is not available.'); return; }
2808
+ const root = this.config.projectRoot;
2809
+ const skillsConfig = mod.loadSkillsConfig(root);
2810
+ const idx = skillsConfig.skills.findIndex((s: any) => s.name === name);
2811
+
2812
+ if (idx < 0) {
2813
+ const available = skillsConfig.skills.map((s: any) => s.name).join(', ') || 'none';
2814
+ await reply(`Skill "${name}" not found in mcp.json. Configured: ${available}`);
2815
+ return;
2816
+ }
2817
+
2818
+ skillsConfig.skills.splice(idx, 1);
2819
+ mod.saveSkillsConfig(skillsConfig, root);
2820
+
2821
+ // Remove from runtime if loaded
2822
+ if (this.skills.has(name)) {
2823
+ await this.removeSkill(name);
2824
+ }
2825
+
2826
+ // Remove skill from all agents' skills lists and persist to INSTRUCTIONS.md
2827
+ const agents = Array.from(this.agents.values());
2828
+ for (const agent of agents) {
2829
+ const cfg = agent.getConfig();
2830
+ if (cfg.skills) {
2831
+ const skillIdx = cfg.skills.indexOf(name);
2832
+ if (skillIdx >= 0) {
2833
+ cfg.skills.splice(skillIdx, 1);
2834
+ agent.config.skills = cfg.skills;
2835
+ }
2836
+ }
2837
+ await this.writeAgentFrontmatter(agent.config.name, agent);
2838
+ }
2839
+
2840
+ await reply(`✅ Skill "${name}" removed from mcp.json.`);
2841
+ } catch (err: any) {
2842
+ await reply(`Failed to remove skill: ${err.message}`);
2843
+ }
2844
+ }
2845
+
2846
+ private async persistWhitelist(): Promise<void> {
2847
+ const whitelist = this.config.trainingMode?.whitelist;
2848
+ if (!whitelist) return;
2849
+ if (this.memory.setSetting) {
2850
+ await this.memory.setSetting('training_whitelist', whitelist.join(','));
2851
+ }
2852
+ }
2853
+
2854
+ /**
2855
+ * Format a date string (YYYY-MM-DD) to a day name (e.g. "Monday")
2856
+ */
2857
+ private formatDayName(dateStr: string): string {
2858
+ try {
2859
+ const d = new Date(dateStr + 'T00:00:00');
2860
+ return d.toLocaleDateString('en-US', { weekday: 'long' });
2861
+ } catch {
2862
+ return dateStr;
2863
+ }
2864
+ }
2865
+
2866
+ /**
2867
+ * Log helper
2868
+ */
2869
+ private getFirstAgent(): Agent | null {
2870
+ const agents = Array.from(this.agents.values());
2871
+ return agents[0] ?? null;
2872
+ }
2873
+
2874
+ private rebuildAgentSystemPrompt(agent: Agent): void {
2875
+ agent.config.systemPrompt = buildSystemPrompt({
2876
+ identity: agent.config._rawIdentity || null,
2877
+ soul: agent.config._rawSoul || null,
2878
+ body: agent.config._rawInstructions || '',
2879
+ userContext: null,
2880
+ guardrails: agent.config.guardrails,
2881
+ });
2882
+ }
2883
+
2884
+ private async writeAgentFiles(agentName: string, agent: Agent): Promise<void> {
2885
+ const agentsDir = this.config.agentsDir;
2886
+ if (!agentsDir) return;
2887
+ const agentDir = path.join(agentsDir, agentName);
2888
+ await fs.mkdir(agentDir, { recursive: true });
2889
+
2890
+ // Write INSTRUCTIONS.md with frontmatter
2891
+ const fm = this.extractFrontmatter(agent.config);
2892
+ const yamlFm = yaml.dump(fm, { lineWidth: -1, noRefs: true }).trim();
2893
+ const body = agent.config._rawInstructions || '';
2894
+ await fs.writeFile(path.join(agentDir, 'INSTRUCTIONS.md'), `---\n${yamlFm}\n---\n\n${body.trim()}\n`, 'utf-8');
2895
+
2896
+ if (agent.config._rawIdentity) {
2897
+ await fs.writeFile(path.join(agentDir, 'IDENTITY.md'), agent.config._rawIdentity.trim() + '\n', 'utf-8');
2898
+ }
2899
+ if (agent.config._rawSoul) {
2900
+ await fs.writeFile(path.join(agentDir, 'SOUL.md'), agent.config._rawSoul.trim() + '\n', 'utf-8');
2901
+ }
2902
+ }
2903
+
2904
+ private async writeAgentFrontmatter(agentName: string, agent: Agent): Promise<void> {
2905
+ const agentsDir = this.config.agentsDir;
2906
+ if (!agentsDir) return;
2907
+ const agentDir = path.join(agentsDir, agentName);
2908
+ await fs.mkdir(agentDir, { recursive: true });
2909
+ const fm = this.extractFrontmatter(agent.config);
2910
+ const yamlFm = yaml.dump(fm, { lineWidth: -1, noRefs: true }).trim();
2911
+ const body = agent.config._rawInstructions || '';
2912
+ await fs.writeFile(path.join(agentDir, 'INSTRUCTIONS.md'), `---\n${yamlFm}\n---\n\n${body.trim()}\n`, 'utf-8');
2913
+ }
2914
+
2915
+ private extractSection(text: string, sectionName: string): string | null {
2916
+ const sections = this.parseSections(text);
2917
+ const match = sections.find(s => s.heading.toLowerCase().includes(sectionName.toLowerCase()));
2918
+ return match?.content || null;
2919
+ }
2920
+
2921
+ private replaceSection(text: string, sectionName: string, newContent: string): string {
2922
+ const sections = this.parseSections(text);
2923
+ const idx = sections.findIndex(s => s.heading.toLowerCase().includes(sectionName.toLowerCase()));
2924
+ if (idx === -1) return text;
2925
+ sections[idx].content = newContent;
2926
+ return sections.map(s => `${s.heading}\n\n${s.content}`).join('\n\n');
2927
+ }
2928
+
2929
+ private parseSections(text: string): Array<{ heading: string; content: string }> {
2930
+ const lines = text.split('\n');
2931
+ const sections: Array<{ heading: string; content: string }> = [];
2932
+ let currentHeading: string | null = null;
2933
+ let currentContent: string[] = [];
2934
+
2935
+ for (const line of lines) {
2936
+ if (line.match(/^##\s+/)) {
2937
+ if (currentHeading) {
2938
+ sections.push({ heading: currentHeading, content: currentContent.join('\n').trim() });
2939
+ }
2940
+ currentHeading = line;
2941
+ currentContent = [];
2942
+ } else if (currentHeading) {
2943
+ currentContent.push(line);
2944
+ }
2945
+ }
2946
+
2947
+ if (currentHeading) {
2948
+ sections.push({ heading: currentHeading, content: currentContent.join('\n').trim() });
2949
+ }
2950
+
2951
+ return sections;
2952
+ }
2953
+
2954
+ private extractFrontmatter(config: AgentConfig): Record<string, any> {
2955
+ const fm: Record<string, any> = { name: config.name };
2956
+ if (config.purpose) fm.purpose = config.purpose;
2957
+ if (config.triggers?.length) fm.triggers = config.triggers;
2958
+ if (config.channels?.length) fm.channels = config.channels;
2959
+ if (config.skills?.length) fm.skills = config.skills;
2960
+ if (config.knowledgeBase != null) fm.knowledgeBase = config.knowledgeBase;
2961
+ if (config.priority != null) fm.priority = config.priority;
2962
+ if (config.escalateTo) fm.escalateTo = config.escalateTo;
2963
+ if (config.guardrails) fm.guardrails = config.guardrails;
2964
+ return fm;
2965
+ }
2966
+
2967
+ private log(message: string): void {
2968
+ if (this.config.debug) {
2969
+ console.log(`[Operor] ${message}`);
2970
+ }
2971
+ }
2972
+ }