From 6f95f22b138ef69cef2be8ef4d8d9035e5dac123 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 24 May 2026 22:27:43 +0100 Subject: [PATCH] Add VoidBot swarm dashboard --- docs/voidbot-swarm-web-interface.md | 51 ++ package.json | 2 + scripts/render-voidbot-swarm-dashboard.mjs | 891 +++++++++++++++++++++ state/evidence.jsonl | 1 + 4 files changed, 945 insertions(+) create mode 100644 docs/voidbot-swarm-web-interface.md create mode 100644 scripts/render-voidbot-swarm-dashboard.mjs diff --git a/docs/voidbot-swarm-web-interface.md b/docs/voidbot-swarm-web-interface.md new file mode 100644 index 0000000..78c8ebd --- /dev/null +++ b/docs/voidbot-swarm-web-interface.md @@ -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 +``` diff --git a/package.json b/package.json index 586f357..eac4ca4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/render-voidbot-swarm-dashboard.mjs b/scripts/render-voidbot-swarm-dashboard.mjs new file mode 100644 index 0000000..bb2d5f5 --- /dev/null +++ b/scripts/render-voidbot-swarm-dashboard.mjs @@ -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 ` + + + + + VoidBot Swarm State + + + +
+ + + + +`; +} + +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, "\\u003e").replace(/&/g, "\\u0026"); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9bd47d4..dcb6472 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."}