Add VoidBot swarm dashboard

This commit is contained in:
Meta Vens 2026-05-24 22:27:43 +01:00
commit 6f95f22b13
4 changed files with 945 additions and 0 deletions

View file

@ -0,0 +1,51 @@
# VoidBot Swarm Web Interface
This is the read-only swarm visibility surface for iPad or LAN use.
## Owner
`scripts/render-voidbot-swarm-dashboard.mjs` owns the projection. It reads the existing state owners:
- `.voidbot/status/repo-face-heartbeats.json`
- `.voidbot/status/gamecult-orchestrator.json`
- `state/agent-swarm-paused.json`
It writes:
- `.voidbot/status/swarm-state.json`
- `.voidbot/status/swarm-dashboard.html`
- `.voidbot/status/cultmesh/voidbot-swarm-state.cc`
The `.cc` file is a CultCache snapshot document with schema id `voidbot.swarm_state_snapshot.v1`. CultMesh should distribute that document when a live mesh bridge is installed. The web page is only a reader.
## Local iPad Use
From `E:\Projects\VoidBot`:
```powershell
npm run swarm:dashboard
```
Open the LAN URL printed by the command on the iPad. The server refreshes the snapshot every 10 seconds and serves the static page from `.voidbot/status`.
For a one-shot render without serving:
```powershell
npm run swarm:render-dashboard
```
## Public Hosting Shape
`voidbot.gamecult.org` should not expose raw private state. Publish `swarm-state.json`, `swarm-dashboard.html`, and the CultCache snapshot only after deciding the access model:
- public redacted view, if agent names, job ids, paths, and timing are acceptable
- private nginx/Heimdall-gated view, if the operational paths and job ids are sensitive
- Quartz-hosted static shell plus a separately synced redacted snapshot, if the domain should behave like the other GameCult static sites
Do not make the public site read Face `.cc` files directly. The exporter is the redaction boundary.
For public/static output, render with:
```powershell
node scripts/render-voidbot-swarm-dashboard.mjs --public --out .voidbot/status/public-swarm-dashboard.html --snapshot .voidbot/status/public-swarm-state.json --cultmeshStore .voidbot/status/cultmesh/public-voidbot-swarm-state.cc
```

View file

@ -21,6 +21,8 @@
"state:check": "powershell -ExecutionPolicy Bypass -File scripts/check-voidbot-operations.ps1",
"state:dashboard": "powershell -ExecutionPolicy Bypass -File scripts/render-voidbot-operations-dashboard.ps1 -Open",
"state:render-dashboard": "powershell -ExecutionPolicy Bypass -File scripts/render-voidbot-operations-dashboard.ps1",
"swarm:dashboard": "node scripts/render-voidbot-swarm-dashboard.mjs --serve --host 0.0.0.0",
"swarm:render-dashboard": "node scripts/render-voidbot-swarm-dashboard.mjs",
"state:sync-offsite": "powershell -ExecutionPolicy Bypass -File scripts/sync-voidbot-backup-offsite.ps1",
"state:install-startup-task": "powershell -ExecutionPolicy Bypass -File scripts/install-stack-startup-task.ps1",
"state:install-offsite-task": "powershell -ExecutionPolicy Bypass -File scripts/install-offsite-backup-task.ps1",

View file

@ -0,0 +1,891 @@
#!/usr/bin/env node
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { networkInterfaces } from "node:os";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultStorageRoot = resolve(repoRoot, ".voidbot");
const defaultStatusDir = resolve(defaultStorageRoot, "status");
const defaultSnapshotPath = resolve(defaultStatusDir, "swarm-state.json");
const defaultDashboardPath = resolve(defaultStatusDir, "swarm-dashboard.html");
const defaultCultMeshStorePath = resolve(defaultStatusDir, "cultmesh", "voidbot-swarm-state.cc");
const snapshotDocumentType = "voidbot.swarm_state_snapshot";
const snapshotSchemaId = "voidbot.swarm_state_snapshot.v1";
const args = parseArgs(process.argv.slice(2));
const env = await readDotEnv(resolve(repoRoot, ".env"));
const storageRoot = resolveConfigPath(env.STORAGE_ROOT, defaultStorageRoot);
const statusDir = resolve(storageRoot, "status");
const heartbeatStatePath = resolveConfigPath(
env.REPO_FACE_HEARTBEAT_STATE_PATH,
resolve(statusDir, "repo-face-heartbeats.json"),
);
const orchestratorPath = resolve(statusDir, "gamecult-orchestrator.json");
const pausePath = resolve(repoRoot, "state", "agent-swarm-paused.json");
const snapshotPath = resolveConfigPath(args.snapshot, defaultSnapshotPath.replace(defaultStatusDir, statusDir));
const dashboardPath = resolveConfigPath(args.out, defaultDashboardPath.replace(defaultStatusDir, statusDir));
const cultMeshStorePath = resolveConfigPath(
args.cultmeshStore,
defaultCultMeshStorePath.replace(defaultStatusDir, statusDir),
);
const renderResult = await render();
if (args.open) {
console.log(pathToFileURL(renderResult.dashboardPath).href);
}
if (args.serve) {
await serveDashboard({
host: args.host ?? "0.0.0.0",
port: Number.parseInt(args.port ?? "8787", 10),
rootDir: dirname(renderResult.dashboardPath),
refreshSeconds: Number.parseInt(args.refreshSeconds ?? "10", 10),
});
} else {
console.log(`Swarm dashboard HTML: ${renderResult.dashboardPath}`);
console.log(`Swarm snapshot JSON: ${renderResult.snapshotPath}`);
console.log(`CultMesh snapshot store: ${renderResult.cultMeshStorePath}`);
}
async function render() {
let snapshot = await buildSnapshot();
if (args.public) {
snapshot = redactSnapshot(snapshot);
}
await mkdir(dirname(snapshotPath), { recursive: true });
await writeFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
await mkdir(dirname(dashboardPath), { recursive: true });
await writeFile(dashboardPath, renderHtml(snapshot), "utf8");
const cultMeshWrite = await writeCultMeshSnapshot(snapshot, cultMeshStorePath);
const finalSnapshot = {
...snapshot,
cultMesh: {
...snapshot.cultMesh,
...cultMeshWrite,
},
};
await writeFile(snapshotPath, `${JSON.stringify(finalSnapshot, null, 2)}\n`, "utf8");
await writeFile(dashboardPath, renderHtml(finalSnapshot), "utf8");
return {
dashboardPath,
snapshotPath,
cultMeshStorePath,
};
}
async function buildSnapshot() {
const now = new Date();
const heartbeat = await readJsonFile(heartbeatStatePath);
const orchestrator = await readJsonFile(orchestratorPath);
const pause = await readJsonFile(pausePath);
const heartbeatMtime = await getMtime(heartbeatStatePath);
const orchestratorMtime = await getMtime(orchestratorPath);
const paused = pause.ok ? pause.value?.paused !== false : false;
const pauseReason = pause.ok && typeof pause.value?.reason === "string" ? pause.value.reason : undefined;
const participants = Array.isArray(heartbeat.value?.participants) ? heartbeat.value.participants : [];
const pendingMentions = Array.isArray(heartbeat.value?.pendingMentions) ? heartbeat.value.pendingMentions : [];
const initiativeClock = numberOrNull(heartbeat.value?.initiativeClock);
const participantSnapshots = participants.map((participant) =>
projectParticipant(participant, pendingMentions, initiativeClock),
);
const activeTurns = participantSnapshots.filter((participant) => participant.activeJobId);
const readyNow = participantSnapshots.filter((participant) =>
participant.status === "active" &&
!participant.activeJobId &&
typeof participant.nextTurnInMinutes === "number" &&
participant.nextTurnInMinutes <= 0,
);
const nextParticipant = [...participantSnapshots]
.filter((participant) => typeof participant.nextTurnInMinutes === "number")
.sort((left, right) => left.nextTurnInMinutes - right.nextTurnInMinutes)[0];
const organs = projectOrgans(orchestrator.value);
const recentEvents = Array.isArray(heartbeat.value?.history)
? heartbeat.value.history.slice(-24).reverse().map(projectHistoryEvent)
: [];
return {
schemaVersion: snapshotSchemaId,
generatedAt: now.toISOString(),
title: "VoidBot Swarm State",
sources: {
heartbeatStatePath,
heartbeatStateUpdatedAt: heartbeatMtime,
heartbeatStateReadable: heartbeat.ok,
heartbeatStateError: heartbeat.ok ? undefined : heartbeat.error,
orchestratorPath,
orchestratorUpdatedAt: orchestratorMtime,
orchestratorReadable: orchestrator.ok,
orchestratorError: orchestrator.ok ? undefined : orchestrator.error,
pausePath,
pauseReadable: pause.ok,
pauseError: pause.ok ? undefined : pause.error,
},
cultMesh: {
documentType: snapshotDocumentType,
schemaId: snapshotSchemaId,
documentKey: "voidbot-swarm",
storePath: cultMeshStorePath,
writeStatus: "pending",
note: "CultMesh should distribute this typed CultCache snapshot; the web page remains read-only.",
},
summary: {
state: deriveSwarmState({ heartbeat, orchestrator, paused, activeTurns }),
paused,
pauseReason,
participantCount: participantSnapshots.length,
activeTurnCount: activeTurns.length,
pendingMentionCount: pendingMentions.length,
readyNowCount: readyNow.length,
nextIdentityId: nextParticipant?.identityId ?? null,
nextDisplayName: nextParticipant?.displayName ?? null,
nextTurnInMinutes: nextParticipant?.nextTurnInMinutes ?? null,
initiativeClock,
baseRecoveryMinutes: numberOrNull(heartbeat.value?.baseRecoveryMinutes),
globalHeat: numberOrNull(heartbeat.value?.globalHeat),
lastTickAt: stringOrNull(heartbeat.value?.lastTickAt),
},
participants: participantSnapshots,
activeTurns,
pendingMentions: pendingMentions.map(projectPendingMention),
orchestrator: {
state: deriveOrchestratorState(organs),
organs,
},
recentEvents,
};
}
function projectParticipant(participant, pendingMentions, initiativeClock) {
const identityId = String(participant.identityId ?? "unknown");
const nextTurnAt = numberOrNull(participant.nextTurnAt);
const lastTurnAt = numberOrNull(participant.lastTurnAt);
const activeTurnStartedAt = numberOrNull(participant.activeTurnStartedAt);
const channelCount = Array.isArray(participant.groups)
? participant.groups.filter((entry) => typeof entry === "string" && entry.startsWith("channel:")).length
: 0;
const mentionCount = pendingMentions.filter((mention) => mention?.identityId === identityId).length;
const nextTurnInMinutes =
typeof nextTurnAt === "number" && typeof initiativeClock === "number"
? round(nextTurnAt - initiativeClock, 3)
: null;
return {
identityId,
displayName: String(participant.displayName ?? identityId),
repoName: String(participant.repoName ?? "unknown"),
participantKind: String(participant.participantKind ?? "repo_face"),
turnKind: String(participant.turnKind ?? "repo_face_rumination"),
status: String(participant.status ?? "unknown"),
currentLoad: numberOrNull(participant.currentLoad),
heat: numberOrNull(participant.heat),
effectiveSpeed: numberOrNull(participant.effectiveSpeed),
initiativeSpeed: numberOrNull(participant.initiativeSpeed),
nextTurnAt,
nextTurnInMinutes,
lastTurnAt,
activeTurnStartedAt,
activeJobId: stringOrNull(participant.activeJobId),
lastQueuedAt: stringOrNull(participant.lastQueuedAt),
queuedCount: numberOrNull(participant.queuedCount),
pendingMentionCount: mentionCount,
channelCount,
constraintCount: Array.isArray(participant.constraints) ? participant.constraints.length : 0,
constraints: Array.isArray(participant.constraints) ? participant.constraints.slice(0, 4).map(String) : [],
};
}
function projectOrgans(orchestrator) {
const organs = orchestrator?.organs && typeof orchestrator.organs === "object" ? orchestrator.organs : {};
return Object.entries(organs).map(([id, organ]) => ({
id,
label: String(organ?.label ?? id),
intervalMinutes: numberOrNull(organ?.intervalMinutes),
lastStartedAt: stringOrNull(organ?.lastStartedAt),
lastFinishedAt: stringOrNull(organ?.lastFinishedAt),
lastExitCode: numberOrNull(organ?.lastExitCode),
lastStatus: String(organ?.lastStatus ?? "unknown"),
lastLogPath: stringOrNull(organ?.lastLogPath),
}));
}
function projectHistoryEvent(event) {
return {
type: String(event?.type ?? "event"),
identityId: stringOrNull(event?.identityId),
observedAt: stringOrNull(event?.observedAt ?? event?.queuedAt),
activeJobId: stringOrNull(event?.activeJobId),
statusPath: stringOrNull(event?.statusPath),
reason: stringOrNull(event?.reason),
nextTurnAt: numberOrNull(event?.nextTurnAt),
recoveryMinutes: numberOrNull(event?.recoveryMinutes),
};
}
function projectPendingMention(mention) {
return {
identityId: stringOrNull(mention?.identityId),
sourceChannelId: stringOrNull(mention?.sourceChannelId),
sourceMessageId: stringOrNull(mention?.sourceMessageId),
createdAt: stringOrNull(mention?.createdAt),
prompt: truncate(String(mention?.prompt ?? mention?.content ?? ""), 220),
};
}
function deriveSwarmState({ heartbeat, orchestrator, paused, activeTurns }) {
if (!heartbeat.ok) return "missing";
if (paused) return "paused";
const organ = projectOrgans(orchestrator.value).find((entry) => entry.id === "repo-face-heartbeats");
if (organ && organ.lastStatus && !["ok", "skipped_disabled"].includes(organ.lastStatus)) {
return "warning";
}
if (activeTurns.length > 0) return "running";
return "ready";
}
function deriveOrchestratorState(organs) {
if (organs.length === 0) return "missing";
if (organs.some((organ) => ["failed", "error", "stalled"].includes(organ.lastStatus))) return "warning";
if (organs.some((organ) => organ.lastStatus === "running")) return "running";
return "ok";
}
async function writeCultMeshSnapshot(snapshot, storePath) {
try {
const cultCacheRoot = resolve(repoRoot, "..", "CultCacheTS");
const cultCachePackage = resolve(cultCacheRoot, "package.json");
if (!existsSync(cultCachePackage)) {
return {
writeStatus: "skipped",
writeError: `CultCacheTS package was not found at ${cultCachePackage}.`,
};
}
const requireCultCache = createRequire(cultCachePackage);
const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType } = requireCultCache(
resolve(cultCacheRoot, "dist", "index.js"),
);
const snapshotDefinition = defineDocumentType({
type: snapshotDocumentType,
schemaName: snapshotDocumentType,
schemaId: snapshotSchemaId,
schemaVersion: snapshotSchemaId,
contentHash: snapshotSchemaId,
canonicalSchemaJson: JSON.stringify({
schemaName: snapshotDocumentType,
schemaVersion: snapshotSchemaId,
description: "Redacted read-only VoidBot swarm state snapshot for CultMesh distribution.",
}),
global: true,
schema: {
parse(input) {
if (!input || typeof input !== "object") {
throw new Error("VoidBot swarm snapshot must be an object.");
}
return input;
},
},
});
const store = new SingleFileMessagePackBackingStore(storePath);
const cache = CultCache.builder()
.withDocumentType(snapshotDefinition)
.withGenericStore(store)
.build();
await cache.pullAllBackingStores();
await cache.put(snapshotDefinition, "voidbot-swarm", snapshot);
return {
writeStatus: "ok",
storePath,
writtenAt: new Date().toISOString(),
};
} catch (error) {
return {
writeStatus: "failed",
storePath,
writeError: error instanceof Error ? error.message : String(error),
};
}
}
function renderHtml(snapshot) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VoidBot Swarm State</title>
<style>
:root {
color-scheme: dark;
--bg: #101113;
--panel: #181a1d;
--panel-2: #202327;
--line: #343941;
--text: #f1efe7;
--muted: #aaa69a;
--green: #61d394;
--amber: #e6b450;
--coral: #e06c66;
--cyan: #55b8c6;
--violet: #a891e8;
--ink: #070808;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "SFMono-Regular", "Cascadia Mono", Consolas, monospace;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body {
margin: 0;
color: var(--text);
font: 15px/1.5 var(--font);
letter-spacing: 0;
background:
linear-gradient(180deg, rgba(16, 17, 19, 0.98), rgba(16, 17, 19, 1)),
var(--bg);
}
main {
width: min(1440px, 100%);
margin: 0 auto;
padding: 20px;
display: grid;
gap: 16px;
}
header {
display: grid;
gap: 10px;
padding: 18px 0 6px;
}
h1, h2, h3, p { margin: 0; }
h1 {
font-size: clamp(2rem, 5vw, 4.5rem);
line-height: 0.95;
font-weight: 760;
}
h2 {
font-size: 1rem;
font-weight: 720;
text-transform: uppercase;
}
.topline {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
color: var(--muted);
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.metric, section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.metric {
padding: 14px;
min-height: 92px;
display: grid;
align-content: space-between;
gap: 8px;
}
.metric strong {
font-size: 2rem;
line-height: 1;
}
.metric span {
color: var(--muted);
font-size: 0.86rem;
text-transform: uppercase;
font-weight: 680;
}
section {
padding: 14px;
display: grid;
gap: 12px;
overflow: hidden;
}
.participants {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
}
.agent {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
}
.agent-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: start;
}
.agent h3 {
font-size: 1.08rem;
font-weight: 760;
overflow-wrap: anywhere;
}
.repo {
color: var(--muted);
font-family: var(--mono);
font-size: 0.76rem;
overflow-wrap: anywhere;
}
.facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.fact {
display: grid;
gap: 2px;
min-width: 0;
}
.fact span {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
font-weight: 700;
}
.fact strong {
font-family: var(--mono);
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.tables {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 10px;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 9px 8px;
border-top: 1px solid var(--line);
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
th {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
}
code, .mono {
font-family: var(--mono);
font-size: 0.88em;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 4px 9px;
border-radius: 999px;
color: var(--ink);
background: var(--muted);
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
white-space: normal;
overflow-wrap: anywhere;
text-align: center;
}
.ready, .ok, .active { background: var(--green); }
.running, .info { background: var(--cyan); }
.paused, .warning { background: var(--amber); }
.failed, .missing, .error { background: var(--coral); }
.repo-face { background: var(--violet); }
.muted { color: var(--muted); }
.footer {
color: var(--muted);
font-size: 0.82rem;
padding-bottom: 14px;
}
@media (max-width: 720px) {
main { padding: 12px; }
.summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.participants { grid-template-columns: 1fr; }
.tables { grid-template-columns: 1fr; }
.facts { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<main id="app"></main>
<script id="initial-snapshot" type="application/json">${escapeScriptJson(JSON.stringify(snapshot))}</script>
<script>
const initialSnapshot = JSON.parse(document.getElementById("initial-snapshot").textContent);
const app = document.getElementById("app");
const tone = (value) => String(value || "unknown").toLowerCase().replace(/_/g, "-");
const text = (value) => value === null || value === undefined || value === "" ? "missing" : String(value);
const number = (value) => typeof value === "number" ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : "missing";
const minutes = (value) => typeof value === "number" ? (value <= 0 ? "ready" : value.toLocaleString(undefined, { maximumFractionDigits: 1 }) + "m") : "missing";
const relative = (timestamp) => {
if (!timestamp) return "missing";
const time = Date.parse(timestamp);
if (!Number.isFinite(time)) return timestamp;
const seconds = Math.max(0, Math.round((Date.now() - time) / 1000));
if (seconds < 60) return seconds + "s ago";
const mins = Math.round(seconds / 60);
if (mins < 60) return mins + "m ago";
const hours = Math.round(mins / 60);
if (hours < 48) return hours + "h ago";
return Math.round(hours / 24) + "d ago";
};
const esc = (value) => text(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const badge = (value, label = value) => "<span class=\\"badge " + tone(value) + "\\">" + esc(String(label).replace(/_/g, " ")) + "</span>";
const metric = (label, value, klass = "") => "<article class=\\"metric\\"><span>" + esc(label) + "</span><strong class=\\"" + klass + "\\">" + esc(value) + "</strong></article>";
function render(snapshot) {
const summary = snapshot.summary || {};
const participants = [...(snapshot.participants || [])].sort((a, b) => {
const active = Number(Boolean(b.activeJobId)) - Number(Boolean(a.activeJobId));
if (active) return active;
return (a.nextTurnInMinutes ?? 999999) - (b.nextTurnInMinutes ?? 999999);
});
const organs = snapshot.orchestrator?.organs || [];
const events = snapshot.recentEvents || [];
app.innerHTML = [
"<header>",
"<div class=\\"topline\\">" + badge(summary.state) + "<span>Generated " + esc(relative(snapshot.generatedAt)) + "</span><span class=\\"mono\\">" + esc(snapshot.cultMesh?.schemaId) + "</span></div>",
"<h1>VoidBot Swarm State</h1>",
"<p class=\\"muted\\">Read-only CTB scheduler state, rendered from the local status files and mirrored as a CultCache snapshot for CultMesh.</p>",
"</header>",
"<section class=\\"summary\\">",
metric("Participants", number(summary.participantCount)),
metric("Active turns", number(summary.activeTurnCount)),
metric("Pending mentions", number(summary.pendingMentionCount)),
metric("Ready now", number(summary.readyNowCount)),
metric("Next", summary.nextDisplayName ? summary.nextDisplayName : "missing"),
metric("Next turn", minutes(summary.nextTurnInMinutes)),
metric("Global heat", number(summary.globalHeat)),
metric("CultMesh", snapshot.cultMesh?.writeStatus || "missing"),
"</section>",
"<section>",
"<h2>Agents</h2>",
"<div class=\\"participants\\">",
participants.map(agentCard).join(""),
"</div>",
"</section>",
"<div class=\\"tables\\">",
"<section><h2>Orchestrator</h2><table><thead><tr><th>Organ</th><th>Status</th><th>Last run</th></tr></thead><tbody>",
organs.length ? organs.map((organ) => "<tr><td>" + esc(organ.label) + "<div class=\\"repo\\">" + esc(organ.id) + "</div></td><td>" + badge(organ.lastStatus) + "</td><td>" + esc(relative(organ.lastFinishedAt || organ.lastStartedAt)) + "</td></tr>").join("") : "<tr><td colspan=\\"3\\" class=\\"muted\\">No orchestrator state.</td></tr>",
"</tbody></table></section>",
"<section><h2>Recent Events</h2><table><thead><tr><th>Event</th><th>Identity</th><th>When</th></tr></thead><tbody>",
events.length ? events.slice(0, 16).map((event) => "<tr><td>" + esc(event.type) + (event.reason ? "<div class=\\"repo\\">" + esc(event.reason) + "</div>" : "") + "</td><td>" + esc(event.identityId || "") + "</td><td>" + esc(relative(event.observedAt)) + "</td></tr>").join("") : "<tr><td colspan=\\"3\\" class=\\"muted\\">No heartbeat events.</td></tr>",
"</tbody></table></section>",
"</div>",
"<section><h2>State Files</h2><table><tbody>",
row("Heartbeat", snapshot.sources?.heartbeatStatePath, snapshot.sources?.heartbeatStateReadable ? "ok" : "missing"),
row("Orchestrator", snapshot.sources?.orchestratorPath, snapshot.sources?.orchestratorReadable ? "ok" : "missing"),
row("Pause flag", snapshot.sources?.pausePath, snapshot.sources?.pauseReadable ? (summary.paused ? "paused" : "ok") : "missing"),
row("CultMesh store", snapshot.cultMesh?.storePath, snapshot.cultMesh?.writeStatus),
"</tbody></table></section>",
"<div class=\\"footer\\">This page polls <code>swarm-state.json</code> every 10 seconds when served over HTTP. It does not mutate VoidBot, Discord, CultCache, or the scheduler.</div>",
].join("");
}
function agentCard(agent) {
return [
"<article class=\\"agent\\">",
"<div class=\\"agent-head\\"><div><h3>" + esc(agent.displayName) + "</h3><div class=\\"repo\\">" + esc(agent.identityId) + " / " + esc(agent.repoName) + "</div></div>" + badge(agent.activeJobId ? "running" : agent.status) + "</div>",
"<div class=\\"facts\\">",
fact("Next", minutes(agent.nextTurnInMinutes)),
fact("Load", number(agent.currentLoad)),
fact("Speed", number(agent.effectiveSpeed)),
fact("Heat", number(agent.heat)),
fact("Queued", number(agent.queuedCount)),
fact("Mentions", number(agent.pendingMentionCount)),
fact("Channels", number(agent.channelCount)),
fact("Last queued", relative(agent.lastQueuedAt)),
"</div>",
agent.activeJobId ? "<div class=\\"repo\\">active " + esc(agent.activeJobId) + "</div>" : "",
"</article>",
].join("");
}
function fact(label, value) {
return "<div class=\\"fact\\"><span>" + esc(label) + "</span><strong>" + esc(value) + "</strong></div>";
}
function row(label, value, state) {
return "<tr><th>" + esc(label) + "</th><td class=\\"mono\\">" + esc(value) + "</td><td>" + badge(state || "unknown") + "</td></tr>";
}
async function refresh() {
try {
const response = await fetch("swarm-state.json?ts=" + Date.now(), { cache: "no-store" });
if (response.ok) {
render(await response.json());
return;
}
} catch {}
render(initialSnapshot);
}
render(initialSnapshot);
setInterval(refresh, 10000);
refresh();
</script>
</body>
</html>
`;
}
function redactSnapshot(snapshot) {
const redacted = JSON.parse(JSON.stringify(snapshot));
redacted.visibility = "public_redacted";
redacted.sources = {
heartbeatStateReadable: snapshot.sources?.heartbeatStateReadable ?? false,
heartbeatStateUpdatedAt: snapshot.sources?.heartbeatStateUpdatedAt ?? null,
orchestratorReadable: snapshot.sources?.orchestratorReadable ?? false,
orchestratorUpdatedAt: snapshot.sources?.orchestratorUpdatedAt ?? null,
pauseReadable: snapshot.sources?.pauseReadable ?? false,
};
redacted.cultMesh = {
documentType: snapshot.cultMesh?.documentType,
schemaId: snapshot.cultMesh?.schemaId,
documentKey: snapshot.cultMesh?.documentKey,
writeStatus: snapshot.cultMesh?.writeStatus,
writtenAt: snapshot.cultMesh?.writtenAt,
note: "Public redacted snapshot. Local paths and job ids are withheld.",
};
redacted.participants = redacted.participants.map((participant) => ({
...participant,
activeJobId: participant.activeJobId ? "active" : null,
constraints: [],
constraintCount: participant.constraintCount,
}));
redacted.activeTurns = redacted.activeTurns.map((participant) => ({
...participant,
activeJobId: participant.activeJobId ? "active" : null,
constraints: [],
}));
redacted.pendingMentions = redacted.pendingMentions.map((mention) => ({
identityId: mention.identityId,
createdAt: mention.createdAt,
}));
redacted.orchestrator.organs = redacted.orchestrator.organs.map((organ) => ({
...organ,
lastLogPath: null,
}));
redacted.recentEvents = redacted.recentEvents.map((event) => ({
...event,
activeJobId: event.activeJobId ? "active" : null,
statusPath: null,
}));
return redacted;
}
async function serveDashboard({ host, port, rootDir, refreshSeconds }) {
const interval = setInterval(async () => {
try {
await render();
} catch (error) {
console.error(`Swarm dashboard refresh failed: ${error instanceof Error ? error.message : String(error)}`);
}
}, Math.max(refreshSeconds, 2) * 1000);
interval.unref();
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
const pathname = requestUrl.pathname === "/" ? "/swarm-dashboard.html" : decodeURIComponent(requestUrl.pathname);
const target = resolve(rootDir, `.${pathname}`);
if (!target.startsWith(rootDir)) {
response.writeHead(403);
response.end("Forbidden");
return;
}
const fileStat = await stat(target);
if (!fileStat.isFile()) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"content-type": contentType(target),
"cache-control": "no-store",
});
response.end(await readFile(target));
} catch {
response.writeHead(404);
response.end("Not found");
}
});
await new Promise((resolveServer) => server.listen(port, host, resolveServer));
console.log(`Swarm dashboard serving ${rootDir}`);
for (const url of localUrls(port, host)) {
console.log(url);
}
}
function contentType(path) {
switch (extname(path).toLowerCase()) {
case ".html":
return "text/html; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".cc":
return "application/octet-stream";
default:
return "text/plain; charset=utf-8";
}
}
function localUrls(port, host) {
const urls = [];
if (host === "127.0.0.1" || host === "localhost") {
urls.push(`http://127.0.0.1:${port}/`);
return urls;
}
urls.push(`http://127.0.0.1:${port}/`);
for (const entries of Object.values(networkInterfaces())) {
for (const entry of entries ?? []) {
if (entry.family === "IPv4" && !entry.internal) {
urls.push(`http://${entry.address}:${port}/`);
}
}
}
return [...new Set(urls)];
}
async function readJsonFile(path) {
try {
const raw = await readFile(path, "utf8");
return { ok: true, value: JSON.parse(stripBom(raw)) };
} catch (error) {
return {
ok: false,
value: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function getMtime(path) {
try {
return (await stat(path)).mtime.toISOString();
} catch {
return null;
}
}
async function readDotEnv(path) {
const values = {};
try {
const raw = await readFile(path, "utf8");
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator < 1) continue;
values[line.slice(0, separator).trim()] = line.slice(separator + 1);
}
} catch {}
return values;
}
function resolveConfigPath(value, fallback) {
const raw = typeof value === "string" && value.trim() ? value.trim() : fallback;
return resolve(repoRoot, raw);
}
function parseArgs(argv) {
const parsed = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--serve") parsed.serve = true;
else if (arg === "--open") parsed.open = true;
else if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = argv[index + 1];
if (next && !next.startsWith("--")) {
parsed[key] = next;
index += 1;
} else {
parsed[key] = "true";
}
}
}
return parsed;
}
function stripBom(value) {
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
}
function numberOrNull(value) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function stringOrNull(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
function round(value, places) {
const factor = 10 ** places;
return Math.round(value * factor) / factor;
}
function truncate(value, maxLength) {
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
}
function escapeScriptJson(value) {
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
}

View file

@ -323,3 +323,4 @@
{"ts":"2026-05-24T18:36:00Z","type":"implementation","status":"ok","note":"Repo Face CTB urgency now derives from typed speaking pressure; Kiko's StreamPixels state carries a June 10 2026 delivery pressure for Metacrat's sister's project."}
{"ts":"2026-05-24T19:38:23Z","type":"implementation","status":"ok","note":"Repo Face affect now has socialBiases for neuroticism, threat sensitivity, hostile attribution, reassurance need, grievance retention, status vigilance, and trust baseline. Active Faces plus Void were seeded with archetypal bias baselines; Nibu's projection smoke shows the biases reaching child prompt context."}
{"ts":"2026-05-24T21:25:43Z","type":"implementation","status":"ok","note":"Repo Face idle cooling now decays over hours by default: cooling after 180 minutes, nap mode after 360 minutes, cooled unprompted turns every 120 minutes; nap mode can select napping Faces for sparse dream turns and nap memory maintenance has a 60-minute completion cooldown."}
{"ts":"2026-05-24T21:27:15Z","type":"implementation","status":"ok","note":"Added read-only VoidBot swarm web exporter: renders .voidbot/status/swarm-dashboard.html and swarm-state.json from heartbeat/orchestrator status, writes a CultCache snapshot for CultMesh at .voidbot/status/cultmesh/voidbot-swarm-state.cc, and supports --public redaction for future voidbot.gamecult.org hosting."}