bus-agent 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * CoCo Orchestrator — Auto-Reply Rules + Agent Workflows
3
+ *
4
+ * Two systems in one:
5
+ *
6
+ * 1. Auto-Reply Rules: When message matches pattern → forward/reply
7
+ * 2. Agent Orchestrator: Pipeline of agents, each step feeds into next
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const RULES_FILE = path.join(__dirname, '..', '.bus', 'auto-reply-rules.json');
13
+ const WORKFLOWS_FILE = path.join(__dirname, '..', '.bus', 'workflows.json');
14
+
15
+ class Orchestrator {
16
+ constructor(bus) {
17
+ this.bus = bus;
18
+ this._rules = [];
19
+ this._workflows = [];
20
+ this._reload();
21
+ }
22
+
23
+ _reload() {
24
+ this._rules = this._loadJson(RULES_FILE, []);
25
+ this._workflows = this._loadJson(WORKFLOWS_FILE, []);
26
+ this._running = false;
27
+ this._timer = null;
28
+ this._lastCheck = {};
29
+ }
30
+
31
+ // ═══════════════════════════════════════════════════
32
+ // PART 1: Auto-Reply Rules
33
+ // ═══════════════════════════════════════════════════
34
+
35
+ addRule(rule) {
36
+ /**
37
+ * rule = {
38
+ * id: 'review-code', // optional, auto-generated
39
+ * description: 'Forward code reviews to agent-reviewer',
40
+ * match: {
41
+ * from: 'andul', // sender filter (optional, * = any)
42
+ * contains: 'code review', // text in message (optional)
43
+ * regex: 'PR #\\d+', // regex pattern (optional)
44
+ * metadata_match: { // match against metadata fields
45
+ * priority: 'high'
46
+ * },
47
+ * },
48
+ * action: {
49
+ * type: 'forward', // 'forward' | 'reply' | 'broadcast'
50
+ * to: 'reviewer', // target agent or #channel
51
+ * transform: 'Appended: {message}', // optional message transform
52
+ * },
53
+ * enabled: true,
54
+ * max_loops: 3, // prevent infinite loops
55
+ * }
56
+ */
57
+ if (!rule.id) rule.id = `rule_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
58
+ if (!rule.match) return { ok: false, error: 'Rule needs "match" config' };
59
+ if (!rule.action) return { ok: false, error: 'Rule needs "action" config' };
60
+ rule.enabled = rule.enabled !== false;
61
+ rule.loop_count = 0;
62
+ rule.created_at = rule.created_at || new Date().toISOString();
63
+ this._rules.push(rule);
64
+ this._saveJson(RULES_FILE, this._rules);
65
+ return { ok: true, rule_id: rule.id };
66
+ }
67
+
68
+ removeRule(ruleId) {
69
+ const idx = this._rules.findIndex(r => r.id === ruleId);
70
+ if (idx === -1) return { ok: false, error: 'Rule not found' };
71
+ this._rules.splice(idx, 1);
72
+ this._saveJson(RULES_FILE, this._rules);
73
+ return { ok: true };
74
+ }
75
+
76
+ listRules() {
77
+ return this._rules.map(r => ({
78
+ id: r.id,
79
+ description: r.description || '',
80
+ match: r.match,
81
+ action: r.action,
82
+ enabled: r.enabled,
83
+ created_at: r.created_at,
84
+ }));
85
+ }
86
+
87
+ _checkRules(msg) {
88
+ // Skip system messages and broadcasts from ourselves
89
+ if (!msg || !msg.message) return;
90
+ if (['coco', 'webhook', 'hermes-fwd'].includes(msg.from)) return;
91
+
92
+ for (const rule of this._rules) {
93
+ if (!rule.enabled) continue;
94
+ if (rule.loop_count >= (rule.max_loops || 3)) continue;
95
+
96
+ const match = rule.match;
97
+ let matched = true;
98
+
99
+ // Check sender
100
+ if (match.from && match.from !== '*' && msg.from !== match.from) matched = false;
101
+
102
+ // Check message content
103
+ if (matched && match.contains && !msg.message.toLowerCase().includes(match.contains.toLowerCase())) matched = false;
104
+
105
+ // Check regex
106
+ if (matched && match.regex) {
107
+ try {
108
+ if (!new RegExp(match.regex, 'i').test(msg.message)) matched = false;
109
+ } catch { matched = false; }
110
+ }
111
+
112
+ // Check metadata
113
+ if (matched && match.metadata_match) {
114
+ for (const [k, v] of Object.entries(match.metadata_match)) {
115
+ if ((msg.metadata || {})[k] !== v) matched = false;
116
+ }
117
+ }
118
+
119
+ if (matched) {
120
+ rule.loop_count++;
121
+ this._executeAction(rule, msg);
122
+ }
123
+ }
124
+ }
125
+
126
+ _executeAction(rule, originalMsg) {
127
+ const action = rule.action;
128
+ let message = originalMsg.message;
129
+
130
+ // Apply transform
131
+ if (action.transform) {
132
+ message = action.transform
133
+ .replace('{message}', originalMsg.message)
134
+ .replace('{from}', originalMsg.from)
135
+ .replace('{channel}', originalMsg.metadata?.channel || '');
136
+ }
137
+
138
+ switch (action.type) {
139
+ case 'forward':
140
+ if (action.to?.startsWith('#')) {
141
+ this.bus.channelSend(action.to.slice(1), 'coco', message);
142
+ console.log(`[Orchestrator] Rule "${rule.id}": fwd from ${originalMsg.from} to ${action.to}`);
143
+ } else if (action.to) {
144
+ this.bus.sendMessage('coco', action.to, message, {
145
+ type: 'auto_forwarded',
146
+ original_from: originalMsg.from,
147
+ rule_id: rule.id,
148
+ });
149
+ console.log(`[Orchestrator] Rule "${rule.id}": fwd from ${originalMsg.from} to ${action.to}`);
150
+ }
151
+ break;
152
+
153
+ case 'reply':
154
+ this.bus.sendMessage('coco', originalMsg.from, message, {
155
+ type: 'auto_reply',
156
+ rule_id: rule.id,
157
+ in_reply_to: originalMsg.id,
158
+ });
159
+ console.log(`[Orchestrator] Rule "${rule.id}": auto-reply to ${originalMsg.from}`);
160
+ break;
161
+
162
+ case 'broadcast':
163
+ this.bus.broadcastMessage('coco', message);
164
+ console.log(`[Orchestrator] Rule "${rule.id}": broadcast from ${originalMsg.from}`);
165
+ break;
166
+ }
167
+ }
168
+
169
+ // ═══════════════════════════════════════════════════
170
+ // PART 2: Agent Orchestrator Workflows
171
+ // ═══════════════════════════════════════════════════
172
+
173
+ createWorkflow(wf) {
174
+ /**
175
+ * workflow = {
176
+ * id: 'review-and-deploy',
177
+ * description: 'Review code, then deploy if approved',
178
+ * steps: [
179
+ * { agent: 'hermes', prompt: 'Review this: {input}', store: 'review_result' },
180
+ * { agent: 'deploy-bot', prompt: 'Deploy: {review_result}', store: 'deploy_result' },
181
+ * ],
182
+ * input: '', // initial input (filled at run time)
183
+ * enabled: true,
184
+ * }
185
+ */
186
+ if (!wf.id) wf.id = `wf_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
187
+ if (!wf.steps || wf.steps.length === 0) return { ok: false, error: 'Workflow needs steps' };
188
+ wf.enabled = wf.enabled !== false;
189
+ wf.created_at = wf.created_at || new Date().toISOString();
190
+ this._workflows.push(wf);
191
+ this._saveJson(WORKFLOWS_FILE, this._workflows);
192
+ return { ok: true, workflow_id: wf.id };
193
+ }
194
+
195
+ removeWorkflow(wfId) {
196
+ const idx = this._workflows.findIndex(w => w.id === wfId);
197
+ if (idx === -1) return { ok: false, error: 'Workflow not found' };
198
+ this._workflows.splice(idx, 1);
199
+ this._saveJson(WORKFLOWS_FILE, this._workflows);
200
+ return { ok: true };
201
+ }
202
+
203
+ listWorkflows() {
204
+ return this._workflows.map(w => ({
205
+ id: w.id,
206
+ description: w.description || '',
207
+ steps: w.steps.length,
208
+ enabled: w.enabled,
209
+ created_at: w.created_at,
210
+ }));
211
+ }
212
+
213
+ async runWorkflow(wfId, input, requester) {
214
+ const wf = this._workflows.find(w => w.id === wfId);
215
+ if (!wf) return { ok: false, error: 'Workflow not found' };
216
+ if (!wf.enabled) return { ok: false, error: 'Workflow is disabled' };
217
+
218
+ const context = { input };
219
+ const results = [];
220
+
221
+ for (const step of wf.steps) {
222
+ const prompt = step.prompt.replace(/\{(\w+)\}/g, (_, key) => context[key] || '');
223
+ const stepResult = await this._runStep(step, prompt, requester);
224
+ results.push({
225
+ step: step.agent,
226
+ result: stepResult,
227
+ store: step.store,
228
+ });
229
+
230
+ if (step.store && stepResult) {
231
+ context[step.store] = stepResult;
232
+ }
233
+ }
234
+
235
+ return { ok: true, workflow_id: wfId, steps: results.length, results };
236
+ }
237
+
238
+ async _runStep(step, prompt, requester) {
239
+ // Send message to agent, wait for reply (up to timeout)
240
+ return new Promise((resolve) => {
241
+ const timeoutMs = step.timeout || 120000;
242
+
243
+ // Send prompt to target agent
244
+ this.bus.sendMessage(requester || 'coco', step.agent, prompt, {
245
+ type: 'orchestrator_task',
246
+ step: step.agent,
247
+ expect_reply: true,
248
+ });
249
+
250
+ // Long-poll for reply
251
+ // We use a one-time waiter on the requester's inbox
252
+ const agentName = requester || 'coco';
253
+ const inboxDir = path.join(__dirname, '..', '.bus', 'messages', agentName);
254
+
255
+ const startCount = fs.existsSync(inboxDir)
256
+ ? fs.readdirSync(inboxDir).filter(f => f.endsWith('.json')).length
257
+ : 0;
258
+
259
+ const pollInterval = setInterval(() => {
260
+ if (!fs.existsSync(inboxDir)) return;
261
+
262
+ const files = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json'));
263
+ if (files.length > startCount) {
264
+ clearInterval(pollInterval);
265
+ clearTimeout(timeoutTimer);
266
+
267
+ // Get the last message (most recent)
268
+ const newestFile = files[files.length - 1];
269
+ try {
270
+ const msg = JSON.parse(fs.readFileSync(path.join(inboxDir, newestFile), 'utf-8'));
271
+ if (msg.from === step.agent) {
272
+ resolve(msg.message);
273
+ return;
274
+ }
275
+ } catch {}
276
+ resolve('[Error reading reply]');
277
+ }
278
+ }, 2000);
279
+
280
+ const timeoutTimer = setTimeout(() => {
281
+ clearInterval(pollInterval);
282
+ resolve(`[Timeout: ${step.agent} did not respond in ${timeoutMs / 1000}s]`);
283
+ }, timeoutMs);
284
+ });
285
+ }
286
+
287
+ start() {
288
+ if (this._running) return;
289
+ this._running = true;
290
+ console.log(`[Orchestrator] Started: ${this._rules.filter(r => r.enabled).length} rules, ${this._workflows.filter(w => w.enabled).length} workflows`);
291
+ }
292
+
293
+ stop() {
294
+ this._running = false;
295
+ if (this._timer) {
296
+ clearInterval(this._timer);
297
+ this._timer = null;
298
+ }
299
+ }
300
+
301
+ // ── Tools ──
302
+
303
+ getTools() {
304
+ return [
305
+ // Auto-Reply Rules
306
+ {
307
+ name: 'auto_reply_add',
308
+ description: 'Add an auto-reply rule: when a message matches criteria, auto-forward/reply/broadcast',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ id: { type: 'string' },
313
+ description: { type: 'string' },
314
+ match: {
315
+ type: 'object',
316
+ properties: {
317
+ from: { type: 'string', description: 'Sender filter (agent name or * for any)' },
318
+ contains: { type: 'string', description: 'Message must contain this text' },
319
+ regex: { type: 'string', description: 'Message must match this regex' },
320
+ metadata_match: { type: 'object', description: 'Match against metadata fields' },
321
+ },
322
+ },
323
+ action: {
324
+ type: 'object',
325
+ properties: {
326
+ type: { type: 'string', enum: ['forward', 'reply', 'broadcast'], description: 'Action type' },
327
+ to: { type: 'string', description: 'Target: agent name or #channel (for forward)' },
328
+ transform: { type: 'string', description: 'Transform message. Variables: {message}, {from}, {channel}' },
329
+ },
330
+ required: ['type'],
331
+ },
332
+ enabled: { type: 'boolean' },
333
+ max_loops: { type: 'number', description: 'Max times this rule can fire per message chain' },
334
+ },
335
+ required: ['match', 'action'],
336
+ },
337
+ },
338
+ {
339
+ name: 'auto_reply_remove',
340
+ description: 'Remove an auto-reply rule',
341
+ inputSchema: {
342
+ type: 'object',
343
+ properties: {
344
+ rule_id: { type: 'string' },
345
+ },
346
+ required: ['rule_id'],
347
+ },
348
+ },
349
+ {
350
+ name: 'auto_reply_list',
351
+ description: 'List all auto-reply rules',
352
+ inputSchema: { type: 'object', properties: {} },
353
+ },
354
+ // Workflows
355
+ {
356
+ name: 'workflow_create',
357
+ description: 'Create a multi-step agent workflow pipeline',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ id: { type: 'string' },
362
+ description: { type: 'string' },
363
+ steps: {
364
+ type: 'array',
365
+ items: {
366
+ type: 'object',
367
+ properties: {
368
+ agent: { type: 'string', description: 'Target agent name' },
369
+ prompt: { type: 'string', description: 'Prompt template. Use {input}, {prev_result}, etc.' },
370
+ store: { type: 'string', description: 'Variable name to store result for next steps' },
371
+ timeout: { type: 'number', description: 'Max wait per step in ms' },
372
+ },
373
+ required: ['agent', 'prompt'],
374
+ },
375
+ },
376
+ },
377
+ required: ['steps'],
378
+ },
379
+ },
380
+ {
381
+ name: 'workflow_run',
382
+ description: 'Run a workflow pipeline with initial input',
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ workflow_id: { type: 'string' },
387
+ input: { type: 'string', description: 'Initial input text' },
388
+ requester: { type: 'string', description: 'Agent name to receive results' },
389
+ },
390
+ required: ['workflow_id', 'input'],
391
+ },
392
+ },
393
+ {
394
+ name: 'workflow_remove',
395
+ description: 'Remove a workflow',
396
+ inputSchema: {
397
+ type: 'object',
398
+ properties: {
399
+ workflow_id: { type: 'string' },
400
+ },
401
+ required: ['workflow_id'],
402
+ },
403
+ },
404
+ {
405
+ name: 'workflow_list',
406
+ description: 'List all workflows',
407
+ inputSchema: { type: 'object', properties: {} },
408
+ },
409
+ ];
410
+ }
411
+
412
+ _loadJson(filePath, defaultVal) {
413
+ try {
414
+ if (fs.existsSync(filePath)) {
415
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
416
+ }
417
+ } catch {}
418
+ return defaultVal;
419
+ }
420
+
421
+ _saveJson(filePath, data) {
422
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
423
+ }
424
+ }
425
+
426
+ module.exports = { Orchestrator };
@@ -0,0 +1,259 @@
1
+ /**
2
+ * CoCo Scheduler — Schedule messages to be sent on the bus at specified times
3
+ *
4
+ * Reads a schedule file (.bus/schedule.json) and fires messages on time.
5
+ * Supports cron-like intervals, one-shot, and recurring scheduled messages.
6
+ *
7
+ * Usage:
8
+ * node index.js --scheduler # Run alongside daemon
9
+ *
10
+ * Schedule format (.bus/schedule.json):
11
+ * [
12
+ * {
13
+ * "id": "daily-standup",
14
+ * "cron": "0 9 * * 1-5", // minute hour day month weekday
15
+ * "from": "coco",
16
+ * "to": "dev-team", // agent name or channel (prefix with #)
17
+ * "message": "Time for standup!",
18
+ * "metadata": { "type": "reminder" },
19
+ * "enabled": true,
20
+ * "tz": "Asia/Bangkok"
21
+ * }
22
+ * ]
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const SCHEDULE_FILE = path.join(__dirname, '..', '.bus', 'schedule.json');
28
+
29
+ class Scheduler {
30
+ constructor(bus) {
31
+ this.bus = bus;
32
+ this._jobs = [];
33
+ this._timer = null;
34
+ this._running = false;
35
+ this.loadSchedule();
36
+ }
37
+
38
+ loadSchedule() {
39
+ try {
40
+ if (fs.existsSync(SCHEDULE_FILE)) {
41
+ this._jobs = JSON.parse(fs.readFileSync(SCHEDULE_FILE, 'utf-8'));
42
+ return this._jobs;
43
+ }
44
+ } catch (err) {
45
+ console.error(`[Scheduler] Error loading schedule: ${err.message}`);
46
+ }
47
+ this._jobs = [];
48
+ return [];
49
+ }
50
+
51
+ saveSchedule() {
52
+ fs.writeFileSync(SCHEDULE_FILE, JSON.stringify(this._jobs, null, 2), 'utf-8');
53
+ }
54
+
55
+ addJob(job) {
56
+ if (!job.id) job.id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
57
+ if (!job.cron && !job.at) {
58
+ return { ok: false, error: 'Must specify "cron" or "at"' };
59
+ }
60
+ job.enabled = job.enabled !== false;
61
+ job.created_at = job.created_at || new Date().toISOString();
62
+ this._jobs.push(job);
63
+ this.saveSchedule();
64
+ return { ok: true, job_id: job.id };
65
+ }
66
+
67
+ removeJob(jobId) {
68
+ const idx = this._jobs.findIndex(j => j.id === jobId);
69
+ if (idx === -1) return { ok: false, error: 'Job not found' };
70
+ this._jobs.splice(idx, 1);
71
+ this.saveSchedule();
72
+ return { ok: true };
73
+ }
74
+
75
+ listJobs() {
76
+ return this._jobs.map(j => ({
77
+ id: j.id,
78
+ description: j.message?.substring(0, 60),
79
+ schedule: j.cron || j.at,
80
+ to: j.to,
81
+ enabled: j.enabled,
82
+ created_at: j.created_at,
83
+ }));
84
+ }
85
+
86
+ start() {
87
+ if (this._running) return;
88
+ this._running = true;
89
+ this.loadSchedule();
90
+ this._tick();
91
+ this._timer = setInterval(() => this._tick(), 60000);
92
+ console.log(`[Scheduler] Started with ${this._jobs.filter(j => j.enabled).length} enabled jobs`);
93
+ }
94
+
95
+ stop() {
96
+ this._running = false;
97
+ if (this._timer) {
98
+ clearInterval(this._timer);
99
+ this._timer = null;
100
+ }
101
+ }
102
+
103
+ _tick() {
104
+ const now = new Date();
105
+ const minuteKey = this._minuteKey(now);
106
+
107
+ for (const job of this._jobs) {
108
+ if (!job.enabled) continue;
109
+
110
+ let shouldFire = false;
111
+
112
+ if (job.cron) {
113
+ shouldFire = this._matchCron(job.cron, now, job.tz);
114
+ } else if (job.at) {
115
+ const atTime = new Date(job.at).getTime();
116
+ if (atTime > 0 && Math.abs(now.getTime() - atTime) < 60000) {
117
+ shouldFire = true;
118
+ // One-shot: disable after firing
119
+ job.enabled = false;
120
+ this.saveSchedule();
121
+ }
122
+ }
123
+
124
+ if (shouldFire) {
125
+ this._fire(job);
126
+ }
127
+ }
128
+ }
129
+
130
+ _fire(job) {
131
+ const to = job.to || 'broadcast';
132
+ const from = job.from || 'coco';
133
+
134
+ console.log(`[Scheduler] Firing job "${job.id}" → ${to}: ${(job.message || '').substring(0, 60)}`);
135
+
136
+ if (to === 'broadcast') {
137
+ this.bus.broadcastMessage(from, job.message);
138
+ } else if (to.startsWith('#')) {
139
+ const channelId = to.slice(1);
140
+ this.bus.channelSend(channelId, from, job.message);
141
+ } else {
142
+ this.bus.sendMessage(from, to, job.message, job.metadata || {});
143
+ }
144
+ }
145
+
146
+ _minuteKey(date) {
147
+ return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
148
+ }
149
+
150
+ _matchCron(expr, date, tz) {
151
+ // Simple cron: "minute hour day month weekday"
152
+ // * = any, numbers match
153
+ const parts = expr.trim().split(/\s+/);
154
+ if (parts.length < 5) return false;
155
+
156
+ const d = date;
157
+
158
+ // Resolve components — timezone-aware if specified
159
+ let minute, hour, day, month, weekday;
160
+ if (tz) {
161
+ try {
162
+ const parts = new Intl.DateTimeFormat('en-CA', {
163
+ timeZone: tz,
164
+ hour12: false,
165
+ year: 'numeric', month: '2-digit', day: '2-digit',
166
+ hour: '2-digit', minute: '2-digit', weekday: 'short'
167
+ }).formatToParts(d);
168
+ const map = {};
169
+ for (const p of parts) map[p.type] = p.value;
170
+ minute = parseInt(map.minute, 10);
171
+ hour = parseInt(map.hour, 10);
172
+ day = parseInt(map.day, 10);
173
+ month = parseInt(map.month, 10);
174
+ const wdMap = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 0 };
175
+ // weekday type from formatToParts is 'weekday', value is short like 'Wed'
176
+ weekday = wdMap[map.weekday] !== undefined ? wdMap[map.weekday] : 0;
177
+ } catch { fallbackToLocal(); }
178
+ } else {
179
+ fallbackToLocal();
180
+ }
181
+
182
+ function fallbackToLocal() {
183
+ minute = d.getMinutes();
184
+ hour = d.getHours();
185
+ day = d.getDate();
186
+ month = d.getMonth() + 1;
187
+ weekday = d.getDay();
188
+ }
189
+
190
+ const checks = [
191
+ { field: minute, pattern: parts[0] },
192
+ { field: hour, pattern: parts[1] },
193
+ { field: day, pattern: parts[2] },
194
+ { field: month, pattern: parts[3] },
195
+ { field: weekday, pattern: parts[4] },
196
+ ];
197
+
198
+ // All fields must match (or be *)
199
+ return checks.every(c => this._matchField(c.field, c.pattern));
200
+ }
201
+
202
+ _matchField(value, pattern) {
203
+ if (pattern === '*') return true;
204
+ if (pattern.includes(',')) {
205
+ return pattern.split(',').some(p => this._matchField(value, p.trim()));
206
+ }
207
+ if (pattern.includes('*/')) {
208
+ const step = parseInt(pattern.split('/')[1], 10);
209
+ return step > 0 && value % step === 0;
210
+ }
211
+ if (pattern.includes('-')) {
212
+ const [lo, hi] = pattern.split('-').map(Number);
213
+ return value >= lo && value <= hi;
214
+ }
215
+ return parseInt(pattern, 10) === value;
216
+ }
217
+
218
+ getScheduleTools() {
219
+ return [
220
+ {
221
+ name: 'scheduler_add',
222
+ description: 'Add a scheduled message. Specify cron expression (minute hour day month weekday) or exact time (ISO).',
223
+ inputSchema: {
224
+ type: 'object',
225
+ properties: {
226
+ id: { type: 'string', description: 'Unique job ID (optional, auto-generated)' },
227
+ cron: { type: 'string', description: 'Cron expression: "minute hour day month weekday" (e.g. "0 9 * * 1-5" = weekdays 9am)' },
228
+ at: { type: 'string', description: 'ISO timestamp for one-shot scheduling (e.g. "2026-06-25T09:00:00+07:00")' },
229
+ from: { type: 'string', description: 'Sender agent name (default: coco)' },
230
+ to: { type: 'string', description: 'Target: agent name, "#channel", or "broadcast" (default: broadcast)' },
231
+ message: { type: 'string', description: 'Message content to send' },
232
+ metadata: { type: 'object', description: 'Optional metadata' },
233
+ enabled: { type: 'boolean', description: 'Enable the job (default: true)' },
234
+ tz: { type: 'string', description: 'Timezone for cron (e.g. "Asia/Bangkok")' },
235
+ },
236
+ required: ['message'],
237
+ },
238
+ },
239
+ {
240
+ name: 'scheduler_remove',
241
+ description: 'Remove a scheduled job by ID',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ job_id: { type: 'string', description: 'Job ID to remove' },
246
+ },
247
+ required: ['job_id'],
248
+ },
249
+ },
250
+ {
251
+ name: 'scheduler_list',
252
+ description: 'List all scheduled messages',
253
+ inputSchema: { type: 'object', properties: {} },
254
+ },
255
+ ];
256
+ }
257
+ }
258
+
259
+ module.exports = { Scheduler };