Files
openclaw-taskboard/lib/agents.ts

365 lines
12 KiB
TypeScript

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<string, { systemPrompt?: string }>;
}
>;
};
};
};
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<Omit<TaskRecord, "tags"> & { 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<Omit<TaskRecord, "tags"> & { 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<TaskEvent>(
`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})`),
})),
};
}