Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string>() | |
| 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<string, unknown> = {} | |
| 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<string, DiskAgent>() | |
| 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<string, AgentRow>() | |
| 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 */ } | |
| } | |