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.
- 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 +31 -23
- package/dist/runtime/chronos/worker.test.js +1 -0
- package/dist/runtime/oracle.js +38 -1
- package/dist/runtime/tasks/dispatcher.js +34 -24
- package/dist/runtime/tools/chronos-tools.js +30 -2
- package/dist/runtime/tools/neo-tool.js +2 -3
- 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;
|
|
@@ -88,20 +84,21 @@ export class ChronosWorker {
|
|
|
88
84
|
session_id: activeSessionId,
|
|
89
85
|
});
|
|
90
86
|
try {
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
const
|
|
100
|
-
?
|
|
101
|
-
:
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
}
|
package/dist/runtime/oracle.js
CHANGED
|
@@ -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
|
-
//
|
|
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 ────────────────────────────────────────────────────────────
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
}
|