269 lines
8.6 KiB
TypeScript
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";
|
|
}
|