Files
openclaw-taskboard/lib/agents.ts

269 lines
8.6 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import {
ARCHITECTURE_DOCUMENT,
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, 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 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 heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.md"));
const tools = parseBulletValues(toolsMd);
const capabilities = parseResponsibilities(agentsMd);
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
return {
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) =>
fs.existsSync(path.join(workspaceRoot, fileName)),
),
tools,
capabilities,
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
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 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);
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,
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),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
notes: workspace.noteValues,
} satisfies FleetAgent;
}),
);
}
async function buildZeroClawAgents() {
const configuredAgents = [
{
slug: "grizzley-zeroclaw",
assignmentKey: "grizzley-zeroclaw",
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
name: "ZeroClaw Grizzley",
host: "grizzley",
role: "Edge host operator for grizzley",
runtimePath: ZEROCLAW_PRIMARY_DIR,
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
model: "glm-4.7",
emoji: "🛰️",
channels: [
{ label: "Gateway", value: "HTTP gateway :3000" },
{ label: "Access", value: "paired remote gateway via ice" },
],
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
},
{
slug: "ice-zeroclaw",
assignmentKey: "ice-zeroclaw",
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
name: "ZeroClaw Ice",
host: "ice",
role: "Control-plane operator for ice",
runtimePath: ZEROCLAW_CONTROL_DIR,
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
model: "glm-5",
emoji: "🧊",
channels: [
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
{ label: "Gateway", value: "paired webhook + status routing" },
],
notes: ["Control-plane runtime and topic router for remote host delegation."],
},
];
return Promise.all(
configuredAgents.map(async (configuredAgent) => {
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
return {
...configuredAgent,
family: "zeroclaw" as const,
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
notes: [...configuredAgent.notes, ...workspace.noteValues],
};
}),
);
}
export async function listFleetAgents() {
const [openclawAgents, zeroclawAgents] = await Promise.all([
buildOpenClawAgents(),
buildZeroClawAgents(),
]);
return [...openclawAgents, ...zeroclawAgents];
}
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})`),
})),
};
}
function deriveStatus(activeTaskCount: number): AgentStatus {
return activeTaskCount > 0 ? "busy" : "active";
}