morpheus-cli 0.6.2 → 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.
- package/README.md +75 -3
- package/dist/channels/discord.js +580 -0
- package/dist/channels/registry.js +41 -0
- package/dist/channels/telegram.js +1 -0
- package/dist/cli/commands/restart.js +2 -4
- package/dist/cli/commands/start.js +20 -5
- package/dist/config/manager.js +3 -2
- package/dist/config/schemas.js +1 -0
- package/dist/http/routers/chronos.js +5 -1
- package/dist/runtime/__tests__/manual_start_verify.js +1 -1
- package/dist/runtime/chronos/repository.js +14 -3
- package/dist/runtime/chronos/worker.js +25 -16
- package/dist/runtime/chronos/worker.test.js +1 -0
- package/dist/runtime/oracle.js +11 -0
- package/dist/runtime/tasks/dispatcher.js +34 -24
- package/dist/runtime/tools/chronos-tools.js +30 -2
- package/dist/runtime/webhooks/dispatcher.js +19 -30
- package/dist/types/config.js +1 -1
- package/dist/ui/assets/{index-DVQvTlPe.js → index-DTUgtIcM.js} +36 -36
- package/dist/ui/index.html +1 -1
- package/dist/ui/sw.js +1 -1
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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();
|
package/dist/config/manager.js
CHANGED
|
@@ -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,
|
|
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
|
package/dist/config/schemas.js
CHANGED
|
@@ -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;
|
|
@@ -93,11 +89,13 @@ export class ChronosWorker {
|
|
|
93
89
|
// This avoids persisting an AIMessage with the marker in conversation history,
|
|
94
90
|
// which would cause the LLM to reproduce the format in future scheduling responses.
|
|
95
91
|
const promptWithContext = `[CHRONOS EXECUTION — job_id: ${job.id}]\n${job.prompt}`;
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 };
|
|
101
99
|
// Hard-block Chronos management tools during execution.
|
|
102
100
|
ChronosWorker.isExecuting = true;
|
|
103
101
|
const response = await this.oracle.chat(promptWithContext, undefined, false, taskContext);
|
|
@@ -126,15 +124,26 @@ export class ChronosWorker {
|
|
|
126
124
|
}
|
|
127
125
|
}
|
|
128
126
|
async notify(job, response) {
|
|
129
|
-
if (
|
|
127
|
+
if (ChannelRegistry.getAll().length === 0)
|
|
130
128
|
return;
|
|
131
|
-
const display = DisplayManager.getInstance();
|
|
132
129
|
const header = `⏰ *Chronos* — _${job.prompt.slice(0, 80)}${job.prompt.length > 80 ? '…' : ''}_\n\n`;
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
|
135
134
|
}
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
147
|
}
|
|
139
148
|
}
|
|
140
149
|
}
|
package/dist/runtime/oracle.js
CHANGED
|
@@ -212,6 +212,17 @@ Rules:
|
|
|
212
212
|
10. After enqueuing all required delegated tasks for the current message, stop calling tools and return a concise acknowledgement.
|
|
213
213
|
11. If a delegation is rejected as "not atomic", immediately split into smaller delegations and retry.
|
|
214
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
|
+
|
|
215
226
|
## Chronos Scheduled Execution
|
|
216
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.
|
|
217
228
|
|
|
@@ -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
|
-
//
|
|
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
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
TaskDispatcher.display.log(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ────────────────────────────────────────────────────────────
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
*
|
|
132
|
+
* Sends a notification to all channels configured on the webhook.
|
|
133
|
+
* Uses ChannelRegistry — no adapter references needed here.
|
|
145
134
|
*/
|
|
146
|
-
async
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
}
|