360 lines
12 KiB
TypeScript
360 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"].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 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})`),
|
|
})),
|
|
};
|
|
}
|