kernelbot 1.0.26 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.env.example +4 -0
  2. package/README.md +198 -124
  3. package/bin/kernel.js +208 -4
  4. package/config.example.yaml +14 -1
  5. package/package.json +1 -1
  6. package/src/agent.js +839 -209
  7. package/src/automation/automation-manager.js +377 -0
  8. package/src/automation/automation.js +79 -0
  9. package/src/automation/index.js +2 -0
  10. package/src/automation/scheduler.js +141 -0
  11. package/src/bot.js +1001 -18
  12. package/src/claude-auth.js +93 -0
  13. package/src/coder.js +48 -6
  14. package/src/conversation.js +33 -0
  15. package/src/intents/detector.js +50 -0
  16. package/src/intents/index.js +2 -0
  17. package/src/intents/planner.js +58 -0
  18. package/src/persona.js +68 -0
  19. package/src/prompts/orchestrator.js +124 -0
  20. package/src/prompts/persona.md +21 -0
  21. package/src/prompts/system.js +59 -6
  22. package/src/prompts/workers.js +148 -0
  23. package/src/providers/anthropic.js +23 -16
  24. package/src/providers/base.js +76 -2
  25. package/src/providers/index.js +1 -0
  26. package/src/providers/models.js +2 -1
  27. package/src/providers/openai-compat.js +5 -3
  28. package/src/security/audit.js +0 -0
  29. package/src/security/auth.js +0 -0
  30. package/src/security/confirm.js +7 -2
  31. package/src/self.js +122 -0
  32. package/src/services/stt.js +139 -0
  33. package/src/services/tts.js +124 -0
  34. package/src/skills/catalog.js +506 -0
  35. package/src/skills/custom.js +128 -0
  36. package/src/swarm/job-manager.js +216 -0
  37. package/src/swarm/job.js +85 -0
  38. package/src/swarm/worker-registry.js +79 -0
  39. package/src/tools/browser.js +458 -335
  40. package/src/tools/categories.js +3 -3
  41. package/src/tools/coding.js +5 -0
  42. package/src/tools/docker.js +0 -0
  43. package/src/tools/git.js +0 -0
  44. package/src/tools/github.js +0 -0
  45. package/src/tools/index.js +3 -0
  46. package/src/tools/jira.js +0 -0
  47. package/src/tools/monitor.js +0 -0
  48. package/src/tools/network.js +0 -0
  49. package/src/tools/orchestrator-tools.js +428 -0
  50. package/src/tools/os.js +14 -1
  51. package/src/tools/persona.js +32 -0
  52. package/src/tools/process.js +0 -0
  53. package/src/utils/config.js +153 -15
  54. package/src/utils/display.js +0 -0
  55. package/src/utils/logger.js +0 -0
  56. package/src/worker.js +396 -0
  57. package/.agents/skills/interface-design/SKILL.md +0 -391
  58. package/.agents/skills/interface-design/references/critique.md +0 -67
  59. package/.agents/skills/interface-design/references/example.md +0 -86
  60. package/.agents/skills/interface-design/references/principles.md +0 -235
  61. package/.agents/skills/interface-design/references/validation.md +0 -48
package/src/bot.js CHANGED
@@ -1,8 +1,20 @@
1
1
  import TelegramBot from 'node-telegram-bot-api';
2
- import { createReadStream } from 'fs';
2
+ import { createReadStream, readFileSync } from 'fs';
3
3
  import { isAllowedUser, getUnauthorizedMessage } from './security/auth.js';
4
4
  import { getLogger } from './utils/logger.js';
5
5
  import { PROVIDERS } from './providers/models.js';
6
+ import {
7
+ getUnifiedSkillById,
8
+ getUnifiedCategoryList,
9
+ getUnifiedSkillsByCategory,
10
+ loadCustomSkills,
11
+ addCustomSkill,
12
+ deleteCustomSkill,
13
+ getCustomSkills,
14
+ } from './skills/custom.js';
15
+ import { TTSService } from './services/tts.js';
16
+ import { STTService } from './services/stt.js';
17
+ import { getClaudeAuthStatus, claudeLogout } from './claude-auth.js';
6
18
 
7
19
  function splitMessage(text, maxLength = 4096) {
8
20
  if (text.length <= maxLength) return [text];
@@ -41,12 +53,18 @@ class ChatQueue {
41
53
  }
42
54
  }
43
55
 
44
- export function startBot(config, agent, conversationManager) {
56
+ export function startBot(config, agent, conversationManager, jobManager, automationManager) {
45
57
  const logger = getLogger();
46
58
  const bot = new TelegramBot(config.telegram.bot_token, { polling: true });
47
59
  const chatQueue = new ChatQueue();
48
60
  const batchWindowMs = config.telegram.batch_window_ms || 3000;
49
61
 
62
+ // Initialize voice services
63
+ const ttsService = new TTSService(config);
64
+ const sttService = new STTService(config);
65
+ if (ttsService.isAvailable()) logger.info('[Bot] TTS service enabled (ElevenLabs)');
66
+ if (sttService.isAvailable()) logger.info('[Bot] STT service enabled');
67
+
50
68
  // Per-chat message batching: chatId -> { messages[], timer, resolve }
51
69
  const chatBatches = new Map();
52
70
 
@@ -56,11 +74,108 @@ export function startBot(config, agent, conversationManager) {
56
74
  logger.info('Loaded previous conversations from disk');
57
75
  }
58
76
 
77
+ // Load custom skills from disk
78
+ loadCustomSkills();
79
+
80
+ // Register commands in Telegram's menu button
81
+ bot.setMyCommands([
82
+ { command: 'brain', description: 'Switch worker AI model/provider' },
83
+ { command: 'orchestrator', description: 'Switch orchestrator AI model/provider' },
84
+ { command: 'claudemodel', description: 'Switch Claude Code model' },
85
+ { command: 'claude', description: 'Manage Claude Code authentication' },
86
+ { command: 'skills', description: 'Browse and activate persona skills' },
87
+ { command: 'jobs', description: 'List running and recent jobs' },
88
+ { command: 'cancel', description: 'Cancel running job(s)' },
89
+ { command: 'auto', description: 'Manage recurring automations' },
90
+ { command: 'context', description: 'Show all models, auth, and context info' },
91
+ { command: 'clean', description: 'Clear conversation and start fresh' },
92
+ { command: 'history', description: 'Show message count in memory' },
93
+ { command: 'help', description: 'Show all available commands' },
94
+ ]).catch((err) => logger.warn(`Failed to set bot commands menu: ${err.message}`));
95
+
59
96
  logger.info('Telegram bot started with polling');
60
97
 
98
+ // Initialize automation manager with bot context
99
+ if (automationManager) {
100
+ const sendMsg = async (chatId, text) => {
101
+ try {
102
+ await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
103
+ } catch {
104
+ await bot.sendMessage(chatId, text);
105
+ }
106
+ };
107
+
108
+ const sendAction = (chatId, action) => bot.sendChatAction(chatId, action).catch(() => {});
109
+
110
+ const agentFactory = (chatId) => {
111
+ const onUpdate = async (update, opts = {}) => {
112
+ if (opts.editMessageId) {
113
+ try {
114
+ const edited = await bot.editMessageText(update, {
115
+ chat_id: chatId,
116
+ message_id: opts.editMessageId,
117
+ parse_mode: 'Markdown',
118
+ });
119
+ return edited.message_id;
120
+ } catch {
121
+ try {
122
+ const edited = await bot.editMessageText(update, {
123
+ chat_id: chatId,
124
+ message_id: opts.editMessageId,
125
+ });
126
+ return edited.message_id;
127
+ } catch {
128
+ return opts.editMessageId;
129
+ }
130
+ }
131
+ }
132
+ const parts = splitMessage(update);
133
+ let lastMsgId = null;
134
+ for (const part of parts) {
135
+ try {
136
+ const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
137
+ lastMsgId = sent.message_id;
138
+ } catch {
139
+ const sent = await bot.sendMessage(chatId, part);
140
+ lastMsgId = sent.message_id;
141
+ }
142
+ }
143
+ return lastMsgId;
144
+ };
145
+
146
+ const sendPhoto = async (filePath, caption) => {
147
+ const fileOpts = { contentType: 'image/png' };
148
+ try {
149
+ await bot.sendPhoto(chatId, createReadStream(filePath), { caption: caption || '', parse_mode: 'Markdown' }, fileOpts);
150
+ } catch {
151
+ try {
152
+ await bot.sendPhoto(chatId, createReadStream(filePath), { caption: caption || '' }, fileOpts);
153
+ } catch (err) {
154
+ logger.error(`[Automation] Failed to send photo: ${err.message}`);
155
+ }
156
+ }
157
+ };
158
+
159
+ return { agent, onUpdate, sendPhoto };
160
+ };
161
+
162
+ automationManager.init({ sendMessage: sendMsg, sendChatAction: sendAction, agentFactory, config });
163
+ automationManager.startAll();
164
+ logger.info('[Bot] Automation manager initialized and started');
165
+ }
166
+
61
167
  // Track pending brain API key input: chatId -> { providerKey, modelId }
62
168
  const pendingBrainKey = new Map();
63
169
 
170
+ // Track pending orchestrator API key input: chatId -> { providerKey, modelId }
171
+ const pendingOrchKey = new Map();
172
+
173
+ // Track pending Claude Code auth input: chatId -> { type: 'api_key' | 'oauth_token' }
174
+ const pendingClaudeAuth = new Map();
175
+
176
+ // Track pending custom skill creation: chatId -> { step: 'name' | 'prompt', name?: string }
177
+ const pendingCustomSkill = new Map();
178
+
64
179
  // Handle inline keyboard callbacks for /brain
65
180
  bot.on('callback_query', async (query) => {
66
181
  const chatId = query.message.chat.id;
@@ -72,6 +187,8 @@ export function startBot(config, agent, conversationManager) {
72
187
  }
73
188
 
74
189
  try {
190
+ logger.info(`[Bot] Callback query from chat ${chatId}: ${data}`);
191
+
75
192
  if (data.startsWith('brain_provider:')) {
76
193
  // User picked a provider — show model list
77
194
  const providerKey = data.split(':')[1];
@@ -102,12 +219,35 @@ export function startBot(config, agent, conversationManager) {
102
219
  const modelEntry = providerDef?.models.find((m) => m.id === modelId);
103
220
  const modelLabel = modelEntry ? modelEntry.label : modelId;
104
221
 
105
- const missing = agent.switchBrain(providerKey, modelId);
106
- if (missing) {
222
+ await bot.editMessageText(
223
+ `⏳ Verifying *${providerDef.name}* / *${modelLabel}*...`,
224
+ {
225
+ chat_id: chatId,
226
+ message_id: query.message.message_id,
227
+ parse_mode: 'Markdown',
228
+ },
229
+ );
230
+
231
+ logger.info(`[Bot] Brain switch request: ${providerKey}/${modelId} from chat ${chatId}`);
232
+ const result = await agent.switchBrain(providerKey, modelId);
233
+ if (result && typeof result === 'object' && result.error) {
234
+ // Validation failed — keep current model
235
+ logger.warn(`[Bot] Brain switch failed: ${result.error}`);
236
+ const current = agent.getBrainInfo();
237
+ await bot.editMessageText(
238
+ `❌ Failed to switch: ${result.error}\n\nKeeping *${current.providerName}* / *${current.modelLabel}*`,
239
+ {
240
+ chat_id: chatId,
241
+ message_id: query.message.message_id,
242
+ parse_mode: 'Markdown',
243
+ },
244
+ );
245
+ } else if (result) {
107
246
  // API key missing — ask for it
247
+ logger.info(`[Bot] Brain switch needs API key: ${result} for ${providerKey}/${modelId}`);
108
248
  pendingBrainKey.set(chatId, { providerKey, modelId });
109
249
  await bot.editMessageText(
110
- `🔑 *${providerDef.name}* API key is required.\n\nPlease send your \`${missing}\` now.\n\nOr send *cancel* to abort.`,
250
+ `🔑 *${providerDef.name}* API key is required.\n\nPlease send your \`${result}\` now.\n\nOr send *cancel* to abort.`,
111
251
  {
112
252
  chat_id: chatId,
113
253
  message_id: query.message.message_id,
@@ -116,6 +256,7 @@ export function startBot(config, agent, conversationManager) {
116
256
  );
117
257
  } else {
118
258
  const info = agent.getBrainInfo();
259
+ logger.info(`[Bot] Brain switched successfully to ${info.providerName}/${info.modelLabel}`);
119
260
  await bot.editMessageText(
120
261
  `🧠 Brain switched to *${info.providerName}* / *${info.modelLabel}*`,
121
262
  {
@@ -134,9 +275,350 @@ export function startBot(config, agent, conversationManager) {
134
275
  message_id: query.message.message_id,
135
276
  });
136
277
  await bot.answerCallbackQuery(query.id);
278
+
279
+ // ── Skill callbacks ──────────────────────────────────────────
280
+ } else if (data.startsWith('skill_category:')) {
281
+ const categoryKey = data.split(':')[1];
282
+ const skills = getUnifiedSkillsByCategory(categoryKey);
283
+ const categories = getUnifiedCategoryList();
284
+ const cat = categories.find((c) => c.key === categoryKey);
285
+ if (!skills.length) {
286
+ await bot.answerCallbackQuery(query.id, { text: 'No skills in this category' });
287
+ return;
288
+ }
289
+
290
+ const activeSkill = agent.getActiveSkill(chatId);
291
+ const buttons = skills.map((s) => ([{
292
+ text: `${s.emoji} ${s.name}${activeSkill && activeSkill.id === s.id ? ' ✓' : ''}`,
293
+ callback_data: `skill_select:${s.id}`,
294
+ }]));
295
+ buttons.push([
296
+ { text: '« Back', callback_data: 'skill_back' },
297
+ { text: 'Cancel', callback_data: 'skill_cancel' },
298
+ ]);
299
+
300
+ await bot.editMessageText(
301
+ `${cat ? cat.emoji : ''} *${cat ? cat.name : categoryKey}* — select a skill:`,
302
+ {
303
+ chat_id: chatId,
304
+ message_id: query.message.message_id,
305
+ parse_mode: 'Markdown',
306
+ reply_markup: { inline_keyboard: buttons },
307
+ },
308
+ );
309
+ await bot.answerCallbackQuery(query.id);
310
+
311
+ } else if (data.startsWith('skill_select:')) {
312
+ const skillId = data.split(':')[1];
313
+ const skill = getUnifiedSkillById(skillId);
314
+ if (!skill) {
315
+ logger.warn(`[Bot] Unknown skill selected: ${skillId}`);
316
+ await bot.answerCallbackQuery(query.id, { text: 'Unknown skill' });
317
+ return;
318
+ }
319
+
320
+ logger.info(`[Bot] Skill activated: ${skill.name} (${skillId}) for chat ${chatId}`);
321
+ agent.setSkill(chatId, skillId);
322
+ await bot.editMessageText(
323
+ `${skill.emoji} *${skill.name}* activated!\n\n_${skill.description}_\n\nThe agent will now respond as this persona. Use /skills reset to return to default.`,
324
+ {
325
+ chat_id: chatId,
326
+ message_id: query.message.message_id,
327
+ parse_mode: 'Markdown',
328
+ },
329
+ );
330
+ await bot.answerCallbackQuery(query.id);
331
+
332
+ } else if (data === 'skill_reset') {
333
+ logger.info(`[Bot] Skill reset for chat ${chatId}`);
334
+ agent.clearSkill(chatId);
335
+ await bot.editMessageText('🔄 Skill cleared — back to default persona.', {
336
+ chat_id: chatId,
337
+ message_id: query.message.message_id,
338
+ });
339
+ await bot.answerCallbackQuery(query.id);
340
+
341
+ } else if (data === 'skill_custom_add') {
342
+ pendingCustomSkill.set(chatId, { step: 'name' });
343
+ await bot.editMessageText(
344
+ '✏️ Send me a *name* for your custom skill:',
345
+ {
346
+ chat_id: chatId,
347
+ message_id: query.message.message_id,
348
+ parse_mode: 'Markdown',
349
+ },
350
+ );
351
+ await bot.answerCallbackQuery(query.id);
352
+
353
+ } else if (data === 'skill_custom_manage') {
354
+ const customs = getCustomSkills();
355
+ if (!customs.length) {
356
+ await bot.answerCallbackQuery(query.id, { text: 'No custom skills yet' });
357
+ return;
358
+ }
359
+ const buttons = customs.map((s) => ([{
360
+ text: `🗑️ ${s.name}`,
361
+ callback_data: `skill_custom_delete:${s.id}`,
362
+ }]));
363
+ buttons.push([{ text: '« Back', callback_data: 'skill_back' }]);
364
+
365
+ await bot.editMessageText('🛠️ *Custom Skills* — tap to delete:', {
366
+ chat_id: chatId,
367
+ message_id: query.message.message_id,
368
+ parse_mode: 'Markdown',
369
+ reply_markup: { inline_keyboard: buttons },
370
+ });
371
+ await bot.answerCallbackQuery(query.id);
372
+
373
+ } else if (data.startsWith('skill_custom_delete:')) {
374
+ const skillId = data.slice('skill_custom_delete:'.length);
375
+ logger.info(`[Bot] Custom skill delete request: ${skillId} from chat ${chatId}`);
376
+ const activeSkill = agent.getActiveSkill(chatId);
377
+ if (activeSkill && activeSkill.id === skillId) {
378
+ logger.info(`[Bot] Clearing active skill before deletion: ${skillId}`);
379
+ agent.clearSkill(chatId);
380
+ }
381
+ const deleted = deleteCustomSkill(skillId);
382
+ const msg = deleted ? '🗑️ Custom skill deleted.' : 'Skill not found.';
383
+ await bot.editMessageText(msg, {
384
+ chat_id: chatId,
385
+ message_id: query.message.message_id,
386
+ });
387
+ await bot.answerCallbackQuery(query.id);
388
+
389
+ } else if (data === 'skill_back') {
390
+ // Re-show category list
391
+ const categories = getUnifiedCategoryList();
392
+ const activeSkill = agent.getActiveSkill(chatId);
393
+ const buttons = categories.map((cat) => ([{
394
+ text: `${cat.emoji} ${cat.name} (${cat.count})`,
395
+ callback_data: `skill_category:${cat.key}`,
396
+ }]));
397
+ // Custom skill management row
398
+ const customRow = [{ text: '➕ Add Custom', callback_data: 'skill_custom_add' }];
399
+ if (getCustomSkills().length > 0) {
400
+ customRow.push({ text: '🗑️ Manage Custom', callback_data: 'skill_custom_manage' });
401
+ }
402
+ buttons.push(customRow);
403
+ const footerRow = [{ text: 'Cancel', callback_data: 'skill_cancel' }];
404
+ if (activeSkill) {
405
+ footerRow.unshift({ text: '🔄 Reset to Default', callback_data: 'skill_reset' });
406
+ }
407
+ buttons.push(footerRow);
408
+
409
+ const header = activeSkill
410
+ ? `🎭 *Active skill:* ${activeSkill.emoji} ${activeSkill.name}\n\nSelect a category:`
411
+ : '🎭 *Skills* — select a category:';
412
+
413
+ await bot.editMessageText(header, {
414
+ chat_id: chatId,
415
+ message_id: query.message.message_id,
416
+ parse_mode: 'Markdown',
417
+ reply_markup: { inline_keyboard: buttons },
418
+ });
419
+ await bot.answerCallbackQuery(query.id);
420
+
421
+ } else if (data === 'skill_cancel') {
422
+ await bot.editMessageText('Skill selection cancelled.', {
423
+ chat_id: chatId,
424
+ message_id: query.message.message_id,
425
+ });
426
+ await bot.answerCallbackQuery(query.id);
427
+
428
+ // ── Job cancellation callbacks ────────────────────────────────
429
+ } else if (data.startsWith('cancel_job:')) {
430
+ const jobId = data.slice('cancel_job:'.length);
431
+ logger.info(`[Bot] Job cancel request via callback: ${jobId} from chat ${chatId}`);
432
+ const job = jobManager.cancelJob(jobId);
433
+ if (job) {
434
+ logger.info(`[Bot] Job cancelled via callback: ${jobId} [${job.workerType}]`);
435
+ await bot.editMessageText(`🚫 Cancelled job \`${jobId}\` (${job.workerType})`, {
436
+ chat_id: chatId,
437
+ message_id: query.message.message_id,
438
+ parse_mode: 'Markdown',
439
+ });
440
+ } else {
441
+ await bot.editMessageText(`Job \`${jobId}\` not found or already finished.`, {
442
+ chat_id: chatId,
443
+ message_id: query.message.message_id,
444
+ parse_mode: 'Markdown',
445
+ });
446
+ }
447
+ await bot.answerCallbackQuery(query.id);
448
+
449
+ } else if (data === 'cancel_all_jobs') {
450
+ logger.info(`[Bot] Cancel all jobs request via callback from chat ${chatId}`);
451
+ const cancelled = jobManager.cancelAllForChat(chatId);
452
+ const msg = cancelled.length > 0
453
+ ? `🚫 Cancelled ${cancelled.length} job(s).`
454
+ : 'No running jobs to cancel.';
455
+ await bot.editMessageText(msg, {
456
+ chat_id: chatId,
457
+ message_id: query.message.message_id,
458
+ });
459
+ await bot.answerCallbackQuery(query.id);
460
+
461
+ // ── Automation callbacks ─────────────────────────────────────────
462
+ } else if (data.startsWith('auto_pause:')) {
463
+ const autoId = data.slice('auto_pause:'.length);
464
+ logger.info(`[Bot] Automation pause request: ${autoId} from chat ${chatId}`);
465
+ const auto = automationManager?.update(autoId, { enabled: false });
466
+ const msg = auto ? `⏸️ Paused automation \`${autoId}\` (${auto.name})` : `Automation \`${autoId}\` not found.`;
467
+ await bot.editMessageText(msg, { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' });
468
+ await bot.answerCallbackQuery(query.id);
469
+
470
+ } else if (data.startsWith('auto_resume:')) {
471
+ const autoId = data.slice('auto_resume:'.length);
472
+ logger.info(`[Bot] Automation resume request: ${autoId} from chat ${chatId}`);
473
+ const auto = automationManager?.update(autoId, { enabled: true });
474
+ const msg = auto ? `▶️ Resumed automation \`${autoId}\` (${auto.name})` : `Automation \`${autoId}\` not found.`;
475
+ await bot.editMessageText(msg, { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' });
476
+ await bot.answerCallbackQuery(query.id);
477
+
478
+ } else if (data.startsWith('auto_delete:')) {
479
+ const autoId = data.slice('auto_delete:'.length);
480
+ logger.info(`[Bot] Automation delete request: ${autoId} from chat ${chatId}`);
481
+ const deleted = automationManager?.delete(autoId);
482
+ const msg = deleted ? `🗑️ Deleted automation \`${autoId}\`` : `Automation \`${autoId}\` not found.`;
483
+ await bot.editMessageText(msg, { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' });
484
+ await bot.answerCallbackQuery(query.id);
485
+
486
+ // ── Orchestrator callbacks ─────────────────────────────────────
487
+ } else if (data.startsWith('orch_provider:')) {
488
+ const providerKey = data.split(':')[1];
489
+ const providerDef = PROVIDERS[providerKey];
490
+ if (!providerDef) {
491
+ await bot.answerCallbackQuery(query.id, { text: 'Unknown provider' });
492
+ return;
493
+ }
494
+
495
+ const modelButtons = providerDef.models.map((m) => ([{
496
+ text: m.label,
497
+ callback_data: `orch_model:${providerKey}:${m.id}`,
498
+ }]));
499
+ modelButtons.push([{ text: 'Cancel', callback_data: 'orch_cancel' }]);
500
+
501
+ await bot.editMessageText(`Select a *${providerDef.name}* model for orchestrator:`, {
502
+ chat_id: chatId,
503
+ message_id: query.message.message_id,
504
+ parse_mode: 'Markdown',
505
+ reply_markup: { inline_keyboard: modelButtons },
506
+ });
507
+ await bot.answerCallbackQuery(query.id);
508
+
509
+ } else if (data.startsWith('orch_model:')) {
510
+ const [, providerKey, modelId] = data.split(':');
511
+ const providerDef = PROVIDERS[providerKey];
512
+ const modelEntry = providerDef?.models.find((m) => m.id === modelId);
513
+ const modelLabel = modelEntry ? modelEntry.label : modelId;
514
+
515
+ await bot.editMessageText(
516
+ `⏳ Verifying *${providerDef.name}* / *${modelLabel}* for orchestrator...`,
517
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
518
+ );
519
+
520
+ logger.info(`[Bot] Orchestrator switch request: ${providerKey}/${modelId} from chat ${chatId}`);
521
+ const result = await agent.switchOrchestrator(providerKey, modelId);
522
+ if (result && typeof result === 'object' && result.error) {
523
+ const current = agent.getOrchestratorInfo();
524
+ await bot.editMessageText(
525
+ `❌ Failed to switch: ${result.error}\n\nKeeping *${current.providerName}* / *${current.modelLabel}*`,
526
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
527
+ );
528
+ } else if (result) {
529
+ // API key missing
530
+ logger.info(`[Bot] Orchestrator switch needs API key: ${result} for ${providerKey}/${modelId}`);
531
+ pendingOrchKey.set(chatId, { providerKey, modelId });
532
+ await bot.editMessageText(
533
+ `🔑 *${providerDef.name}* API key is required.\n\nPlease send your \`${result}\` now.\n\nOr send *cancel* to abort.`,
534
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
535
+ );
536
+ } else {
537
+ const info = agent.getOrchestratorInfo();
538
+ await bot.editMessageText(
539
+ `🎛️ Orchestrator switched to *${info.providerName}* / *${info.modelLabel}*`,
540
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
541
+ );
542
+ }
543
+ await bot.answerCallbackQuery(query.id);
544
+
545
+ } else if (data === 'orch_cancel') {
546
+ pendingOrchKey.delete(chatId);
547
+ await bot.editMessageText('Orchestrator change cancelled.', {
548
+ chat_id: chatId, message_id: query.message.message_id,
549
+ });
550
+ await bot.answerCallbackQuery(query.id);
551
+
552
+ // ── Claude Code model callbacks ────────────────────────────────
553
+ } else if (data.startsWith('ccmodel:')) {
554
+ const modelId = data.slice('ccmodel:'.length);
555
+ agent.switchClaudeCodeModel(modelId);
556
+ const info = agent.getClaudeCodeInfo();
557
+ logger.info(`[Bot] Claude Code model switched to ${info.modelLabel} from chat ${chatId}`);
558
+ await bot.editMessageText(
559
+ `💻 Claude Code model switched to *${info.modelLabel}*`,
560
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
561
+ );
562
+ await bot.answerCallbackQuery(query.id);
563
+
564
+ } else if (data === 'ccmodel_cancel') {
565
+ await bot.editMessageText('Claude Code model change cancelled.', {
566
+ chat_id: chatId, message_id: query.message.message_id,
567
+ });
568
+ await bot.answerCallbackQuery(query.id);
569
+
570
+ // ── Claude Code auth callbacks ─────────────────────────────────
571
+ } else if (data === 'claude_apikey') {
572
+ pendingClaudeAuth.set(chatId, { type: 'api_key' });
573
+ await bot.editMessageText(
574
+ '🔑 Send your *Anthropic API key* for Claude Code.\n\nOr send *cancel* to abort.',
575
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
576
+ );
577
+ await bot.answerCallbackQuery(query.id);
578
+
579
+ } else if (data === 'claude_oauth') {
580
+ pendingClaudeAuth.set(chatId, { type: 'oauth_token' });
581
+ await bot.editMessageText(
582
+ '🔑 Run `claude setup-token` locally and paste the *OAuth token* here.\n\nThis uses your Pro/Max subscription instead of an API key.\n\nOr send *cancel* to abort.',
583
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
584
+ );
585
+ await bot.answerCallbackQuery(query.id);
586
+
587
+ } else if (data === 'claude_system') {
588
+ agent.setClaudeCodeAuth('system', null);
589
+ logger.info(`[Bot] Claude Code auth set to system from chat ${chatId}`);
590
+ await bot.editMessageText(
591
+ '🔓 Claude Code set to *system auth* — using host machine credentials.',
592
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
593
+ );
594
+ await bot.answerCallbackQuery(query.id);
595
+
596
+ } else if (data === 'claude_status') {
597
+ await bot.answerCallbackQuery(query.id, { text: 'Checking...' });
598
+ const status = await getClaudeAuthStatus();
599
+ const authConfig = agent.getClaudeAuthConfig();
600
+ await bot.editMessageText(
601
+ `🔐 *Claude Code Auth*\n\n*Mode:* ${authConfig.mode}\n*Credential:* ${authConfig.credential}\n\n*CLI Status:*\n\`\`\`\n${status.output.slice(0, 500)}\n\`\`\``,
602
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
603
+ );
604
+
605
+ } else if (data === 'claude_logout') {
606
+ await bot.answerCallbackQuery(query.id, { text: 'Logging out...' });
607
+ const result = await claudeLogout();
608
+ await bot.editMessageText(
609
+ `🚪 Claude Code logout: ${result.output || 'Done.'}`,
610
+ { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown' },
611
+ );
612
+
613
+ } else if (data === 'claude_cancel') {
614
+ pendingClaudeAuth.delete(chatId);
615
+ await bot.editMessageText('Claude Code auth management dismissed.', {
616
+ chat_id: chatId, message_id: query.message.message_id,
617
+ });
618
+ await bot.answerCallbackQuery(query.id);
137
619
  }
138
620
  } catch (err) {
139
- logger.error(`Callback query error: ${err.message}`);
621
+ logger.error(`[Bot] Callback query error for "${data}" in chat ${chatId}: ${err.message}`);
140
622
  await bot.answerCallbackQuery(query.id, { text: 'Error' });
141
623
  }
142
624
  });
@@ -167,6 +649,10 @@ export function startBot(config, agent, conversationManager) {
167
649
  ? batch.messages[0]
168
650
  : batch.messages.map((m, i) => `[${i + 1}]: ${m}`).join('\n\n');
169
651
 
652
+ if (batch.messages.length > 1) {
653
+ logger.info(`[Bot] Batch merged ${batch.messages.length} messages for chat ${key}`);
654
+ }
655
+
170
656
  // First resolver gets the merged text, rest get null (skip)
171
657
  batch.resolvers[0](merged);
172
658
  for (let i = 1; i < batch.resolvers.length; i++) {
@@ -177,19 +663,82 @@ export function startBot(config, agent, conversationManager) {
177
663
  }
178
664
 
179
665
  bot.on('message', async (msg) => {
180
- if (!msg.text) return; // ignore non-text
181
-
182
666
  const chatId = msg.chat.id;
183
667
  const userId = msg.from.id;
184
668
  const username = msg.from.username || msg.from.first_name || 'unknown';
185
669
 
186
670
  // Auth check
187
671
  if (!isAllowedUser(userId, config)) {
188
- logger.warn(`Unauthorized access attempt from ${username} (${userId})`);
189
- await bot.sendMessage(chatId, getUnauthorizedMessage());
672
+ if (msg.text || msg.document) {
673
+ logger.warn(`Unauthorized access attempt from ${username} (${userId})`);
674
+ await bot.sendMessage(chatId, getUnauthorizedMessage());
675
+ }
190
676
  return;
191
677
  }
192
678
 
679
+ // Handle file upload for pending custom skill prompt step
680
+ if (msg.document && pendingCustomSkill.has(chatId)) {
681
+ const pending = pendingCustomSkill.get(chatId);
682
+ if (pending.step === 'prompt') {
683
+ const doc = msg.document;
684
+ const mime = doc.mime_type || '';
685
+ const fname = doc.file_name || '';
686
+ if (!fname.endsWith('.md') && mime !== 'text/markdown' && mime !== 'text/plain') {
687
+ await bot.sendMessage(chatId, 'Please upload a `.md` or plain text file, or type the prompt directly.');
688
+ return;
689
+ }
690
+ try {
691
+ const filePath = await bot.downloadFile(doc.file_id, '/tmp');
692
+ const content = readFileSync(filePath, 'utf-8').trim();
693
+ if (!content) {
694
+ await bot.sendMessage(chatId, 'The file appears to be empty. Please try again.');
695
+ return;
696
+ }
697
+ pendingCustomSkill.delete(chatId);
698
+ const skill = addCustomSkill({ name: pending.name, systemPrompt: content });
699
+ logger.info(`[Bot] Custom skill created from file: "${skill.name}" (${skill.id}) — ${content.length} chars, by ${username} in chat ${chatId}`);
700
+ agent.setSkill(chatId, skill.id);
701
+ await bot.sendMessage(
702
+ chatId,
703
+ `✅ Custom skill *${skill.name}* created and activated!\n\n_Prompt loaded from file (${content.length} chars)_`,
704
+ { parse_mode: 'Markdown' },
705
+ );
706
+ } catch (err) {
707
+ logger.error(`Custom skill file upload error: ${err.message}`);
708
+ await bot.sendMessage(chatId, `Failed to read file: ${err.message}`);
709
+ }
710
+ return;
711
+ }
712
+ }
713
+
714
+ // Handle voice messages — transcribe and process as text
715
+ if (msg.voice && sttService.isAvailable()) {
716
+ logger.info(`[Bot] Voice message from ${username} (${userId}) in chat ${chatId}, duration: ${msg.voice.duration}s`);
717
+ let tmpPath = null;
718
+ try {
719
+ const fileUrl = await bot.getFileLink(msg.voice.file_id);
720
+ tmpPath = await sttService.downloadAudio(fileUrl);
721
+ const transcribed = await sttService.transcribe(tmpPath);
722
+ if (!transcribed) {
723
+ await bot.sendMessage(chatId, 'Could not transcribe the voice message. Please try again or send text.');
724
+ return;
725
+ }
726
+ logger.info(`[Bot] Transcribed voice: "${transcribed.slice(0, 100)}" from ${username} in chat ${chatId}`);
727
+ // Show the user what was heard
728
+ await bot.sendMessage(chatId, `🎤 _"${transcribed}"_`, { parse_mode: 'Markdown' });
729
+ // Process as a normal text message (fall through below)
730
+ msg.text = transcribed;
731
+ } catch (err) {
732
+ logger.error(`[Bot] Voice transcription failed: ${err.message}`);
733
+ await bot.sendMessage(chatId, 'Failed to process voice message. Please try sending text instead.');
734
+ return;
735
+ } finally {
736
+ if (tmpPath) sttService.cleanup(tmpPath);
737
+ }
738
+ }
739
+
740
+ if (!msg.text) return; // ignore non-text (and non-document) messages
741
+
193
742
  let text = msg.text.trim();
194
743
 
195
744
  // Handle pending brain API key input
@@ -198,22 +747,125 @@ export function startBot(config, agent, conversationManager) {
198
747
  pendingBrainKey.delete(chatId);
199
748
 
200
749
  if (text.toLowerCase() === 'cancel') {
750
+ logger.info(`[Bot] Brain key input cancelled by ${username} in chat ${chatId}`);
201
751
  await bot.sendMessage(chatId, 'Brain change cancelled.');
202
752
  return;
203
753
  }
204
754
 
205
- agent.switchBrainWithKey(pending.providerKey, pending.modelId, text);
206
- const info = agent.getBrainInfo();
755
+ logger.info(`[Bot] Brain key received for ${pending.providerKey}/${pending.modelId} from ${username} in chat ${chatId}`);
756
+ await bot.sendMessage(chatId, '⏳ Verifying API key...');
757
+ const switchResult = await agent.switchBrainWithKey(pending.providerKey, pending.modelId, text);
758
+ if (switchResult && switchResult.error) {
759
+ const current = agent.getBrainInfo();
760
+ await bot.sendMessage(
761
+ chatId,
762
+ `❌ Failed to switch: ${switchResult.error}\n\nKeeping *${current.providerName}* / *${current.modelLabel}*`,
763
+ { parse_mode: 'Markdown' },
764
+ );
765
+ } else {
766
+ const info = agent.getBrainInfo();
767
+ await bot.sendMessage(
768
+ chatId,
769
+ `🧠 Brain switched to *${info.providerName}* / *${info.modelLabel}*\n\nAPI key saved.`,
770
+ { parse_mode: 'Markdown' },
771
+ );
772
+ }
773
+ return;
774
+ }
775
+
776
+ // Handle pending orchestrator API key input
777
+ if (pendingOrchKey.has(chatId)) {
778
+ const pending = pendingOrchKey.get(chatId);
779
+ pendingOrchKey.delete(chatId);
780
+
781
+ if (text.toLowerCase() === 'cancel') {
782
+ logger.info(`[Bot] Orchestrator key input cancelled by ${username} in chat ${chatId}`);
783
+ await bot.sendMessage(chatId, 'Orchestrator change cancelled.');
784
+ return;
785
+ }
786
+
787
+ logger.info(`[Bot] Orchestrator key received for ${pending.providerKey}/${pending.modelId} from ${username} in chat ${chatId}`);
788
+ await bot.sendMessage(chatId, '⏳ Verifying API key...');
789
+ const switchResult = await agent.switchOrchestratorWithKey(pending.providerKey, pending.modelId, text);
790
+ if (switchResult && switchResult.error) {
791
+ const current = agent.getOrchestratorInfo();
792
+ await bot.sendMessage(
793
+ chatId,
794
+ `❌ Failed to switch: ${switchResult.error}\n\nKeeping *${current.providerName}* / *${current.modelLabel}*`,
795
+ { parse_mode: 'Markdown' },
796
+ );
797
+ } else {
798
+ const info = agent.getOrchestratorInfo();
799
+ await bot.sendMessage(
800
+ chatId,
801
+ `🎛️ Orchestrator switched to *${info.providerName}* / *${info.modelLabel}*\n\nAPI key saved.`,
802
+ { parse_mode: 'Markdown' },
803
+ );
804
+ }
805
+ return;
806
+ }
807
+
808
+ // Handle pending Claude Code auth input
809
+ if (pendingClaudeAuth.has(chatId)) {
810
+ const pending = pendingClaudeAuth.get(chatId);
811
+ pendingClaudeAuth.delete(chatId);
812
+
813
+ if (text.toLowerCase() === 'cancel') {
814
+ logger.info(`[Bot] Claude Code auth input cancelled by ${username} in chat ${chatId}`);
815
+ await bot.sendMessage(chatId, 'Claude Code auth setup cancelled.');
816
+ return;
817
+ }
818
+
819
+ agent.setClaudeCodeAuth(pending.type, text);
820
+ const label = pending.type === 'api_key' ? 'API Key' : 'OAuth Token';
821
+ logger.info(`[Bot] Claude Code ${label} saved from ${username} in chat ${chatId}`);
207
822
  await bot.sendMessage(
208
823
  chatId,
209
- `🧠 Brain switched to *${info.providerName}* / *${info.modelLabel}*\n\nAPI key saved.`,
824
+ `🔐 Claude Code *${label}* saved and activated.\n\nNext Claude Code spawn will use this credential.`,
210
825
  { parse_mode: 'Markdown' },
211
826
  );
212
827
  return;
213
828
  }
214
829
 
830
+ // Handle pending custom skill creation (text input for name or prompt)
831
+ if (pendingCustomSkill.has(chatId)) {
832
+ const pending = pendingCustomSkill.get(chatId);
833
+
834
+ if (text.toLowerCase() === 'cancel') {
835
+ pendingCustomSkill.delete(chatId);
836
+ await bot.sendMessage(chatId, 'Custom skill creation cancelled.');
837
+ return;
838
+ }
839
+
840
+ if (pending.step === 'name') {
841
+ pending.name = text;
842
+ pending.step = 'prompt';
843
+ pendingCustomSkill.set(chatId, pending);
844
+ await bot.sendMessage(
845
+ chatId,
846
+ `Got it: *${text}*\n\nNow send the system prompt — type it out or upload a \`.md\` file:`,
847
+ { parse_mode: 'Markdown' },
848
+ );
849
+ return;
850
+ }
851
+
852
+ if (pending.step === 'prompt') {
853
+ pendingCustomSkill.delete(chatId);
854
+ const skill = addCustomSkill({ name: pending.name, systemPrompt: text });
855
+ logger.info(`[Bot] Custom skill created: "${skill.name}" (${skill.id}) by ${username} in chat ${chatId}`);
856
+ agent.setSkill(chatId, skill.id);
857
+ await bot.sendMessage(
858
+ chatId,
859
+ `✅ Custom skill *${skill.name}* created and activated!`,
860
+ { parse_mode: 'Markdown' },
861
+ );
862
+ return;
863
+ }
864
+ }
865
+
215
866
  // Handle commands — these bypass batching entirely
216
867
  if (text === '/brain') {
868
+ logger.info(`[Bot] /brain command from ${username} (${userId}) in chat ${chatId}`);
217
869
  const info = agent.getBrainInfo();
218
870
  const providerKeys = Object.keys(PROVIDERS);
219
871
  const buttons = providerKeys.map((key) => ([{
@@ -233,6 +885,116 @@ export function startBot(config, agent, conversationManager) {
233
885
  return;
234
886
  }
235
887
 
888
+ if (text === '/orchestrator') {
889
+ logger.info(`[Bot] /orchestrator command from ${username} (${userId}) in chat ${chatId}`);
890
+ const info = agent.getOrchestratorInfo();
891
+ const providerKeys = Object.keys(PROVIDERS);
892
+ const buttons = providerKeys.map((key) => ([{
893
+ text: `${PROVIDERS[key].name}${key === info.provider ? ' ✓' : ''}`,
894
+ callback_data: `orch_provider:${key}`,
895
+ }]));
896
+ buttons.push([{ text: 'Cancel', callback_data: 'orch_cancel' }]);
897
+
898
+ await bot.sendMessage(
899
+ chatId,
900
+ `🎛️ *Current orchestrator:* ${info.providerName} / ${info.modelLabel}\n\nSelect a provider to switch:`,
901
+ {
902
+ parse_mode: 'Markdown',
903
+ reply_markup: { inline_keyboard: buttons },
904
+ },
905
+ );
906
+ return;
907
+ }
908
+
909
+ if (text === '/claudemodel') {
910
+ logger.info(`[Bot] /claudemodel command from ${username} (${userId}) in chat ${chatId}`);
911
+ const info = agent.getClaudeCodeInfo();
912
+ const anthropicModels = PROVIDERS.anthropic.models;
913
+ const buttons = anthropicModels.map((m) => ([{
914
+ text: `${m.label}${m.id === info.model ? ' ✓' : ''}`,
915
+ callback_data: `ccmodel:${m.id}`,
916
+ }]));
917
+ buttons.push([{ text: 'Cancel', callback_data: 'ccmodel_cancel' }]);
918
+
919
+ await bot.sendMessage(
920
+ chatId,
921
+ `💻 *Current Claude Code model:* ${info.modelLabel}\n\nSelect a model:`,
922
+ {
923
+ parse_mode: 'Markdown',
924
+ reply_markup: { inline_keyboard: buttons },
925
+ },
926
+ );
927
+ return;
928
+ }
929
+
930
+ if (text === '/claude') {
931
+ logger.info(`[Bot] /claude command from ${username} (${userId}) in chat ${chatId}`);
932
+ const authConfig = agent.getClaudeAuthConfig();
933
+ const ccInfo = agent.getClaudeCodeInfo();
934
+
935
+ const modeLabels = { system: '🔓 System Login', api_key: '🔑 API Key', oauth_token: '🎫 OAuth Token (Pro/Max)' };
936
+ const modeLabel = modeLabels[authConfig.mode] || authConfig.mode;
937
+
938
+ const buttons = [
939
+ [{ text: '🔑 Set API Key', callback_data: 'claude_apikey' }],
940
+ [{ text: '🎫 Set OAuth Token (Pro/Max)', callback_data: 'claude_oauth' }],
941
+ [{ text: '🔓 Use System Auth', callback_data: 'claude_system' }],
942
+ [
943
+ { text: '🔄 Refresh Status', callback_data: 'claude_status' },
944
+ { text: '🚪 Logout', callback_data: 'claude_logout' },
945
+ ],
946
+ [{ text: 'Cancel', callback_data: 'claude_cancel' }],
947
+ ];
948
+
949
+ await bot.sendMessage(
950
+ chatId,
951
+ `🔐 *Claude Code Auth*\n\n*Auth Mode:* ${modeLabel}\n*Credential:* ${authConfig.credential}\n*Model:* ${ccInfo.modelLabel}\n\nSelect an action:`,
952
+ {
953
+ parse_mode: 'Markdown',
954
+ reply_markup: { inline_keyboard: buttons },
955
+ },
956
+ );
957
+ return;
958
+ }
959
+
960
+ if (text === '/skills reset' || text === '/skill reset') {
961
+ logger.info(`[Bot] /skills reset from ${username} (${userId}) in chat ${chatId}`);
962
+ agent.clearSkill(chatId);
963
+ await bot.sendMessage(chatId, '🔄 Skill cleared — back to default persona.');
964
+ return;
965
+ }
966
+
967
+ if (text === '/skills' || text === '/skill') {
968
+ logger.info(`[Bot] /skills command from ${username} (${userId}) in chat ${chatId}`);
969
+ const categories = getUnifiedCategoryList();
970
+ const activeSkill = agent.getActiveSkill(chatId);
971
+ const buttons = categories.map((cat) => ([{
972
+ text: `${cat.emoji} ${cat.name} (${cat.count})`,
973
+ callback_data: `skill_category:${cat.key}`,
974
+ }]));
975
+ // Custom skill management row
976
+ const customRow = [{ text: '➕ Add Custom', callback_data: 'skill_custom_add' }];
977
+ if (getCustomSkills().length > 0) {
978
+ customRow.push({ text: '🗑️ Manage Custom', callback_data: 'skill_custom_manage' });
979
+ }
980
+ buttons.push(customRow);
981
+ const footerRow = [{ text: 'Cancel', callback_data: 'skill_cancel' }];
982
+ if (activeSkill) {
983
+ footerRow.unshift({ text: '🔄 Reset to Default', callback_data: 'skill_reset' });
984
+ }
985
+ buttons.push(footerRow);
986
+
987
+ const header = activeSkill
988
+ ? `🎭 *Active skill:* ${activeSkill.emoji} ${activeSkill.name}\n\nSelect a category:`
989
+ : '🎭 *Skills* — select a category:';
990
+
991
+ await bot.sendMessage(chatId, header, {
992
+ parse_mode: 'Markdown',
993
+ reply_markup: { inline_keyboard: buttons },
994
+ });
995
+ return;
996
+ }
997
+
236
998
  if (text === '/clean' || text === '/clear' || text === '/reset') {
237
999
  conversationManager.clear(chatId);
238
1000
  logger.info(`Conversation cleared for chat ${chatId} by ${username}`);
@@ -246,11 +1008,116 @@ export function startBot(config, agent, conversationManager) {
246
1008
  return;
247
1009
  }
248
1010
 
1011
+ if (text === '/context') {
1012
+ const info = agent.getBrainInfo();
1013
+ const orchInfo = agent.getOrchestratorInfo();
1014
+ const ccInfo = agent.getClaudeCodeInfo();
1015
+ const authConfig = agent.getClaudeAuthConfig();
1016
+ const activeSkill = agent.getActiveSkill(chatId);
1017
+ const msgCount = conversationManager.getMessageCount(chatId);
1018
+ const history = conversationManager.getHistory(chatId);
1019
+ const maxHistory = conversationManager.maxHistory;
1020
+ const recentWindow = conversationManager.recentWindow;
1021
+
1022
+ // Build recent topics from last few user messages
1023
+ const recentUserMsgs = history
1024
+ .filter((m) => m.role === 'user')
1025
+ .slice(-5)
1026
+ .map((m) => {
1027
+ const txt = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
1028
+ return txt.length > 80 ? txt.slice(0, 80) + '…' : txt;
1029
+ });
1030
+
1031
+ const lines = [
1032
+ '📋 *Conversation Context*',
1033
+ '',
1034
+ `🎛️ *Orchestrator:* ${orchInfo.providerName} / ${orchInfo.modelLabel}`,
1035
+ `🧠 *Brain (Workers):* ${info.providerName} / ${info.modelLabel}`,
1036
+ `💻 *Claude Code:* ${ccInfo.modelLabel} (auth: ${authConfig.mode})`,
1037
+ activeSkill
1038
+ ? `🎭 *Skill:* ${activeSkill.emoji} ${activeSkill.name}`
1039
+ : '🎭 *Skill:* Default persona',
1040
+ `💬 *Messages in memory:* ${msgCount} / ${maxHistory}`,
1041
+ `📌 *Recent window:* ${recentWindow} messages`,
1042
+ ];
1043
+
1044
+ if (recentUserMsgs.length > 0) {
1045
+ lines.push('', '🕐 *Recent topics:*');
1046
+ recentUserMsgs.forEach((msg) => lines.push(` • ${msg}`));
1047
+ } else {
1048
+ lines.push('', '_No messages yet — start chatting!_');
1049
+ }
1050
+
1051
+ await bot.sendMessage(chatId, lines.join('\n'), { parse_mode: 'Markdown' });
1052
+ return;
1053
+ }
1054
+
1055
+ if (text === '/jobs') {
1056
+ logger.info(`[Bot] /jobs command from ${username} (${userId}) in chat ${chatId}`);
1057
+ const jobs = jobManager.getJobsForChat(chatId);
1058
+ if (jobs.length === 0) {
1059
+ await bot.sendMessage(chatId, 'No jobs for this chat.');
1060
+ return;
1061
+ }
1062
+ const lines = ['*Jobs*', ''];
1063
+ for (const job of jobs.slice(0, 15)) {
1064
+ lines.push(job.toSummary());
1065
+ }
1066
+ if (jobs.length > 15) {
1067
+ lines.push(`\n_... and ${jobs.length - 15} more_`);
1068
+ }
1069
+ await bot.sendMessage(chatId, lines.join('\n'), { parse_mode: 'Markdown' });
1070
+ return;
1071
+ }
1072
+
1073
+ if (text === '/cancel') {
1074
+ logger.info(`[Bot] /cancel command from ${username} (${userId}) in chat ${chatId}`);
1075
+ const running = jobManager.getRunningJobsForChat(chatId);
1076
+ if (running.length === 0) {
1077
+ logger.debug(`[Bot] /cancel — no running jobs for chat ${chatId}`);
1078
+ await bot.sendMessage(chatId, 'No running jobs to cancel.');
1079
+ return;
1080
+ }
1081
+ if (running.length === 1) {
1082
+ logger.info(`[Bot] /cancel — single job ${running[0].id}, cancelling directly`);
1083
+ const job = jobManager.cancelJob(running[0].id);
1084
+ if (job) {
1085
+ await bot.sendMessage(chatId, `🚫 Cancelled \`${job.id}\` (${job.workerType})`, { parse_mode: 'Markdown' });
1086
+ }
1087
+ return;
1088
+ }
1089
+ // Multiple running — show inline keyboard
1090
+ logger.info(`[Bot] /cancel — ${running.length} running jobs, showing picker`);
1091
+ const buttons = running.map((j) => ([{
1092
+ text: `🚫 ${j.workerType} (${j.id})`,
1093
+ callback_data: `cancel_job:${j.id}`,
1094
+ }]));
1095
+ buttons.push([{ text: '🚫 Cancel All', callback_data: 'cancel_all_jobs' }]);
1096
+ await bot.sendMessage(chatId, `*${running.length} running jobs* — select one to cancel:`, {
1097
+ parse_mode: 'Markdown',
1098
+ reply_markup: { inline_keyboard: buttons },
1099
+ });
1100
+ return;
1101
+ }
1102
+
249
1103
  if (text === '/help') {
1104
+ const activeSkill = agent.getActiveSkill(chatId);
1105
+ const skillLine = activeSkill
1106
+ ? `\n🎭 *Active skill:* ${activeSkill.emoji} ${activeSkill.name}\n`
1107
+ : '';
250
1108
  await bot.sendMessage(chatId, [
251
1109
  '*KernelBot Commands*',
252
- '',
253
- '/brain — Show current AI model and switch provider/model',
1110
+ skillLine,
1111
+ '/brain — Switch worker AI model/provider',
1112
+ '/orchestrator — Switch orchestrator AI model/provider',
1113
+ '/claudemodel — Switch Claude Code model',
1114
+ '/claude — Manage Claude Code authentication',
1115
+ '/skills — Browse and activate persona skills',
1116
+ '/skills reset — Clear active skill back to default',
1117
+ '/jobs — List running and recent jobs',
1118
+ '/cancel — Cancel running job(s)',
1119
+ '/auto — Manage recurring automations',
1120
+ '/context — Show all models, auth, and context info',
254
1121
  '/clean — Clear conversation and start fresh',
255
1122
  '/history — Show message count in memory',
256
1123
  '/browse <url> — Browse a website and get a summary',
@@ -263,6 +1130,107 @@ export function startBot(config, agent, conversationManager) {
263
1130
  return;
264
1131
  }
265
1132
 
1133
+ // ── /auto command ──────────────────────────────────────────────
1134
+ if (text === '/auto' || text.startsWith('/auto ')) {
1135
+ logger.info(`[Bot] /auto command from ${username} (${userId}) in chat ${chatId}`);
1136
+ const args = text.slice('/auto'.length).trim();
1137
+
1138
+ if (!automationManager) {
1139
+ await bot.sendMessage(chatId, 'Automation system not available.');
1140
+ return;
1141
+ }
1142
+
1143
+ // /auto (no args) — list automations
1144
+ if (!args) {
1145
+ const autos = automationManager.listForChat(chatId);
1146
+ if (autos.length === 0) {
1147
+ await bot.sendMessage(chatId, [
1148
+ '⏰ *No automations set up yet.*',
1149
+ '',
1150
+ 'Tell me what to automate in natural language, e.g.:',
1151
+ ' "check my server health every hour"',
1152
+ ' "send me a news summary every morning at 9am"',
1153
+ '',
1154
+ 'Or use `/auto` subcommands:',
1155
+ ' `/auto pause <id>` — pause an automation',
1156
+ ' `/auto resume <id>` — resume an automation',
1157
+ ' `/auto delete <id>` — delete an automation',
1158
+ ' `/auto run <id>` — trigger immediately',
1159
+ ].join('\n'), { parse_mode: 'Markdown' });
1160
+ return;
1161
+ }
1162
+
1163
+ const lines = ['⏰ *Automations*', ''];
1164
+ for (const auto of autos) {
1165
+ lines.push(auto.toSummary());
1166
+ }
1167
+ lines.push('', '_Use `/auto pause|resume|delete|run <id>` to manage._');
1168
+
1169
+ // Build inline keyboard for quick actions
1170
+ const buttons = autos.map((a) => {
1171
+ const row = [];
1172
+ if (a.enabled) {
1173
+ row.push({ text: `⏸️ Pause ${a.id}`, callback_data: `auto_pause:${a.id}` });
1174
+ } else {
1175
+ row.push({ text: `▶️ Resume ${a.id}`, callback_data: `auto_resume:${a.id}` });
1176
+ }
1177
+ row.push({ text: `🗑️ Delete ${a.id}`, callback_data: `auto_delete:${a.id}` });
1178
+ return row;
1179
+ });
1180
+
1181
+ await bot.sendMessage(chatId, lines.join('\n'), {
1182
+ parse_mode: 'Markdown',
1183
+ reply_markup: { inline_keyboard: buttons },
1184
+ });
1185
+ return;
1186
+ }
1187
+
1188
+ // /auto pause <id>
1189
+ if (args.startsWith('pause ')) {
1190
+ const autoId = args.slice('pause '.length).trim();
1191
+ const auto = automationManager.update(autoId, { enabled: false });
1192
+ await bot.sendMessage(chatId, auto
1193
+ ? `⏸️ Paused automation \`${autoId}\` (${auto.name})`
1194
+ : `Automation \`${autoId}\` not found.`, { parse_mode: 'Markdown' });
1195
+ return;
1196
+ }
1197
+
1198
+ // /auto resume <id>
1199
+ if (args.startsWith('resume ')) {
1200
+ const autoId = args.slice('resume '.length).trim();
1201
+ const auto = automationManager.update(autoId, { enabled: true });
1202
+ await bot.sendMessage(chatId, auto
1203
+ ? `▶️ Resumed automation \`${autoId}\` (${auto.name})`
1204
+ : `Automation \`${autoId}\` not found.`, { parse_mode: 'Markdown' });
1205
+ return;
1206
+ }
1207
+
1208
+ // /auto delete <id>
1209
+ if (args.startsWith('delete ')) {
1210
+ const autoId = args.slice('delete '.length).trim();
1211
+ const deleted = automationManager.delete(autoId);
1212
+ await bot.sendMessage(chatId, deleted
1213
+ ? `🗑️ Deleted automation \`${autoId}\``
1214
+ : `Automation \`${autoId}\` not found.`, { parse_mode: 'Markdown' });
1215
+ return;
1216
+ }
1217
+
1218
+ // /auto run <id> — trigger immediately
1219
+ if (args.startsWith('run ')) {
1220
+ const autoId = args.slice('run '.length).trim();
1221
+ try {
1222
+ await automationManager.runNow(autoId);
1223
+ } catch (err) {
1224
+ await bot.sendMessage(chatId, `Failed: ${err.message}`);
1225
+ }
1226
+ return;
1227
+ }
1228
+
1229
+ // /auto <anything else> — treat as natural language automation request
1230
+ text = `Set up an automation: ${args}`;
1231
+ // Fall through to normal message processing below
1232
+ }
1233
+
266
1234
  // Web browsing shortcut commands — rewrite as natural language for the agent
267
1235
  if (text.startsWith('/browse ')) {
268
1236
  const browseUrl = text.slice('/browse '.length).trim();
@@ -346,22 +1314,24 @@ export function startBot(config, agent, conversationManager) {
346
1314
  };
347
1315
 
348
1316
  const sendPhoto = async (filePath, caption) => {
1317
+ const fileOpts = { contentType: 'image/png' };
349
1318
  try {
350
1319
  await bot.sendPhoto(chatId, createReadStream(filePath), {
351
1320
  caption: caption || '',
352
1321
  parse_mode: 'Markdown',
353
- });
1322
+ }, fileOpts);
354
1323
  } catch {
355
1324
  try {
356
1325
  await bot.sendPhoto(chatId, createReadStream(filePath), {
357
1326
  caption: caption || '',
358
- });
1327
+ }, fileOpts);
359
1328
  } catch (err) {
360
1329
  logger.error(`Failed to send photo: ${err.message}`);
361
1330
  }
362
1331
  }
363
1332
  };
364
1333
 
1334
+ logger.debug(`[Bot] Sending to orchestrator: chat ${chatId}, text="${mergedText.slice(0, 80)}"`);
365
1335
  const reply = await agent.processMessage(chatId, mergedText, {
366
1336
  id: userId,
367
1337
  username,
@@ -369,6 +1339,7 @@ export function startBot(config, agent, conversationManager) {
369
1339
 
370
1340
  clearInterval(typingInterval);
371
1341
 
1342
+ logger.info(`[Bot] Reply for chat ${chatId}: ${(reply || '').length} chars`);
372
1343
  const chunks = splitMessage(reply || 'Done.');
373
1344
  for (const chunk of chunks) {
374
1345
  try {
@@ -378,9 +1349,21 @@ export function startBot(config, agent, conversationManager) {
378
1349
  await bot.sendMessage(chatId, chunk);
379
1350
  }
380
1351
  }
1352
+
1353
+ // Send voice reply if TTS is available and the reply isn't too short
1354
+ if (ttsService.isAvailable() && reply && reply.length > 5) {
1355
+ try {
1356
+ const audioPath = await ttsService.synthesize(reply);
1357
+ if (audioPath) {
1358
+ await bot.sendVoice(chatId, createReadStream(audioPath));
1359
+ }
1360
+ } catch (err) {
1361
+ logger.warn(`[Bot] TTS voice reply failed: ${err.message}`);
1362
+ }
1363
+ }
381
1364
  } catch (err) {
382
1365
  clearInterval(typingInterval);
383
- logger.error(`Error processing message: ${err.message}`);
1366
+ logger.error(`[Bot] Error processing message in chat ${chatId}: ${err.message}`);
384
1367
  await bot.sendMessage(chatId, `Error: ${err.message}`);
385
1368
  }
386
1369
  });