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.
- package/.env.coco +11 -0
- package/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/SKILL.md +314 -0
- package/backup.js +57 -0
- package/bin/cli.js +41 -0
- package/bridge.js +325 -0
- package/claude-mcp.json +10 -0
- package/clients/coco-client.ts +245 -0
- package/clients/coco_client.py +216 -0
- package/coco-aliases.sh +10 -0
- package/coco-cli.js +1002 -0
- package/coco-tool.js +177 -0
- package/coco.js +26 -0
- package/cursor-mcp.json +3 -0
- package/doctor.js +24 -0
- package/hermes-forwarder.js +152 -0
- package/hermes.example.json +9 -0
- package/index.js +52 -0
- package/lib/backup.js +256 -0
- package/lib/bus.js +516 -0
- package/lib/daemon.js +96 -0
- package/lib/doctor.js +333 -0
- package/lib/hermes.js +162 -0
- package/lib/mcp.js +730 -0
- package/lib/memory.js +667 -0
- package/lib/orchestrator.js +426 -0
- package/lib/scheduler.js +259 -0
- package/lib/tunnel.js +317 -0
- package/mcporter.example.json +14 -0
- package/opencode-mcp.json +10 -0
- package/package.json +76 -0
- package/scripts/install.bat +5 -0
- package/scripts/install.ps1 +100 -0
- package/setup.js +320 -0
- package/tunnel.js +66 -0
- package/webhook-gateway.js +420 -0
|
@@ -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 };
|
package/lib/scheduler.js
ADDED
|
@@ -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 };
|