VoidBot/scripts/inspect-agent-state-requests.ts
2026-06-05 19:06:08 +01:00

451 lines
14 KiB
TypeScript

import "dotenv/config";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { loadConfig } from "@voidbot/config";
import {
loadRepoDiscordIdentityRegistry,
loadVoidSelfStateTypedDocuments,
resolveRepoPersonaStatePath,
} from "@voidbot/core";
type Target = {
kind?: string;
id?: string;
label?: string;
};
type AgentAsk = {
agentId: string;
displayName: string;
repoName?: string;
statePath: string;
source: "agency_pressure" | "candidate_intervention" | "incubation" | "short_term_memory";
id: string;
status?: string;
kind?: string;
target?: Target;
summary: string;
claimOrQuestion?: string;
tension?: string;
actionImplication?: string;
intensity: number;
updatedAt?: string;
tags: string[];
};
type AgentSummary = {
agentId: string;
displayName: string;
repoName?: string;
statePath: string;
askCount: number;
topAsks: AgentAsk[];
};
type ProposalSeed = {
proposalId: string;
title: string;
targetKey: string;
agentCount: number;
askCount: number;
averageIntensity: number;
agents: string[];
recommendedAction: string;
asks: AgentAsk[];
};
type InspectionPacket = {
generatedAt: string;
agentCount: number;
agentsWithAsks: number;
allAgentsHaveActiveAsks: boolean;
summaries: AgentSummary[];
proposalSeeds: ProposalSeed[];
topIndividualAsks: AgentAsk[];
};
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const config = loadConfig();
const generatedAt = new Date().toISOString();
const agents = await loadAgentSummaries(config);
const asks = agents.flatMap((agent) => agent.topAsks);
const packet: InspectionPacket = {
generatedAt,
agentCount: agents.length,
agentsWithAsks: agents.filter((agent) => agent.askCount > 0).length,
allAgentsHaveActiveAsks: agents.length > 0 && agents.every((agent) => agent.askCount > 0),
summaries: agents,
proposalSeeds: buildProposalSeeds(asks),
topIndividualAsks: asks
.slice()
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 12),
};
const jsonPath = resolve(options.jsonOut ?? ".voidbot/status/agent-state-action-proposals.json");
const markdownPath = resolve(options.markdownOut ?? ".voidbot/status/agent-state-action-proposals.md");
await mkdir(dirname(jsonPath), { recursive: true });
await mkdir(dirname(markdownPath), { recursive: true });
await writeFile(jsonPath, `${JSON.stringify(packet, null, 2)}\n`, "utf8");
await writeFile(markdownPath, renderMarkdown(packet), "utf8");
if (options.format === "json") {
process.stdout.write(`${JSON.stringify(packet, null, 2)}\n`);
return;
}
process.stdout.write(renderMarkdown(packet));
}
async function loadAgentSummaries(config: ReturnType<typeof loadConfig>): Promise<AgentSummary[]> {
const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath);
const specs = [
{
agentId: "void",
displayName: "Void",
repoName: "VoidBot",
statePath: config.moderationAgentStatePath,
identity: undefined,
},
...registry.identities.map((identity) => ({
agentId: identity.id,
displayName: identity.displayName,
repoName: identity.repoName,
statePath: resolveRepoPersonaStatePath(identity, config.storageRoot),
identity,
})),
];
const summaries: AgentSummary[] = [];
for (const spec of specs) {
try {
const state = await loadVoidSelfStateTypedDocuments({
canonicalPath: spec.statePath,
identity: {
agentId: spec.agentId,
publicName: spec.displayName,
publicDescription: spec.identity?.description,
},
});
const asks = extractAsks({
agentId: spec.agentId,
displayName: spec.displayName,
repoName: spec.repoName,
statePath: spec.statePath,
state,
});
summaries.push({
agentId: spec.agentId,
displayName: spec.displayName,
repoName: spec.repoName,
statePath: spec.statePath,
askCount: asks.length,
topAsks: asks
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 8),
});
} catch (error) {
summaries.push({
agentId: spec.agentId,
displayName: spec.displayName,
repoName: spec.repoName,
statePath: spec.statePath,
askCount: 1,
topAsks: [{
agentId: spec.agentId,
displayName: spec.displayName,
repoName: spec.repoName,
statePath: spec.statePath,
source: "agency_pressure",
id: `state-load-error:${spec.agentId}`,
status: "active",
kind: "inspection_error",
target: { kind: "system", id: "agent-state-inspection", label: "Agent state inspection" },
summary: `Could not load ${spec.displayName} state: ${error instanceof Error ? error.message : String(error)}`,
actionImplication: "Inspect and repair the state path or registry entry before trusting this agent's asks.",
intensity: 1,
updatedAt: new Date().toISOString(),
tags: ["inspection:error"],
}],
});
}
}
return summaries;
}
function extractAsks(input: {
agentId: string;
displayName: string;
repoName?: string;
statePath: string;
state: any;
}): AgentAsk[] {
const base = {
agentId: input.agentId,
displayName: input.displayName,
repoName: input.repoName,
statePath: input.statePath,
};
const asks: AgentAsk[] = [];
for (const pressure of input.state.agencyPressure?.pressures ?? []) {
if (!["active", "ready_to_act", "cooling"].includes(pressure.status)) {
continue;
}
asks.push({
...base,
source: "agency_pressure",
id: pressure.pressureId,
status: pressure.status,
kind: pressure.kind,
target: pressure.target,
summary: pressure.summary,
claimOrQuestion: pressure.claim ?? pressure.question,
tension: pressure.tension,
actionImplication: pressure.actionImplication,
intensity: clampNumber(pressure.intensity ?? 0.5, 0, 1),
updatedAt: pressure.updatedAt,
tags: pressure.tags ?? [],
});
}
for (const intervention of input.state.candidateInterventions?.interventions ?? []) {
if (!["queued", "deferred"].includes(intervention.status)) {
continue;
}
asks.push({
...base,
source: "candidate_intervention",
id: intervention.interventionId,
status: intervention.status,
kind: intervention.kind,
target: intervention.target,
summary: intervention.summary,
actionImplication: intervention.draft,
intensity: clampNumber(intervention.priority ?? 0.5, 0, 1),
updatedAt: intervention.updatedAt,
tags: intervention.tags ?? [],
});
}
for (const thread of input.state.thoughtMemory?.incubation ?? []) {
if (!["active", "ready_to_share"].includes(thread.status)) {
continue;
}
const intensity = Math.max(thread.maturation ?? 0, thread.desireToSpeak ?? 0, 0.35);
if (intensity < 0.55 && thread.status !== "ready_to_share") {
continue;
}
asks.push({
...base,
source: "incubation",
id: thread.threadId,
status: thread.status,
target: thread.target,
summary: thread.summary,
claimOrQuestion: thread.topic,
actionImplication: "Decide whether this incubating thought should become a proposal, public post, article, or retired thread.",
intensity: clampNumber(intensity, 0, 1),
updatedAt: thread.updatedAt,
tags: thread.tags ?? [],
});
}
for (const memory of input.state.thoughtMemory?.shortTerm ?? []) {
if (!memory.actionImplication) {
continue;
}
asks.push({
...base,
source: "short_term_memory",
id: memory.memoryId,
status: "short_term",
kind: memory.kind,
target: memory.target,
summary: memory.summary,
claimOrQuestion: memory.claim ?? memory.question,
tension: memory.tension,
actionImplication: memory.actionImplication,
intensity: 0.45,
updatedAt: memory.updatedAt,
tags: memory.tags ?? [],
});
}
return asks;
}
function buildProposalSeeds(asks: AgentAsk[]): ProposalSeed[] {
const groups = new Map<string, AgentAsk[]>();
for (const ask of asks) {
const key = targetKey(ask);
groups.set(key, [...(groups.get(key) ?? []), ask]);
}
return Array.from(groups.entries())
.map(([key, group]) => {
const agents = Array.from(new Set(group.map((ask) => ask.displayName))).sort();
const averageIntensity = group.reduce((sum, ask) => sum + ask.intensity, 0) / Math.max(group.length, 1);
return {
proposalId: slugify(key),
title: proposalTitle(key, group),
targetKey: key,
agentCount: agents.length,
askCount: group.length,
averageIntensity: round3(averageIntensity),
agents,
recommendedAction: recommendedAction(group),
asks: group
.slice()
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 10),
};
})
.filter((proposal) => proposal.agentCount >= 2 || proposal.askCount >= 3 || proposal.averageIntensity >= 0.75)
.sort((left, right) => {
if (right.agentCount !== left.agentCount) {
return right.agentCount - left.agentCount;
}
if (right.averageIntensity !== left.averageIntensity) {
return right.averageIntensity - left.averageIntensity;
}
return right.askCount - left.askCount;
})
.slice(0, 10);
}
function targetKey(ask: AgentAsk): string {
const target = ask.target;
if (target?.kind && target?.id) {
return `${target.kind}:${target.id}`;
}
if (ask.repoName) {
return `repo:${ask.repoName}`;
}
return `agent:${ask.agentId}`;
}
function proposalTitle(key: string, asks: AgentAsk[]): string {
const targetLabel = asks.find((ask) => ask.target?.label)?.target?.label ?? key;
const sourceKinds = Array.from(new Set(asks.map((ask) => ask.source.replace(/_/g, " ")))).join(", ");
return `${targetLabel}: ${asks.length} active ask${asks.length === 1 ? "" : "s"} from ${sourceKinds}`;
}
function recommendedAction(asks: AgentAsk[]): string {
const hasCandidate = asks.some((ask) => ask.source === "candidate_intervention");
const hasReadyPressure = asks.some((ask) => ask.source === "agency_pressure" && ask.status === "ready_to_act");
const hasArticle = asks.some((ask) => /\b(article|essay|blog|draft|proposal|pr)\b/i.test(`${ask.summary} ${ask.actionImplication ?? ""}`));
if (hasCandidate) {
return "Inspect queued/deferred candidates and either deliver, convert to a PR/intake request, or retire them explicitly.";
}
if (hasArticle) {
return "Turn the converged ask into a concrete article/proposal packet, then route it through Bifrost or the target repo's Codex intake lane.";
}
if (hasReadyPressure) {
return "Promote ready agency pressure into a concrete work packet with owner-visible proposed action and acceptance criteria.";
}
return "Ask whether this converged pressure should become a work packet, article, PR proposal, or be cooled as non-actionable.";
}
function renderMarkdown(packet: InspectionPacket): string {
const lines = [
`# Agent State Action Proposals`,
"",
`Generated: ${packet.generatedAt}`,
`Agents with asks: ${packet.agentsWithAsks}/${packet.agentCount}`,
`All agents have active asks: ${packet.allAgentsHaveActiveAsks ? "yes" : "no"}`,
"",
"## Proposal Seeds",
];
if (packet.proposalSeeds.length === 0) {
lines.push("", "No cross-agent proposal seeds met the deterministic threshold.");
} else {
for (const proposal of packet.proposalSeeds) {
lines.push(
"",
`### ${proposal.title}`,
`- Target: ${proposal.targetKey}`,
`- Agents: ${proposal.agents.join(", ")}`,
`- Ask count: ${proposal.askCount}; average intensity: ${proposal.averageIntensity}`,
`- Recommended action: ${proposal.recommendedAction}`,
);
for (const ask of proposal.asks.slice(0, 4)) {
lines.push(` - ${ask.displayName} [${ask.source}/${ask.status ?? "n/a"}]: ${singleLine(ask.summary)}`);
if (ask.actionImplication) {
lines.push(` Action: ${singleLine(ask.actionImplication)}`);
}
}
}
}
lines.push("", "## Per-Agent Top Asks");
for (const summary of packet.summaries) {
lines.push("", `### ${summary.displayName} (${summary.agentId})`, `State: ${summary.statePath}`);
if (summary.topAsks.length === 0) {
lines.push("- No active asks found.");
continue;
}
for (const ask of summary.topAsks.slice(0, 5)) {
lines.push(`- ${ask.source}/${ask.status ?? "n/a"} intensity=${ask.intensity}: ${singleLine(ask.summary)}`);
if (ask.actionImplication) {
lines.push(` Action: ${singleLine(ask.actionImplication)}`);
}
}
}
return `${lines.join("\n")}\n`;
}
function parseArgs(args: string[]): { format: "markdown" | "json"; jsonOut?: string; markdownOut?: string } {
const options: { format: "markdown" | "json"; jsonOut?: string; markdownOut?: string } = {
format: "markdown",
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--format":
options.format = requireValue(args, ++index, arg) === "json" ? "json" : "markdown";
break;
case "--json-out":
options.jsonOut = requireValue(args, ++index, arg);
break;
case "--markdown-out":
options.markdownOut = requireValue(args, ++index, arg);
break;
}
}
return options;
}
function requireValue(args: string[], index: number, flag: string): string {
const value = args[index];
if (!value) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
function singleLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function slugify(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || "proposal";
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function round3(value: number): number {
return Math.round(value * 1000) / 1000;
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});