/** * Local Agent Sync — Discovers agent definitions from local directories * and syncs them bidirectionally with the MC database. * * Scans: * ~/.agents/ — top-level dirs with agent config files * ~/.codex/agents/ — Codex agent definitions * ~/.claude/agents/ — Claude agent definitions (if present) * * A directory counts as an agent if it contains one of: * AGENT.md, agent.md, soul.md, identity.md, config.json, agent.json */ import { createHash } from 'node:crypto' import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' import { getDatabase, logAuditEvent } from './db' import { logger } from './logger' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface DiskAgent { name: string dir: string role: string soulContent: string | null configContent: string | null contentHash: string } interface AgentRow { id: number name: string role: string soul_content: string | null status: string source: string | null content_hash: string | null workspace_path: string | null config: string | null } // Detection files — order matters: first found wins for role extraction const IDENTITY_FILES = ['soul.md', 'AGENT.md', 'agent.md', 'identity.md', 'SKILL.md'] const CONFIG_FILES = ['config.json', 'agent.json'] const ALL_MARKERS = [...IDENTITY_FILES, ...CONFIG_FILES] // YAML frontmatter fields for flat .md agent files (Claude Code format) interface AgentFrontmatter { name?: string description?: string model?: string color?: string tools?: string[] } function parseYamlFrontmatter(content: string): { frontmatter: AgentFrontmatter; body: string } { const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) if (!match) return { frontmatter: {}, body: content } const raw = match[1] const body = match[2] const fm: AgentFrontmatter = {} for (const line of raw.split('\n')) { const kv = line.match(/^(\w+)\s*:\s*(.+)$/) if (!kv) continue const [, key, val] = kv const cleaned = val.replace(/^["']|["']$/g, '').trim() if (key === 'name') fm.name = cleaned else if (key === 'description') fm.description = cleaned else if (key === 'model') fm.model = cleaned else if (key === 'color') fm.color = cleaned else if (key === 'tools') { try { fm.tools = JSON.parse(val) } catch { /* ignore */ } } } return { frontmatter: fm, body } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function sha256(content: string): string { return createHash('sha256').update(content, 'utf8').digest('hex') } function extractRole(content: string): string { const lines = content.split('\n').map(l => l.trim()).filter(Boolean) // Look for "role:" or "theme:" in first 10 lines for (const line of lines.slice(0, 10)) { const match = line.match(/^(?:role|theme)\s*:\s*(.+)$/i) if (match?.[1]) return match[1].trim() } return 'agent' } function getLocalAgentRoots(): string[] { const home = homedir() return [ join(home, '.agents'), join(home, '.codex', 'agents'), join(home, '.claude', 'agents'), join(home, '.hermes', 'skills'), ] } // --------------------------------------------------------------------------- // Disk scanner // --------------------------------------------------------------------------- function scanLocalAgents(): DiskAgent[] { const agents: DiskAgent[] = [] const seen = new Set() for (const root of getLocalAgentRoots()) { if (!existsSync(root)) continue let entries: string[] try { entries = readdirSync(root) } catch { continue } for (const entry of entries) { // Skip 'skills' subdirectory — that's the skill roots if (entry === 'skills') continue const fullPath = join(root, entry) let stat try { stat = statSync(fullPath) } catch { continue } // --- Flat .md agent files (Claude Code format) --- if (stat.isFile() && entry.endsWith('.md') && entry !== 'CLAUDE.md' && entry !== 'AGENTS.md') { try { const content = readFileSync(fullPath, 'utf8') const { frontmatter, body } = parseYamlFrontmatter(content) const agentName = frontmatter.name || entry.replace(/\.md$/, '') if (seen.has(agentName)) continue seen.add(agentName) const configObj: Record = {} if (frontmatter.model) configObj.model = frontmatter.model if (frontmatter.color) configObj.color = frontmatter.color if (frontmatter.tools) configObj.tools = frontmatter.tools if (frontmatter.description) configObj.description = frontmatter.description const configJson = Object.keys(configObj).length > 0 ? JSON.stringify(configObj) : null agents.push({ name: agentName, dir: fullPath, role: frontmatter.description ? 'agent' : 'agent', soulContent: body.trim() || null, configContent: configJson, contentHash: sha256(content), }) } catch { /* unreadable */ } continue } // --- Directory-based agents (workspace format) --- if (!stat.isDirectory()) continue // Check if any marker file exists const hasMarker = ALL_MARKERS.some(f => existsSync(join(fullPath, f))) if (!hasMarker) continue if (seen.has(entry)) continue seen.add(entry) // Read identity content (soul/agent/identity.md) let soulContent: string | null = null let role = 'agent' for (const f of IDENTITY_FILES) { const p = join(fullPath, f) if (existsSync(p)) { try { soulContent = readFileSync(p, 'utf8') role = extractRole(soulContent) break } catch { /* unreadable */ } } } // Read config JSON if present let configContent: string | null = null for (const f of CONFIG_FILES) { const p = join(fullPath, f) if (existsSync(p)) { try { configContent = readFileSync(p, 'utf8') break } catch { /* unreadable */ } } } // Build content hash from whatever identity files exist const hashInput = (soulContent || '') + (configContent || '') if (!hashInput) continue agents.push({ name: entry, dir: fullPath, role, soulContent, configContent, contentHash: sha256(hashInput), }) } } return agents } // --------------------------------------------------------------------------- // Sync engine // --------------------------------------------------------------------------- export async function syncLocalAgents(): Promise<{ ok: boolean; message: string }> { try { const db = getDatabase() const diskAgents = scanLocalAgents() const now = Math.floor(Date.now() / 1000) const diskMap = new Map() for (const a of diskAgents) { diskMap.set(a.name, a) } // Fetch DB agents with source='local' const dbRows = db.prepare( `SELECT id, name, role, soul_content, status, source, content_hash, workspace_path, config FROM agents WHERE source = 'local'` ).all() as AgentRow[] const dbMap = new Map() for (const r of dbRows) { dbMap.set(r.name, r) } let created = 0 let updated = 0 let removed = 0 const insertStmt = db.prepare(` INSERT INTO agents (name, role, soul_content, status, source, content_hash, workspace_path, config, created_at, updated_at) VALUES (?, ?, ?, 'offline', 'local', ?, ?, ?, ?, ?) `) const updateStmt = db.prepare(` UPDATE agents SET role = ?, soul_content = ?, content_hash = ?, workspace_path = ?, config = ?, updated_at = ? WHERE id = ? `) const markRemovedStmt = db.prepare(` UPDATE agents SET status = 'offline', updated_at = ? WHERE id = ? `) db.transaction(() => { // Disk → DB: additions and changes for (const [name, disk] of diskMap) { const existing = dbMap.get(name) const configJson = disk.configContent ? disk.configContent : null if (!existing) { insertStmt.run(name, disk.role, disk.soulContent, disk.contentHash, disk.dir, configJson, now, now) created++ } else if (existing.content_hash !== disk.contentHash) { updateStmt.run(disk.role, disk.soulContent, disk.contentHash, disk.dir, configJson, now, existing.id) updated++ } } // Agents that vanished from disk — mark offline but don't delete for (const [name, row] of dbMap) { if (!diskMap.has(name) && row.status !== 'offline') { markRemovedStmt.run(now, row.id) removed++ } } })() const msg = `Local agent sync: ${created} added, ${updated} updated, ${removed} marked offline (${diskAgents.length} on disk)` if (created > 0 || updated > 0 || removed > 0) { logger.info(msg) logAuditEvent({ action: 'local_agent_sync', actor: 'scheduler', detail: { created, updated, removed, total: diskAgents.length }, }) } return { ok: true, message: msg } } catch (err: any) { logger.error({ err }, 'Local agent sync failed') return { ok: false, message: `Local agent sync failed: ${err.message}` } } } /** * Write agent soul content back to disk (UI → Disk direction). * Called when a user edits a local agent's soul in the MC UI. */ export function writeLocalAgentSoul(agentDir: string, soulContent: string): void { // Prefer soul.md, fall back to AGENT.md const soulPath = join(agentDir, 'soul.md') const agentMdPath = join(agentDir, 'AGENT.md') const targetPath = existsSync(soulPath) ? soulPath : existsSync(agentMdPath) ? agentMdPath : soulPath mkdirSync(agentDir, { recursive: true }) writeFileSync(targetPath, soulContent, 'utf8') // Update the DB hash so the next sync doesn't re-overwrite try { const db = getDatabase() const hash = sha256(soulContent) db.prepare(`UPDATE agents SET content_hash = ?, updated_at = ? WHERE workspace_path = ? AND source = 'local'`) .run(hash, Math.floor(Date.now() / 1000), agentDir) } catch { /* best-effort */ } }