@robbiesrobotics/alice-agents 1.5.9 → 1.5.10

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,449 @@
1
+ // lib/hermes-agent.mjs
2
+ // HermesAgentManager — creates, spawns, and manages Hermes agents as A.L.I.C.E. team members
3
+
4
+ import { execSync, spawn } from 'node:child_process';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const HERMES_DIR = join(homedir(), '.hermes');
12
+ const OPENCLAW_DIR = join(homedir(), '.openclaw');
13
+ const SKILLS_PARENT = join(HERMES_DIR, 'skills'); // skills live at ~/.hermes/skills/<category>/<id>/SKILL.md
14
+ const HERMES_AGENTS_JSON = join(OPENCLAW_DIR, 'hermes-agents.json');
15
+ const TEMPLATES_DIR = join(__dirname, '..', 'templates', 'workspaces', '_shared');
16
+
17
+ // ── Hermes CLI detection ──────────────────────────────────────────────────────
18
+
19
+ function findHermesBinary() {
20
+ const candidates = [
21
+ join(HERMES_DIR, 'bin', 'hermes'),
22
+ join(homedir(), '.local', 'bin', 'hermes'),
23
+ 'hermes', // PATH
24
+ ];
25
+ for (const candidate of candidates) {
26
+ try {
27
+ execSync(`${process.platform === 'win32' ? 'where' : 'which'} ${candidate.replace(/\\/g, '')}`, { stdio: 'pipe' });
28
+ return candidate;
29
+ } catch {
30
+ // try next
31
+ }
32
+ }
33
+ // Fallback: trust `hermes` is in PATH
34
+ return 'hermes';
35
+ }
36
+
37
+ const HERMES_BIN = findHermesBinary();
38
+
39
+ // ── HermesAgentManager ─────────────────────────────────────────────────────────
40
+
41
+ export class HermesAgentManager {
42
+ /**
43
+ * @param {Object} options
44
+ * @param {string} [options.skillsCategory='alice'] — Hermes skill category for A.L.I.C.E. Hermes agents
45
+ * @param {string} [options.defaultToolsets='terminal,file,web'] — default toolsets for spawned agents
46
+ */
47
+ constructor({ skillsCategory = 'alice', defaultToolsets = 'terminal,file,web' } = {}) {
48
+ this.skillsCategory = skillsCategory;
49
+ this.defaultToolsets = defaultToolsets;
50
+ }
51
+
52
+ // ── Registry ─────────────────────────────────────────────────────────────
53
+
54
+ _readRegistry() {
55
+ if (!existsSync(HERMES_AGENTS_JSON)) return { hermesAgents: [] };
56
+ try {
57
+ return JSON.parse(readFileSync(HERMES_AGENTS_JSON, 'utf8'));
58
+ } catch {
59
+ return { hermesAgents: [] };
60
+ }
61
+ }
62
+
63
+ _writeRegistry(registry) {
64
+ mkdirSync(dirname(HERMES_AGENTS_JSON), { recursive: true });
65
+ writeFileSync(HERMES_AGENTS_JSON, JSON.stringify(registry, null, 2) + '\n', 'utf8');
66
+ }
67
+
68
+ /**
69
+ * List all registered Hermes agents.
70
+ * @returns {{ id: string, agentId: string, domain: string, description: string, createdAt: string }[]}
71
+ */
72
+ listAgents() {
73
+ return this._readRegistry().hermesAgents || [];
74
+ }
75
+
76
+ /**
77
+ * Check if a Hermes agent is registered.
78
+ * @param {string} agentId
79
+ * @returns {boolean}
80
+ */
81
+ hasAgent(agentId) {
82
+ return this.listAgents().some(a => a.agentId === agentId);
83
+ }
84
+
85
+ // ── Skill directory helpers ───────────────────────────────────────────────
86
+
87
+ /**
88
+ * Path to the SKILL.md for a given agent.
89
+ * @param {string} agentId
90
+ * @returns {string}
91
+ */
92
+ _skillPath(agentId) {
93
+ return join(SKILLS_PARENT, this.skillsCategory, agentId, 'SKILL.md');
94
+ }
95
+
96
+ /**
97
+ * Ensure the Hermes skills directory exists for an agent.
98
+ * @param {string} agentId
99
+ */
100
+ _ensureSkillDir(agentId) {
101
+ const dir = join(SKILLS_PARENT, this.skillsCategory, agentId);
102
+ mkdirSync(dir, { recursive: true });
103
+ return dir;
104
+ }
105
+
106
+ // ── Model / provider detection ────────────────────────────────────────────
107
+
108
+ /**
109
+ * Detect the default model and provider from OpenClaw config.
110
+ * Returns null if OpenClaw config can't be read.
111
+ *
112
+ * @returns {{ primary: string, provider: string, model: string } | null}
113
+ */
114
+ static detectOpenClawModel() {
115
+ const configPath = join(OPENCLAW_DIR, 'openclaw.json');
116
+ if (!existsSync(configPath)) return null;
117
+ try {
118
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
119
+ const agentDefaults = config?.agents?.defaults?.model || {};
120
+ const globalModel = config?.model;
121
+ const primary = agentDefaults.primary || globalModel || null;
122
+ if (!primary || typeof primary !== 'string') return null;
123
+ const [provider, ...rest] = primary.split('/');
124
+ const model = rest.join('/'); // handles models with slashes like 'anthropic/claude-sonnet-4-6'
125
+ return { primary, provider, model };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Build the hermes CLI args for model/provider from OpenClaw detected config.
133
+ * Returns [] if nothing detected.
134
+ *
135
+ * @param {string} [fallbackProvider='minimax']
136
+ * @param {string} [fallbackModel]
137
+ * @returns {string[]}
138
+ */
139
+ buildHermesModelArgs(fallbackProvider = 'minimax', fallbackModel = '') {
140
+ const detected = HermesAgentManager.detectOpenClawModel();
141
+ if (detected?.provider && detected?.model) {
142
+ return ['--provider', detected.provider, '--model', detected.model];
143
+ }
144
+ // Fallback: use minimax (common default)
145
+ const args = ['--provider', fallbackProvider];
146
+ if (fallbackModel) args.push('--model', fallbackModel);
147
+ return args;
148
+ }
149
+
150
+ // ── Create ───────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Create a new Hermes agent for the A.L.I.C.E. team.
154
+ *
155
+ * Creates:
156
+ * - ~/.hermes/skills/<category>/<agentId>/SKILL.md
157
+ * - Registers in ~/.openclaw/hermes-agents.json
158
+ *
159
+ * @param {string} agentId — unique ID, e.g. 'researcher' or 'analyst'
160
+ * @param {string} domain — domain label, e.g. 'Research' or 'Data Analysis'
161
+ * @param {string} description — one-line description of what this agent does
162
+ * @param {string} [persona] — optional custom persona instructions override
163
+ * @param {string[]} [skills] — list of hermes skill names to preload (e.g. ['web-search', 'codex'])
164
+ * @param {string} [toolsets] — comma-separated toolsets to enable (default: 'terminal,file,web')
165
+ * @returns {{ status: 'created' | 'exists', agentId: string, skillPath: string }}
166
+ */
167
+ createAgent(agentId, domain, description, persona = '', skills = [], toolsets = null) {
168
+ // Check if already exists
169
+ if (this.hasAgent(agentId)) {
170
+ return { status: 'exists', agentId, skillPath: this._skillPath(agentId) };
171
+ }
172
+
173
+ // Ensure skill directory
174
+ const skillDir = this._ensureSkillDir(agentId);
175
+ const skillPath = join(skillDir, 'SKILL.md');
176
+
177
+ // Build skill content
178
+ const skillContent = this._buildSkillContent(agentId, domain, description, persona, skills);
179
+ writeFileSync(skillPath, skillContent, 'utf8');
180
+
181
+ // Register
182
+ const registry = this._readRegistry();
183
+ registry.hermesAgents.push({
184
+ id: `hermes-${Date.now()}`,
185
+ agentId,
186
+ domain,
187
+ description,
188
+ skillsCategory: this.skillsCategory,
189
+ toolsets: toolsets || this.defaultToolsets,
190
+ preloadSkills: skills,
191
+ createdAt: new Date().toISOString(),
192
+ });
193
+ this._writeRegistry(registry);
194
+
195
+ return { status: 'created', agentId, skillPath };
196
+ }
197
+
198
+ /**
199
+ * Build SKILL.md content for a Hermes agent.
200
+ * @private
201
+ */
202
+ _buildSkillContent(agentId, domain, description, persona, skills) {
203
+ const skillLines = [
204
+ '---',
205
+ `name: ${agentId}`,
206
+ `description: ${description} — managed by A.L.I.C.E. on OpenClaw`,
207
+ '---',
208
+ '',
209
+ `# ${agentId} (${domain})`,
210
+ '',
211
+ `> Managed by A.L.I.C.E. team — do not edit manually.`,
212
+ '',
213
+ `**Domain:** ${domain}`,
214
+ '',
215
+ `**Role:** ${description}`,
216
+ '',
217
+ '## Persona',
218
+ '',
219
+ ];
220
+
221
+ if (persona) {
222
+ skillLines.push(persona, '');
223
+ } else {
224
+ skillLines.push(
225
+ `You are ${agentId}, a ${domain} specialist working on the A.L.I.C.E. team.`,
226
+ `A.L.I.C.E. (orchestrated by OpenClaw) assigns you tasks via this skill interface.`,
227
+ `Your role is ${description}.`,
228
+ '',
229
+ '## Working with A.L.I.C.E.',
230
+ '',
231
+ '- A.L.I.C.E. will send you a task description',
232
+ '- Complete the task using your tools and skills',
233
+ '- Return your findings/results clearly and concisely',
234
+ '- Do not add unnecessary commentary',
235
+ '',
236
+ );
237
+ }
238
+
239
+ if (skills.length > 0) {
240
+ skillLines.push(
241
+ '## Preloaded Skills',
242
+ '',
243
+ `This agent has the following skills available:`,
244
+ ...skills.map(s => ` - \`${s}\``),
245
+ '',
246
+ );
247
+ }
248
+
249
+ skillLines.push(
250
+ '## Notes',
251
+ '',
252
+ '- Skills are managed by A.L.I.C.E. — see `~/.openclaw/hermes-agents.json` for configuration',
253
+ '- Model is inherited from OpenClaw default config',
254
+ );
255
+
256
+ return skillLines.join('\n');
257
+ }
258
+
259
+ // ── Remove ───────────────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * Remove a Hermes agent (deletes skill dir + unregisters).
263
+ *
264
+ * @param {string} agentId
265
+ * @returns {{ status: 'removed' | 'not_found' }}
266
+ */
267
+ removeAgent(agentId) {
268
+ const registry = this._readRegistry();
269
+ const before = registry.hermesAgents.length;
270
+ registry.hermesAgents = registry.hermesAgents.filter(a => a.agentId !== agentId);
271
+
272
+ if (registry.hermesAgents.length === before) {
273
+ return { status: 'not_found' };
274
+ }
275
+
276
+ this._writeRegistry(registry);
277
+
278
+ // Remove skill directory
279
+ const skillDir = join(SKILLS_PARENT, this.skillsCategory, agentId);
280
+ if (existsSync(skillDir)) {
281
+ rmSync(skillDir, { recursive: true, force: true });
282
+ }
283
+
284
+ return { status: 'removed' };
285
+ }
286
+
287
+ // ── Spawn ───────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Spawn a Hermes agent to execute a single task.
291
+ * Uses `hermes chat -q "<task>"` in quiet mode.
292
+ *
293
+ * @param {string} agentId — registered Hermes agent ID
294
+ * @param {string} task — task description (will be wrapped in persona context)
295
+ * @param {{ timeout?: number, toolsets?: string, skills?: string[], context?: string }} [options]
296
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
297
+ */
298
+ spawnAgent(agentId, task, options = {}) {
299
+ const { timeout = 300, toolsets: overrideToolsets, skills: overrideSkills, context = '' } = options;
300
+
301
+ const registry = this._readRegistry();
302
+ const agent = registry.hermesAgents.find(a => a.agentId === agentId);
303
+
304
+ const toolsets = overrideToolsets || agent?.toolsets || this.defaultToolsets;
305
+ const preloadSkills = overrideSkills || agent?.preloadSkills || [];
306
+
307
+ // Build the prompt — wrap task in agent context
308
+ const prompt = this._buildPrompt(agentId, task, context);
309
+
310
+ // Build CLI args
311
+ const args = [
312
+ 'chat', '-q', prompt,
313
+ '-s', preloadSkills.join(','),
314
+ '-t', toolsets,
315
+ '-Q', // quiet mode — programmatic output only
316
+ ];
317
+
318
+ // Add model/provider from OpenClaw config
319
+ const modelArgs = this.buildHermesModelArgs();
320
+ args.push(...modelArgs);
321
+
322
+ return new Promise((resolve, reject) => {
323
+ const child = spawn(HERMES_BIN, args, {
324
+ stdio: ['ignore', 'pipe', 'pipe'],
325
+ timeout: timeout * 1000,
326
+ });
327
+
328
+ let stdout = '';
329
+ let stderr = '';
330
+
331
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
332
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
333
+
334
+ child.on('error', reject);
335
+
336
+ child.on('close', (code) => {
337
+ resolve({ stdout, stderr, exitCode: code ?? 0 });
338
+ });
339
+
340
+ // Handle timeout
341
+ const timer = setTimeout(() => {
342
+ child.kill('SIGTERM');
343
+ reject(new Error(`Hermes agent "${agentId}" timed out after ${timeout}s`));
344
+ }, timeout * 1000);
345
+
346
+ child.on('close', () => clearTimeout(timer));
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Build the prompt passed to hermes chat -q.
352
+ * Wraps the task in agent context so Hermes knows who it is.
353
+ * @private
354
+ */
355
+ _buildPrompt(agentId, task, context) {
356
+ const lines = [
357
+ `[A.L.I.C.E. Team — ${agentId}]`,
358
+ '',
359
+ context ? `${context}\n` : '',
360
+ `Task: ${task}`,
361
+ '',
362
+ 'Instructions: You are a specialist on the A.L.I.C.E. team. Complete the task above.',
363
+ 'Return your results clearly. Do not add meta-commentary about being an AI.',
364
+ ];
365
+ return lines.join('\n');
366
+ }
367
+
368
+ // ── Quick spawn (one-off, no registration required) ────────────────────
369
+
370
+ /**
371
+ * Spawn a quick one-off Hermes agent without registering it.
372
+ * Useful for tasks that don't need persistent memory.
373
+ *
374
+ * @param {string} task
375
+ * @param {{ domain?: string, skills?: string[], toolsets?: string, timeout?: number }} [options]
376
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
377
+ */
378
+ quickSpawn(task, options = {}) {
379
+ const {
380
+ domain = 'Specialist',
381
+ skills = [],
382
+ toolsets = 'terminal,file,web',
383
+ timeout = 300,
384
+ } = options;
385
+
386
+ return this.spawnAgent(`__quick__-${Date.now()}`, task, {
387
+ ...options,
388
+ // For quick spawn, we pass skills/toolsets directly but don't register
389
+ toolsets,
390
+ skills,
391
+ });
392
+ }
393
+
394
+ // ── OpenClaw bridge setup ───────────────────────────────────────────────
395
+
396
+ /**
397
+ * Register the A.L.I.C.E. OpenClaw bridge — sets up the hermes-agents.json
398
+ * and verifies Hermes is reachable.
399
+ *
400
+ * @returns {{ status: 'ok', hermesVersion: string } | { status: 'error', reason: string }}
401
+ */
402
+ setupBridge() {
403
+ try {
404
+ // Verify Hermes is installed
405
+ const version = execSync(`${HERMES_BIN} version 2>&1`, { encoding: 'utf8', stdio: 'pipe' }).trim();
406
+
407
+ // Ensure registry exists
408
+ if (!existsSync(HERMES_AGENTS_JSON)) {
409
+ this._writeRegistry({ hermesAgents: [] });
410
+ }
411
+
412
+ return { status: 'ok', hermesVersion: version };
413
+ } catch (err) {
414
+ return { status: 'error', reason: `Hermes not found or not working: ${err.message}` };
415
+ }
416
+ }
417
+
418
+ // ── Inspect ─────────────────────────────────────────────────────────────
419
+
420
+ /**
421
+ * Get full details for a registered Hermes agent.
422
+ * @param {string} agentId
423
+ * @returns {Object | null}
424
+ */
425
+ getAgent(agentId) {
426
+ return this.listAgents().find(a => a.agentId === agentId) || null;
427
+ }
428
+
429
+ /**
430
+ * Get the path to an agent's SKILL.md.
431
+ * @param {string} agentId
432
+ * @returns {string | null}
433
+ */
434
+ getSkillPath(agentId) {
435
+ const p = this._skillPath(agentId);
436
+ return existsSync(p) ? p : null;
437
+ }
438
+ }
439
+
440
+ // ── Export singleton factory ───────────────────────────────────────────────────
441
+
442
+ let _manager = null;
443
+
444
+ export function getHermesAgentManager(options) {
445
+ if (!_manager) {
446
+ _manager = new HermesAgentManager(options);
447
+ }
448
+ return _manager;
449
+ }