morpheus-cli 0.6.1 → 0.6.3

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.
@@ -0,0 +1,580 @@
1
+ import { Client, GatewayIntentBits, Partials, Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
7
+ import { DisplayManager } from '../runtime/display.js';
8
+ import { ConfigManager } from '../config/manager.js';
9
+ import { createTelephonist } from '../runtime/telephonist.js';
10
+ // ─── Slash Command Definitions ────────────────────────────────────────────────
11
+ const SLASH_COMMANDS = [
12
+ new SlashCommandBuilder()
13
+ .setName('help')
14
+ .setDescription('Show available commands')
15
+ .setDMPermission(true),
16
+ new SlashCommandBuilder()
17
+ .setName('status')
18
+ .setDescription('Check Morpheus agent status')
19
+ .setDMPermission(true),
20
+ new SlashCommandBuilder()
21
+ .setName('stats')
22
+ .setDescription('Show token usage statistics')
23
+ .setDMPermission(true),
24
+ new SlashCommandBuilder()
25
+ .setName('newsession')
26
+ .setDescription('Archive current session and start a new one')
27
+ .setDMPermission(true),
28
+ new SlashCommandBuilder()
29
+ .setName('chronos')
30
+ .setDescription('Schedule a prompt for the Oracle')
31
+ .addStringOption(opt => opt.setName('prompt').setDescription('What should the Oracle do?').setRequired(true))
32
+ .addStringOption(opt => opt.setName('time').setDescription('When? e.g. "tomorrow at 9am", "in 30 minutes"').setRequired(true))
33
+ .setDMPermission(true),
34
+ new SlashCommandBuilder()
35
+ .setName('chronos_list')
36
+ .setDescription('List all Chronos scheduled jobs')
37
+ .setDMPermission(true),
38
+ new SlashCommandBuilder()
39
+ .setName('chronos_view')
40
+ .setDescription('View a Chronos job and its last executions')
41
+ .addStringOption(opt => opt.setName('id').setDescription('Job ID').setRequired(true))
42
+ .setDMPermission(true),
43
+ new SlashCommandBuilder()
44
+ .setName('chronos_disable')
45
+ .setDescription('Disable a Chronos job')
46
+ .addStringOption(opt => opt.setName('id').setDescription('Job ID').setRequired(true))
47
+ .setDMPermission(true),
48
+ new SlashCommandBuilder()
49
+ .setName('chronos_enable')
50
+ .setDescription('Enable a Chronos job')
51
+ .addStringOption(opt => opt.setName('id').setDescription('Job ID').setRequired(true))
52
+ .setDMPermission(true),
53
+ new SlashCommandBuilder()
54
+ .setName('chronos_delete')
55
+ .setDescription('Delete a Chronos job')
56
+ .addStringOption(opt => opt.setName('id').setDescription('Job ID').setRequired(true))
57
+ .setDMPermission(true),
58
+ ].map(cmd => cmd.toJSON());
59
+ // ─── Adapter ──────────────────────────────────────────────────────────────────
60
+ export class DiscordAdapter {
61
+ channel = 'discord';
62
+ client = null;
63
+ oracle;
64
+ allowedUsers = [];
65
+ rateLimitMap = new Map();
66
+ display = DisplayManager.getInstance();
67
+ config = ConfigManager.getInstance();
68
+ history = new SQLiteChatMessageHistory({ sessionId: '' });
69
+ telephonist = null;
70
+ telephonistProvider = null;
71
+ telephonistModel = null;
72
+ RATE_LIMIT_MS = 3000;
73
+ constructor(oracle) {
74
+ this.oracle = oracle;
75
+ }
76
+ async connect(token, allowedUsers) {
77
+ this.allowedUsers = allowedUsers;
78
+ this.client = new Client({
79
+ intents: [
80
+ GatewayIntentBits.DirectMessages,
81
+ GatewayIntentBits.MessageContent,
82
+ ],
83
+ partials: [Partials.Channel, Partials.Message],
84
+ });
85
+ this.client.once(Events.ClientReady, async (readyClient) => {
86
+ this.display.log(chalk.green(`✓ Discord bot online: @${readyClient.user.tag}`), { source: 'Discord' });
87
+ this.display.log(`Allowed Users: ${allowedUsers.length > 0 ? allowedUsers.join(', ') : '(none)'}`, { source: 'Discord', level: 'info' });
88
+ // Register slash commands globally
89
+ try {
90
+ const rest = new REST().setToken(token);
91
+ await rest.put(Routes.applicationCommands(readyClient.user.id), { body: SLASH_COMMANDS });
92
+ this.display.log('Discord slash commands registered.', { source: 'Discord', level: 'info' });
93
+ }
94
+ catch (err) {
95
+ this.display.log(`Failed to register slash commands: ${err.message}`, { source: 'Discord', level: 'error' });
96
+ }
97
+ });
98
+ this.client.on(Events.ShardError, (error) => {
99
+ this.display.log(`Discord WebSocket error: ${error.message}`, { source: 'Discord', level: 'error' });
100
+ });
101
+ // ─── Slash Command Interactions ──────────────────────────────────────────
102
+ this.client.on(Events.InteractionCreate, async (interaction) => {
103
+ if (!interaction.isChatInputCommand())
104
+ return;
105
+ if (interaction.inGuild())
106
+ return; // DM only
107
+ const userId = interaction.user.id;
108
+ if (!this.isAuthorized(userId))
109
+ return;
110
+ await this.handleSlashCommand(interaction);
111
+ });
112
+ // ─── Direct Messages ─────────────────────────────────────────────────────
113
+ this.client.on(Events.MessageCreate, async (message) => {
114
+ if (message.author.bot)
115
+ return;
116
+ if (message.channel.type !== ChannelType.DM)
117
+ return;
118
+ const userId = message.author.id;
119
+ if (!this.isAuthorized(userId)) {
120
+ this.display.log(`Unauthorized access attempt by ${message.author.tag} (ID: ${userId})`, { source: 'Discord', level: 'warning' });
121
+ return;
122
+ }
123
+ if (this.isRateLimited(userId)) {
124
+ try {
125
+ await message.channel.send('Please wait a moment before sending another message.');
126
+ }
127
+ catch {
128
+ // ignore
129
+ }
130
+ return;
131
+ }
132
+ // ── Audio attachment ──────────────────────────────────────────────────
133
+ const audioAttachment = message.attachments.find(att => att.contentType?.startsWith('audio/'));
134
+ if (audioAttachment) {
135
+ await this.handleAudioMessage(message, userId, audioAttachment);
136
+ return;
137
+ }
138
+ // ── Text message ──────────────────────────────────────────────────────
139
+ const text = message.content;
140
+ if (!text.trim())
141
+ return;
142
+ this.display.log(`${message.author.tag}: ${text}`, { source: 'Discord' });
143
+ try {
144
+ const sessionId = await this.history.getCurrentSessionOrCreate();
145
+ await this.oracle.setSessionId(sessionId);
146
+ const response = await this.oracle.chat(text, undefined, false, {
147
+ origin_channel: 'discord',
148
+ session_id: sessionId,
149
+ origin_message_id: message.id,
150
+ origin_user_id: userId,
151
+ });
152
+ if (response) {
153
+ const chunks = this.chunkText(response);
154
+ for (const chunk of chunks) {
155
+ await message.channel.send(chunk);
156
+ }
157
+ this.display.log(`Responded to ${message.author.tag}`, { source: 'Discord' });
158
+ }
159
+ }
160
+ catch (error) {
161
+ this.display.log(`Error processing message from ${message.author.tag}: ${error.message}`, { source: 'Discord', level: 'error' });
162
+ try {
163
+ await message.channel.send(`Sorry, I encountered an error: ${error.message}`);
164
+ }
165
+ catch {
166
+ // ignore
167
+ }
168
+ }
169
+ });
170
+ // login() validates the token and initiates the WS connection.
171
+ // ClientReady fires asynchronously — we don't block on it.
172
+ await this.client.login(token);
173
+ }
174
+ async disconnect() {
175
+ if (!this.client)
176
+ return;
177
+ this.display.log('Disconnecting Discord...', { source: 'Discord', level: 'warning' });
178
+ try {
179
+ this.client.destroy();
180
+ }
181
+ catch {
182
+ // ignore
183
+ }
184
+ this.client = null;
185
+ this.display.log(chalk.gray('Discord disconnected.'), { source: 'Discord' });
186
+ }
187
+ async sendMessage(text) {
188
+ if (!this.client) {
189
+ this.display.log('Cannot send message: Discord bot not connected.', { source: 'Discord', level: 'warning' });
190
+ return;
191
+ }
192
+ if (this.allowedUsers.length === 0) {
193
+ this.display.log('No allowed Discord users configured — skipping notification.', { source: 'Discord', level: 'warning' });
194
+ return;
195
+ }
196
+ for (const userId of this.allowedUsers) {
197
+ await this.sendMessageToUser(userId, text);
198
+ }
199
+ }
200
+ async sendMessageToUser(userId, text) {
201
+ if (!this.client)
202
+ return;
203
+ try {
204
+ const user = await this.client.users.fetch(userId);
205
+ const chunks = this.chunkText(text);
206
+ for (const chunk of chunks) {
207
+ await user.send(chunk);
208
+ }
209
+ }
210
+ catch (error) {
211
+ this.display.log(`Failed to send message to Discord user ${userId}: ${error.message}`, { source: 'Discord', level: 'error' });
212
+ }
213
+ }
214
+ // ─── Audio Handler ────────────────────────────────────────────────────────
215
+ async handleAudioMessage(message, userId, attachment) {
216
+ const channel = message.channel;
217
+ const config = this.config.get();
218
+ if (!config.audio.enabled) {
219
+ await channel.send('Audio transcription is currently disabled.');
220
+ return;
221
+ }
222
+ const apiKey = config.audio.apiKey ||
223
+ (config.llm.provider === config.audio.provider ? config.llm.api_key : undefined);
224
+ if (!apiKey) {
225
+ this.display.log(`Audio transcription failed: No API key for provider '${config.audio.provider}'`, { source: 'Telephonist', level: 'error' });
226
+ await channel.send(`Audio transcription requires an API key for provider '${config.audio.provider}'.`);
227
+ return;
228
+ }
229
+ // Reuse telephonist instance unless provider/model changed
230
+ if (!this.telephonist ||
231
+ this.telephonistProvider !== config.audio.provider ||
232
+ this.telephonistModel !== config.audio.model) {
233
+ this.telephonist = createTelephonist(config.audio);
234
+ this.telephonistProvider = config.audio.provider;
235
+ this.telephonistModel = config.audio.model;
236
+ }
237
+ // Voice messages expose duration (in seconds); regular attachments don't
238
+ const duration = attachment.duration;
239
+ if (duration && duration > config.audio.maxDurationSeconds) {
240
+ await channel.send(`Audio too long. Max duration is ${config.audio.maxDurationSeconds}s.`);
241
+ return;
242
+ }
243
+ const contentType = attachment.contentType ?? 'audio/ogg';
244
+ this.display.log(`Receiving audio from ${message.author.tag} (${contentType})...`, { source: 'Telephonist' });
245
+ let processingMsg = null;
246
+ let filePath = null;
247
+ try {
248
+ processingMsg = await channel.send('🎧 Listening...');
249
+ // Download audio to temp file
250
+ filePath = await this.downloadAudioToTemp(attachment.url, contentType);
251
+ // Transcribe
252
+ this.display.log(`Transcribing audio for ${message.author.tag}...`, { source: 'Telephonist' });
253
+ const { text, usage } = await this.telephonist.transcribe(filePath, contentType, apiKey);
254
+ this.display.log(`Transcription for ${message.author.tag}: "${text}"`, { source: 'Telephonist', level: 'success' });
255
+ // Show transcription
256
+ await channel.send(`🎤 "${text}"`);
257
+ // Process with Oracle
258
+ const sessionId = await this.history.getCurrentSessionOrCreate();
259
+ await this.oracle.setSessionId(sessionId);
260
+ const response = await this.oracle.chat(text, usage, true, {
261
+ origin_channel: 'discord',
262
+ session_id: sessionId,
263
+ origin_message_id: message.id,
264
+ origin_user_id: userId,
265
+ });
266
+ if (response) {
267
+ const chunks = this.chunkText(response);
268
+ for (const chunk of chunks) {
269
+ await channel.send(chunk);
270
+ }
271
+ this.display.log(`Responded to ${message.author.tag} (via audio)`, { source: 'Discord' });
272
+ }
273
+ processingMsg?.delete().catch(() => { });
274
+ }
275
+ catch (error) {
276
+ const detail = error?.cause?.message || error?.response?.data?.error?.message || error.message;
277
+ this.display.log(`Audio processing error for ${message.author.tag}: ${detail}`, { source: 'Telephonist', level: 'error' });
278
+ try {
279
+ await channel.send('Sorry, I failed to process your audio message.');
280
+ }
281
+ catch {
282
+ // ignore
283
+ }
284
+ }
285
+ finally {
286
+ if (filePath && await fs.pathExists(filePath)) {
287
+ await fs.unlink(filePath).catch(() => { });
288
+ }
289
+ }
290
+ }
291
+ async downloadAudioToTemp(url, contentType) {
292
+ const ext = contentType === 'audio/ogg' ? '.ogg' :
293
+ contentType === 'audio/mpeg' ? '.mp3' :
294
+ contentType === 'audio/mp4' ? '.m4a' :
295
+ contentType === 'audio/wav' ? '.wav' :
296
+ contentType === 'audio/webm' ? '.webm' : '.audio';
297
+ const filePath = path.join(os.tmpdir(), `morpheus-discord-${Date.now()}${ext}`);
298
+ const response = await fetch(url);
299
+ if (!response.ok)
300
+ throw new Error(`Failed to download audio: ${response.statusText}`);
301
+ const buffer = Buffer.from(await response.arrayBuffer());
302
+ await fs.writeFile(filePath, buffer);
303
+ return filePath;
304
+ }
305
+ // ─── Slash Command Handlers ───────────────────────────────────────────────
306
+ async handleSlashCommand(interaction) {
307
+ const { commandName } = interaction;
308
+ switch (commandName) {
309
+ case 'help':
310
+ await this.cmdHelp(interaction);
311
+ break;
312
+ case 'status':
313
+ await this.cmdStatus(interaction);
314
+ break;
315
+ case 'stats':
316
+ await this.cmdStats(interaction);
317
+ break;
318
+ case 'newsession':
319
+ await this.cmdNewSession(interaction);
320
+ break;
321
+ case 'chronos':
322
+ await this.cmdChronos(interaction);
323
+ break;
324
+ case 'chronos_list':
325
+ await this.cmdChronosList(interaction);
326
+ break;
327
+ case 'chronos_view':
328
+ await this.cmdChronosView(interaction);
329
+ break;
330
+ case 'chronos_disable':
331
+ await this.cmdChronosDisable(interaction);
332
+ break;
333
+ case 'chronos_enable':
334
+ await this.cmdChronosEnable(interaction);
335
+ break;
336
+ case 'chronos_delete':
337
+ await this.cmdChronosDelete(interaction);
338
+ break;
339
+ }
340
+ }
341
+ async cmdHelp(interaction) {
342
+ const content = [
343
+ '**Available Commands**',
344
+ '',
345
+ '`/help` — Show this message',
346
+ '`/status` — Check Morpheus status',
347
+ '`/stats` — Token usage statistics',
348
+ '`/newsession` — Start a new session',
349
+ '',
350
+ '**Chronos (Scheduler)**',
351
+ '`/chronos prompt: time:` — Schedule a job for the Oracle',
352
+ '`/chronos_list` — List all scheduled jobs',
353
+ '`/chronos_view id:` — View a job and its executions',
354
+ '`/chronos_disable id:` — Disable a job',
355
+ '`/chronos_enable id:` — Enable a job',
356
+ '`/chronos_delete id:` — Delete a job',
357
+ '',
358
+ 'You can also send text or voice messages to chat with the Oracle.',
359
+ ].join('\n');
360
+ await interaction.reply({ content });
361
+ }
362
+ async cmdStatus(interaction) {
363
+ await interaction.reply({ content: '✅ Morpheus is running.' });
364
+ }
365
+ async cmdStats(interaction) {
366
+ await interaction.deferReply();
367
+ try {
368
+ const history = new SQLiteChatMessageHistory({ sessionId: 'default' });
369
+ const [stats, groupedStats] = await Promise.all([
370
+ history.getGlobalUsageStats(),
371
+ history.getUsageStatsByProviderAndModel(),
372
+ ]);
373
+ history.close();
374
+ const totalTokens = stats.totalInputTokens + stats.totalOutputTokens;
375
+ const totalAudioSeconds = groupedStats.reduce((sum, s) => sum + (s.totalAudioSeconds || 0), 0);
376
+ let content = '**Token Usage Statistics**\n\n';
377
+ content += `Input: ${stats.totalInputTokens.toLocaleString()} tokens\n`;
378
+ content += `Output: ${stats.totalOutputTokens.toLocaleString()} tokens\n`;
379
+ content += `Total: ${totalTokens.toLocaleString()} tokens\n`;
380
+ if (totalAudioSeconds > 0)
381
+ content += `Audio: ${totalAudioSeconds.toFixed(1)}s\n`;
382
+ if (stats.totalEstimatedCostUsd != null) {
383
+ content += `Estimated Cost: $${stats.totalEstimatedCostUsd.toFixed(4)}\n`;
384
+ }
385
+ if (groupedStats.length > 0) {
386
+ content += '\n**By Provider/Model:**\n';
387
+ for (const s of groupedStats.slice(0, 5)) {
388
+ content += `\n**${s.provider}/${s.model}**\n`;
389
+ content += ` ${s.totalTokens.toLocaleString()} tokens (${s.messageCount} msgs)`;
390
+ if (s.estimatedCostUsd != null)
391
+ content += ` — $${s.estimatedCostUsd.toFixed(4)}`;
392
+ content += '\n';
393
+ }
394
+ }
395
+ await interaction.editReply({ content: content.slice(0, 2000) });
396
+ }
397
+ catch (err) {
398
+ await interaction.editReply({ content: `Error: ${err.message}` });
399
+ }
400
+ }
401
+ async cmdNewSession(interaction) {
402
+ try {
403
+ const history = new SQLiteChatMessageHistory({ sessionId: '' });
404
+ await history.createNewSession();
405
+ history.close();
406
+ await interaction.reply({ content: '✅ New session started.' });
407
+ }
408
+ catch (err) {
409
+ await interaction.reply({ content: `Error: ${err.message}` });
410
+ }
411
+ }
412
+ async cmdChronos(interaction) {
413
+ const prompt = interaction.options.getString('prompt', true);
414
+ const timeExpr = interaction.options.getString('time', true);
415
+ await interaction.deferReply();
416
+ try {
417
+ const { parseScheduleExpression } = await import('../runtime/chronos/parser.js');
418
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
419
+ const globalTz = this.config.getChronosConfig().timezone;
420
+ const schedule = parseScheduleExpression(timeExpr, 'once', { timezone: globalTz });
421
+ const formatted = new Date(schedule.next_run_at).toLocaleString('en-US', {
422
+ timeZone: globalTz, year: 'numeric', month: 'short', day: 'numeric',
423
+ hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
424
+ });
425
+ const repo = ChronosRepository.getInstance();
426
+ const job = repo.createJob({
427
+ prompt,
428
+ schedule_type: 'once',
429
+ schedule_expression: timeExpr,
430
+ cron_normalized: schedule.cron_normalized,
431
+ timezone: globalTz,
432
+ next_run_at: schedule.next_run_at,
433
+ created_by: 'discord',
434
+ });
435
+ await interaction.editReply({
436
+ content: `✅ Job created (\`${job.id.slice(0, 8)}\`)\n**${prompt}**\n${schedule.human_readable} — ${formatted}`,
437
+ });
438
+ }
439
+ catch (err) {
440
+ await interaction.editReply({ content: `Error: ${err.message}` });
441
+ }
442
+ }
443
+ async cmdChronosList(interaction) {
444
+ try {
445
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
446
+ const repo = ChronosRepository.getInstance();
447
+ const jobs = repo.listJobs();
448
+ if (!jobs.length) {
449
+ await interaction.reply({ content: 'No Chronos jobs found.' });
450
+ return;
451
+ }
452
+ const lines = jobs.map((j, i) => {
453
+ const status = j.enabled ? '🟢' : '🔴';
454
+ const next = j.enabled && j.next_run_at
455
+ ? new Date(j.next_run_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
456
+ : j.enabled ? 'N/A' : 'disabled';
457
+ const shortPrompt = j.prompt.length > 40 ? j.prompt.slice(0, 40) + '…' : j.prompt;
458
+ return `${status} ${i + 1}. \`${j.id.slice(0, 8)}\` — ${shortPrompt} — *${next}*`;
459
+ });
460
+ await interaction.reply({ content: `**Chronos Jobs**\n\n${lines.join('\n')}`.slice(0, 2000) });
461
+ }
462
+ catch (err) {
463
+ await interaction.reply({ content: `Error: ${err.message}` });
464
+ }
465
+ }
466
+ async cmdChronosView(interaction) {
467
+ const id = interaction.options.getString('id', true);
468
+ try {
469
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
470
+ const repo = ChronosRepository.getInstance();
471
+ const job = repo.getJob(id);
472
+ if (!job) {
473
+ await interaction.reply({ content: 'Job not found.' });
474
+ return;
475
+ }
476
+ const executions = repo.listExecutions(id, 3);
477
+ const next = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
478
+ const last = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'Never';
479
+ const execLines = executions.map(e => `• ${e.status.toUpperCase()} — ${new Date(e.triggered_at).toLocaleString()}`).join('\n') || 'None yet';
480
+ const content = `**Chronos Job** \`${id.slice(0, 8)}\`\n\n` +
481
+ `**Prompt:** ${job.prompt}\n` +
482
+ `**Schedule:** ${job.schedule_type} — \`${job.schedule_expression}\`\n` +
483
+ `**Timezone:** ${job.timezone}\n` +
484
+ `**Status:** ${job.enabled ? 'Enabled' : 'Disabled'}\n` +
485
+ `**Next Run:** ${next}\n` +
486
+ `**Last Run:** ${last}\n\n` +
487
+ `**Last 3 Executions:**\n${execLines}`;
488
+ await interaction.reply({ content: content.slice(0, 2000) });
489
+ }
490
+ catch (err) {
491
+ await interaction.reply({ content: `Error: ${err.message}` });
492
+ }
493
+ }
494
+ async cmdChronosDisable(interaction) {
495
+ const id = interaction.options.getString('id', true);
496
+ try {
497
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
498
+ const repo = ChronosRepository.getInstance();
499
+ const job = repo.disableJob(id);
500
+ if (!job) {
501
+ await interaction.reply({ content: 'Job not found.' });
502
+ return;
503
+ }
504
+ await interaction.reply({ content: `Job \`${id.slice(0, 8)}\` disabled.` });
505
+ }
506
+ catch (err) {
507
+ await interaction.reply({ content: `Error: ${err.message}` });
508
+ }
509
+ }
510
+ async cmdChronosEnable(interaction) {
511
+ const id = interaction.options.getString('id', true);
512
+ try {
513
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
514
+ const { parseNextRun } = await import('../runtime/chronos/parser.js');
515
+ const repo = ChronosRepository.getInstance();
516
+ const existing = repo.getJob(id);
517
+ if (!existing) {
518
+ await interaction.reply({ content: 'Job not found.' });
519
+ return;
520
+ }
521
+ let nextRunAt;
522
+ if (existing.cron_normalized) {
523
+ nextRunAt = parseNextRun(existing.cron_normalized, existing.timezone);
524
+ }
525
+ repo.updateJob(id, { enabled: true, next_run_at: nextRunAt });
526
+ const job = repo.getJob(id);
527
+ const next = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
528
+ await interaction.reply({ content: `Job \`${id.slice(0, 8)}\` enabled. Next run: ${next}` });
529
+ }
530
+ catch (err) {
531
+ await interaction.reply({ content: `Error: ${err.message}` });
532
+ }
533
+ }
534
+ async cmdChronosDelete(interaction) {
535
+ const id = interaction.options.getString('id', true);
536
+ try {
537
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
538
+ const repo = ChronosRepository.getInstance();
539
+ const deleted = repo.deleteJob(id);
540
+ if (!deleted) {
541
+ await interaction.reply({ content: 'Job not found.' });
542
+ return;
543
+ }
544
+ await interaction.reply({ content: `Job \`${id.slice(0, 8)}\` deleted.` });
545
+ }
546
+ catch (err) {
547
+ await interaction.reply({ content: `Error: ${err.message}` });
548
+ }
549
+ }
550
+ // ─── Helpers ──────────────────────────────────────────────────────────────
551
+ isAuthorized(userId) {
552
+ return this.allowedUsers.includes(userId);
553
+ }
554
+ isRateLimited(userId) {
555
+ const now = Date.now();
556
+ const last = this.rateLimitMap.get(userId);
557
+ if (last !== undefined && now - last < this.RATE_LIMIT_MS)
558
+ return true;
559
+ this.rateLimitMap.set(userId, now);
560
+ return false;
561
+ }
562
+ chunkText(text, limit = 2000) {
563
+ if (text.length <= limit)
564
+ return [text];
565
+ const chunks = [];
566
+ let remaining = text;
567
+ while (remaining.length > limit) {
568
+ let splitAt = remaining.lastIndexOf('\n', limit - 1);
569
+ if (splitAt < limit / 4)
570
+ splitAt = remaining.lastIndexOf(' ', limit - 1);
571
+ if (splitAt <= 0)
572
+ splitAt = limit;
573
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
574
+ remaining = remaining.slice(splitAt).trimStart();
575
+ }
576
+ if (remaining)
577
+ chunks.push(remaining);
578
+ return chunks.filter(Boolean);
579
+ }
580
+ }
@@ -0,0 +1,41 @@
1
+ import { DisplayManager } from '../runtime/display.js';
2
+ /**
3
+ * Central registry for all active channel adapters.
4
+ * TaskDispatcher, ChronosWorker and WebhookDispatcher use this
5
+ * instead of holding direct adapter references.
6
+ */
7
+ export class ChannelRegistry {
8
+ static adapters = new Map();
9
+ static display = DisplayManager.getInstance();
10
+ static register(adapter) {
11
+ ChannelRegistry.adapters.set(adapter.channel, adapter);
12
+ ChannelRegistry.display.log(`Channel adapter registered: ${adapter.channel}`, { source: 'ChannelRegistry', level: 'info' });
13
+ }
14
+ static unregister(channel) {
15
+ ChannelRegistry.adapters.delete(channel);
16
+ }
17
+ static get(channel) {
18
+ return ChannelRegistry.adapters.get(channel);
19
+ }
20
+ static getAll() {
21
+ return [...ChannelRegistry.adapters.values()];
22
+ }
23
+ /** Broadcast to every registered adapter */
24
+ static async broadcast(text) {
25
+ const results = await Promise.allSettled(ChannelRegistry.getAll().map((a) => a.sendMessage(text)));
26
+ for (const result of results) {
27
+ if (result.status === 'rejected') {
28
+ ChannelRegistry.display.log(`Broadcast error: ${result.reason?.message ?? result.reason}`, { source: 'ChannelRegistry', level: 'error' });
29
+ }
30
+ }
31
+ }
32
+ /** Send to a specific user on a specific channel */
33
+ static async sendToUser(channel, userId, text) {
34
+ const adapter = ChannelRegistry.get(channel);
35
+ if (!adapter) {
36
+ ChannelRegistry.display.log(`sendToUser: no adapter registered for channel "${channel}"`, { source: 'ChannelRegistry', level: 'warning' });
37
+ return;
38
+ }
39
+ await adapter.sendMessageToUser(userId, text);
40
+ }
41
+ }
@@ -132,6 +132,7 @@ function escMdRaw(value) {
132
132
  return String(value).replace(/([_*[\]()~`>#+=|{}.!\\-])/g, '\\$1');
133
133
  }
134
134
  export class TelegramAdapter {
135
+ channel = 'telegram';
135
136
  bot = null;
136
137
  isConnected = false;
137
138
  display = DisplayManager.getInstance();
@@ -7,6 +7,7 @@ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid } from '..
7
7
  import { ConfigManager } from '../../config/manager.js';
8
8
  import { renderBanner } from '../utils/render.js';
9
9
  import { TelegramAdapter } from '../../channels/telegram.js';
10
+ import { ChannelRegistry } from '../../channels/registry.js';
10
11
  import { PATHS } from '../../config/paths.js';
11
12
  import { Oracle } from '../../runtime/oracle.js';
12
13
  import { ProviderError } from '../../runtime/errors.js';
@@ -14,8 +15,6 @@ import { HttpServer } from '../../http/server.js';
14
15
  import { getVersion } from '../utils/version.js';
15
16
  import { TaskWorker } from '../../runtime/tasks/worker.js';
16
17
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
17
- import { TaskDispatcher } from '../../runtime/tasks/dispatcher.js';
18
- import { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
19
18
  export const restartCommand = new Command('restart')
20
19
  .description('Restart the Morpheus agent')
21
20
  .option('--ui', 'Enable web UI', true)
@@ -121,8 +120,7 @@ export const restartCommand = new Command('restart')
121
120
  const telegram = new TelegramAdapter(oracle);
122
121
  try {
123
122
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
124
- WebhookDispatcher.setTelegramAdapter(telegram);
125
- TaskDispatcher.setTelegramAdapter(telegram);
123
+ ChannelRegistry.register(telegram);
126
124
  adapters.push(telegram);
127
125
  }
128
126
  catch (e) {