UNPKG

overmind-mcp

Version:

Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mémoire (Private Memory Context) et le support pour

452 lines • 21.5 kB
/** * HermesProfileManager — Thin wrapper around `hermes profile` CLI. * * ╔════════════════════════════════════════════════════════════════════════╗ * ║ PURPOSE ║ * ║ ║ * ║ Replaces the old dual-layout (HERMES_HOME/agents/<name>/settings.json ║ * ║ vs .claude/settings_<name>.json) with the native Hermes profile system. ║ * ║ ║ * ║ Each Overmind Hermes agent = one Hermes profile: ║ * ║ ~/.hermes/profiles/<name>/ ║ * ║ ├── config.yaml (provider, model, mcp_servers) ║ * ║ ├── .env (credentials) ║ * ║ ├── SOUL.md (system prompt) ║ * ║ ├── memories/ (state.db — isolated) ║ * ║ ├── sessions/ (conversation history) ║ * ║ └── skills/ (procedural memory) ║ * ║ ║ * ║ All operations go through `hermes profile` CLI — no manual file ║ * ║ management, no HERMES_HOME guessing. ║ * ╚════════════════════════════════════════════════════════════════════════╝ */ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { rootLogger } from '../lib/logger.js'; import { HermesPoolClient } from './HermesPoolClient.js'; import { getNativeHermesHome } from '../lib/config.js'; /** * v3.10.5 (audit E2E 2026-10-11): every `hermes` CLI call in this module runs * with the NATIVE Hermes home, matching both the live agent ecosystem (Windows: * %LOCALAPPDATA%\hermes, Linux: ~/.hermes) and HermesRunner.spawn. Previously * these calls ran WITHOUT HERMES_HOME while HermesRunner spawned children with * getSharedHermesHome() (~/.overmind/hermes) — create wrote to one home, run * looked in the other: "Profile does not exist" + EXIT_CODE_1 on every freshly * created agent. OVERMIND_HERMES_HOME still overrides for isolated installs * (getNativeHermesHome honors an inherited HERMES_HOME). */ function hermesExecEnv() { return { ...process.env, HERMES_HOME: getNativeHermesHome() }; } /** execAsync with the native Hermes home injected (see hermesExecEnv). */ async function hermesExec(cmd, timeout = 120000) { return promisify(exec)(cmd, { timeout, env: hermesExecEnv() }); } const logger = rootLogger.child({ module: 'HermesProfileManager' }); const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/; /** * Detect the Hermes provider name from a model string. * Uses simple heuristics matching the actual provider IDs in Hermes pool: * glm/zai → 'zai', minimax/m3 → 'minimax-cn', claude/sonnet/opus/haiku → 'anthropic', * gpt → 'openai-api', deepseek → 'deepseek', gemini → 'gemini', etc. * * For unknown models, falls back to 'openai-api' (most permissive). */ function detectProviderFromModel(model) { const lower = model.toLowerCase(); if (lower.includes('glm') || lower.includes('zai')) return 'zai'; if (lower.includes('minimax') || lower.includes('m3') || lower.includes('MiniMax')) return 'minimax-cn'; if (lower.includes('claude') || lower.includes('sonnet') || lower.includes('opus') || lower.includes('haiku')) return 'anthropic'; if (lower.includes('gpt') || lower.includes('o1') || lower.includes('o3')) return 'openai-api'; if (lower.includes('deepseek')) return 'deepseek'; if (lower.includes('gemini')) return 'gemini'; if (lower.includes('qwen')) return 'qwen-oauth'; if (lower.includes('grok') || lower.includes('xai')) return 'xai'; return 'openai-api'; // safe default (most permissive) } export class HermesProfileManager { /** * Check if a Hermes profile exists. */ static exists(name) { if (!SAFE_NAME_RE.test(name)) return false; try { const stdout = execSync(`hermes profile list`, { encoding: 'utf-8', timeout: 10000 }); return stdout.includes(name); } catch { return false; } } /** * List all Hermes profiles. */ static async list() { try { const { stdout } = await hermesExec('hermes profile list', 30000); return this.parseProfileList(stdout); } catch (e) { logger.warn({ error: e }, 'Failed to list Hermes profiles'); return []; } } /** * Parse `hermes profile list` output into structured data. * * Output format: * Profile Model Gateway Alias * ─────────────── ─────────────────────────── ─────────── ─────────── * ◆default MiniMax-M3 stopped — * testwrapper — stopped — */ static parseProfileList(rawOutput) { const lines = rawOutput.split(/\r?\n/); const profiles = []; for (const line of lines) { // Match lines with profile data (skip header + separator) // Active profile has ◆ prefix, others have leading space const match = line.match(/^(◆|\s)\s*(\S+)\s+(.+?)\s{2,}(\S+)\s{2,}(\S+)/); if (!match) continue; const active = match[1] === '◆'; const name = match[2]; const model = match[3].trim(); const gateway = match[4]; // Skills count is sometimes missing from the basic list output // Skip header lines if (name === 'Profile' || name.startsWith('─')) continue; profiles.push({ name, model, gateway, skills: 0, active }); } return profiles; } /** * Create a new Hermes profile that uses the NATIVE Hermes credential pool. * * Steps: * 1. Validate provider is healthy in the Hermes pool (round_robin rotation) * 2. hermes profile create <name> --no-alias --description "<desc>" * 3. hermes -p <name> config set model.provider <provider> * 4. hermes -p <name> config set model.model <model> * 5. Write SOUL.md with system prompt * 6. Optionally set mcp_servers in config.yaml * 7. Generate profile.yaml/workspace.yaml/README * * NOTE: We do NOT write credentials to the profile's .env anymore — Hermes * resolves them from the global pool via `hermes auth list`. The profile's * .env stays empty (just the placeholder comment Hermes writes by default). * * If `opts.credentials` is provided, they are added to the GLOBAL POOL via * `hermes auth add` (deduped by label) rather than written to the profile. */ static async create(opts) { const { name, prompt, model } = opts; if (!SAFE_NAME_RE.test(name)) { throw new Error(`Invalid profile name: '${name}'. Only [a-zA-Z0-9_-] allowed.`); } const provider = opts.provider || detectProviderFromModel(model); const description = opts.description || `Overmind agent: ${name}`; logger.info({ name, model, provider }, '[CREATE] Creating Hermes profile.'); // 0. If credentials were provided, add them to the GLOBAL POOL (not the profile) // This way multiple profiles can share the same key with round_robin rotation. if (opts.credentials && Object.keys(opts.credentials).length > 0) { const apiKey = opts.credentials.api_key ?? opts.credentials.API_KEY ?? Object.values(opts.credentials)[0]; if (apiKey) { const label = `overmind-${name}-${Date.now()}`; try { await HermesPoolClient.addCredential(provider, apiKey, { label }); logger.info({ provider, label }, '[CREATE] Credential added to global pool.'); } catch (e) { logger.warn({ provider, error: e }, '[CREATE] Failed to add credential to pool (continuing).'); } } } // 0b. Validate that the provider is healthy in the pool (has at least one active key) // This prevents creating a profile that can't actually run. const healthy = await HermesPoolClient.isProviderHealthy(provider); if (!healthy) { logger.warn({ provider, name }, '[CREATE] Provider has NO healthy credentials in the pool. Profile will fail to run until you add one via: hermes auth add ' + provider + ' --api-key <key>'); } // 1. Create profile (no-alias to avoid polluting PATH). // Use \\ to escape the inner quote cleanly without no-useless-escape. const createCmd = `hermes profile create "${name}" --no-alias --description "${description.replace(/"/g, '\\"')}"`; try { await hermesExec(createCmd); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (!msg.includes('already exists')) { throw new Error(`Failed to create profile '${name}': ${msg}`, { cause: e }); } logger.warn({ name }, '[CREATE] Profile already exists, will update configuration.'); } // 2. Set model + provider in config.yaml. // v3.10.5: write the requested model into model.default (the key Hermes // actually reads; model.model is ignored) AND set provider LAST — // `config set model.default` resets model.provider to "auto", so the // order below is load-bearing. await hermesExec(`hermes -p "${name}" config set model.default "${model}"`); await hermesExec(`hermes -p "${name}" config set model.provider "${provider}"`); // 3. Get profile path const profilePath = await this.getProfilePath(name); if (!profilePath) { throw new Error(`Could not resolve profile path for '${name}'`); } // 4. SOUL.md (system prompt) — the ONLY profile-specific file we write const soulPath = path.join(profilePath, 'SOUL.md'); fs.writeFileSync(soulPath, prompt, 'utf-8'); // 5. Optionally set MCP servers if (opts.mcpServers && opts.mcpServers.length > 0) { await this.setMcpServers(name, opts.mcpServers, profilePath); } // 6. Generate profile.yaml (kanban routing — OBLIGATOIRE) this.writeProfileYaml(profilePath, name, description, model); // 7. Generate workspace.yaml this.writeWorkspaceYaml(profilePath, 'persistent'); // 8. Generate README.md this.writeReadme(profilePath, name, description); logger.info({ name, profilePath, provider, poolHealthy: healthy }, '[CREATE] Profile created successfully (credentials via global pool).'); return { profilePath, soulPath, provider }; } /** * Delete a Hermes profile permanently. */ static async delete(name) { if (!SAFE_NAME_RE.test(name)) { throw new Error(`Invalid profile name: '${name}'`); } try { await hermesExec(`hermes profile delete "${name}" --yes`); logger.info({ name }, '[DELETE] Profile deleted.'); } catch (e) { logger.warn({ name, error: e }, '[DELETE] Failed to delete profile.'); throw e; } } /** * Get the filesystem path for a profile. * * Queries `hermes profile show <name>` to get the real path from Hermes itself. * This is the ONLY reliable way — Hermes controls where profiles live, and the * location depends on HERMES_HOME (Windows: AppData\Local\hermes, Linux: ~/.hermes). * * Falls back to the canonical layout under the shared Hermes home if the CLI * call fails (e.g. profile doesn't exist yet). */ static async getProfilePath(name) { // 1. Try `hermes profile show <name>` — the authoritative source try { const { stdout } = await hermesExec(`hermes profile show "${name}"`); const match = stdout.match(/Path:\s*(.+)/); if (match?.[1]) { const resolved = match[1].trim(); if (fs.existsSync(resolved)) return resolved; } } catch { // Profile may not exist yet, or hermes CLI unavailable — fall through } // 2. Fallback: construct canonical path under shared Hermes home // Hermes default: HERMES_HOME/profiles/<name>/ // On Windows official: AppData\Local\hermes\profiles\<name>\ // On Linux: ~/.hermes/profiles/<name>/ const hermesHome = process.env.HERMES_HOME || (process.platform === 'win32' ? path.join(process.env.LOCALAPPDATA || process.env.USERPROFILE || os.homedir(), 'hermes') : path.join(os.homedir(), '.hermes')); return path.join(hermesHome, 'profiles', name); } /** * Set MCP servers in a profile's config.yaml. * * Reads the workspace .mcp.json to get the actual server configs (URL, command, etc.) * and writes them as proper YAML entries in the profile's config.yaml. */ static async setMcpServers(profileName, servers, profilePath) { const configPath = path.join(profilePath, 'config.yaml'); // Read existing config or start fresh let configContent; try { configContent = fs.readFileSync(configPath, 'utf-8'); } catch { configContent = ''; } // Read the workspace .mcp.json to get actual server configs const { getWorkspaceDir } = await import('../lib/config.js'); let mcpConfigs; try { const mcpJsonPath = path.join(getWorkspaceDir(), '.mcp.json'); const mcpContent = fs.readFileSync(mcpJsonPath, 'utf-8'); const parsed = JSON.parse(mcpContent); mcpConfigs = parsed.mcpServers || parsed.mcp_servers || {}; } catch { logger.warn('[SET_MCP] Could not read workspace .mcp.json — MCP servers will not be configured.'); return; } // Build the mcp_servers YAML block // Remove any existing mcp_servers section first const lines = configContent.split(/\r?\n/); const filtered = lines.filter((line, i) => { // Remove existing mcp_servers block if (line.match(/^mcp_servers:/)) return false; // Check if we're inside an mcp_servers block (indented after mcp_servers:) let inBlock = false; for (let j = i - 1; j >= 0; j--) { if (lines[j].match(/^mcp_servers:/)) { // Check if current line is indented (part of the block) if (line.match(/^\s/) && !line.match(/^\S/)) { inBlock = true; } break; } // If we hit a non-indented non-empty line that's not mcp_servers, we're outside if (lines[j].trim() && !lines[j].match(/^\s/)) break; } return !inBlock; }); configContent = filtered .join('\n') .replace(/\n{3,}/g, '\n\n') .trimEnd(); // Add the mcp_servers block with real configs const yamlLines = ['\n\nmcp_servers:']; let added = 0; for (const serverName of servers) { const cfg = mcpConfigs[serverName]; if (cfg && cfg.url) { yamlLines.push(` ${serverName}:`); yamlLines.push(` url: ${cfg.url}`); added++; } else if (cfg && cfg.command) { yamlLines.push(` ${serverName}:`); yamlLines.push(` command: ${cfg.command}`); if (cfg.args && cfg.args.length > 0) { yamlLines.push(` args:`); for (const arg of cfg.args) { yamlLines.push(` - ${arg}`); } } added++; } else { logger.warn({ serverName }, '[SET_MCP] Server not found in .mcp.json — skipping.'); } } if (added > 0) { configContent += '\n' + yamlLines.join('\n') + '\n'; fs.writeFileSync(configPath, configContent, 'utf-8'); logger.info({ profileName, added }, '[SET_MCP] MCP servers configured in config.yaml.'); } else { logger.warn({ profileName }, '[SET_MCP] No MCP servers could be configured.'); } } // ─── v3.1 Profile Scaffolding ──────────────────────────────────────────── /** Write profile.yaml (kanban description — OBLIGATOIRE) */ static writeProfileYaml(profilePath, name, description, model) { const yaml = [ '# Profile metadata — kanban routing (OBLIGATOIRE)', `name: "${name}"`, `description: "${description.replace(/"/g, '\\"')}"`, `model: "${model}"`, 'workspace: persistent', `created: ${new Date().toISOString()}`, '', ].join('\n'); fs.writeFileSync(path.join(profilePath, 'profile.yaml'), yaml, 'utf-8'); } /** Write workspace.yaml (kind: scratch|persistent|shared) */ static writeWorkspaceYaml(profilePath, kind) { const yaml = [`kind: ${kind}`, `path: ${profilePath}`, `gc_eligible: false`, ''].join('\n'); fs.writeFileSync(path.join(profilePath, 'workspace.yaml'), yaml, 'utf-8'); } /** Write README.md (role + contacts) */ static writeReadme(profilePath, name, description) { const md = [ `# ${name}`, '', `**Role**: ${description}`, '', `**Owner**: overmind-mcp`, `**Created**: ${new Date().toISOString()}`, '', '---', 'Generated by overmind-mcp v3.1', '', ].join('\n'); fs.writeFileSync(path.join(profilePath, 'README.md'), md, 'utf-8'); } /** * Update specific fields of an existing profile. */ static async update(name, updates) { if (!SAFE_NAME_RE.test(name)) { throw new Error(`Invalid profile name: '${name}'`); } const profilePath = await this.getProfilePath(name); if (!profilePath || !fs.existsSync(profilePath)) { throw new Error(`Profile '${name}' not found.`); } // Update model + provider (default FIRST, provider LAST — setting // model.default resets model.provider to "auto"). if (updates.model) { const provider = updates.provider || detectProviderFromModel(updates.model); await hermesExec(`hermes -p "${name}" config set model.default "${updates.model}"`); await hermesExec(`hermes -p "${name}" config set model.provider "${provider}"`); } // Update credentials if (updates.credentials) { const envPath = path.join(profilePath, '.env'); let envContent; try { envContent = fs.readFileSync(envPath, 'utf-8'); } catch { envContent = ''; } for (const [key, value] of Object.entries(updates.credentials)) { // Replace existing key or append const regex = new RegExp(`^${key}=.*$`, 'm'); if (regex.test(envContent)) { envContent = envContent.replace(regex, `${key}=${value}`); } else { envContent += `${key}=${value}\n`; } } fs.writeFileSync(envPath, envContent, 'utf-8'); } // Update SOUL.md if (updates.prompt) { const soulPath = path.join(profilePath, 'SOUL.md'); fs.writeFileSync(soulPath, updates.prompt, 'utf-8'); } // Update MCP servers if (updates.mcpServers) { await this.setMcpServers(name, updates.mcpServers, profilePath); } logger.info({ name, updates: Object.keys(updates) }, '[UPDATE] Profile updated.'); } } //# sourceMappingURL=HermesProfileManager.js.map