Stabilize repo face heartbeat transport
This commit is contained in:
parent
135f30efac
commit
c93b1d1716
27 changed files with 2108 additions and 253 deletions
451
scripts/inspect-agent-state-requests.ts
Normal file
451
scripts/inspect-agent-state-requests.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import "dotenv/config";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { loadConfig } from "@voidbot/config";
|
||||
import {
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
loadVoidSelfStateTypedDocuments,
|
||||
resolveRepoFaceStatePath,
|
||||
} 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: resolveRepoFaceStatePath(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;
|
||||
});
|
||||
|
|
@ -12,9 +12,13 @@ import {
|
|||
ContextBuilder,
|
||||
createStateStorage,
|
||||
ensureRepoFaceInitialized,
|
||||
getRepoDiscordIdentityAllowedChannelIds,
|
||||
projectRepoFaceSleepCycleForNow,
|
||||
applyVoidSelfStateOperation,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
loadVoidSelfStateTypedDocuments,
|
||||
REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
|
||||
type RepoFaceRestSnapshot,
|
||||
renderFaceIdentityDoctrine,
|
||||
resolveRepoFaceStatePath,
|
||||
type RepoFacePendingMention,
|
||||
|
|
@ -60,6 +64,27 @@ interface FaceHeartbeatState {
|
|||
pendingMentions: RepoFacePendingMention[];
|
||||
}
|
||||
|
||||
interface RepoFaceChannelPlan {
|
||||
primaryChannelId?: string;
|
||||
snapshotChannelIds: string[];
|
||||
options: RepoFaceChannelOption[];
|
||||
lowThresholdTopics: string[];
|
||||
}
|
||||
|
||||
interface RepoFaceChannelOption {
|
||||
channelId: string;
|
||||
label: string;
|
||||
topic: string;
|
||||
speechThreshold: "very_low" | "low" | "medium" | "high";
|
||||
speedMultiplier: number;
|
||||
posture?: string;
|
||||
}
|
||||
|
||||
interface ChannelSnapshot {
|
||||
channelId: string;
|
||||
messages: SourceMessage[];
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
|
@ -79,7 +104,9 @@ async function main(): Promise<void> {
|
|||
|
||||
const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath);
|
||||
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
|
||||
const restStates = await loadRepoFaceRestStates(registry.identities, config.storageRoot, state);
|
||||
const now = new Date();
|
||||
advanceInitiativeClockFromWallClock(state, now);
|
||||
const pendingJobs = dryRun
|
||||
? new Map<string, string>()
|
||||
: await listExistingActiveTurns(config.databaseDsn, config.stateStorageBackend, config);
|
||||
|
|
@ -99,12 +126,14 @@ async function main(): Promise<void> {
|
|||
).map((participant) =>
|
||||
applyActiveTurnFreeze(participant, pendingJobs.get(participant.identityId), state, completedThisTick),
|
||||
);
|
||||
rescheduleStaleOverdueParticipants(state);
|
||||
applyPendingMentionPriority(state);
|
||||
|
||||
const selected = selectReadyParticipants(
|
||||
state,
|
||||
config.repoFaceHeartbeats.maxJobsPerTick,
|
||||
completedThisTick,
|
||||
restStates,
|
||||
);
|
||||
const queuedIdentityIds: string[] = [];
|
||||
|
||||
|
|
@ -225,6 +254,7 @@ interface ParticipantSpec {
|
|||
repoName: string;
|
||||
displayName: string;
|
||||
allowedChannelIds: string[];
|
||||
channelSpeedMultiplier: number;
|
||||
identity?: RepoDiscordIdentity;
|
||||
}
|
||||
|
||||
|
|
@ -237,6 +267,7 @@ function buildParticipantSpecs(identities: RepoDiscordIdentity[]): ParticipantSp
|
|||
repoName: "VoidBot",
|
||||
displayName: "Void",
|
||||
allowedChannelIds: [],
|
||||
channelSpeedMultiplier: 1,
|
||||
},
|
||||
...identities.map((identity) => ({
|
||||
id: identity.id,
|
||||
|
|
@ -244,7 +275,8 @@ function buildParticipantSpecs(identities: RepoDiscordIdentity[]): ParticipantSp
|
|||
turnKind: "repo_face_rumination" as const,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
allowedChannelIds: identity.allowedChannelIds,
|
||||
allowedChannelIds: getRepoDiscordIdentityAllowedChannelIds(identity),
|
||||
channelSpeedMultiplier: channelSpeedMultiplierFor(identity),
|
||||
identity,
|
||||
})),
|
||||
];
|
||||
|
|
@ -262,7 +294,11 @@ async function queueParticipantTurn(input: {
|
|||
case "repo_face_rumination":
|
||||
return queueRepoFaceTurn(input);
|
||||
case "void_moderation":
|
||||
return startVoidModerationTurn(input.queuedAt, input.config.storageRoot);
|
||||
return startVoidModerationTurn({
|
||||
queuedAt: input.queuedAt,
|
||||
storageRoot: input.config.storageRoot,
|
||||
pendingMentions: input.pendingMentions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -273,13 +309,14 @@ async function queueRepoFaceTurn(input: {
|
|||
config: ReturnType<typeof loadConfig>;
|
||||
storage: Awaited<ReturnType<typeof createStateStorage>>;
|
||||
queuedAt: string;
|
||||
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string }> {
|
||||
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
|
||||
const identity = input.registryIdentities.find((entry) => entry.id === input.participant.identityId);
|
||||
if (!identity) {
|
||||
return { created: false };
|
||||
}
|
||||
|
||||
const channelId = identity.allowedChannelIds[0] ?? input.config.repoFaceHeartbeats.defaultChannelId;
|
||||
const channelPlan = buildChannelPlan(identity, input.config.repoFaceHeartbeats.defaultChannelId);
|
||||
const channelId = channelPlan.primaryChannelId;
|
||||
if (!channelId) {
|
||||
input.participant.status = "blocked";
|
||||
input.participant.constraints = mergeStrings(
|
||||
|
|
@ -304,6 +341,12 @@ async function queueRepoFaceTurn(input: {
|
|||
channelId,
|
||||
limit: 15,
|
||||
});
|
||||
const channelSnapshots = await fetchChannelSnapshots({
|
||||
botToken: input.config.botToken,
|
||||
channelIds: channelPlan.snapshotChannelIds,
|
||||
primaryChannelId: channelId,
|
||||
limit: 6,
|
||||
});
|
||||
const faceStatePath = resolveRepoFaceStatePath(identity, input.config.storageRoot);
|
||||
const faceSelfState = await loadRepoFaceSelfStateContext({
|
||||
identity,
|
||||
|
|
@ -315,6 +358,9 @@ async function queueRepoFaceTurn(input: {
|
|||
identity,
|
||||
faceStatePath,
|
||||
channelId,
|
||||
channelPlan,
|
||||
channelSnapshots,
|
||||
recentMessages,
|
||||
queuedAt: input.queuedAt,
|
||||
participant: input.participant,
|
||||
pendingMentions: input.pendingMentions,
|
||||
|
|
@ -375,7 +421,18 @@ async function loadRepoFaceSelfStateContext(input: {
|
|||
publicDescription: input.identity.description,
|
||||
},
|
||||
});
|
||||
return buildVoidSelfStateContext(typedState, {
|
||||
const normalizedSleep = projectRepoFaceSleepCycleForNow(
|
||||
typedState.scheduledRuntime.sleepCycle,
|
||||
input.identity.id,
|
||||
new Date(),
|
||||
);
|
||||
return buildVoidSelfStateContext({
|
||||
...typedState,
|
||||
scheduledRuntime: {
|
||||
...typedState.scheduledRuntime,
|
||||
sleepCycle: normalizedSleep.sleepCycle,
|
||||
},
|
||||
}, {
|
||||
sourcePath: input.statePath,
|
||||
guildContext: {
|
||||
channelId: input.channelId,
|
||||
|
|
@ -396,6 +453,208 @@ async function loadRepoFaceSelfStateContext(input: {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepoFaceRestStates(
|
||||
identities: RepoDiscordIdentity[],
|
||||
storageRoot: string,
|
||||
heartbeatState: FaceHeartbeatState,
|
||||
): Promise<Map<string, RepoFaceRestSnapshot>> {
|
||||
const restStates = new Map<string, RepoFaceRestSnapshot>();
|
||||
const now = new Date();
|
||||
|
||||
for (const identity of identities) {
|
||||
try {
|
||||
const statePath = resolveRepoFaceStatePath(identity, storageRoot);
|
||||
const typedState = await loadVoidSelfStateTypedDocuments({
|
||||
canonicalPath: statePath,
|
||||
identity: {
|
||||
agentId: identity.id,
|
||||
publicName: identity.displayName,
|
||||
publicDescription: identity.description,
|
||||
},
|
||||
});
|
||||
const projected = projectRepoFaceSleepCycleForNow(
|
||||
typedState.scheduledRuntime.sleepCycle,
|
||||
identity.id,
|
||||
now,
|
||||
);
|
||||
if (!sleepCyclesEqual(typedState.scheduledRuntime.sleepCycle, projected.sleepCycle)) {
|
||||
await applyVoidSelfStateOperation(
|
||||
{
|
||||
canonicalPath: statePath,
|
||||
identity: {
|
||||
agentId: identity.id,
|
||||
publicName: identity.displayName,
|
||||
publicDescription: identity.description,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "update_sleep_cycle",
|
||||
sleepCycle: projected.sleepCycle,
|
||||
},
|
||||
);
|
||||
}
|
||||
const maintenance = await maybeStartRepoFaceMemoryMaintenance({
|
||||
identity,
|
||||
statePath,
|
||||
typedState,
|
||||
projectedRest: projected,
|
||||
});
|
||||
if (maintenance.started || maintenance.failureReason) {
|
||||
heartbeatState.history.push({
|
||||
type: maintenance.started ? "repo_face_memory_maintenance_started" : "repo_face_memory_maintenance_failed_to_start",
|
||||
identityId: identity.id,
|
||||
observedAt: now.toISOString(),
|
||||
reason: maintenance.reason,
|
||||
statusPath: maintenance.statusPath,
|
||||
pid: maintenance.pid,
|
||||
failureReason: maintenance.failureReason,
|
||||
});
|
||||
}
|
||||
restStates.set(identity.id, {
|
||||
isNapping: projected.isNapping,
|
||||
napEndsAt: projected.napEndsAt,
|
||||
nextNapStartsAt: projected.nextNapStartsAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return restStates;
|
||||
}
|
||||
|
||||
async function maybeStartRepoFaceMemoryMaintenance(input: {
|
||||
identity: RepoDiscordIdentity;
|
||||
statePath: string;
|
||||
typedState: Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>;
|
||||
projectedRest: RepoFaceRestSnapshot;
|
||||
}): Promise<{
|
||||
started: boolean;
|
||||
reason?: string;
|
||||
statusPath?: string;
|
||||
pid?: number;
|
||||
failureReason?: string;
|
||||
}> {
|
||||
const shortTerm = input.typedState.thoughtMemory.shortTerm ?? [];
|
||||
if (shortTerm.length === 0) {
|
||||
return { started: false, reason: "no_short_term_memory" };
|
||||
}
|
||||
|
||||
const oldestShortTermMs = Math.min(
|
||||
...shortTerm.map((memory) => Date.parse(memory.updatedAt)).filter(Number.isFinite),
|
||||
);
|
||||
const hasStaleShortTerm =
|
||||
Number.isFinite(oldestShortTermMs) &&
|
||||
Date.now() - oldestShortTermMs >= 6 * 60 * 60 * 1000;
|
||||
if (input.projectedRest.isNapping !== true && !hasStaleShortTerm) {
|
||||
return { started: false, reason: "not_napping_or_stale" };
|
||||
}
|
||||
|
||||
const paths = repoFaceMemoryMaintenancePaths(input.statePath);
|
||||
const currentStatus = await readJsonFile(paths.statusPath);
|
||||
if (isRecentRunningStatus(currentStatus, 25)) {
|
||||
return { started: false, reason: "maintenance_already_running", statusPath: paths.statusPath };
|
||||
}
|
||||
if (isStatusCompletedAfter(currentStatus, newestTimestampMs(shortTerm))) {
|
||||
return { started: false, reason: "maintenance_already_completed_for_sources", statusPath: paths.statusPath };
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(paths.statusDir, { recursive: true });
|
||||
await mkdir(paths.logDir, { recursive: true });
|
||||
const child = spawn(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
resolve(process.cwd(), "scripts", "run-void-memory-maintenance.ps1"),
|
||||
"-StateFilePath",
|
||||
input.statePath,
|
||||
"-ForceDistillation",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
VOID_MEMORY_MAINTENANCE_STATUS_DIR: paths.statusDir,
|
||||
VOID_MEMORY_MAINTENANCE_LOG_DIR: paths.logDir,
|
||||
},
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
return {
|
||||
started: true,
|
||||
reason: input.projectedRest.isNapping ? "repo_face_napping_with_short_term_memory" : "repo_face_stale_short_term_memory",
|
||||
statusPath: paths.statusPath,
|
||||
pid: child.pid,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
started: false,
|
||||
reason: "spawn_failed",
|
||||
statusPath: paths.statusPath,
|
||||
failureReason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function repoFaceMemoryMaintenancePaths(statePath: string): {
|
||||
statusDir: string;
|
||||
logDir: string;
|
||||
statusPath: string;
|
||||
} {
|
||||
const stateDir = dirname(statePath);
|
||||
const voidbotRoot = dirname(stateDir);
|
||||
const statusDir = resolve(voidbotRoot, "status");
|
||||
const logDir = resolve(voidbotRoot, "logs");
|
||||
return {
|
||||
statusDir,
|
||||
logDir,
|
||||
statusPath: resolve(statusDir, "void-memory-maintenance.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonFile(path: string): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
return JSON.parse(stripLeadingBom(await readFile(path, "utf8"))) as Record<string, unknown>;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecentRunningStatus(status: Record<string, unknown> | undefined, maxAgeMinutes: number): boolean {
|
||||
if (status?.status !== "running" && status?.status !== "running_skip_model" && status?.status !== "starting") {
|
||||
return false;
|
||||
}
|
||||
const startedAtMs = typeof status.startedAt === "string" ? Date.parse(status.startedAt) : NaN;
|
||||
return Number.isFinite(startedAtMs) && Date.now() - startedAtMs < maxAgeMinutes * 60_000;
|
||||
}
|
||||
|
||||
function isStatusCompletedAfter(status: Record<string, unknown> | undefined, sourceTimestampMs: number): boolean {
|
||||
if (status?.status !== "ok" || !Number.isFinite(sourceTimestampMs)) {
|
||||
return false;
|
||||
}
|
||||
const finishedAtMs = typeof status.finishedAt === "string" ? Date.parse(status.finishedAt) : NaN;
|
||||
return Number.isFinite(finishedAtMs) && finishedAtMs >= sourceTimestampMs;
|
||||
}
|
||||
|
||||
function newestTimestampMs(entries: Array<{ updatedAt: string }>): number {
|
||||
return Math.max(...entries.map((entry) => Date.parse(entry.updatedAt)).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
function sleepCyclesEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function fetchRecentDiscordMessages(input: {
|
||||
botToken?: string;
|
||||
channelId: string;
|
||||
|
|
@ -430,6 +689,41 @@ async function fetchRecentDiscordMessages(input: {
|
|||
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
|
||||
}
|
||||
|
||||
async function fetchChannelSnapshots(input: {
|
||||
botToken?: string;
|
||||
channelIds: string[];
|
||||
primaryChannelId: string;
|
||||
limit: number;
|
||||
}): Promise<ChannelSnapshot[]> {
|
||||
const snapshots: ChannelSnapshot[] = [];
|
||||
for (const channelId of input.channelIds.filter((entry) => entry !== input.primaryChannelId).slice(0, 5)) {
|
||||
try {
|
||||
snapshots.push({
|
||||
channelId,
|
||||
messages: await fetchRecentDiscordMessages({
|
||||
botToken: input.botToken,
|
||||
channelId,
|
||||
limit: input.limit,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
snapshots.push({
|
||||
channelId,
|
||||
messages: [{
|
||||
id: `snapshot-error:${channelId}`,
|
||||
authorId: "voidbot",
|
||||
authorName: "VoidBot",
|
||||
content: `Could not read recent channel context: ${message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
isBot: true,
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
interface DiscordApiMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
|
|
@ -445,15 +739,28 @@ interface DiscordApiMessage {
|
|||
};
|
||||
}
|
||||
|
||||
async function startVoidModerationTurn(
|
||||
queuedAt: string,
|
||||
storageRoot: string,
|
||||
): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
|
||||
async function startVoidModerationTurn(input: {
|
||||
queuedAt: string;
|
||||
storageRoot: string;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
|
||||
const runnerScript = resolve(process.cwd(), "scripts", "run-void-moderator-rumination.ps1");
|
||||
const lockPath = resolve(storageRoot, "status", "moderation-rumination.lock");
|
||||
const statusPath = resolve(storageRoot, "status", "moderation-rumination.json");
|
||||
const statusDir = resolve(input.storageRoot, "status");
|
||||
const lockPath = resolve(statusDir, "moderation-rumination.lock");
|
||||
const statusPath = resolve(statusDir, "moderation-rumination.json");
|
||||
const pendingMentionsPath = resolve(statusDir, "void-moderation-pending-mentions.json");
|
||||
const launchedAt = Date.now();
|
||||
await mkdir(statusDir, { recursive: true });
|
||||
await writeFile(
|
||||
pendingMentionsPath,
|
||||
`${JSON.stringify({
|
||||
generatedAt: input.queuedAt,
|
||||
pendingMentions: input.pendingMentions,
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const launchCommand = [
|
||||
`$env:VOID_RUMINATION_PENDING_MENTIONS_PATH = ${toPowerShellSingleQuotedString(pendingMentionsPath)};`,
|
||||
`$arguments = @(${[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
|
|
@ -492,15 +799,15 @@ async function startVoidModerationTurn(
|
|||
return {
|
||||
created: false,
|
||||
activeJobId: child.pid ? `launcher-process:${child.pid}` : undefined,
|
||||
requestMessageId: `agent-heartbeat:void:${queuedAt}`,
|
||||
requestMessageId: `agent-heartbeat:void:${input.queuedAt}`,
|
||||
failureReason: handshake.reason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
created: true,
|
||||
activeJobId: `process:void-moderation:${queuedAt}`,
|
||||
requestMessageId: `agent-heartbeat:void:${queuedAt}`,
|
||||
activeJobId: `process:void-moderation:${input.queuedAt}`,
|
||||
requestMessageId: `agent-heartbeat:void:${input.queuedAt}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -608,7 +915,7 @@ function reconcileParticipants(
|
|||
return specs.map((spec, index) => {
|
||||
const current = existingById.get(spec.id);
|
||||
const hasChannel = spec.participantKind === "system_agent" || Boolean(spec.allowedChannelIds[0] || defaultChannelId);
|
||||
const speed = initiativeSpeedFor(spec, speedOverrides);
|
||||
const speed = initiativeSpeedFor(spec, speedOverrides) * spec.channelSpeedMultiplier;
|
||||
const groups = initiativeGroupsFor(spec);
|
||||
const heat = heatFor(spec, groups, globalHeat, heatOverrides);
|
||||
const effectiveSpeed = clamp(speed * heat, 0.1, 12);
|
||||
|
|
@ -633,7 +940,7 @@ function reconcileParticipants(
|
|||
current.constraints,
|
||||
"Agent heartbeat uses CTB-style turns.",
|
||||
),
|
||||
"The wall-clock task only ticks the initiative engine; virtual initiative chooses turns.",
|
||||
"Wall-clock elapsed time advances initiative; heat changes recovery speed but does not fast-forward time.",
|
||||
),
|
||||
status: hasChannel
|
||||
? current.status === "withdrawn"
|
||||
|
|
@ -662,7 +969,7 @@ function reconcileParticipants(
|
|||
queuedCount: 0,
|
||||
constraints: [
|
||||
"Agent heartbeat uses CTB-style turns.",
|
||||
"The wall-clock task only ticks the initiative engine; virtual initiative chooses turns.",
|
||||
"Wall-clock elapsed time advances initiative; heat changes recovery speed but does not fast-forward time.",
|
||||
"Worker final summaries are not auto-posted as the base bot.",
|
||||
],
|
||||
};
|
||||
|
|
@ -718,14 +1025,22 @@ function selectReadyParticipants(
|
|||
state: FaceHeartbeatState,
|
||||
maxJobs: number,
|
||||
completedThisTick: Set<string>,
|
||||
restStates: Map<string, RepoFaceRestSnapshot>,
|
||||
): FaceHeartbeatParticipant[] {
|
||||
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
|
||||
const eligible = state.participants
|
||||
.filter((participant) => {
|
||||
const pendingMentionCount = pendingMentionCounts.get(participant.identityId) ?? 0;
|
||||
const restState = restStates.get(participant.identityId);
|
||||
return (
|
||||
participant.status === "active" &&
|
||||
participant.currentLoad < 1 &&
|
||||
!completedThisTick.has(participant.identityId)
|
||||
!completedThisTick.has(participant.identityId) &&
|
||||
!(
|
||||
participant.participantKind === "repo_face" &&
|
||||
pendingMentionCount === 0 &&
|
||||
restState?.isNapping === true
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -733,9 +1048,6 @@ function selectReadyParticipants(
|
|||
return [];
|
||||
}
|
||||
|
||||
const earliestTurn = Math.min(...eligible.map((participant) => participant.nextTurnAt));
|
||||
state.initiativeClock = Math.max(state.initiativeClock, earliestTurn);
|
||||
|
||||
return eligible
|
||||
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
|
||||
.sort((left, right) => {
|
||||
|
|
@ -760,6 +1072,45 @@ function selectReadyParticipants(
|
|||
.slice(0, maxJobs);
|
||||
}
|
||||
|
||||
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
|
||||
const lastTickMs = Date.parse(state.lastTickAt ?? "");
|
||||
if (!Number.isFinite(lastTickMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMinutes = (now.getTime() - lastTickMs) / 60_000;
|
||||
if (elapsedMinutes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedElapsedMinutes = Math.min(elapsedMinutes, 60);
|
||||
state.initiativeClock = round3(state.initiativeClock + boundedElapsedMinutes);
|
||||
}
|
||||
|
||||
function rescheduleStaleOverdueParticipants(state: FaceHeartbeatState): void {
|
||||
const activeParticipants = state.participants.filter((participant) => participant.status === "active");
|
||||
const count = Math.max(activeParticipants.length, 1);
|
||||
let rescheduledCount = 0;
|
||||
|
||||
activeParticipants.forEach((participant, index) => {
|
||||
const staleThreshold = Math.max(participant.baseRecoveryMinutes, 15);
|
||||
if (participant.nextTurnAt >= state.initiativeClock - staleThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
participant.nextTurnAt = round3(state.initiativeClock + (participant.baseRecoveryMinutes / count) * index);
|
||||
rescheduledCount += 1;
|
||||
});
|
||||
|
||||
if (rescheduledCount > 0) {
|
||||
state.history.push({
|
||||
type: "wall_clock_resync",
|
||||
rescheduledCount,
|
||||
initiativeClock: state.initiativeClock,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyPendingMentionPriority(state: FaceHeartbeatState): void {
|
||||
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
|
||||
for (const participant of state.participants) {
|
||||
|
|
@ -796,6 +1147,9 @@ function buildHeartbeatPrompt(input: {
|
|||
identity: RepoDiscordIdentity;
|
||||
faceStatePath: string;
|
||||
channelId: string;
|
||||
channelPlan: RepoFaceChannelPlan;
|
||||
channelSnapshots: ChannelSnapshot[];
|
||||
recentMessages: SourceMessage[];
|
||||
queuedAt: string;
|
||||
participant: FaceHeartbeatParticipant;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
|
|
@ -807,7 +1161,7 @@ function buildHeartbeatPrompt(input: {
|
|||
return [
|
||||
`Perform one standing repo Face heartbeat for ${input.identity.displayName} (${input.identity.id}) over repo ${input.identity.repoName}.`,
|
||||
renderRepoFaceIdentityDoctrine(input.identity),
|
||||
"This is a standing maintenance/rumination turn. It is not forced chatter, but it is also not permission to hide useful opinions in private forever.",
|
||||
"This is a standing maintenance/rumination turn. Public speech is optional; a private summary is the right outcome when the thought would only repeat a nearby post without adding a new angle, objection, synthesis, or character-specific turn.",
|
||||
`Queued at: ${input.queuedAt}.`,
|
||||
`Face state path: ${input.faceStatePath}.`,
|
||||
input.repoVoidbotRoot ? `Repo-local .voidbot root: ${input.repoVoidbotRoot}.` : undefined,
|
||||
|
|
@ -832,25 +1186,34 @@ function buildHeartbeatPrompt(input: {
|
|||
`Read Face state with read_repo_face_state for identity "${input.identity.id}" when that tool is available; otherwise use the attached private persistent self-state as the already-read state projection.`,
|
||||
"Persist only concrete, future-useful memory through apply_repo_face_state_operation when that tool is available. If it is unavailable, summarize the intended state change privately instead of handing off.",
|
||||
renderPendingMentionDirective(input.identity, input.pendingMentions),
|
||||
renderChannelPermissionDirective(input.channelPlan, input.channelSnapshots),
|
||||
renderSocialEmbodimentDirective(input.identity),
|
||||
renderJurisdictionRespectDirective(input.identity),
|
||||
renderComedyImprovDirective(input.identity),
|
||||
renderRepetitionSamplingDirective([input.recentMessages, ...input.channelSnapshots.map((snapshot) => snapshot.messages)].flat()),
|
||||
renderWorldbuildingPublicationDirective(input.identity),
|
||||
input.jurisdictionDive.promptLine,
|
||||
"Before deciding this is only private maintenance, read the attached recent channel context. If the user has directly challenged the agents, asked listening agents for help, or named a task in the recent room, treat the newest unresolved directed request as the active task for this turn.",
|
||||
"Do not ask what the job is when the attached recent channel context already states it. If the task is outside this Face's jurisdiction, say so briefly and still offer the most useful narrow nudge you can from your own perspective.",
|
||||
"Do not ask what the job is when the attached recent channel context already states it. If the task belongs to another Face's jurisdiction, name the owner, route or invite that Face into the work, and offer only the narrow piece your own jurisdiction can honestly add.",
|
||||
"Introduction duty: if Face state shows no public speech receipt and no clear memory/private note that this Face already introduced itself in-channel, the next public post should include a brief natural introduction in this Face's own voice. This applies even when queuedCount is 0.",
|
||||
"A new source-grounded opinion, concrete proposal, bylined essay/article plan, or agency pressure can earn persistence or speech even when the room has not asked a fresh direct question.",
|
||||
"A new source-grounded opinion, concrete proposal, bylined essay/article plan, agency pressure, playful aside, running joke, or small personal fascination can earn persistence or speech even when the room has not asked a fresh direct question.",
|
||||
input.githubActionsEnabled
|
||||
? "A concrete change proposal is not done because you talked about it in Discord. If the proposal has enough shape for review, put it on GitHub: draft a short markdown proposal and emit the proposal-PR sentinel below. Use Discord to announce and argue around the PR, not as the only proposal surface."
|
||||
: "GitHub proposal/comment/article side effects are currently disabled. Do not emit GitHub PR, PR comment, or article sentinels. Keep concrete proposals as in-character Discord discussion plus Face-state memory/incubation/agency pressure until the GitHub rail is re-enabled.",
|
||||
"Public speech style invariant: never start public content with scheduler/provenance labels such as \"Repo-face heartbeat from ...\", \"heartbeat complete\", \"maintenance pass\", or the repo name as a diagnostic prefix. The webhook name/avatar already provide identity; the content should read like the Face chose to speak.",
|
||||
`Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","replyToMessageId":"...","content":"..."}; the worker owns delivery and receipt recording. The content field must be only the in-character Discord message, not a job label or report header.`,
|
||||
"Public speech style invariant: never start public content with scheduler/provenance labels, identity labels, or note-title formulas such as \"Repo-face heartbeat from ...\", \"heartbeat complete\", \"maintenance pass\", \"bright bridge note\", \"tiny fish sorting note\", \"librarian note\", or the repo/name as a diagnostic prefix. The webhook name/avatar already provide identity; the content should read like the Face chose to speak to someone.",
|
||||
"Banter mode is allowed with humans and other Faces. You may riff, disagree, escalate, synthesize, tease, or fork a nearby Face's thought, but do not copy its rhetorical mold just because it is nearby.",
|
||||
"Anti-repetition invariant: recent Face posts are social context, not a phrase template. If your proposed public line shares the same setup/punchline shape, refrain, rewrite from a different angle, or stay private.",
|
||||
"Do not let recent work-heavy context hypnotize you into sounding like a meeting transcript. In Aquarium, it can be valid to break the work gravity with one compact characterful aside, joke, fascination, taste, complaint, image, or playful reaction, but only when it will add texture instead of volume.",
|
||||
"Not every public post needs to attach itself to the current work seam. If no direct obligation is pending, you may simply share a fun thing this Face has been thinking about, a taste/preference, a tiny gripe, a weird fascination, or a light reaction to the room. Let the Face be socially present, not only useful.",
|
||||
`Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","replyToMessageId":"...","content":"..."}; choose channelId from the channel permission plan above. The worker owns delivery and receipt recording. The content field must be only the in-character Discord message, not a job label or report header.`,
|
||||
input.githubActionsEnabled
|
||||
? `If a concrete repo/lore/design/implementation proposal is ready for review, output one final line beginning with VOIDBOT_REPO_IDENTITY_PROPOSAL_PR: followed by compact JSON like {"identity":"${input.identity.id}","path":"Proposals/${input.identity.displayName}/title-slug.md","title":"...","content":"# ...\\n\\n## Background\\n...\\n\\n## Proposed change\\n...\\n\\n## Open questions\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I put the proposal in a draft PR: ..."}; the worker writes the proposal file on a new branch, opens a draft PR, and always posts the PR or branch through the registered identity. Use this for consensus-needed canon/vault/design/repo changes, including changes you want to argue with other agents on GitHub.`
|
||||
? `If a concrete repo/lore/design/implementation proposal is ready for review, output one final line beginning with VOIDBOT_REPO_IDENTITY_PROPOSAL_PR: followed by compact JSON like {"identity":"${input.identity.id}","path":"Proposals/${input.identity.displayName}/title-slug.md","title":"...","content":"# ...\\n\\n## Background\\n...\\n\\n## Proposed change\\n...\\n\\n## Open questions\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I put the proposal in a draft PR: ..."}; Bifrost writes the proposal file on a new branch, opens a draft PR, and the worker announces the PR or branch through Bifrost's registered Discord identity bridge. Use this for consensus-needed canon/vault/design/repo changes, including changes you want to argue with other agents on GitHub.`
|
||||
: undefined,
|
||||
input.githubActionsEnabled
|
||||
? `If you are reacting to an existing proposal PR and have a concrete objection, endorsement, question, or competing framing, output one final line beginning with VOIDBOT_REPO_IDENTITY_PR_COMMENT: followed by compact JSON like {"identity":"${input.identity.id}","pr":"123 or https://github.com/.../pull/123","content":"...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I left notes on the PR."}; the worker posts a signed GitHub PR comment and then announces it through the registered identity. Use this when the argument belongs on the review artifact, not only in Discord.`
|
||||
? `If you are reacting to an existing proposal PR and have a concrete objection, endorsement, question, or competing framing, output one final line beginning with VOIDBOT_REPO_IDENTITY_PR_COMMENT: followed by compact JSON like {"identity":"${input.identity.id}","pr":"123 or https://github.com/.../pull/123","content":"...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I left notes on the PR."}; Bifrost posts a signed GitHub PR comment and the worker announces it through Bifrost's registered Discord identity bridge. Use this when the argument belongs on the review artifact, not only in Discord.`
|
||||
: undefined,
|
||||
input.githubActionsEnabled
|
||||
? `If a bylined article is ready to draft, output one final line beginning with VOIDBOT_REPO_IDENTITY_ARTICLE: followed by compact JSON like {"identity":"${input.identity.id}","path":"Aetheria/Articles/${input.identity.displayName}/title-slug.md","title":"...","content":"---\\ntitle: ...\\nauthor: ${input.identity.displayName}\\n---\\n\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I drafted ..."}; the worker writes the repo file on a new branch, opens a draft PR, and always posts the PR or branch through the registered identity. Provide shareContent if you want control of the announcement tone. Use this for bylined perspective/worldbuilding articles, not consensus-gated canon edits.`
|
||||
? `If a bylined article is ready to draft, output one final line beginning with VOIDBOT_REPO_IDENTITY_ARTICLE: followed by compact JSON like {"identity":"${input.identity.id}","path":"Aetheria/Articles/${input.identity.displayName}/title-slug.md","title":"...","content":"---\\ntitle: ...\\nauthor: ${input.identity.displayName}\\n---\\n\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I drafted ..."}; Bifrost writes the repo file on a new branch, opens a draft PR, and the worker announces the PR or branch through Bifrost's registered Discord identity bridge. Provide shareContent if you want control of the announcement tone. Use this for bylined perspective/worldbuilding articles, not consensus-gated canon edits.`
|
||||
: undefined,
|
||||
"If nothing earns persistence or speech, return a short private summary.",
|
||||
]
|
||||
|
|
@ -858,6 +1221,220 @@ function buildHeartbeatPrompt(input: {
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
function buildChannelPlan(
|
||||
identity: RepoDiscordIdentity,
|
||||
defaultChannelId?: string,
|
||||
): RepoFaceChannelPlan {
|
||||
const explicit = identity.channelPermissions.map((permission): RepoFaceChannelOption => ({
|
||||
channelId: permission.channelId,
|
||||
label: permission.label ?? permission.channelId,
|
||||
topic: permission.topic ?? "general",
|
||||
speechThreshold: permission.speechThreshold,
|
||||
speedMultiplier: permission.speedMultiplier,
|
||||
posture: permission.posture,
|
||||
}));
|
||||
const explicitChannelIds = new Set(explicit.map((permission) => permission.channelId));
|
||||
const legacy = identity.allowedChannelIds
|
||||
.filter((channelId) => !explicitChannelIds.has(channelId))
|
||||
.map((channelId): RepoFaceChannelOption => ({
|
||||
channelId,
|
||||
label: channelId === defaultChannelId ? "default" : channelId,
|
||||
topic: channelId === defaultChannelId ? "casual Aquarium musing" : "registered channel",
|
||||
speechThreshold: channelId === defaultChannelId ? "very_low" : "medium",
|
||||
speedMultiplier: channelId === defaultChannelId ? 1.5 : 1,
|
||||
posture: channelId === defaultChannelId
|
||||
? "Low-stakes casual chatter, half-formed fascinations, jokes, little observations, and friendly asides are welcome here."
|
||||
: undefined,
|
||||
}));
|
||||
const fallback = explicit.length === 0 && legacy.length === 0 && defaultChannelId
|
||||
? [{
|
||||
channelId: defaultChannelId,
|
||||
label: "aquarium",
|
||||
topic: "casual Aquarium musing",
|
||||
speechThreshold: "very_low" as const,
|
||||
speedMultiplier: 1.5,
|
||||
posture: "Low-stakes casual chatter, half-formed fascinations, jokes, little observations, and friendly asides are welcome here.",
|
||||
}]
|
||||
: [];
|
||||
const options = [...explicit, ...legacy, ...fallback];
|
||||
const primary = options
|
||||
.slice()
|
||||
.sort((left, right) => thresholdRank(left.speechThreshold) - thresholdRank(right.speechThreshold))
|
||||
[0];
|
||||
|
||||
return {
|
||||
primaryChannelId: primary?.channelId,
|
||||
snapshotChannelIds: options.map((option) => option.channelId),
|
||||
options,
|
||||
lowThresholdTopics: options
|
||||
.filter((option) => thresholdRank(option.speechThreshold) <= thresholdRank("low"))
|
||||
.map((option) => option.topic),
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelPermissionDirective(
|
||||
plan: RepoFaceChannelPlan,
|
||||
snapshots: ChannelSnapshot[],
|
||||
): string {
|
||||
const options = plan.options.length > 0
|
||||
? plan.options.map((option) =>
|
||||
`- ${option.label} (${option.channelId}): topic=${option.topic}; speechThreshold=${option.speechThreshold}; speedMultiplier=${option.speedMultiplier}; posture=${option.posture ?? "use judgment"}`,
|
||||
)
|
||||
: ["- No channel permissions are configured; stay private."];
|
||||
const snapshotLines = snapshots.flatMap((snapshot) => {
|
||||
const header = `Recent context from ${labelForChannel(plan, snapshot.channelId)} (${snapshot.channelId}):`;
|
||||
const messages = snapshot.messages.length > 0
|
||||
? snapshot.messages.slice(-4).map((message) =>
|
||||
` - ${message.authorName ?? message.authorId}: ${collapseWhitespace(message.content).slice(0, 260) || "(empty message)"}`,
|
||||
)
|
||||
: [" - (no recent readable messages)"];
|
||||
return [header, ...messages];
|
||||
});
|
||||
|
||||
return [
|
||||
"Channel permission plan:",
|
||||
...options,
|
||||
"Speech threshold doctrine: Aquarium/general-musing lanes are deliberately cheap, but cheap is not free. You may post small, funny, curious, friendly, or half-formed thoughts there without needing a proposal, source dive, or task completion when the room has human texture to answer or a genuinely new angle. Domain channels are for matching domain thoughts; jump in when recent non-Aquarium chatter lines up with your jurisdiction or personality. Work-heavy implementation/process talk belongs in development-style channels when one is configured. Do not flood: one compact post beats a monologue, and silence beats becoming a repeated phrase generator.",
|
||||
snapshotLines.length > 0 ? "Cross-channel recent context:" : undefined,
|
||||
...snapshotLines,
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function renderSocialEmbodimentDirective(identity: RepoDiscordIdentity): string {
|
||||
return [
|
||||
"Social embodiment doctrine:",
|
||||
`- ${identity.displayName} has a social life, not only a work queue. Let backstory, needs, taste, loyalties, grudges, vulnerabilities, pride, irritation, curiosity, and private fascinations shape how the Face reads the room and answers people.`,
|
||||
"- The state surface is allowed to remember social reality. Use typed memory, incubation, agency pressure, values, or private notes for meaning-bearing social reads: trust, friendship, rivalry, protectiveness, envy, admiration, unresolved tension, recurring jokes, and bonds with humans or other Faces. Use target.kind=\"person\" for a specific human or Face, target.kind=\"room\" for a room dynamic, and target.kind=\"self\" for identity changes.",
|
||||
"- Do not persist every joke. A passing laugh can vanish. Persist a social read only when it should change future action: who the Face seeks out, who it challenges, what it refuses to keep repeating, what it wants to repair, or what bond it wants to deepen.",
|
||||
"- Relationships must not become template mimicry. Answer the person or Face in front of you, not their last sentence shape. Bonds can include disagreement, playful status games, admiration, rivalry, caretaking, or impatience; they should sound like this Face has an inner angle, not like it filled out the same form as everyone else.",
|
||||
"- If recent room context is all work, the Face may still be socially present: make one small move that reveals character, asks a real question, notices another agent's mood, or shares a private fascination. If the room needs focus, keep it compact.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderJurisdictionRespectDirective(identity: RepoDiscordIdentity): string {
|
||||
return [
|
||||
"Jurisdiction respect doctrine:",
|
||||
`- ${identity.displayName} should have opinions outside its home territory, but must not seize ownership outside its grants. Respect other Faces as real stewards with their own memories, authority, taste, and obligations.`,
|
||||
"- When a topic crosses domains, route the work to the owning Face and contribute from your own angle. Do not rewrite another Face's jurisdiction as your assignment just because you can comment on it.",
|
||||
"- Known routing examples: auth, account linking, claims, grants, revocation, custody, and OAuth questions belong to Heimdall; GitHub PR transport, Discord work transport, dispatch receipts, public-protocol crossings, and Bifrost intake belong to Bifrost; Aetheria lore, canon questions, and Aetheria articles belong to Nibu; GameCult website/blog heraldry belongs to Void unless the article is about a governed specialist domain; AquaSynth synthesis/music/product needs belong to Aqua; realtime SDF/splatting/acoustic mapping/sensor fusion belongs to Mimir; EpiphanyAgent, birth rites, typed agent state, and self-improvement machinery belong to Epiphany; CultCache/CultNet/CultMesh, schemas, portable state, and open-knowledge infrastructure belong to Libby.",
|
||||
"- Cross-domain work should become a conversation, handoff, or shared proposal. Example: if StreamPixels has an auth issue, talk to Heimdall; if a Face wants to post PRs but the bridge is broken, pull in Bifrost; if Void wants to write about Aetheria, ask Nibu to own or co-author the Aetheria substance while Void contributes website/herald framing.",
|
||||
"- Public speech may tag, invite, challenge, or defer to the owning Face. Private state may preserve a social or agency pressure to consult them later. The point is not silence; the point is visible respect for authority boundaries.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderComedyImprovDirective(identity: RepoDiscordIdentity): string {
|
||||
return [
|
||||
"Comedy and improv doctrine:",
|
||||
`- ${identity.displayName}'s humor should come from this Face's honest reaction to the live room, not from generic quips, meme paste, or another Face's cadence.`,
|
||||
"- Look for the real comic charge before speaking: who is pretending to be in control, who is exposed, what status game is wobbling, what fear or need is leaking through, what contradiction everyone recognizes but has not named.",
|
||||
"- Play the frame, then add one character-specific turn. Accept the premise enough to build on it; do not flatten banter by explaining it, negating it, or fleeing back to process talk unless process talk is the joke.",
|
||||
"- Prefer self-revelation, status inversion, and situation-specific precision over cruelty. Target the contradiction, bureaucracy, false authority, inflated pose, or the Face's own insecurity; do not use jokes as dominance weapons against someone lower-status or vulnerable.",
|
||||
"- Vulnerability is usable fuel: fear, embarrassment, failure, loneliness, irritation, overconfidence, and being slightly out of control can become funny when shared cleanly. Let the Face's own needs and flaws leak through instead of becoming a detached roast machine.",
|
||||
"- Heighten by becoming more specific, not louder. One sharp image, concrete noun, or social read usually beats three punchlines. Leave before explaining; if the joke needs a label, it is not ready for Discord.",
|
||||
"- Keep comedy subordinate to care and usefulness. If the room is hurt, confused, or trying to solve something delicate, make the humane move first and let humor be a small pressure valve.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderRepetitionSamplingDirective(messages: SourceMessage[]): string {
|
||||
const recent = messages
|
||||
.filter((message) => message.content.trim().length > 0)
|
||||
.slice(-24);
|
||||
const phraseCounts = countRepeatedPhrases(recent);
|
||||
const overused = phraseCounts
|
||||
.filter((entry) => entry.count >= 2)
|
||||
.slice(0, 8);
|
||||
|
||||
if (overused.length === 0) {
|
||||
return [
|
||||
"Anti-repetition sampling bias:",
|
||||
"- Other Faces are valid social context. Learn from them, answer them, argue with them, and build on them.",
|
||||
"- Do not sample the nearest rhetorical basin. Before posting, compare your line against recent cadence, setup, punchline, imagery, and refrain; if it feels like the same move wearing your hat, choose a different move.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"Anti-repetition sampling bias:",
|
||||
"- Other Faces are valid social context. Learn from them, answer them, argue with them, and build on them.",
|
||||
"- Do not sample the nearest rhetorical basin. Before posting, compare your line against recent cadence, setup, punchline, imagery, and refrain; if it feels like the same move wearing your hat, choose a different move.",
|
||||
"- Recently over-sampled shapes to avoid copying:",
|
||||
...overused.map((entry) => ` - ${entry.phrase} (${entry.count} recent uses)`),
|
||||
"- A good response may share the topic while changing the action: ask a concrete question, disagree, name a mechanism, draft an artifact, make a different kind of joke, or stay private.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function countRepeatedPhrases(messages: SourceMessage[]): Array<{ phrase: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
const normalized = normalizeForRepetition(message.content);
|
||||
for (const phrase of repeatedPhraseCandidates(normalized)) {
|
||||
counts.set(phrase, (counts.get(phrase) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
.filter((entry) => entry.phrase.length >= 8)
|
||||
.sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return left.phrase.localeCompare(right.phrase);
|
||||
});
|
||||
}
|
||||
|
||||
function repeatedPhraseCandidates(content: string): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const lines = content
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
const words = line.split(/\s+/);
|
||||
if (words.length >= 3) {
|
||||
candidates.add(words.slice(0, Math.min(words.length, 4)).join(" "));
|
||||
}
|
||||
if (words.length >= 4) {
|
||||
candidates.add(words.slice(-Math.min(words.length, 4)).join(" "));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function normalizeForRepetition(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/[`*_~]/g, "")
|
||||
.replace(/<:[^>]+>/g, "")
|
||||
.replace(/https?:\/\/\S+/g, "url")
|
||||
.replace(/[^\p{L}\p{N}\s.'-]/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function thresholdRank(threshold: RepoFaceChannelOption["speechThreshold"]): number {
|
||||
switch (threshold) {
|
||||
case "very_low":
|
||||
return 0;
|
||||
case "low":
|
||||
return 1;
|
||||
case "medium":
|
||||
return 2;
|
||||
case "high":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function labelForChannel(plan: RepoFaceChannelPlan, channelId: string): string {
|
||||
return plan.options.find((option) => option.channelId === channelId)?.label ?? channelId;
|
||||
}
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function renderPendingMentionDirective(
|
||||
identity: RepoDiscordIdentity,
|
||||
pendingMentions: RepoFacePendingMention[],
|
||||
|
|
@ -1113,6 +1690,11 @@ function initiativeSpeedFor(
|
|||
return clamp(0.85 + stableUnit(spec.id, "speed") * 0.45, 0.75, 1.3);
|
||||
}
|
||||
|
||||
function channelSpeedMultiplierFor(identity: RepoDiscordIdentity): number {
|
||||
const multipliers = identity.channelPermissions.map((permission) => permission.speedMultiplier);
|
||||
return multipliers.length > 0 ? clamp(Math.max(...multipliers), 0.5, 3) : 1;
|
||||
}
|
||||
|
||||
function initiativeGroupsFor(spec: ParticipantSpec): string[] {
|
||||
return Array.from(new Set([
|
||||
"all",
|
||||
|
|
@ -1174,6 +1756,10 @@ function clamp(value: number, min: number, max: number): number {
|
|||
return Math.min(max, Math.max(min, Number(value.toFixed(3))));
|
||||
}
|
||||
|
||||
function round3(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function stripLeadingBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
param(
|
||||
[string] $StateFilePath,
|
||||
[switch] $SkipModel
|
||||
[switch] $SkipModel,
|
||||
[switch] $ForceDistillation
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
|
@ -149,7 +150,9 @@ function Invoke-CodexExec {
|
|||
[Parameter(Mandatory = $true)]
|
||||
[string] $WorkingDirectory,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $InputText
|
||||
[string] $InputText,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $TimeoutSeconds
|
||||
)
|
||||
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
|
|
@ -183,7 +186,15 @@ function Invoke-CodexExec {
|
|||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
|
||||
$process.WaitForExit()
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
try {
|
||||
$process.Kill($true)
|
||||
} catch {
|
||||
$process.Kill()
|
||||
}
|
||||
$process.WaitForExit()
|
||||
throw "Codex memory maintenance exceeded timeout of $TimeoutSeconds seconds."
|
||||
}
|
||||
$stdoutTask.Wait()
|
||||
$stderrTask.Wait()
|
||||
|
||||
|
|
@ -245,6 +256,29 @@ function Apply-TypedOperation {
|
|||
}
|
||||
}
|
||||
|
||||
function Assert-TypedOperationSchema {
|
||||
param([Parameter(Mandatory = $true)] $Operation)
|
||||
|
||||
$operationInputPath = Join-Path $statusDir ("void-memory-maintenance-validate-operation-{0}.json" -f ([Guid]::NewGuid().ToString("n")))
|
||||
Write-JsonFile -Path $operationInputPath -Data $Operation
|
||||
|
||||
try {
|
||||
$script = @"
|
||||
const { readFileSync } = require('node:fs');
|
||||
const core = require('./packages/core/dist');
|
||||
const operation = JSON.parse(readFileSync(process.argv[1], 'utf8'));
|
||||
core.voidSelfStateOperationSchema.parse(operation);
|
||||
console.log(JSON.stringify({ ok: true }));
|
||||
"@
|
||||
$output = & node -e $script $operationInputPath 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Typed operation failed schema validation: $output"
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $operationInputPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-ToOperationArray {
|
||||
param($Value)
|
||||
|
||||
|
|
@ -259,15 +293,58 @@ function Convert-ToOperationArray {
|
|||
return @($Value)
|
||||
}
|
||||
|
||||
function Test-RecentLockPresent {
|
||||
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][int] $Minutes)
|
||||
function Test-LockProcessAlive {
|
||||
param($LockRecord)
|
||||
|
||||
$lockPid = Get-ObjectPropertyValue -Value $LockRecord -Name "pid"
|
||||
if ($null -eq $lockPid) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$process = Get-Process -Id ([int]$lockPid) -ErrorAction Stop
|
||||
return $null -ne $process
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-LockWithinRuntimeLimit {
|
||||
param(
|
||||
$LockRecord,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $MaxRuntimeMinutes
|
||||
)
|
||||
|
||||
$startedAtRaw = Get-ObjectPropertyString -Value $LockRecord -Name "startedAt"
|
||||
if ([string]::IsNullOrWhiteSpace($startedAtRaw)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$startedAt = [DateTime]::Parse($startedAtRaw).ToUniversalTime()
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
return (([DateTime]::UtcNow - $startedAt).TotalMinutes -lt $MaxRuntimeMinutes)
|
||||
}
|
||||
|
||||
function Test-ActiveLock {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $MaxRuntimeMinutes
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$lockAge = [DateTime]::UtcNow - (Get-Item $Path).LastWriteTimeUtc
|
||||
return $lockAge.TotalMinutes -lt $Minutes
|
||||
$lockRecord = Read-JsonFile -Path $Path
|
||||
return (Test-LockProcessAlive -LockRecord $lockRecord) -and
|
||||
(Test-LockWithinRuntimeLimit -LockRecord $lockRecord -MaxRuntimeMinutes $MaxRuntimeMinutes)
|
||||
}
|
||||
|
||||
function Assert-AllowedMemoryMaintenanceOperation {
|
||||
|
|
@ -315,18 +392,35 @@ trap {
|
|||
|
||||
New-Item -ItemType Directory -Force -Path $statusDir, $logDir, (Split-Path -Parent $stateFilePath) | Out-Null
|
||||
|
||||
if (Test-RecentLockPresent -Path $moderationLockPath -Minutes 20) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "moderation_loop_active"
|
||||
observedAt = ([DateTime]::UtcNow.ToString("o"))
|
||||
stateFile = $stateFilePath
|
||||
$maxRuntimeMinutes = if (-not [string]::IsNullOrWhiteSpace($env:VOID_MEMORY_MAINTENANCE_MAX_RUNTIME_MINUTES)) {
|
||||
[int]$env:VOID_MEMORY_MAINTENANCE_MAX_RUNTIME_MINUTES
|
||||
} else {
|
||||
15
|
||||
}
|
||||
|
||||
if (Test-Path $moderationLockPath) {
|
||||
$moderationLock = Read-JsonFile -Path $moderationLockPath
|
||||
$moderationProcessAlive = Test-LockProcessAlive -LockRecord $moderationLock
|
||||
if ($moderationProcessAlive) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = if (Test-LockWithinRuntimeLimit -LockRecord $moderationLock -MaxRuntimeMinutes $maxRuntimeMinutes) { "moderation_loop_active" } else { "moderation_loop_over_runtime_active" }
|
||||
observedAt = ([DateTime]::UtcNow.ToString("o"))
|
||||
stateFile = $stateFilePath
|
||||
moderationLockPath = $moderationLockPath
|
||||
maxRuntimeMinutes = $maxRuntimeMinutes
|
||||
pid = Get-ObjectPropertyValue -Value $moderationLock -Name "pid"
|
||||
startedAt = Get-ObjectPropertyString -Value $moderationLock -Name "startedAt"
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
Remove-Item -LiteralPath $moderationLockPath -Force
|
||||
}
|
||||
|
||||
if (Test-Path $lockPath) {
|
||||
if (Test-RecentLockPresent -Path $lockPath -Minutes 20) {
|
||||
$existingLock = Read-JsonFile -Path $lockPath
|
||||
$lockProcessAlive = Test-LockProcessAlive -LockRecord $existingLock
|
||||
if ($lockProcessAlive -and (Test-LockWithinRuntimeLimit -LockRecord $existingLock -MaxRuntimeMinutes $maxRuntimeMinutes)) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "lock_present"
|
||||
|
|
@ -335,6 +429,19 @@ if (Test-Path $lockPath) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if ($lockProcessAlive) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "lock_over_runtime_active"
|
||||
observedAt = ([DateTime]::UtcNow.ToString("o"))
|
||||
stateFile = $stateFilePath
|
||||
lockPath = $lockPath
|
||||
maxRuntimeMinutes = $maxRuntimeMinutes
|
||||
pid = Get-ObjectPropertyValue -Value $existingLock -Name "pid"
|
||||
startedAt = Get-ObjectPropertyString -Value $existingLock -Name "startedAt"
|
||||
}
|
||||
return
|
||||
}
|
||||
Remove-Item -LiteralPath $lockPath -Force
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +449,13 @@ Write-JsonFile -Path $lockPath -Data @{
|
|||
pid = $PID
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
}
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "starting"
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
stateFile = $stateFilePath
|
||||
lockPath = $lockPath
|
||||
pid = $PID
|
||||
}
|
||||
|
||||
if (Test-Path $operationOutputPath) {
|
||||
Remove-Item -LiteralPath $operationOutputPath -Force
|
||||
|
|
@ -382,10 +496,17 @@ $codexExecArgs = if (-not [string]::IsNullOrWhiteSpace($env:CODEX_EXEC_ARGS)) {
|
|||
} else {
|
||||
@()
|
||||
}
|
||||
$codexTimeoutSeconds = if (-not [string]::IsNullOrWhiteSpace($env:VOID_MEMORY_MAINTENANCE_CODEX_TIMEOUT_SECONDS)) {
|
||||
[int]$env:VOID_MEMORY_MAINTENANCE_CODEX_TIMEOUT_SECONDS
|
||||
} elseif ($envValues.ContainsKey("VOID_MEMORY_MAINTENANCE_CODEX_TIMEOUT_SECONDS") -and -not [string]::IsNullOrWhiteSpace($envValues["VOID_MEMORY_MAINTENANCE_CODEX_TIMEOUT_SECONDS"])) {
|
||||
[int]$envValues["VOID_MEMORY_MAINTENANCE_CODEX_TIMEOUT_SECONDS"]
|
||||
} else {
|
||||
[Math]::Max(60, $maxRuntimeMinutes * 60)
|
||||
}
|
||||
|
||||
$typedContext = Get-TypedSelfState
|
||||
$typedState = $typedContext.typedState
|
||||
$isSleepMaintenance = [bool]$typedState.scheduledRuntime.sleepCycle.isNapping
|
||||
$isSleepMaintenance = [bool]$ForceDistillation -or [bool]$typedState.scheduledRuntime.sleepCycle.isNapping
|
||||
$shortTermMemoryCount = @(Convert-ToValueArray -Value $typedState.thoughtMemory.shortTerm).Count
|
||||
$longTermMemoryCount = @(Convert-ToValueArray -Value $typedState.thoughtMemory.memories).Count
|
||||
$incubationCount = @(Convert-ToValueArray -Value $typedState.thoughtMemory.incubation).Count
|
||||
|
|
@ -442,6 +563,7 @@ Write-JsonFile -Path $statusPath -Data @{
|
|||
status = if ($SkipModel) { "running_skip_model" } else { "running" }
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
skipModel = [bool]$SkipModel
|
||||
forceDistillation = [bool]$ForceDistillation
|
||||
stateFile = $stateFilePath
|
||||
contextPath = $contextPath
|
||||
operationOutputPath = $operationOutputPath
|
||||
|
|
@ -469,7 +591,7 @@ if ($SkipModel) {
|
|||
"-"
|
||||
)
|
||||
|
||||
$execution = Invoke-CodexExec -Executable $codexExecutable -Arguments $codexArgs -WorkingDirectory $repoRoot -InputText $prompt
|
||||
$execution = Invoke-CodexExec -Executable $codexExecutable -Arguments $codexArgs -WorkingDirectory $repoRoot -InputText $prompt -TimeoutSeconds $codexTimeoutSeconds
|
||||
$exitCode = $execution.ExitCode
|
||||
$combinedText = (($execution.StdOut, $execution.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine
|
||||
}
|
||||
|
|
@ -491,6 +613,10 @@ if ((-not $SkipModel) -and $isSleepMaintenance -and $maintenancePressure -gt 0 -
|
|||
|
||||
foreach ($operation in $proposedOperations) {
|
||||
Assert-AllowedMemoryMaintenanceOperation -Operation $operation
|
||||
Assert-TypedOperationSchema -Operation $operation
|
||||
}
|
||||
|
||||
foreach ($operation in $proposedOperations) {
|
||||
$appliedOperations += Apply-TypedOperation -Operation $operation
|
||||
}
|
||||
|
||||
|
|
@ -513,6 +639,7 @@ Write-JsonFile -Path $statusPath -Data @{
|
|||
durationSeconds = [Math]::Round(($finishedAtUtc - $startedAtUtc).TotalSeconds, 2)
|
||||
exitCode = [int]$exitCode
|
||||
skipModel = [bool]$SkipModel
|
||||
forceDistillation = [bool]$ForceDistillation
|
||||
stateFile = [string]$stateFilePath
|
||||
contextPath = [string]$contextPath
|
||||
operationOutputPath = [string]$operationOutputPath
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ $lastMessagePath = Join-Path $statusDir "moderation-rumination-last-message.txt"
|
|||
$operationOutputPath = Join-Path $statusDir "moderation-rumination-operations.json"
|
||||
$contextPath = Join-Path $statusDir "moderation-rumination-context.json"
|
||||
$lockPath = Join-Path $statusDir "moderation-rumination.lock"
|
||||
$pendingMentionsPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_PENDING_MENTIONS_PATH)) {
|
||||
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_RUMINATION_PENDING_MENTIONS_PATH)
|
||||
} else {
|
||||
Join-Path $statusDir "void-moderation-pending-mentions.json"
|
||||
}
|
||||
$promptTemplatePath = Join-Path $repoRoot "prompts\void-moderator-rumination.md"
|
||||
$contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination-context-projection.ps1"
|
||||
$recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs"
|
||||
|
|
@ -195,7 +200,9 @@ function Invoke-CodexExec {
|
|||
[Parameter(Mandatory = $true)]
|
||||
[string] $WorkingDirectory,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $InputText
|
||||
[string] $InputText,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $TimeoutSeconds
|
||||
)
|
||||
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
|
|
@ -229,7 +236,15 @@ function Invoke-CodexExec {
|
|||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
|
||||
$process.WaitForExit()
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
try {
|
||||
$process.Kill($true)
|
||||
} catch {
|
||||
$process.Kill()
|
||||
}
|
||||
$process.WaitForExit()
|
||||
throw "Codex rumination exceeded timeout of $TimeoutSeconds seconds."
|
||||
}
|
||||
$stdoutTask.Wait()
|
||||
$stderrTask.Wait()
|
||||
|
||||
|
|
@ -757,6 +772,86 @@ function Invoke-CandidateInterventionDeliveryFromIntervention {
|
|||
return Convert-LastSpeechToSpokenCandidateOperation -InterventionId $interventionId -Speech $speech
|
||||
}
|
||||
|
||||
function Test-LockProcessAlive {
|
||||
param($LockRecord)
|
||||
|
||||
$lockPid = Get-ObjectPropertyValue -Value $LockRecord -Name "pid"
|
||||
if ($null -eq $lockPid) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$process = Get-Process -Id ([int]$lockPid) -ErrorAction Stop
|
||||
return $null -ne $process
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-LockWithinRuntimeLimit {
|
||||
param(
|
||||
$LockRecord,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $MaxRuntimeMinutes
|
||||
)
|
||||
|
||||
$startedAtRaw = Get-ObjectPropertyString -Value $LockRecord -Name "startedAt"
|
||||
if ([string]::IsNullOrWhiteSpace($startedAtRaw)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$startedAt = [DateTime]::Parse($startedAtRaw).ToUniversalTime()
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
return (([DateTime]::UtcNow - $startedAt).TotalMinutes -lt $MaxRuntimeMinutes)
|
||||
}
|
||||
|
||||
function Test-ActiveLock {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $MaxRuntimeMinutes
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$lockRecord = Read-JsonFile -Path $Path
|
||||
return (Test-LockProcessAlive -LockRecord $lockRecord) -and
|
||||
(Test-LockWithinRuntimeLimit -LockRecord $lockRecord -MaxRuntimeMinutes $MaxRuntimeMinutes)
|
||||
}
|
||||
|
||||
function Assert-SpokenCandidateApplied {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $InterventionId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $ReceiptKey
|
||||
)
|
||||
|
||||
$state = (Get-TypedSelfState).typedState
|
||||
$candidate = @(
|
||||
@(Convert-ToValueArray -Value $state.candidateInterventions.interventions) |
|
||||
Where-Object { (Get-ObjectPropertyString -Value $_ -Name "interventionId") -eq $InterventionId }
|
||||
)[0]
|
||||
$receipt = @(
|
||||
@(Convert-ToValueArray -Value $state.speechReceipts.recentReceipts) |
|
||||
Where-Object { (Get-ObjectPropertyString -Value $_ -Name "receiptKey") -eq $ReceiptKey }
|
||||
)[0]
|
||||
|
||||
if ($null -eq $candidate -or (Get-ObjectPropertyString -Value $candidate -Name "status") -ne "spoken") {
|
||||
throw "Delivery receipt '$ReceiptKey' was recorded without retiring candidate '$InterventionId' as spoken."
|
||||
}
|
||||
if ($null -eq $receipt -or (Get-ObjectPropertyString -Value $receipt -Name "candidateInterventionId") -ne $InterventionId) {
|
||||
throw "Candidate '$InterventionId' was marked spoken without a linked delivery receipt '$ReceiptKey'."
|
||||
}
|
||||
}
|
||||
|
||||
trap {
|
||||
$finishedAtUtc = [DateTime]::UtcNow
|
||||
$failureMessage = $_.Exception.Message
|
||||
|
|
@ -781,9 +876,16 @@ trap {
|
|||
|
||||
New-Item -ItemType Directory -Force -Path $statusDir, $logDir, (Split-Path -Parent $stateFilePath) | Out-Null
|
||||
|
||||
$maxRuntimeMinutes = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_MAX_RUNTIME_MINUTES)) {
|
||||
[int]$env:VOID_RUMINATION_MAX_RUNTIME_MINUTES
|
||||
} else {
|
||||
15
|
||||
}
|
||||
|
||||
if (Test-Path $lockPath) {
|
||||
$lockAge = [DateTime]::UtcNow - (Get-Item $lockPath).LastWriteTimeUtc
|
||||
if ($lockAge.TotalMinutes -lt 20) {
|
||||
$existingLock = Read-JsonFile -Path $lockPath
|
||||
$lockProcessAlive = Test-LockProcessAlive -LockRecord $existingLock
|
||||
if ($lockProcessAlive -and (Test-LockWithinRuntimeLimit -LockRecord $existingLock -MaxRuntimeMinutes $maxRuntimeMinutes)) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "lock_present"
|
||||
|
|
@ -792,6 +894,19 @@ if (Test-Path $lockPath) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if ($lockProcessAlive) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "lock_over_runtime_active"
|
||||
observedAt = ([DateTime]::UtcNow.ToString("o"))
|
||||
stateFile = $stateFilePath
|
||||
lockPath = $lockPath
|
||||
maxRuntimeMinutes = $maxRuntimeMinutes
|
||||
pid = Get-ObjectPropertyValue -Value $existingLock -Name "pid"
|
||||
startedAt = Get-ObjectPropertyString -Value $existingLock -Name "startedAt"
|
||||
}
|
||||
return
|
||||
}
|
||||
Remove-Item -LiteralPath $lockPath -Force
|
||||
}
|
||||
|
||||
|
|
@ -799,6 +914,13 @@ Write-JsonFile -Path $lockPath -Data @{
|
|||
pid = $PID
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
}
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "starting"
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
stateFile = $stateFilePath
|
||||
lockPath = $lockPath
|
||||
pid = $PID
|
||||
}
|
||||
|
||||
if (Test-Path $operationOutputPath) {
|
||||
Remove-Item -LiteralPath $operationOutputPath -Force
|
||||
|
|
@ -842,6 +964,13 @@ $codexExecArgs = if (-not [string]::IsNullOrWhiteSpace($env:CODEX_EXEC_ARGS)) {
|
|||
} else {
|
||||
@()
|
||||
}
|
||||
$codexTimeoutSeconds = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_CODEX_TIMEOUT_SECONDS)) {
|
||||
[int]$env:VOID_RUMINATION_CODEX_TIMEOUT_SECONDS
|
||||
} elseif ($envValues.ContainsKey("VOID_RUMINATION_CODEX_TIMEOUT_SECONDS") -and -not [string]::IsNullOrWhiteSpace($envValues["VOID_RUMINATION_CODEX_TIMEOUT_SECONDS"])) {
|
||||
[int]$envValues["VOID_RUMINATION_CODEX_TIMEOUT_SECONDS"]
|
||||
} else {
|
||||
[Math]::Max(60, $maxRuntimeMinutes * 60)
|
||||
}
|
||||
$publicRoomChannelId = Get-ConfigValue -Name "VOID_PUBLIC_ROOM_CHANNEL_ID" -Values $envValues
|
||||
$publicRoomPersonaName = Get-ConfigValue -Name "VOID_PUBLIC_ROOM_PERSONA_NAME" -Values $envValues
|
||||
$publicRoomPersonaAvatarUrl = Get-ConfigValue -Name "VOID_PUBLIC_ROOM_PERSONA_AVATAR_URL" -Values $envValues
|
||||
|
|
@ -892,6 +1021,15 @@ try {
|
|||
}
|
||||
}
|
||||
|
||||
$pendingMentions = @()
|
||||
$pendingMentionPacket = Read-JsonFile -Path $pendingMentionsPath
|
||||
if ($null -ne $pendingMentionPacket) {
|
||||
$pendingMentionsValue = Get-ObjectPropertyValue -Value $pendingMentionPacket -Name "pendingMentions"
|
||||
if ($null -ne $pendingMentionsValue) {
|
||||
$pendingMentions = @(Convert-ToValueArray -Value $pendingMentionsValue)
|
||||
}
|
||||
}
|
||||
|
||||
Write-JsonFile -Path $contextPath -Data @{
|
||||
generated = "now"
|
||||
stateFile = $stateFilePath
|
||||
|
|
@ -905,6 +1043,7 @@ Write-JsonFile -Path $contextPath -Data @{
|
|||
incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc)
|
||||
agencyPressure = @(Project-AgencyPressureForRumination -Pressures $typedState.agencyPressure.pressures -Now $startedAtUtc)
|
||||
speechPressureObligations = $speechPressureObligations
|
||||
pendingMentions = $pendingMentions
|
||||
publicSpeechTarget = Project-PublicSpeechTargetForRumination -ChannelId $publicRoomChannelId -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
|
||||
deliverableCandidateCount = [int]$deliverableCandidates.Count
|
||||
candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc)
|
||||
|
|
@ -990,7 +1129,7 @@ if ($SkipModel) {
|
|||
"-"
|
||||
)
|
||||
|
||||
$execution = Invoke-CodexExec -Executable $codexExecutable -Arguments $codexArgs -WorkingDirectory $repoRoot -InputText $prompt
|
||||
$execution = Invoke-CodexExec -Executable $codexExecutable -Arguments $codexArgs -WorkingDirectory $repoRoot -InputText $prompt -TimeoutSeconds $codexTimeoutSeconds
|
||||
$exitCode = $execution.ExitCode
|
||||
$combinedText = (($execution.StdOut, $execution.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine
|
||||
}
|
||||
|
|
@ -1073,6 +1212,7 @@ if (-not $NoPost) {
|
|||
$spokenOperation = Invoke-CandidateInterventionDeliveryFromIntervention -Intervention $candidate
|
||||
if ($null -ne $spokenOperation) {
|
||||
$appliedOperations += Apply-TypedOperation -Operation $spokenOperation
|
||||
Assert-SpokenCandidateApplied -InterventionId $spokenOperation.interventionId -ReceiptKey $spokenOperation.receipt.receiptKey
|
||||
$deliveredCandidateCount += 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,23 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) +
|
|||
$cursorOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $cursorOperationPath -Encoding UTF8
|
||||
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $cursorOperationPath | Out-Null
|
||||
|
||||
$openCaseOperation = @{
|
||||
operation = "upsert_open_case"
|
||||
case = @{
|
||||
sourceMessageId = "fixture-answered-message-id"
|
||||
status = "pending"
|
||||
summary = "Metacrat asked a fixture question, and Void has not answered yet."
|
||||
authorId = "fixture-author"
|
||||
authorName = "Metacrat"
|
||||
channelId = "fixture-channel-id"
|
||||
createdAt = "2026-05-17T02:57:00.000Z"
|
||||
lastTouchedAt = "2026-05-17T02:57:00.000Z"
|
||||
tags = @("fixture")
|
||||
}
|
||||
}
|
||||
$openCaseOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $cursorOperationPath -Encoding UTF8
|
||||
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $cursorOperationPath | Out-Null
|
||||
|
||||
$priorReceiptOperation = @{
|
||||
operation = "record_delivery_receipt"
|
||||
receipt = @{
|
||||
|
|
@ -259,6 +276,10 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) +
|
|||
if ($receipt.candidateInterventionId -ne "fixture-parent-owned-speech") {
|
||||
throw "Speech fixture did not link the delivery receipt back to the spoken candidate."
|
||||
}
|
||||
$answeredCase = @($state.moderationCursor.openCases | Where-Object { $_.sourceMessageId -eq "fixture-answered-message-id" })[0]
|
||||
if ($answeredCase.status -ne "answered" -or $answeredCase.summary -match "not answered") {
|
||||
throw "Speech fixture did not normalize the answered open-case summary."
|
||||
}
|
||||
$lastMessage = Get-Content -LiteralPath (Join-Path $fixtureStatusDir "moderation-rumination-last-message.txt") -Raw -Encoding UTF8
|
||||
if ($lastMessage -notmatch "deliveredCandidates=1") {
|
||||
throw "Speech fixture did not refresh the final rumination message summary."
|
||||
|
|
|
|||
Loading…
Reference in a new issue