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
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoCo Doctor — Diagnostics & Health Check Module
|
|
3
|
+
*
|
|
4
|
+
* Export functions, no process.exit, accepts busDir as parameter.
|
|
5
|
+
* Used by: coco-cli.js, doctor.js (thin CLI wrapper)
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const REPORT_FILE = 'diagnostic-report.json';
|
|
11
|
+
|
|
12
|
+
function defaults(opts = {}) {
|
|
13
|
+
return {
|
|
14
|
+
busDir: opts.busDir || path.join(process.cwd(), '.bus'),
|
|
15
|
+
fix: !!opts.fix,
|
|
16
|
+
quick: !!opts.quick,
|
|
17
|
+
report: !!opts.report,
|
|
18
|
+
watch: !!opts.watch,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runDiagnostics(opts = {}) {
|
|
23
|
+
const cfg = defaults(opts);
|
|
24
|
+
const BUS_DIR = cfg.busDir;
|
|
25
|
+
const AGENTS_FILE = path.join(BUS_DIR, 'agents.json');
|
|
26
|
+
const MSGS_DIR = path.join(BUS_DIR, 'messages');
|
|
27
|
+
const CHANNELS_DIR = path.join(BUS_DIR, 'channels');
|
|
28
|
+
const EVENTS_DIR = path.join(BUS_DIR, 'events');
|
|
29
|
+
const SCHEDULE_FILE = path.join(BUS_DIR, 'schedule.json');
|
|
30
|
+
const RULES_FILE = path.join(BUS_DIR, 'auto-reply-rules.json');
|
|
31
|
+
const WORKFLOWS_FILE = path.join(BUS_DIR, 'workflows.json');
|
|
32
|
+
|
|
33
|
+
const tests = [];
|
|
34
|
+
let passed = 0, failed = 0, warnings = 0, fixes = 0;
|
|
35
|
+
const log = [];
|
|
36
|
+
|
|
37
|
+
function emit(line) { log.push(line); if (!cfg.quick) console.log(line); }
|
|
38
|
+
|
|
39
|
+
function check(name, fn) { tests.push({ name, fn }); }
|
|
40
|
+
function ok(name, detail) { passed++; emit(` ✅ ${name}${detail ? ' — ' + detail : ''}`); }
|
|
41
|
+
function warn(name, detail) { warnings++; emit(` ⚠️ ${name}${detail ? ' — ' + detail : ''}`); }
|
|
42
|
+
function fail(name, detail) { failed++; emit(` ❌ ${name}${detail ? ' — ' + detail : ''}`); }
|
|
43
|
+
function fix(name, detail) { fixes++; emit(` 🔧 Fixed: ${name}${detail ? ' — ' + detail : ''}`); }
|
|
44
|
+
|
|
45
|
+
function msAgo(ts) {
|
|
46
|
+
const diff = Date.now() - ts;
|
|
47
|
+
if (diff < 60000) return 'just now';
|
|
48
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
49
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
50
|
+
return `${Math.floor(diff / 86400000)}d ago`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Bus Directory ──
|
|
54
|
+
check('Bus directory exists', () => {
|
|
55
|
+
if (!fs.existsSync(BUS_DIR)) { fail('Bus directory missing', BUS_DIR); return false; }
|
|
56
|
+
ok('Bus directory', BUS_DIR); return true;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
check('Required subdirectories', () => {
|
|
60
|
+
let allOk = true;
|
|
61
|
+
for (const dir of [MSGS_DIR, CHANNELS_DIR, EVENTS_DIR]) {
|
|
62
|
+
if (!fs.existsSync(dir)) {
|
|
63
|
+
if (cfg.fix) { fs.mkdirSync(dir, { recursive: true }); fix(`Created ${path.basename(dir)}`); }
|
|
64
|
+
else { warn(`Missing ${path.basename(dir)} directory`); allOk = false; }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (allOk) ok('All required subdirectories exist');
|
|
68
|
+
return allOk;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
check('Bus directory permissions', () => {
|
|
72
|
+
try {
|
|
73
|
+
const testFile = path.join(BUS_DIR, '.doctor-test');
|
|
74
|
+
fs.writeFileSync(testFile, 'test', 'utf-8'); fs.unlinkSync(testFile);
|
|
75
|
+
ok('Read/Write permissions'); return true;
|
|
76
|
+
} catch { fail('Cannot write to bus directory — check permissions'); return false; }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── Agent Registry ──
|
|
80
|
+
check('Agent registry file', () => {
|
|
81
|
+
if (!fs.existsSync(AGENTS_FILE)) { warn('No agents registered yet'); return true; }
|
|
82
|
+
try {
|
|
83
|
+
const agents = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf-8'));
|
|
84
|
+
const count = Object.keys(agents).length;
|
|
85
|
+
ok('Agent registry', `${count} agent(s) registered`);
|
|
86
|
+
let issues = 0;
|
|
87
|
+
for (const [name, profile] of Object.entries(agents)) {
|
|
88
|
+
const missing = [];
|
|
89
|
+
if (!profile.last_seen) missing.push('last_seen');
|
|
90
|
+
if (!profile.registered_at) missing.push('registered_at');
|
|
91
|
+
if (missing.length > 0) { warn(`Agent "${name}" missing fields: ${missing.join(', ')}`); issues++; }
|
|
92
|
+
if (profile.last_seen) {
|
|
93
|
+
const age = Date.now() - new Date(profile.last_seen).getTime();
|
|
94
|
+
if (age > 86400000) { warn(`Agent "${name}" not seen for ${msAgo(age)}`); issues++; }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (issues === 0) ok('All agent profiles valid');
|
|
98
|
+
return true;
|
|
99
|
+
} catch (err) { fail('Agent registry corrupted', err.message); return false; }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── Orphaned Agents ──
|
|
103
|
+
check('Orphaned agents', () => {
|
|
104
|
+
if (!fs.existsSync(AGENTS_FILE)) return true;
|
|
105
|
+
const agents = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf-8'));
|
|
106
|
+
const orphans = [];
|
|
107
|
+
for (const [name] of Object.entries(agents)) {
|
|
108
|
+
const hasInbox = fs.existsSync(path.join(MSGS_DIR, name));
|
|
109
|
+
const hasOutbox = fs.existsSync(path.join(MSGS_DIR, `${name}_outbox`));
|
|
110
|
+
if (!hasInbox && !hasOutbox) {
|
|
111
|
+
const age = Date.now() - new Date(agents[name].last_seen).getTime();
|
|
112
|
+
if (age > 604800000) orphans.push(name);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (orphans.length > 0) {
|
|
116
|
+
warn(`${orphans.length} orphaned agent(s)`, orphans.join(', '));
|
|
117
|
+
if (cfg.fix) {
|
|
118
|
+
for (const name of orphans) { delete agents[name]; fix(`Removed orphaned agent "${name}" from registry`); }
|
|
119
|
+
fs.writeFileSync(AGENTS_FILE, JSON.stringify(agents, null, 2), 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
} else ok('No orphaned agents');
|
|
122
|
+
return true;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Messages ──
|
|
126
|
+
check('Message inboxes', () => {
|
|
127
|
+
if (!fs.existsSync(MSGS_DIR)) return true;
|
|
128
|
+
const inboxes = fs.readdirSync(MSGS_DIR).filter(d => fs.statSync(path.join(MSGS_DIR, d)).isDirectory());
|
|
129
|
+
if (inboxes.length === 0) { ok('No message inboxes'); return true; }
|
|
130
|
+
let total = 0, largest = { name: '', count: 0 }, old = 0;
|
|
131
|
+
for (const inbox of inboxes) {
|
|
132
|
+
const dir = path.join(MSGS_DIR, inbox);
|
|
133
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
134
|
+
total += files.length;
|
|
135
|
+
if (files.length > largest.count) largest = { name: inbox, count: files.length };
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
try {
|
|
138
|
+
const msg = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
|
|
139
|
+
if (Date.now() - new Date(msg.timestamp).getTime() > 604800000) old++;
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ok('Message system', `${total} messages across ${inboxes.length} inboxes`);
|
|
144
|
+
if (old > 0) warn(`${old} message(s) older than 7 days`, 'Consider archiving or cleanup');
|
|
145
|
+
if (largest.count > 100) warn(`Inbox "${largest.name}" has ${largest.count} messages`, 'Consider reading and deleting old ones');
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
check('Message file integrity', () => {
|
|
150
|
+
if (!fs.existsSync(MSGS_DIR)) return true;
|
|
151
|
+
let corrupted = 0;
|
|
152
|
+
const dirs = fs.readdirSync(MSGS_DIR).filter(d => fs.statSync(path.join(MSGS_DIR, d)).isDirectory());
|
|
153
|
+
for (const dir of dirs) {
|
|
154
|
+
const fullDir = path.join(MSGS_DIR, dir);
|
|
155
|
+
for (const f of fs.readdirSync(fullDir).filter(f => f.endsWith('.json'))) {
|
|
156
|
+
try { JSON.parse(fs.readFileSync(path.join(fullDir, f), 'utf-8')); }
|
|
157
|
+
catch { corrupted++; if (cfg.fix) { fs.unlinkSync(path.join(fullDir, f)); fix(`Removed corrupted message: ${dir}/${f}`); } }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (corrupted > 0) warn(`${corrupted} corrupted message file(s)`);
|
|
161
|
+
else ok('All message files valid JSON');
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── Channels ──
|
|
166
|
+
check('Channels', () => {
|
|
167
|
+
if (!fs.existsSync(CHANNELS_DIR)) return true;
|
|
168
|
+
const files = fs.readdirSync(CHANNELS_DIR).filter(f => f.endsWith('.json'));
|
|
169
|
+
if (files.length === 0) { ok('No channels'); return true; }
|
|
170
|
+
let valid = 0, issues = 0;
|
|
171
|
+
for (const f of files) {
|
|
172
|
+
try {
|
|
173
|
+
const ch = JSON.parse(fs.readFileSync(path.join(CHANNELS_DIR, f), 'utf-8'));
|
|
174
|
+
valid++;
|
|
175
|
+
const logDir = path.join(CHANNELS_DIR, ch.id, 'log');
|
|
176
|
+
if (fs.existsSync(logDir)) {
|
|
177
|
+
for (const lf of fs.readdirSync(logDir).filter(lf => lf.endsWith('.json'))) {
|
|
178
|
+
try { JSON.parse(fs.readFileSync(path.join(logDir, lf), 'utf-8')); }
|
|
179
|
+
catch {
|
|
180
|
+
if (cfg.fix) { fs.unlinkSync(path.join(logDir, lf)); fix(`Removed corrupted log entry: ${ch.id}/${lf}`); }
|
|
181
|
+
issues++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch { warn(`Channel file corrupted: ${f}`); issues++; }
|
|
186
|
+
}
|
|
187
|
+
if (issues === 0) ok(`${valid} channel(s)`, 'All valid');
|
|
188
|
+
return true;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── Scheduler ──
|
|
192
|
+
check('Schedule file', () => {
|
|
193
|
+
if (!fs.existsSync(SCHEDULE_FILE)) { ok('No scheduled jobs'); return true; }
|
|
194
|
+
try {
|
|
195
|
+
const jobs = JSON.parse(fs.readFileSync(SCHEDULE_FILE, 'utf-8'));
|
|
196
|
+
ok('Schedule', `${jobs.length} job(s), ${jobs.filter(j => j.enabled !== false).length} enabled`);
|
|
197
|
+
let issues = 0;
|
|
198
|
+
for (const job of jobs) {
|
|
199
|
+
if (!job.message) { warn(`Job "${job.id}" has no message`); issues++; }
|
|
200
|
+
if (!job.cron && !job.at) { warn(`Job "${job.id}" has no cron or at`); issues++; }
|
|
201
|
+
}
|
|
202
|
+
if (issues === 0) ok('All jobs valid');
|
|
203
|
+
return true;
|
|
204
|
+
} catch (err) { fail('Schedule file corrupted', err.message); return false; }
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── Auto-Reply Rules ──
|
|
208
|
+
check('Auto-reply rules', () => {
|
|
209
|
+
if (!fs.existsSync(RULES_FILE)) { ok('No auto-reply rules'); return true; }
|
|
210
|
+
try {
|
|
211
|
+
const rules = JSON.parse(fs.readFileSync(RULES_FILE, 'utf-8'));
|
|
212
|
+
ok('Auto-reply rules', `${rules.length} rule(s), ${rules.filter(r => r.enabled !== false).length} enabled`);
|
|
213
|
+
let issues = 0;
|
|
214
|
+
for (const rule of rules) {
|
|
215
|
+
if (!rule.match || !rule.action) { warn(`Rule "${rule.id}" missing match or action`); issues++; }
|
|
216
|
+
}
|
|
217
|
+
if (issues === 0) ok('All rules valid');
|
|
218
|
+
return true;
|
|
219
|
+
} catch (err) { fail('Auto-reply rules file corrupted', err.message); return false; }
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Workflows ──
|
|
223
|
+
check('Workflows', () => {
|
|
224
|
+
if (!fs.existsSync(WORKFLOWS_FILE)) { ok('No workflows'); return true; }
|
|
225
|
+
try {
|
|
226
|
+
const wfs = JSON.parse(fs.readFileSync(WORKFLOWS_FILE, 'utf-8'));
|
|
227
|
+
ok(`${wfs.length} workflow(s) defined`);
|
|
228
|
+
let issues = 0;
|
|
229
|
+
for (const wf of wfs) { if (!wf.steps || wf.steps.length === 0) { warn(`Workflow "${wf.id}" has no steps`); issues++; } }
|
|
230
|
+
if (issues === 0) ok('All workflows valid');
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err) { fail('Workflows file corrupted', err.message); return false; }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Disk Usage ──
|
|
236
|
+
check('Disk usage', () => {
|
|
237
|
+
function getSize(dir) {
|
|
238
|
+
if (!fs.existsSync(dir)) return 0;
|
|
239
|
+
let total = 0;
|
|
240
|
+
try {
|
|
241
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
242
|
+
const full = path.join(dir, e.name);
|
|
243
|
+
total += e.isDirectory() ? getSize(full) : fs.statSync(full).size;
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
return total;
|
|
247
|
+
}
|
|
248
|
+
const sizeMB = (getSize(BUS_DIR) / 1024 / 1024).toFixed(2);
|
|
249
|
+
ok('Bus storage', `${sizeMB} MB`);
|
|
250
|
+
if (parseFloat(sizeMB) > 50) warn('Bus directory exceeds 50 MB', 'Consider cleaning old messages and events');
|
|
251
|
+
return true;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── Stale PID Files ──
|
|
255
|
+
check('Stale PID files', () => {
|
|
256
|
+
const pidFiles = ['.coco-daemon.pid', '.hermes-mcp.pid'];
|
|
257
|
+
let found = 0;
|
|
258
|
+
for (const pf of pidFiles) {
|
|
259
|
+
const p = path.join(path.dirname(BUS_DIR), pf);
|
|
260
|
+
if (fs.existsSync(p)) {
|
|
261
|
+
try {
|
|
262
|
+
const pid = parseInt(fs.readFileSync(p, 'utf-8').trim());
|
|
263
|
+
try { process.kill(pid, 0); } catch {
|
|
264
|
+
warn(`Stale PID file: ${pf} (PID ${pid} not running)`); found++;
|
|
265
|
+
if (cfg.fix) { fs.unlinkSync(p); fix(`Removed stale PID file: ${pf}`); }
|
|
266
|
+
}
|
|
267
|
+
} catch { warn(`Corrupted PID file: ${pf}`); found++; if (cfg.fix) { fs.unlinkSync(p); fix(`Removed corrupted PID file: ${pf}`); } }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (found === 0) ok('No stale PID files');
|
|
271
|
+
return true;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Event Logs ──
|
|
275
|
+
check('Event logs', () => {
|
|
276
|
+
if (!fs.existsSync(EVENTS_DIR)) return true;
|
|
277
|
+
const files = fs.readdirSync(EVENTS_DIR).filter(f => f.endsWith('.jsonl'));
|
|
278
|
+
if (files.length === 0) { ok('No event logs'); return true; }
|
|
279
|
+
let totalLines = 0, corruptedLines = 0;
|
|
280
|
+
for (const f of files) {
|
|
281
|
+
try {
|
|
282
|
+
const content = fs.readFileSync(path.join(EVENTS_DIR, f), 'utf-8');
|
|
283
|
+
for (const line of content.split('\n').filter(Boolean)) {
|
|
284
|
+
totalLines++;
|
|
285
|
+
try { JSON.parse(line); } catch { corruptedLines++; }
|
|
286
|
+
}
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
ok('Event logs', `${files.length} file(s), ${totalLines} events`);
|
|
290
|
+
if (corruptedLines > 0) warn(`${corruptedLines} corrupted event log line(s)`);
|
|
291
|
+
return true;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── Run ──
|
|
295
|
+
if (!cfg.quick) {
|
|
296
|
+
console.log(`\n╔═══════════════════════════════════════════════╗\n║ CoCo — Diagnostic Report ║\n╚═══════════════════════════════════════════════╝`);
|
|
297
|
+
}
|
|
298
|
+
for (const test of tests) test.fn();
|
|
299
|
+
const summary = summaryText(passed, failed, warnings, fixes);
|
|
300
|
+
if (!cfg.quick) {
|
|
301
|
+
console.log(`\n ───────────────────────────────────────\n ${summary}${fixes > 0 ? `\n 🔧 Auto-fixes applied: ${fixes}` : ''}\n`);
|
|
302
|
+
} else console.log(summary);
|
|
303
|
+
|
|
304
|
+
// Write report
|
|
305
|
+
if (cfg.report) {
|
|
306
|
+
const report = {
|
|
307
|
+
timestamp: new Date().toISOString(), passed, failed, warnings, fixes, summary,
|
|
308
|
+
bus_dir: BUS_DIR, log,
|
|
309
|
+
};
|
|
310
|
+
const reportPath = path.join(BUS_DIR, REPORT_FILE);
|
|
311
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
312
|
+
console.log(`\n Report saved: ${reportPath}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { passed, failed, warnings, fixes, summary, log };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function summaryText(passed, failed, warnings, fixes) {
|
|
319
|
+
if (failed === 0 && warnings === 0) return `✅ All ${passed} checks passed. Bus is healthy.`;
|
|
320
|
+
if (failed === 0) return `⚠️ ${passed} passed, ${warnings} warnings. Bus is running with minor issues.`;
|
|
321
|
+
return `❌ ${passed} passed, ${warnings} warnings, ${failed} failed. Bus needs attention.`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Watch mode (blocking)
|
|
325
|
+
function watchDiagnostics(opts = {}) {
|
|
326
|
+
const cfg = defaults(opts);
|
|
327
|
+
setInterval(() => {
|
|
328
|
+
console.log(`\n[${new Date().toISOString()}] Running diagnostics...`);
|
|
329
|
+
runDiagnostics(cfg);
|
|
330
|
+
}, 30000);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { runDiagnostics, watchDiagnostics, REPORT_FILE };
|
package/lib/hermes.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes Integration — bridges to Hermes Agent via CLI
|
|
3
|
+
*
|
|
4
|
+
* Uses cmd.exe /c wrapper on Windows to provide a real console
|
|
5
|
+
* for prompt_toolkit (required by hermes chat).
|
|
6
|
+
*/
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
|
|
11
|
+
const HERMES_SESSION_DIR = path.join(__dirname, '..', '.sessions');
|
|
12
|
+
|
|
13
|
+
class HermesBridge {
|
|
14
|
+
constructor() {
|
|
15
|
+
if (!fs.existsSync(HERMES_SESSION_DIR)) {
|
|
16
|
+
fs.mkdirSync(HERMES_SESSION_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Send a prompt to Hermes Agent and get the response.
|
|
22
|
+
* Supports multi-turn via session_key.
|
|
23
|
+
*/
|
|
24
|
+
async askHermes(prompt, sessionKey = '') {
|
|
25
|
+
const cmd = this._buildChatCommand(prompt, sessionKey);
|
|
26
|
+
const result = this._run(cmd);
|
|
27
|
+
|
|
28
|
+
const clean = this._cleanOutput(result.stdout);
|
|
29
|
+
|
|
30
|
+
// Parse session key
|
|
31
|
+
let newSessionKey = '';
|
|
32
|
+
const sessionMatch = clean.match(/session_id:\s*(\S+)/);
|
|
33
|
+
if (sessionMatch) {
|
|
34
|
+
newSessionKey = sessionMatch[1].trim();
|
|
35
|
+
if (newSessionKey) {
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
path.join(HERMES_SESSION_DIR, newSessionKey),
|
|
38
|
+
new Date().toISOString(),
|
|
39
|
+
'utf-8'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Extract just the response
|
|
45
|
+
const response = this._extractResponse(result.stdout);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
response,
|
|
49
|
+
session_key: newSessionKey,
|
|
50
|
+
had_session: !!sessionKey,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Send a message through Hermes to a chat platform.
|
|
56
|
+
*/
|
|
57
|
+
async sendMessage(target, message) {
|
|
58
|
+
try {
|
|
59
|
+
const result = this._run(`hermes send --to "${target}" "${this._escape(message)}"`);
|
|
60
|
+
return { success: true, output: (result.stdout || '').trim() };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { success: false, error: err.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* List available Hermes messaging channels.
|
|
68
|
+
*/
|
|
69
|
+
async listChannels(platform = '') {
|
|
70
|
+
try {
|
|
71
|
+
const args = platform ? `send --list ${platform}` : 'send --list';
|
|
72
|
+
const result = this._run(`hermes ${args}`);
|
|
73
|
+
return { channels: (result.stdout || '').trim() };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { error: err.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Full health check: Hermes gateway + CLI availability.
|
|
81
|
+
*/
|
|
82
|
+
async healthCheck() {
|
|
83
|
+
const checks = {};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = this._run('hermes --version');
|
|
87
|
+
checks.cli = { ok: true, version: (result.stdout || '').trim() };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
checks.cli = { ok: false, error: err.message };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = this._run('hermes gateway status');
|
|
94
|
+
const out = (result.stdout || '') + (result.stderr || '');
|
|
95
|
+
checks.gateway = { ok: out.includes('Running'), status: out.trim() };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
checks.gateway = { ok: false, error: err.message };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
checks.timestamp = new Date().toISOString();
|
|
101
|
+
checks.all_ok = checks.cli.ok && checks.gateway.ok;
|
|
102
|
+
return checks;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_buildChatCommand(prompt, sessionKey) {
|
|
106
|
+
let cmd = `hermes chat -q "${this._escape(prompt)}" --quiet --source tool`;
|
|
107
|
+
if (sessionKey) {
|
|
108
|
+
const resumePath = path.join(HERMES_SESSION_DIR, sessionKey);
|
|
109
|
+
if (fs.existsSync(resumePath)) {
|
|
110
|
+
cmd += ` --resume ${sessionKey}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return cmd;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_run(cmd) {
|
|
117
|
+
// 2>&1: merge stderr into stdout (session_id is on stderr)
|
|
118
|
+
const wrapped = `cmd.exe /c ${cmd} 2>&1`;
|
|
119
|
+
try {
|
|
120
|
+
const stdout = execSync(wrapped, {
|
|
121
|
+
encoding: 'utf-8',
|
|
122
|
+
timeout: 180000,
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
125
|
+
});
|
|
126
|
+
return { stdout, stderr: '' };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
const stdout = e.stdout ? e.stdout.toString() : '';
|
|
129
|
+
const stderr = e.stderr ? e.stderr.toString() : '';
|
|
130
|
+
if (!stdout || stdout.trim().length < 2) throw e;
|
|
131
|
+
return { stdout, stderr };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_escape(str) {
|
|
136
|
+
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_cleanOutput(text) {
|
|
140
|
+
return text ? text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') : '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_extractResponse(text) {
|
|
144
|
+
const clean = this._cleanOutput(text || '');
|
|
145
|
+
const lines = clean.split('\n');
|
|
146
|
+
const result = [];
|
|
147
|
+
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const s = line.trim();
|
|
150
|
+
if (!s) continue;
|
|
151
|
+
if (s.startsWith('Query:') || s.startsWith('Resume') || s.startsWith('session_id:')) continue;
|
|
152
|
+
if (s.includes('Normalized model') || s.includes('Initializing') || s.includes('Initialising')) continue;
|
|
153
|
+
if (s.includes('opencode') || s.includes('deepseek')) continue;
|
|
154
|
+
if (s.startsWith('⚠')) continue;
|
|
155
|
+
result.push(s);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return result.join('\n').trim();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { HermesBridge };
|