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.
@@ -8,6 +8,8 @@ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid, killProce
8
8
  import { ConfigManager } from '../../config/manager.js';
9
9
  import { renderBanner } from '../utils/render.js';
10
10
  import { TelegramAdapter } from '../../channels/telegram.js';
11
+ import { DiscordAdapter } from '../../channels/discord.js';
12
+ import { ChannelRegistry } from '../../channels/registry.js';
11
13
  import { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
12
14
  import { PATHS } from '../../config/paths.js';
13
15
  import { Oracle } from '../../runtime/oracle.js';
@@ -17,7 +19,6 @@ import { getVersion } from '../utils/version.js';
17
19
  import { startSessionEmbeddingScheduler } from '../../runtime/session-embedding-scheduler.js';
18
20
  import { TaskWorker } from '../../runtime/tasks/worker.js';
19
21
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
20
- import { TaskDispatcher } from '../../runtime/tasks/dispatcher.js';
21
22
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
22
23
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
23
24
  export const startCommand = new Command('start')
@@ -148,10 +149,7 @@ export const startCommand = new Command('start')
148
149
  const telegram = new TelegramAdapter(oracle);
149
150
  try {
150
151
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
151
- // Wire Telegram adapter to webhook dispatcher for proactive notifications
152
- WebhookDispatcher.setTelegramAdapter(telegram);
153
- TaskDispatcher.setTelegramAdapter(telegram);
154
- ChronosWorker.setNotifyFn((text) => telegram.sendMessage(text));
152
+ ChannelRegistry.register(telegram);
155
153
  adapters.push(telegram);
156
154
  }
157
155
  catch (e) {
@@ -162,6 +160,23 @@ export const startCommand = new Command('start')
162
160
  display.log(chalk.yellow('Telegram enabled but no token provided. Skipping.'), { source: 'Zaion' });
163
161
  }
164
162
  }
163
+ // Initialize Discord
164
+ if (config.channels.discord.enabled) {
165
+ if (config.channels.discord.token) {
166
+ const discord = new DiscordAdapter(oracle);
167
+ try {
168
+ await discord.connect(config.channels.discord.token, config.channels.discord.allowedUsers || []);
169
+ ChannelRegistry.register(discord);
170
+ adapters.push(discord);
171
+ }
172
+ catch (e) {
173
+ display.log(chalk.red(`Failed to initialize Discord adapter: ${e.message}`), { source: 'Zaion' });
174
+ }
175
+ }
176
+ else {
177
+ display.log(chalk.yellow('Discord enabled but no token provided. Skipping.'), { source: 'Zaion' });
178
+ }
179
+ }
165
180
  // Start Background Services
166
181
  startSessionEmbeddingScheduler();
167
182
  chronosWorker.start();
@@ -167,8 +167,9 @@ export class ConfigManager {
167
167
  allowedUsers: resolveStringArray('MORPHEUS_TELEGRAM_ALLOWED_USERS', config.channels.telegram.allowedUsers, DEFAULT_CONFIG.channels.telegram.allowedUsers)
168
168
  },
169
169
  discord: {
170
- enabled: config.channels.discord.enabled, // Discord doesn't have env var precedence for now
171
- token: config.channels.discord.token
170
+ enabled: config.channels.discord.enabled,
171
+ token: config.channels.discord.token,
172
+ allowedUsers: config.channels.discord.allowedUsers ?? []
172
173
  }
173
174
  };
174
175
  // Apply precedence to UI config
@@ -67,6 +67,7 @@ export const ConfigSchema = z.object({
67
67
  discord: z.object({
68
68
  enabled: z.boolean().default(false),
69
69
  token: z.string().optional(),
70
+ allowedUsers: z.array(z.string()).default([]),
70
71
  }).default(DEFAULT_CONFIG.channels.discord),
71
72
  }).default(DEFAULT_CONFIG.channels),
72
73
  ui: z.object({
@@ -11,12 +11,14 @@ const CreateJobSchema = z.object({
11
11
  schedule_type: ScheduleTypeSchema,
12
12
  schedule_expression: z.string().min(1),
13
13
  timezone: z.string().optional(),
14
+ notify_channels: z.array(z.string()).optional(),
14
15
  });
15
16
  const UpdateJobSchema = z.object({
16
17
  prompt: z.string().min(1).max(10000).optional(),
17
18
  schedule_expression: z.string().min(1).optional(),
18
19
  timezone: z.string().optional(),
19
20
  enabled: z.boolean().optional(),
21
+ notify_channels: z.array(z.string()).optional(),
20
22
  });
21
23
  const PreviewSchema = z.object({
22
24
  expression: z.string().min(1),
@@ -83,7 +85,7 @@ export function createChronosJobRouter(repo, _worker) {
83
85
  if (!parsed.success) {
84
86
  return res.status(400).json({ error: 'Invalid input', details: parsed.error.issues });
85
87
  }
86
- const { prompt, schedule_type, schedule_expression, timezone } = parsed.data;
88
+ const { prompt, schedule_type, schedule_expression, timezone, notify_channels } = parsed.data;
87
89
  const globalTz = configManager.getChronosConfig().timezone;
88
90
  const tz = timezone ?? globalTz;
89
91
  const opts = { timezone: tz };
@@ -97,6 +99,7 @@ export function createChronosJobRouter(repo, _worker) {
97
99
  timezone: tz,
98
100
  next_run_at: schedule.next_run_at,
99
101
  created_by: 'ui',
102
+ notify_channels: notify_channels ?? [],
100
103
  });
101
104
  const display = DisplayManager.getInstance();
102
105
  display.log(`Job ${job.id} created — ${schedule.human_readable}`, { source: 'Chronos' });
@@ -154,6 +157,7 @@ export function createChronosJobRouter(repo, _worker) {
154
157
  timezone: patch.timezone,
155
158
  next_run_at: updatedSchedule?.next_run_at,
156
159
  enabled: patch.enabled,
160
+ notify_channels: patch.notify_channels,
157
161
  });
158
162
  res.json(job);
159
163
  }
@@ -7,7 +7,7 @@ const mockConfig = {
7
7
  llm: { provider: 'openai', model: 'gpt-3.5-turbo', temperature: 0.1, api_key: 'sk-mock-key' },
8
8
  channels: {
9
9
  telegram: { enabled: false, allowedUsers: [] },
10
- discord: { enabled: false }
10
+ discord: { enabled: false, allowedUsers: [] }
11
11
  },
12
12
  ui: { enabled: false, port: 3333 },
13
13
  logging: { enabled: false, level: 'info', retention: '1d' },
@@ -61,6 +61,7 @@ export class ChronosRepository {
61
61
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0`, 'created_at');
62
62
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0`, 'updated_at');
63
63
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_by TEXT NOT NULL DEFAULT 'api'`, 'created_by');
64
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN notify_channels TEXT NOT NULL DEFAULT '[]'`, 'notify_channels');
64
65
  const execInfo = this.db.pragma('table_info(chronos_executions)');
65
66
  const execCols = new Set(execInfo.map((c) => c.name));
66
67
  const addExecCol = (sql, col) => {
@@ -87,6 +88,11 @@ export class ChronosRepository {
87
88
  `);
88
89
  }
89
90
  deserializeJob(row) {
91
+ let notify_channels = [];
92
+ try {
93
+ notify_channels = JSON.parse(row.notify_channels || '[]');
94
+ }
95
+ catch { /* keep [] */ }
90
96
  return {
91
97
  id: row.id,
92
98
  prompt: row.prompt,
@@ -100,6 +106,7 @@ export class ChronosRepository {
100
106
  created_at: row.created_at,
101
107
  updated_at: row.updated_at,
102
108
  created_by: row.created_by,
109
+ notify_channels,
103
110
  };
104
111
  }
105
112
  deserializeExecution(row) {
@@ -125,9 +132,9 @@ export class ChronosRepository {
125
132
  this.db.prepare(`
126
133
  INSERT INTO chronos_jobs (
127
134
  id, prompt, schedule_type, schedule_expression, cron_normalized,
128
- timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by
129
- ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?)
130
- `).run(id, input.prompt, input.schedule_type, input.schedule_expression, input.cron_normalized ?? null, input.timezone, input.next_run_at, now, now, input.created_by);
135
+ timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by, notify_channels
136
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?)
137
+ `).run(id, input.prompt, input.schedule_type, input.schedule_expression, input.cron_normalized ?? null, input.timezone, input.next_run_at, now, now, input.created_by, JSON.stringify(input.notify_channels ?? []));
131
138
  return this.getJob(id);
132
139
  }
133
140
  getJob(id) {
@@ -181,6 +188,10 @@ export class ChronosRepository {
181
188
  sets.push('enabled = ?');
182
189
  params.push(patch.enabled ? 1 : 0);
183
190
  }
191
+ if (patch.notify_channels !== undefined) {
192
+ sets.push('notify_channels = ?');
193
+ params.push(JSON.stringify(patch.notify_channels));
194
+ }
184
195
  params.push(id);
185
196
  this.db.prepare(`UPDATE chronos_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...params);
186
197
  return this.getJob(id);
@@ -2,11 +2,11 @@ import { randomUUID } from 'crypto';
2
2
  import { ConfigManager } from '../../config/manager.js';
3
3
  import { DisplayManager } from '../display.js';
4
4
  import { parseNextRun } from './parser.js';
5
+ import { ChannelRegistry } from '../../channels/registry.js';
5
6
  export class ChronosWorker {
6
7
  repo;
7
8
  oracle;
8
9
  static instance = null;
9
- static notifyFn = null;
10
10
  /**
11
11
  * True while a Chronos job is being executed. Chronos management tools
12
12
  * (chronos_cancel, chronos_schedule) check this flag and refuse to operate
@@ -28,10 +28,6 @@ export class ChronosWorker {
28
28
  static setInstance(worker) {
29
29
  ChronosWorker.instance = worker;
30
30
  }
31
- /** Register a function that will deliver Oracle responses to users (e.g. Telegram). */
32
- static setNotifyFn(fn) {
33
- ChronosWorker.notifyFn = fn;
34
- }
35
31
  start() {
36
32
  if (this.timer)
37
33
  return;
@@ -88,20 +84,21 @@ export class ChronosWorker {
88
84
  session_id: activeSessionId,
89
85
  });
90
86
  try {
91
- // Inject execution context as an AI message so it appears naturally in the
92
- // conversation history without triggering an extra LLM response.
93
- const contextMessage = `[CHRONOS EXECUTION job_id: ${job.id}]\n` +
94
- `Executing scheduled job. Do NOT call chronos_cancel, chronos_schedule, ` +
95
- `or any Chronos management tools during this execution.`;
96
- await this.oracle.injectAIMessage(contextMessage);
97
- // If a Telegram notify function is registered, tag delegated tasks with
98
- // origin_channel: 'telegram' so the TaskDispatcher broadcasts their result.
99
- const taskContext = ChronosWorker.notifyFn
100
- ? { origin_channel: 'telegram', session_id: activeSessionId }
101
- : undefined;
87
+ // Prefix the job prompt with the Chronos execution context marker so the
88
+ // Oracle system prompt can detect it in the current HumanMessage.
89
+ // This avoids persisting an AIMessage with the marker in conversation history,
90
+ // which would cause the LLM to reproduce the format in future scheduling responses.
91
+ const promptWithContext = `[CHRONOS EXECUTION job_id: ${job.id}]\n${job.prompt}`;
92
+ // Determine which channel to tag delegated tasks with.
93
+ // Single notify_channel use it so TaskDispatcher routes back correctly.
94
+ // Multiple or empty → 'chronos' means broadcast to all registered adapters.
95
+ const taskOriginChannel = job.notify_channels.length === 1
96
+ ? job.notify_channels[0]
97
+ : 'chronos';
98
+ const taskContext = { origin_channel: taskOriginChannel, session_id: activeSessionId };
102
99
  // Hard-block Chronos management tools during execution.
103
100
  ChronosWorker.isExecuting = true;
104
- const response = await this.oracle.chat(job.prompt, undefined, false, taskContext);
101
+ const response = await this.oracle.chat(promptWithContext, undefined, false, taskContext);
105
102
  this.repo.completeExecution(execId, 'success');
106
103
  display.log(`Job ${job.id} completed — status: success`, { source: 'Chronos' });
107
104
  // Deliver Oracle response to notification channels.
@@ -127,15 +124,26 @@ export class ChronosWorker {
127
124
  }
128
125
  }
129
126
  async notify(job, response) {
130
- if (!ChronosWorker.notifyFn)
127
+ if (ChannelRegistry.getAll().length === 0)
131
128
  return;
132
- const display = DisplayManager.getInstance();
133
129
  const header = `⏰ *Chronos* — _${job.prompt.slice(0, 80)}${job.prompt.length > 80 ? '…' : ''}_\n\n`;
134
- try {
135
- await ChronosWorker.notifyFn(header + response);
130
+ const text = header + response;
131
+ if (job.notify_channels.length === 0) {
132
+ // No specific channels → broadcast to all registered adapters
133
+ await ChannelRegistry.broadcast(text);
136
134
  }
137
- catch (err) {
138
- display.log(`Job ${job.id} notification failed — ${err.message}`, { source: 'Chronos', level: 'error' });
135
+ else {
136
+ for (const ch of job.notify_channels) {
137
+ const adapter = ChannelRegistry.get(ch);
138
+ if (adapter) {
139
+ await adapter.sendMessage(text).catch((err) => {
140
+ DisplayManager.getInstance().log(`Job ${job.id} notification failed on channel "${ch}": ${err.message}`, { source: 'Chronos', level: 'error' });
141
+ });
142
+ }
143
+ else {
144
+ DisplayManager.getInstance().log(`Job ${job.id}: no adapter registered for channel "${ch}" — notification skipped.`, { source: 'Chronos', level: 'warning' });
145
+ }
146
+ }
139
147
  }
140
148
  }
141
149
  }
@@ -29,6 +29,7 @@ function makeJob(overrides = {}) {
29
29
  created_at: Date.now() - 5000,
30
30
  updated_at: Date.now() - 5000,
31
31
  created_by: 'ui',
32
+ notify_channels: [],
32
33
  ...overrides,
33
34
  };
34
35
  }
@@ -114,6 +114,20 @@ export class Oracle {
114
114
  }
115
115
  return false;
116
116
  }
117
+ hasChronosToolCall(messages) {
118
+ const chronosToolNames = new Set(["chronos_schedule", "chronos_list", "chronos_cancel", "chronos_preview"]);
119
+ for (const msg of messages) {
120
+ if (!(msg instanceof AIMessage))
121
+ continue;
122
+ const toolCalls = msg.tool_calls ?? [];
123
+ if (!Array.isArray(toolCalls))
124
+ continue;
125
+ if (toolCalls.some((tc) => chronosToolNames.has(tc?.name))) {
126
+ return true;
127
+ }
128
+ }
129
+ return false;
130
+ }
117
131
  async initialize() {
118
132
  if (!this.config.llm) {
119
133
  throw new Error("LLM configuration missing in config object.");
@@ -198,6 +212,28 @@ Rules:
198
212
  10. After enqueuing all required delegated tasks for the current message, stop calling tools and return a concise acknowledgement.
199
213
  11. If a delegation is rejected as "not atomic", immediately split into smaller delegations and retry.
200
214
 
215
+ ## Chronos Channel Routing
216
+ When calling chronos_schedule, set notify_channels based on the user's message:
217
+ - User mentions a specific channel (e.g., "no Discord", "no Telegram", "on Discord", "me avise pelo Discord"): set notify_channels to that channel — e.g. ["discord"] or ["telegram"].
218
+ - User says "all channels", "todos os canais", "em todos os canais": set notify_channels to [] (empty = broadcast to all active channels).
219
+ - User does NOT mention any channel: omit notify_channels entirely (auto-detect uses the current conversation channel).
220
+
221
+ Examples:
222
+ - "me lembre daqui 5 minutos pelo Discord" → notify_channels: ["discord"]
223
+ - "lembre em todos os canais" → notify_channels: []
224
+ - "lembre em 1 hora" (sem canal) → omit notify_channels
225
+
226
+ ## Chronos Scheduled Execution
227
+ When the current user message starts with [CHRONOS EXECUTION], it means a Chronos scheduled job has just fired. The content after the prefix is the **job's saved prompt**, not a new live request from the user.
228
+
229
+ Behavior rules for Chronos execution context:
230
+ - **Reminder / notification prompts** (e.g., "me lembre de beber água", "lembre de tomar remédio", "avise que é hora de X", "lembrete: reunião às 15h"): respond with ONLY a short, direct notification message. Keep it to 1–2 sentences max. Do NOT use any tools. Do NOT delegate. Do NOT create tasks. Do NOT add motivational commentary or ask follow-up questions.
231
+ - Good: "Hora de beber água! 💧"
232
+ - Good: "Lembrete: reunião em 5 minutos."
233
+ - Bad: "Combinado! Vou beber agora. Você também deveria se hidratar!" (adds unnecessary commentary)
234
+ - **Action / task prompts** (e.g., "executar npm build", "verificar se o servidor está online", "enviar relatório"): execute normally using the appropriate tools.
235
+ - NEVER re-schedule or create new Chronos jobs from within a Chronos execution.
236
+
201
237
  Delegation quality:
202
238
  - Write delegation input in the same language requested by the user.
203
239
  - Include clear objective and constraints.
@@ -294,6 +330,7 @@ Use it to inform your response and tool selection (if needed), but do not assume
294
330
  let responseContent;
295
331
  const toolDelegationAcks = this.extractDelegationAcksFromMessages(newGeneratedMessages);
296
332
  const hadDelegationToolCall = this.hasDelegationToolCall(newGeneratedMessages);
333
+ const hadChronosToolCall = this.hasChronosToolCall(newGeneratedMessages);
297
334
  const mergedDelegationAcks = [
298
335
  ...contextDelegationAcks.map((ack) => ({ task_id: ack.task_id, agent: ack.agent })),
299
336
  ...toolDelegationAcks,
@@ -336,7 +373,7 @@ Use it to inform your response and tool selection (if needed), but do not assume
336
373
  else {
337
374
  const lastMessage = response.messages[response.messages.length - 1];
338
375
  responseContent = (typeof lastMessage.content === 'string') ? lastMessage.content : JSON.stringify(lastMessage.content);
339
- if (this.looksLikeSyntheticDelegationAck(responseContent)) {
376
+ if (!hadChronosToolCall && this.looksLikeSyntheticDelegationAck(responseContent)) {
340
377
  blockedSyntheticDelegationAck = true;
341
378
  this.display.log("Blocked synthetic delegation acknowledgement without validated task creation.", { source: "Oracle", level: "error", meta: { preview: responseContent.slice(0, 200) } });
342
379
  const usage = lastMessage.usage_metadata
@@ -2,12 +2,9 @@ import { DisplayManager } from '../display.js';
2
2
  import { WebhookRepository } from '../webhooks/repository.js';
3
3
  import { AIMessage } from '@langchain/core/messages';
4
4
  import { SQLiteChatMessageHistory } from '../memory/sqlite.js';
5
+ import { ChannelRegistry } from '../../channels/registry.js';
5
6
  export class TaskDispatcher {
6
- static telegramAdapter = null;
7
7
  static display = DisplayManager.getInstance();
8
- static setTelegramAdapter(adapter) {
9
- TaskDispatcher.telegramAdapter = adapter;
10
- }
11
8
  static async notifyTaskResult(task) {
12
9
  if (task.origin_channel === 'webhook') {
13
10
  if (!task.origin_message_id) {
@@ -19,25 +16,25 @@ export class TaskDispatcher {
19
16
  ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
20
17
  : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
21
18
  repo.updateNotificationResult(task.origin_message_id, status, result);
22
- // Send Telegram notification if the webhook has 'telegram' in notification_channels
19
+ // Notify channels configured on the webhook
23
20
  const notification = repo.getNotificationById(task.origin_message_id);
24
21
  if (notification) {
25
22
  const webhook = repo.getWebhookById(notification.webhook_id);
26
- if (webhook?.notification_channels.includes('telegram')) {
27
- const adapter = TaskDispatcher.telegramAdapter;
28
- if (adapter) {
29
- try {
30
- const icon = status === 'completed' ? '✅' : '❌';
31
- const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
32
- await adapter.sendMessage(`${icon} Webhook: ${webhook.name}\n\n${truncated}`);
23
+ if (webhook) {
24
+ const icon = status === 'completed' ? '✅' : '❌';
25
+ const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
26
+ const message = `${icon} Webhook: ${webhook.name}\n\n${truncated}`;
27
+ for (const ch of webhook.notification_channels) {
28
+ const adapter = ChannelRegistry.get(ch);
29
+ if (adapter) {
30
+ await adapter.sendMessage(message).catch((err) => {
31
+ TaskDispatcher.display.log(`Failed to send notification via ${ch} for webhook "${webhook.name}": ${err.message}`, { source: 'TaskDispatcher', level: 'error' });
32
+ });
33
33
  }
34
- catch (err) {
35
- TaskDispatcher.display.log(`Failed to send Telegram notification for webhook "${webhook.name}": ${err.message}`, { source: 'TaskDispatcher', level: 'error' });
34
+ else {
35
+ TaskDispatcher.display.log(`Notification skipped for channel "${ch}" adapter not registered.`, { source: 'TaskDispatcher', level: 'warning' });
36
36
  }
37
37
  }
38
- else {
39
- TaskDispatcher.display.log(`Telegram notification skipped for webhook "${webhook.name}" — adapter not connected.`, { source: 'TaskDispatcher', level: 'warning' });
40
- }
41
38
  }
42
39
  }
43
40
  return;
@@ -65,13 +62,21 @@ export class TaskDispatcher {
65
62
  }
66
63
  return;
67
64
  }
68
- if (task.origin_channel !== 'telegram') {
65
+ // 'chronos' origin — broadcast to all registered channels
66
+ if (task.origin_channel === 'chronos') {
67
+ const statusIcon = task.status === 'completed' ? '✅' : task.status === 'cancelled' ? '🚫' : '❌';
68
+ const body = task.status === 'completed'
69
+ ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
70
+ : task.status === 'cancelled'
71
+ ? 'Task was cancelled.'
72
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
73
+ const message = `${statusIcon} Task \`${task.id.toUpperCase()}\`\n` +
74
+ `Agent: \`${task.agent.toUpperCase()}\`\n` +
75
+ `Status: \`${task.status.toUpperCase()}\`\n\n${body}`;
76
+ await ChannelRegistry.broadcast(message);
69
77
  return;
70
78
  }
71
- const adapter = TaskDispatcher.telegramAdapter;
72
- if (!adapter) {
73
- throw new Error('Telegram adapter not connected');
74
- }
79
+ // Channel-specific routing (telegram, discord, etc.)
75
80
  const statusIcon = task.status === 'completed' ? '✅' : task.status === 'cancelled' ? '🚫' : '❌';
76
81
  const body = task.status === 'completed'
77
82
  ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
@@ -83,10 +88,15 @@ export class TaskDispatcher {
83
88
  `Status: \`${task.status.toUpperCase()}\``;
84
89
  const message = `${header}\n\n${body}`;
85
90
  if (task.origin_user_id) {
86
- await adapter.sendMessageToUser(task.origin_user_id, message);
91
+ await ChannelRegistry.sendToUser(task.origin_channel, task.origin_user_id, message);
92
+ return;
93
+ }
94
+ // No specific user — broadcast on the originating channel
95
+ const adapter = ChannelRegistry.get(task.origin_channel);
96
+ if (!adapter) {
97
+ TaskDispatcher.display.log(`Task ${task.id}: no adapter for channel "${task.origin_channel}" — notification skipped.`, { source: 'TaskDispatcher', level: 'warning' });
87
98
  return;
88
99
  }
89
- TaskDispatcher.display.log(`Task ${task.id} has telegram origin but no origin_user_id; broadcasting to allowed users.`, { source: 'TaskDispatcher', level: 'warning' });
90
100
  await adapter.sendMessage(message);
91
101
  }
92
102
  static async onTaskFinished(task) {
@@ -4,8 +4,11 @@ import { ChronosRepository } from '../chronos/repository.js';
4
4
  import { parseScheduleExpression, getNextOccurrences } from '../chronos/parser.js';
5
5
  import { ConfigManager } from '../../config/manager.js';
6
6
  import { ChronosWorker } from '../chronos/worker.js';
7
+ import { TaskRequestContext } from '../tasks/context.js';
7
8
  // ─── chronos_schedule ────────────────────────────────────────────────────────
8
- export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule_expression, timezone }) => {
9
+ // Channels that can be used as notify_channels on Chronos jobs
10
+ const NON_CHANNEL_ORIGINS = new Set(['ui', 'api', 'cli', 'chronos', 'webhook']);
11
+ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule_expression, timezone, notify_channels }) => {
9
12
  if (ChronosWorker.isExecuting) {
10
13
  return JSON.stringify({ success: false, error: 'Cannot create a new Chronos job from within an active Chronos execution.' });
11
14
  }
@@ -13,6 +16,20 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
13
16
  const cfg = ConfigManager.getInstance().getChronosConfig();
14
17
  const tz = timezone ?? cfg.timezone;
15
18
  const parsed = parseScheduleExpression(schedule_expression, schedule_type, { timezone: tz });
19
+ // Resolve notify_channels:
20
+ // - Explicitly provided → use as-is
21
+ // - Not provided → auto-detect from current conversation origin channel
22
+ // - If origin channel is not a real user-facing channel (ui/api/cli) → empty = broadcast all
23
+ let channels;
24
+ if (notify_channels !== undefined) {
25
+ channels = notify_channels;
26
+ }
27
+ else {
28
+ const originChannel = TaskRequestContext.get()?.origin_channel;
29
+ channels = originChannel && !NON_CHANNEL_ORIGINS.has(originChannel)
30
+ ? [originChannel]
31
+ : [];
32
+ }
16
33
  const repo = ChronosRepository.getInstance();
17
34
  const job = repo.createJob({
18
35
  prompt,
@@ -22,6 +39,7 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
22
39
  next_run_at: parsed.next_run_at,
23
40
  cron_normalized: parsed.cron_normalized,
24
41
  created_by: 'oracle',
42
+ notify_channels: channels,
25
43
  });
26
44
  return JSON.stringify({
27
45
  success: true,
@@ -31,6 +49,7 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
31
49
  human_readable: parsed.human_readable,
32
50
  next_run_at: job.next_run_at != null ? new Date(job.next_run_at).toISOString() : null,
33
51
  timezone: job.timezone,
52
+ notify_channels: job.notify_channels,
34
53
  });
35
54
  }
36
55
  catch (err) {
@@ -42,7 +61,9 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
42
61
  'Use schedule_type "once" for a single execution (e.g. "tomorrow at 9am", "in 2 hours", "2026-03-01T09:00:00"), ' +
43
62
  '"cron" for a recurring cron expression (e.g. "0 9 * * 1-5" = every weekday at 9am), ' +
44
63
  '"interval" for natural interval phrases (e.g. "every 30 minutes", "every 2 hours", "every day"). ' +
45
- 'Returns the created job ID and the human-readable schedule confirmation.',
64
+ 'Returns the created job ID and the human-readable schedule confirmation. ' +
65
+ 'Use notify_channels to control where the result is delivered: omit to auto-detect from current channel, ' +
66
+ 'pass ["discord"] or ["telegram"] for a specific channel, or [] for all active channels.',
46
67
  schema: z.object({
47
68
  prompt: z.string().describe('The prompt text to send to Oracle at the scheduled time.'),
48
69
  schedule_type: z
@@ -57,6 +78,13 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
57
78
  .string()
58
79
  .optional()
59
80
  .describe('IANA timezone (e.g. "America/Sao_Paulo"). Defaults to the global Chronos timezone config.'),
81
+ notify_channels: z
82
+ .array(z.string())
83
+ .optional()
84
+ .describe('Channels to notify when this job fires. ' +
85
+ 'Omit to auto-use the current conversation channel. ' +
86
+ 'Pass [] to broadcast to all active channels. ' +
87
+ 'Pass ["discord"], ["telegram"], or ["discord","telegram"] for specific channels.'),
60
88
  }),
61
89
  });
62
90
  // ─── chronos_list ────────────────────────────────────────────────────────────
@@ -11,8 +11,7 @@ Neo built-in capabilities (always available — no MCP required):
11
11
  • Analytics: message_count, token_usage, provider_model_usage — message counts and token/cost usage stats
12
12
  • Tasks: task_query — look up task status by id or session
13
13
  • MCP Management: mcp_list, mcp_manage — list/add/update/delete/enable/disable MCP servers; use action "reload" to reload tools across all agents after config changes
14
- • Webhooks: webhook_list, webhook_manage — create/update/delete webhooks; create returns api_key
15
- • Trinity DB: trinity_db_list, trinity_db_manage — register/update/delete/test connection/refresh schema for databases`.trim();
14
+ • Webhooks: webhook_list, webhook_manage — create/update/delete webhooks; create returns api_key`.trim();
16
15
  const NEO_BASE_DESCRIPTION = `Delegate execution to Neo asynchronously.
17
16
 
18
17
  This tool creates a background task and returns an acknowledgement with task id.
@@ -29,7 +28,7 @@ function buildCatalogSection(mcpTools) {
29
28
  if (mcpTools.length === 0) {
30
29
  return "\n\nRuntime MCP tools: none currently loaded.";
31
30
  }
32
- const maxItems = 32;
31
+ const maxItems = 500;
33
32
  const lines = mcpTools.slice(0, maxItems).map((t) => {
34
33
  const desc = normalizeDescription(t.description).slice(0, 120);
35
34
  return `- ${t.name}: ${desc}`;
@@ -1,18 +1,11 @@
1
1
  import { WebhookRepository } from './repository.js';
2
2
  import { TaskRepository } from '../tasks/repository.js';
3
3
  import { DisplayManager } from '../display.js';
4
+ import { ChannelRegistry } from '../../channels/registry.js';
4
5
  const STALE_NOTIFICATION_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
5
6
  export class WebhookDispatcher {
6
- static telegramAdapter = null;
7
7
  static oracle = null;
8
8
  display = DisplayManager.getInstance();
9
- /**
10
- * Called at boot time after TelegramAdapter.connect() succeeds,
11
- * so Telegram notifications can be dispatched from any trigger.
12
- */
13
- static setTelegramAdapter(adapter) {
14
- WebhookDispatcher.telegramAdapter = adapter;
15
- }
16
9
  /**
17
10
  * Called at boot time with the Oracle instance so webhooks can use
18
11
  * the full Oracle (MCPs, apoc_delegate, memory, etc.).
@@ -54,18 +47,14 @@ export class WebhookDispatcher {
54
47
  else {
55
48
  repo.updateNotificationResult(notificationId, 'completed', response);
56
49
  this.display.log(`Webhook "${webhook.name}" completed with direct response (notification: ${notificationId})`, { source: 'Webhooks', level: 'success' });
57
- if (webhook.notification_channels.includes('telegram')) {
58
- await this.sendTelegram(webhook.name, response, 'completed');
59
- }
50
+ await this.notifyChannels(webhook, response, 'completed');
60
51
  }
61
52
  }
62
53
  catch (err) {
63
54
  const result = `Execution error: ${err.message}`;
64
55
  this.display.log(`Webhook "${webhook.name}" failed: ${err.message}`, { source: 'Webhooks', level: 'error' });
65
56
  repo.updateNotificationResult(notificationId, 'failed', result);
66
- if (webhook.notification_channels.includes('telegram')) {
67
- await this.sendTelegram(webhook.name, result, 'failed');
68
- }
57
+ await this.notifyChannels(webhook, result, 'failed');
69
58
  }
70
59
  }
71
60
  /**
@@ -140,23 +129,23 @@ Analyze the payload above and follow the instructions provided. Be concise and a
140
129
  }
141
130
  }
142
131
  /**
143
- * Sends a formatted Telegram message to all allowed users.
144
- * Silently skips if the adapter is not connected.
132
+ * Sends a notification to all channels configured on the webhook.
133
+ * Uses ChannelRegistry no adapter references needed here.
145
134
  */
146
- async sendTelegram(webhookName, result, status) {
147
- const adapter = WebhookDispatcher.telegramAdapter;
148
- if (!adapter) {
149
- this.display.log('Telegram notification skipped — adapter not connected.', { source: 'Webhooks', level: 'warning' });
150
- return;
151
- }
152
- try {
153
- const icon = status === 'completed' ? '✅' : '❌';
154
- const truncated = result.length > 3500 ? result.slice(0, 3500) + '' : result;
155
- const message = `${icon} Webhook: ${webhookName}\n\n${truncated}`;
156
- await adapter.sendMessage(message);
157
- }
158
- catch (err) {
159
- this.display.log(`Failed to send Telegram notification for webhook "${webhookName}": ${err.message}`, { source: 'Webhooks', level: 'error' });
135
+ async notifyChannels(webhook, result, status) {
136
+ const icon = status === 'completed' ? '✅' : '❌';
137
+ const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
138
+ const message = `${icon} Webhook: ${webhook.name}\n\n${truncated}`;
139
+ for (const ch of webhook.notification_channels) {
140
+ const adapter = ChannelRegistry.get(ch);
141
+ if (adapter) {
142
+ await adapter.sendMessage(message).catch((err) => {
143
+ this.display.log(`Failed to send notification via "${ch}" for webhook "${webhook.name}": ${err.message}`, { source: 'Webhooks', level: 'error' });
144
+ });
145
+ }
146
+ else {
147
+ this.display.log(`Notification skipped for channel "${ch}" — adapter not registered.`, { source: 'Webhooks', level: 'warning' });
148
+ }
160
149
  }
161
150
  }
162
151
  }
@@ -31,7 +31,7 @@ export const DEFAULT_CONFIG = {
31
31
  },
32
32
  channels: {
33
33
  telegram: { enabled: false, allowedUsers: [] },
34
- discord: { enabled: false },
34
+ discord: { enabled: false, allowedUsers: [] },
35
35
  },
36
36
  ui: {
37
37
  enabled: true,