import fs from "node:fs"; import path from "node:path"; import { ARCHITECTURE_DOCUMENT, FLEET_CONFIG, OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH, ZEROCLAW_CONTROL_DIR, ZEROCLAW_PRIMARY_DIR, } from "@/lib/fleet-config"; import { all } from "@/lib/db"; import { normalizeTask } from "@/lib/tasks"; import type { AgentStatus, FleetAgent, TaskEvent, TaskRecord } from "@/lib/types"; type OpenClawAgentConfig = { id: string; name?: string; model?: { primary?: string }; identity?: { name?: string; emoji?: string; theme?: string }; subagents?: { allowAgents?: string[] }; }; type OpenClawConfigShape = { agents?: { list?: OpenClawAgentConfig[] }; channels?: { telegram?: { groups?: Record< string, { topics?: Record; } >; }; }; }; function readTextFile(filePath: string) { if (!fs.existsSync(filePath)) { return ""; } return fs.readFileSync(filePath, "utf8"); } function parseBulletValues(content: string) { return content .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("- ")) .map((line) => line.replace(/^- /, "").replace(/`/g, "").trim()); } function parseRoleFromAgentsMd(content: string) { const identityMatch = content.match(/- Scope:\s*(.+)/); if (identityMatch) { return identityMatch[1].trim(); } return "Host-scoped agent"; } function deriveHeartbeatTimestamp(heartbeatPath: string, heartbeatMd: string) { const timestampMatch = heartbeatMd.match( /(Last Heartbeat|Updated|Timestamp):\s*([0-9TZ:.\-+ ]+)/i, ); if (timestampMatch) { const parsed = new Date(timestampMatch[2].trim()); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } } if (!fs.existsSync(heartbeatPath)) { return null; } return fs.statSync(heartbeatPath).mtime.toISOString(); } function parseResponsibilities(content: string) { const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/); return sectionMatch ? parseBulletValues(sectionMatch[1]) : []; } function readWorkspaceAgent(agentRoot: string, fallbackName: string) { const workspaceRoot = path.join(agentRoot, "workspace"); const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md")); const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md")); const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md")); const heartbeatPath = path.join(workspaceRoot, "HEARTBEAT.md"); const heartbeatMd = readTextFile(heartbeatPath); const tools = parseBulletValues(toolsMd); const capabilities = parseResponsibilities(agentsMd); const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i); const heartbeatAt = deriveHeartbeatTimestamp(heartbeatPath, heartbeatMd); return { files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md", "HEARTBEAT.md"].filter((fileName) => fs.existsSync(path.join(workspaceRoot, fileName)), ), tools, capabilities, currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null, heartbeatAt, role: parseRoleFromAgentsMd(agentsMd), noteValues: parseBulletValues(identityMd), workspaceRoot, }; } function readOpenClawConfig(): OpenClawConfigShape { try { return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8")) as OpenClawConfigShape; } catch { return {}; } } function getOpenClawChannels(agentId: string, config: OpenClawConfigShape) { const summaries: { label: string; value: string }[] = [ { label: "Family", value: "OpenClaw telegram + gateway" }, ]; const topicGroups = config.channels?.telegram?.groups?.["-1003809447066"]?.topics; if (!topicGroups) { return summaries; } const topicEntries = Object.entries(topicGroups).filter(([, topic]) => { const prompt = topic.systemPrompt || ""; return prompt.toLowerCase().includes(agentId.toLowerCase()) || agentId === "main"; }); if (topicEntries.length === 0 && agentId === "main") { summaries.push({ label: "Forum", value: "Homelab HQ default route" }); return summaries; } topicEntries.forEach(([topicId]) => { summaries.push({ label: "Topic", value: `Homelab HQ topic ${topicId}` }); }); return summaries; } async function fetchTaskBuckets(aliases: string[]) { const placeholders = aliases.map(() => "?").join(", "); const activeRows = await all & { tags: string }>( `SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status IN ('Todo', 'In Progress', 'Review') ORDER BY priority DESC, created_at ASC`, aliases, ); const completedRows = await all & { tags: string }>( `SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status = 'Done' ORDER BY completed_at DESC LIMIT 5`, aliases, ); return { activeTasks: activeRows.map(normalizeTask), completedTasks: completedRows.map(normalizeTask), }; } async function fetchAgentEventSummary(aliases: string[]) { const placeholders = aliases.map(() => "?").join(", "); const latestEvent = await all( `SELECT * FROM task_events WHERE assignee IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`, aliases, ); const failureRows = await all<{ count: number }>( `SELECT COUNT(*) as count FROM task_events WHERE assignee IN (${placeholders}) AND event_type = 'dispatch_failed'`, aliases, ); return { lastEvent: latestEvent[0] || null, failureStreak: failureRows[0]?.count || 0, }; } function deriveHeartbeatAgeMinutes(heartbeatAt: string | null) { if (!heartbeatAt) { return null; } const diffMs = Date.now() - new Date(heartbeatAt).getTime(); return Math.max(0, Math.round(diffMs / 60000)); } function deriveStatus(activeTaskCount: number, heartbeatAt: string | null): AgentStatus { if (activeTaskCount > 0) { return "busy"; } const heartbeatAge = deriveHeartbeatAgeMinutes(heartbeatAt); if (heartbeatAge !== null && heartbeatAge > 180) { return "idle"; } return "active"; } async function buildOpenClawAgents() { const config = readOpenClawConfig(); const agents = config.agents?.list || []; return Promise.all( agents.map(async (agentConfig) => { const agentRoot = path.join(OPENCLAW_AGENTS_DIR, agentConfig.id); const workspace = readWorkspaceAgent(agentRoot, agentConfig.identity?.name || agentConfig.id); const aliases = [ agentConfig.id, agentConfig.identity?.name || agentConfig.name || agentConfig.id, ]; const taskBuckets = await fetchTaskBuckets(aliases); const eventSummary = await fetchAgentEventSummary(aliases); const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt); return { slug: agentConfig.id, assignmentKey: agentConfig.id, aliases, family: "openclaw", name: agentConfig.identity?.name || agentConfig.name || agentConfig.id, host: "ubuntu", role: agentConfig.identity?.theme || workspace.role, runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR, configPath: OPENCLAW_CONFIG_PATH, defaultDispatchMethod: "openclaw-swarm", model: agentConfig.model?.primary || null, emoji: agentConfig.identity?.emoji || "🦞", channels: getOpenClawChannels(agentConfig.id, config), tools: workspace.tools, capabilities: workspace.capabilities, files: workspace.files, status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt), workload: taskBuckets.activeTasks.length, activeTasks: taskBuckets.activeTasks, completedTasks: taskBuckets.completedTasks, currentTask: workspace.currentTask, heartbeatAt: workspace.heartbeatAt, heartbeatAgeMinutes, lastEvent: eventSummary.lastEvent, failureStreak: eventSummary.failureStreak, notes: workspace.noteValues, } satisfies FleetAgent; }), ); } async function buildZeroClawAgents() { const configuredAgents = FLEET_CONFIG.zeroclawAgents.map((agent) => ({ ...agent, runtimePath: agent.slug === "grizzley-zeroclaw" ? ZEROCLAW_PRIMARY_DIR : agent.slug === "ice-zeroclaw" ? ZEROCLAW_CONTROL_DIR : agent.runtimePath, })); return Promise.all( configuredAgents.map(async (configuredAgent) => { const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name); const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases); const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases); const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt); return { ...configuredAgent, family: "zeroclaw" as const, defaultDispatchMethod: configuredAgent.dispatch.method, tools: workspace.tools, capabilities: workspace.capabilities, files: workspace.files, status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt), workload: taskBuckets.activeTasks.length, activeTasks: taskBuckets.activeTasks, completedTasks: taskBuckets.completedTasks, currentTask: workspace.currentTask, heartbeatAt: workspace.heartbeatAt, heartbeatAgeMinutes, lastEvent: eventSummary.lastEvent, failureStreak: eventSummary.failureStreak, notes: [...configuredAgent.notes, ...workspace.noteValues], }; }), ); } async function buildDirectAgents() { return Promise.all( FLEET_CONFIG.directAgents.map(async (configuredAgent) => { const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases); const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases); const heartbeatAt = eventSummary.lastEvent?.created_at || null; const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(heartbeatAt); return { slug: configuredAgent.slug, assignmentKey: configuredAgent.assignmentKey, aliases: configuredAgent.aliases, family: "direct" as const, name: configuredAgent.name, host: configuredAgent.host, role: configuredAgent.role, runtimePath: configuredAgent.runtimePath, configPath: configuredAgent.configPath, defaultDispatchMethod: configuredAgent.dispatch.method, model: null, emoji: configuredAgent.emoji, channels: configuredAgent.channels, tools: configuredAgent.tools, capabilities: configuredAgent.capabilities, files: configuredAgent.files, status: deriveStatus(taskBuckets.activeTasks.length, heartbeatAt), workload: taskBuckets.activeTasks.length, activeTasks: taskBuckets.activeTasks, completedTasks: taskBuckets.completedTasks, currentTask: taskBuckets.activeTasks[0]?.title || null, heartbeatAt, heartbeatAgeMinutes, lastEvent: eventSummary.lastEvent, failureStreak: eventSummary.failureStreak, notes: configuredAgent.notes, } satisfies FleetAgent; }), ); } export async function listFleetAgents() { const [openclawAgents, zeroclawAgents, directAgents] = await Promise.all([ buildOpenClawAgents(), buildZeroClawAgents(), buildDirectAgents(), ]); return [...openclawAgents, ...zeroclawAgents, ...directAgents]; } export async function findAgentByAssignmentKey(assignmentKey: string) { const agents = await listFleetAgents(); return agents.find((agent) => agent.assignmentKey === assignmentKey || agent.aliases.includes(assignmentKey)) || null; } export async function findAgentBySlug(slug: string) { const agents = await listFleetAgents(); return agents.find((agent) => agent.slug === slug) || null; } export async function listArchitecture() { const agents = await listFleetAgents(); return { ...ARCHITECTURE_DOCUMENT, generatedAt: new Date().toISOString(), sections: ARCHITECTURE_DOCUMENT.sections.map((section) => ({ ...section, configuredAgents: agents .filter((agent) => agent.family === section.id) .map((agent) => `${agent.name} (${agent.host})`), })), }; }