Move moderation heartbeat into daemon organs
This commit is contained in:
parent
5b9bfd6a85
commit
f97e67b1dd
10 changed files with 370 additions and 46 deletions
|
|
@ -89,6 +89,9 @@ METAME_OWNER_VOICE_SOURCE_CHANNEL_ID=
|
|||
METAME_OWNER_VOICE_PERSONA_ID=metame
|
||||
METAME_OWNER_VOICE_STATE_PATH=.voidbot/private/personas/metame/metame-owner-voice-sync.md
|
||||
REPO_FACE_HEARTBEATS_ENABLED=false
|
||||
VOID_MODERATION_DAEMON_ENABLED=false
|
||||
VOID_MODERATION_DAEMON_INTERVAL_MINUTES=15
|
||||
VOID_MODERATION_ENFORCEMENT_MODE=log_only
|
||||
REPO_FACE_HEARTBEAT_STATE_PATH=.voidbot/status/repo-face-heartbeats.json
|
||||
REPO_FACE_HEARTBEAT_TASK_NAME=VoidBot Repo Face Heartbeats
|
||||
REPO_FACE_HEARTBEAT_INTERVAL_MINUTES=15
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import type { loadConfig } from "@voidbot/config";
|
||||
|
||||
export async function requestBoundedTextCompletion(input: {
|
||||
prompt: string;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
purpose: string;
|
||||
model?: string;
|
||||
maxCompletionTokens: number;
|
||||
}, dependencies: { fetch?: typeof fetch; readText?: (path: string) => Promise<string> } = {}): Promise<string> {
|
||||
const api = input.config.openAiApi;
|
||||
const apiKey = api.apiKey ?? (api.apiKeyFile ? (await (dependencies.readText ?? ((path) => readFile(path, "utf8")))(api.apiKeyFile)).trim() : undefined);
|
||||
if (!apiKey) throw new Error(`${input.purpose} requires the configured OpenAI-compatible API key.`);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), api.timeoutMs);
|
||||
try {
|
||||
const response = await (dependencies.fetch ?? fetch)(`${api.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", [api.authHeader]: api.authHeader.toLowerCase() === "authorization" ? `Bearer ${apiKey}` : apiKey },
|
||||
body: JSON.stringify({
|
||||
model: input.model ?? api.model,
|
||||
messages: [{ role: "user", content: input.prompt }],
|
||||
stream: false,
|
||||
max_completion_tokens: Math.min(api.maxCompletionTokens, Math.max(256, input.maxCompletionTokens)),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) throw new Error(`${input.purpose} request failed with ${response.status}: ${redact(responseText, apiKey).slice(0, 1000)}`);
|
||||
const payload = JSON.parse(responseText) as { choices?: Array<{ message?: { content?: unknown } }> };
|
||||
const content = payload.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string" || !content.trim()) throw new Error(`${input.purpose} response contained no assistant text.`);
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") throw new Error(`${input.purpose} request timed out after ${api.timeoutMs}ms.`);
|
||||
throw error;
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
function redact(value: string, secret: string): string { return secret ? value.split(secret).join("[redacted]") : value; }
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import "dotenv/config";
|
||||
|
||||
import { loadConfig } from "@voidbot/config";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { runPersonaSchedulerTick } from "./persona-scheduler-runner.js";
|
||||
import { runVoidPhysiologyOrgan } from "./void-physiology-organ.js";
|
||||
import { runVoidMemoryMaintenance } from "./void-memory-maintenance-organ.js";
|
||||
import { projectVoidMemoryOperations } from "./void-memory-text-actuator.js";
|
||||
import { runVoidModerationHeartbeat } from "./void-moderation-heartbeat-organ.js";
|
||||
import { projectVoidModerationOperations } from "./void-moderation-text-actuator.js";
|
||||
import { runVoidModerationEnforcement } from "./void-moderation-enforcement-organ.js";
|
||||
|
||||
const config = loadConfig();
|
||||
const intervalMs = Math.max(1, config.repoFaceHeartbeats.intervalMinutes) * 60_000;
|
||||
|
|
@ -13,7 +17,9 @@ let stopping = false;
|
|||
let activeTick: Promise<void> | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let lastPhysiologyAt = 0;
|
||||
let lastModerationAt = 0;
|
||||
const physiologyIntervalMs = Math.max(5, Number(process.env.VOIDBOT_MOOD_INTERVAL_MINUTES) || 5) * 60_000;
|
||||
const moderationIntervalMs = config.voidModerationDaemon.intervalMinutes * 60_000;
|
||||
|
||||
function runTick(): Promise<void> {
|
||||
if (activeTick) {
|
||||
|
|
@ -43,6 +49,30 @@ function runTick(): Promise<void> {
|
|||
console.error(`Void physiology pulse failed. ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (config.voidModerationDaemon.enabled && Date.now() - lastModerationAt >= moderationIntervalMs) {
|
||||
lastModerationAt = Date.now();
|
||||
try {
|
||||
const moderation = await runVoidModerationHeartbeat({
|
||||
statePath: resolve(config.storageRoot, "private", "void-self-state.cc"),
|
||||
rules: await readFile(resolve("config", "discord-server-rules.md"), "utf8"),
|
||||
enforcementMode: config.voidModerationDaemon.enforcementMode,
|
||||
}, { projectText: (prompt) => projectVoidModerationOperations({ prompt, config }) });
|
||||
console.log(`Void moderation-heartbeat pulse completed. ${JSON.stringify(moderation)}`);
|
||||
} catch (error) {
|
||||
console.error(`Void moderation-heartbeat pulse failed without automatic evidence-window retry. ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
||||
}
|
||||
try {
|
||||
const enforcement = await runVoidModerationEnforcement({
|
||||
statePath: resolve(config.storageRoot, "private", "void-self-state.cc"),
|
||||
mode: config.voidModerationDaemon.enforcementMode,
|
||||
botToken: config.botToken,
|
||||
guildId: config.developmentGuildId,
|
||||
});
|
||||
console.log(`Void moderation-enforcement pulse completed. ${JSON.stringify(enforcement)}`);
|
||||
} catch (error) {
|
||||
console.error(`Void moderation-enforcement pulse failed; pending cases remain typed debt for the next cadence. ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => { console.error(`Persona scheduler tick failed (started ${startedAt}). ${error instanceof Error ? error.stack ?? error.message : String(error)}`); })
|
||||
.finally(() => { activeTick = undefined; });
|
||||
|
|
|
|||
|
|
@ -1,33 +1,16 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import type { loadConfig } from "@voidbot/config";
|
||||
import { requestBoundedTextCompletion } from "./bounded-text-completion-actuator.js";
|
||||
|
||||
export async function projectVoidMemoryOperations(input: {
|
||||
prompt: string;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
model?: string;
|
||||
}, dependencies: { fetch?: typeof fetch; readText?: (path: string) => Promise<string> } = {}): Promise<string> {
|
||||
const api = input.config.openAiApi;
|
||||
const apiKey = api.apiKey ?? (api.apiKeyFile ? (await (dependencies.readText ?? ((path) => readFile(path, "utf8")))(api.apiKeyFile)).trim() : undefined);
|
||||
if (!apiKey) throw new Error("Void memory maintenance requires the configured OpenAI-compatible API key.");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), api.timeoutMs);
|
||||
try {
|
||||
const response = await (dependencies.fetch ?? fetch)(`${api.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", [api.authHeader]: api.authHeader.toLowerCase() === "authorization" ? `Bearer ${apiKey}` : apiKey },
|
||||
body: JSON.stringify({ model: input.model ?? process.env.VOID_MEMORY_MAINTENANCE_MODEL ?? api.model, messages: [{ role: "user", content: input.prompt }], stream: false, max_completion_tokens: Math.min(api.maxCompletionTokens, Math.max(256, Number(process.env.VOID_MEMORY_MAINTENANCE_MAX_COMPLETION_TOKENS) || 2048)) }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) throw new Error(`Void memory model request failed with ${response.status}: ${redact(responseText, apiKey).slice(0, 1000)}`);
|
||||
const payload = JSON.parse(responseText) as { choices?: Array<{ message?: { content?: unknown } }> };
|
||||
const content = payload.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string" || !content.trim()) throw new Error("Void memory model response contained no assistant text.");
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") throw new Error(`Void memory model request timed out after ${api.timeoutMs}ms.`);
|
||||
throw error;
|
||||
} finally { clearTimeout(timeout); }
|
||||
return requestBoundedTextCompletion({
|
||||
prompt: input.prompt,
|
||||
config: input.config,
|
||||
purpose: "Void memory maintenance",
|
||||
model: input.model ?? process.env.VOID_MEMORY_MAINTENANCE_MODEL,
|
||||
maxCompletionTokens: Number(process.env.VOID_MEMORY_MAINTENANCE_MAX_COMPLETION_TOKENS) || 2048,
|
||||
}, dependencies);
|
||||
}
|
||||
|
||||
function redact(value: string, secret: string): string { return secret ? value.split(secret).join("[redacted]") : value; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { applyVoidSelfStateOperation, loadVoidSelfStateTypedDocuments } from "@voidbot/core";
|
||||
|
||||
const STRIKE_EXPIRY_DAYS: Record<string, number> = {
|
||||
safety_threat: 365, weaponized_intimidation: 365, stalking_or_doxxing: 365,
|
||||
sexual_boundary_violation: 365, bigotry_identity_attack: 180, bad_faith_argument: 90,
|
||||
nsfw_channel_violation: 30, spam_or_deceptive_promotion: 30, moderator_obstruction: 90,
|
||||
empty_words_noise: 14, values_debate_escalation: 14, pg13_language_violation: 14,
|
||||
event_time_coordination: 14,
|
||||
};
|
||||
|
||||
export interface VoidModerationEnforcementResult {
|
||||
status: "ok";
|
||||
mode: string;
|
||||
evaluatedCaseCount: number;
|
||||
actions: Array<{ sourceMessageId: string; status: string; authorId?: string; infringementType?: string; strikeCount?: number }>;
|
||||
}
|
||||
|
||||
export async function runVoidModerationEnforcement(input: {
|
||||
statePath: string;
|
||||
mode: string;
|
||||
botToken?: string;
|
||||
guildId?: string;
|
||||
observedAt?: Date;
|
||||
}, dependencies: {
|
||||
loadState?: typeof loadVoidSelfStateTypedDocuments;
|
||||
applyOperation?: typeof applyVoidSelfStateOperation;
|
||||
banMember?: (input: { botToken: string; guildId: string; userId: string; reason: string }) => Promise<void>;
|
||||
} = {}): Promise<VoidModerationEnforcementResult> {
|
||||
const mode = input.mode.trim().toLowerCase();
|
||||
const state = await (dependencies.loadState ?? loadVoidSelfStateTypedDocuments)({ canonicalPath: input.statePath });
|
||||
const pending = state.moderationCursor.openCases.filter((entry) => entry.status === "pending" || entry.status === "watching");
|
||||
const actions: VoidModerationEnforcementResult["actions"] = [];
|
||||
for (const moderationCase of pending) {
|
||||
const tags = new Set(moderationCase.tags.map((tag) => tag.toLowerCase()));
|
||||
const infringementType = [...tags].find((tag) => tag.startsWith("infringement:"))?.slice("infringement:".length);
|
||||
if (!infringementType || STRIKE_EXPIRY_DAYS[infringementType] === undefined || !moderationCase.authorId) {
|
||||
actions.push({ sourceMessageId: moderationCase.sourceMessageId, status: "missing_policy_or_author", authorId: moderationCase.authorId, infringementType });
|
||||
continue;
|
||||
}
|
||||
if (tags.has("moderation:case_only")) {
|
||||
actions.push({ sourceMessageId: moderationCase.sourceMessageId, status: "case_recorded_no_sanction", authorId: moderationCase.authorId, infringementType });
|
||||
continue;
|
||||
}
|
||||
if (!["policy", "enforce_policy", "enforce-policy", "ban"].includes(mode)) {
|
||||
actions.push({ sourceMessageId: moderationCase.sourceMessageId, status: "enforcement_mode_non_destructive", authorId: moderationCase.authorId, infringementType });
|
||||
continue;
|
||||
}
|
||||
const strikeCount = countActiveStrikes(state.moderationCursor.openCases, moderationCase.authorId, infringementType, STRIKE_EXPIRY_DAYS[infringementType], input.observedAt ?? new Date());
|
||||
const instantBan = tags.has("moderation:instaban") || tags.has("severity:instaban");
|
||||
const thirdStrike = tags.has("moderation:strike") && strikeCount >= 3;
|
||||
if (instantBan || thirdStrike) {
|
||||
if (!input.botToken || !input.guildId) throw new Error("Policy moderation requires Discord bot token and guild id.");
|
||||
const reason = instantBan ? `VoidBot instaban: ${infringementType} at ${moderationCase.sourceMessageId}` : `VoidBot three-strike ban: ${infringementType} strike ${strikeCount}/3 at ${moderationCase.sourceMessageId}`;
|
||||
await (dependencies.banMember ?? banDiscordMember)({ botToken: input.botToken, guildId: input.guildId, userId: moderationCase.authorId, reason });
|
||||
await closeCase(input.statePath, moderationCase.sourceMessageId, input.observedAt ?? new Date(), `${instantBan ? "Instaban" : "Three-strike ban"} applied for ${infringementType}.`, dependencies.applyOperation);
|
||||
actions.push({ sourceMessageId: moderationCase.sourceMessageId, status: instantBan ? "instaban_applied" : "three_strike_ban_applied", authorId: moderationCase.authorId, infringementType, strikeCount });
|
||||
continue;
|
||||
}
|
||||
if (tags.has("moderation:strike")) {
|
||||
await closeCase(input.statePath, moderationCase.sourceMessageId, input.observedAt ?? new Date(), `Strike ${strikeCount}/3 recorded for ${infringementType}; expires after ${STRIKE_EXPIRY_DAYS[infringementType]} days without another matching strike.`, dependencies.applyOperation);
|
||||
actions.push({ sourceMessageId: moderationCase.sourceMessageId, status: "strike_recorded", authorId: moderationCase.authorId, infringementType, strikeCount });
|
||||
}
|
||||
}
|
||||
return { status: "ok", mode, evaluatedCaseCount: pending.length, actions };
|
||||
}
|
||||
|
||||
function countActiveStrikes(cases: Array<{ authorId?: string; createdAt: string; tags: string[] }>, authorId: string, infringementType: string, expiresDays: number, now: Date): number {
|
||||
const cutoff = now.getTime() - expiresDays * 86_400_000;
|
||||
return cases.filter((entry) => entry.authorId === authorId
|
||||
&& entry.tags.map((tag) => tag.toLowerCase()).includes(`infringement:${infringementType}`)
|
||||
&& entry.tags.map((tag) => tag.toLowerCase()).includes("moderation:strike")
|
||||
&& Date.parse(entry.createdAt) >= cutoff).length;
|
||||
}
|
||||
|
||||
async function closeCase(statePath: string, sourceMessageId: string, observedAt: Date, resolutionSummary: string, applyOperation = applyVoidSelfStateOperation): Promise<void> {
|
||||
await applyOperation({ canonicalPath: statePath }, { operation: "close_open_case", sourceMessageId, status: "resolved", resolvedAt: observedAt.toISOString(), resolutionSummary });
|
||||
}
|
||||
|
||||
async function banDiscordMember(input: { botToken: string; guildId: string; userId: string; reason: string }): Promise<void> {
|
||||
const response = await fetch(`https://discord.com/api/v10/guilds/${input.guildId}/bans/${input.userId}`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${input.botToken}`, "Content-Type": "application/json", "X-Audit-Log-Reason": encodeURIComponent(input.reason) },
|
||||
body: JSON.stringify({ delete_message_seconds: 0 }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Discord ban failed with ${response.status}: ${(await response.text()).slice(0, 1000)}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { applyVoidSelfStateOperation, loadVoidSelfStateTypedDocuments, type VoidSelfStateOperation } from "@voidbot/core";
|
||||
import { readVoidModerationEvidence, type VoidModerationEvidence } from "./void-moderation-evidence-source.js";
|
||||
import { buildVoidModerationHeartbeatPrompt, parseVoidModerationHeartbeatOperations, projectVoidModerationHeartbeatContext } from "./void-moderation-heartbeat-projector.js";
|
||||
|
||||
export type VoidModerationHeartbeatResult =
|
||||
| { status: "skipped"; reason: "no_new_messages" | "already_attempted_this_window"; runnerId?: string }
|
||||
| { status: "ok"; runnerId: string; reviewedMessageCount: number; urgentMessageCount: number; appliedOperationCount: number; enforcementOperations: VoidSelfStateOperation[] };
|
||||
|
||||
export async function runVoidModerationHeartbeat(input: {
|
||||
statePath: string;
|
||||
rules: string;
|
||||
enforcementMode: string;
|
||||
observedAt?: Date;
|
||||
}, dependencies: {
|
||||
loadState?: typeof loadVoidSelfStateTypedDocuments;
|
||||
applyOperation?: typeof applyVoidSelfStateOperation;
|
||||
readEvidence?: (input: { priorCursorTimestamp?: string }) => Promise<VoidModerationEvidence>;
|
||||
projectText: (prompt: string) => Promise<string>;
|
||||
}): Promise<VoidModerationHeartbeatResult> {
|
||||
const loadState = dependencies.loadState ?? loadVoidSelfStateTypedDocuments;
|
||||
const applyOperation = dependencies.applyOperation ?? applyVoidSelfStateOperation;
|
||||
const state = await loadState({ canonicalPath: input.statePath });
|
||||
const evidence = await (dependencies.readEvidence ?? ((request) => readVoidModerationEvidence(request)))({
|
||||
priorCursorTimestamp: state.moderationCursor.lastReviewedTimestamp,
|
||||
});
|
||||
if (evidence.messages.length === 0 || !evidence.observedCursor) return { status: "skipped", reason: "no_new_messages" };
|
||||
const runnerId = `void-moderation-heartbeat-attempt:${evidence.observedCursor.lastReviewedMessageId}`;
|
||||
if (state.scheduledRuntime.lastRuns.some((run) => run.runner === runnerId)) return { status: "skipped", reason: "already_attempted_this_window", runnerId };
|
||||
const observedAt = input.observedAt ?? new Date();
|
||||
await applyOperation({ canonicalPath: input.statePath }, {
|
||||
operation: "record_scheduled_run",
|
||||
run: { runner: runnerId, ranAt: observedAt.toISOString(), summary: `Started one bounded moderation review through message ${evidence.observedCursor.lastReviewedMessageId}.` },
|
||||
});
|
||||
const context = projectVoidModerationHeartbeatContext({
|
||||
state,
|
||||
observedAt,
|
||||
observedCursor: evidence.observedCursor,
|
||||
recentHistory: evidence,
|
||||
urgentModerationWitnesses: [],
|
||||
enforcementMode: input.enforcementMode,
|
||||
rules: input.rules,
|
||||
});
|
||||
const decision = parseVoidModerationHeartbeatOperations({
|
||||
outputText: await dependencies.projectText(buildVoidModerationHeartbeatPrompt(context)),
|
||||
state,
|
||||
observedMessageIds: evidence.messages.map((message) => message.id),
|
||||
});
|
||||
for (const operation of decision.operations) await applyOperation({ canonicalPath: input.statePath }, operation);
|
||||
await applyOperation({ canonicalPath: input.statePath }, {
|
||||
operation: "record_reviewed_messages",
|
||||
lastReviewedMessageId: evidence.observedCursor.lastReviewedMessageId,
|
||||
lastReviewedTimestamp: evidence.observedCursor.lastReviewedTimestamp,
|
||||
});
|
||||
return {
|
||||
status: "ok",
|
||||
runnerId,
|
||||
reviewedMessageCount: decision.reviewedMessageIds.length,
|
||||
urgentMessageCount: decision.urgentMessageIds.length,
|
||||
appliedOperationCount: decision.operations.length + 1,
|
||||
enforcementOperations: decision.operations.filter((operation) => operation.operation === "upsert_open_case"),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { voidSelfStateOperationSchema, type VoidSelfStateOperation, type VoidSelfStateTypedProjection } from "@voidbot/core";
|
||||
import { z } from "zod";
|
||||
|
||||
const ALLOWED_MODERATION_TAGS = new Set(["moderation:instaban", "moderation:strike", "moderation:case_only"]);
|
||||
|
||||
|
|
@ -14,6 +15,12 @@ export interface VoidModerationHeartbeatContext {
|
|||
recentHistory: unknown;
|
||||
}
|
||||
|
||||
export interface VoidModerationHeartbeatDecision {
|
||||
reviewedMessageIds: string[];
|
||||
urgentMessageIds: string[];
|
||||
operations: VoidSelfStateOperation[];
|
||||
}
|
||||
|
||||
export function projectVoidModerationHeartbeatContext(input: {
|
||||
state: VoidSelfStateTypedProjection;
|
||||
observedAt: Date;
|
||||
|
|
@ -40,7 +47,8 @@ export function projectVoidModerationHeartbeatContext(input: {
|
|||
export function buildVoidModerationHeartbeatPrompt(context: VoidModerationHeartbeatContext): string {
|
||||
return [
|
||||
"You are Void's rules-only public-community moderation organ. This is not public speech, private rumination, or repo thought.",
|
||||
"Return only a JSON array of complete typed Void self-state operation objects. Do not use markdown fences, commentary, tools, or file writes.",
|
||||
"Return only one JSON object with reviewedMessageIds, urgentMessageIds, and operations. Do not use markdown fences, commentary, tools, or file writes.",
|
||||
"reviewedMessageIds must contain every message ID in recentHistory exactly once. urgentMessageIds is the subset requiring urgent safety accounting; use [] when none are urgent.",
|
||||
"Allowed operations: upsert_open_case, close_open_case.",
|
||||
"Each new case must identify one concrete source message and contain exactly one infringement:<type> tag plus exactly one moderation:instaban, moderation:strike, or moderation:case_only tag.",
|
||||
"Choose the strongest supported infringement type; never multiply cases or sanctions for one message.",
|
||||
|
|
@ -54,13 +62,36 @@ export function buildVoidModerationHeartbeatPrompt(context: VoidModerationHeartb
|
|||
export function parseVoidModerationHeartbeatOperations(input: {
|
||||
outputText: string;
|
||||
state: VoidSelfStateTypedProjection;
|
||||
}): VoidSelfStateOperation[] {
|
||||
observedMessageIds: string[];
|
||||
}): VoidModerationHeartbeatDecision {
|
||||
const trimmed = input.outputText.trim();
|
||||
const candidate = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim() ?? trimmed;
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(candidate); } catch (error) { throw new Error(`Moderation heartbeat output is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
if (!Array.isArray(raw)) throw new Error("Moderation heartbeat output must be a JSON array.");
|
||||
return raw.map((entry, index) => validateOperation(entry, index, input.state));
|
||||
const envelope = z.object({
|
||||
reviewedMessageIds: z.array(z.string().trim().min(1)),
|
||||
urgentMessageIds: z.array(z.string().trim().min(1)),
|
||||
operations: z.array(z.unknown()),
|
||||
}).strict().parse(raw);
|
||||
assertExactReviewLedger(input.observedMessageIds, envelope.reviewedMessageIds);
|
||||
const observed = new Set(input.observedMessageIds);
|
||||
if (envelope.urgentMessageIds.some((id) => !observed.has(id))) throw new Error("Moderation urgent ledger contains a message outside the observed evidence window.");
|
||||
const operations = envelope.operations.map((entry, index) => validateOperation(entry, index, input.state));
|
||||
const accounted = new Set([
|
||||
...input.state.moderationCursor.openCases.map((entry) => entry.sourceMessageId),
|
||||
...operations.flatMap((operation) => operation.operation === "upsert_open_case" ? [operation.case.sourceMessageId] : []),
|
||||
]);
|
||||
const unaccountedUrgent = envelope.urgentMessageIds.filter((id) => !accounted.has(id));
|
||||
if (unaccountedUrgent.length > 0) throw new Error(`Urgent moderation evidence is unaccounted for: ${unaccountedUrgent.join(", ")}.`);
|
||||
return { reviewedMessageIds: envelope.reviewedMessageIds, urgentMessageIds: envelope.urgentMessageIds, operations };
|
||||
}
|
||||
|
||||
function assertExactReviewLedger(observed: string[], reviewed: string[]): void {
|
||||
const expected = [...new Set(observed)].sort();
|
||||
const actual = [...new Set(reviewed)].sort();
|
||||
if (reviewed.length !== actual.length || expected.length !== actual.length || expected.some((id, index) => id !== actual[index])) {
|
||||
throw new Error("Moderation review ledger must account for every observed message exactly once before cursor advancement.");
|
||||
}
|
||||
}
|
||||
|
||||
function validateOperation(entry: unknown, index: number, state: VoidSelfStateTypedProjection): VoidSelfStateOperation {
|
||||
|
|
|
|||
16
apps/persona-scheduler/src/void-moderation-text-actuator.ts
Normal file
16
apps/persona-scheduler/src/void-moderation-text-actuator.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { loadConfig } from "@voidbot/config";
|
||||
import { requestBoundedTextCompletion } from "./bounded-text-completion-actuator.js";
|
||||
|
||||
export async function projectVoidModerationOperations(input: {
|
||||
prompt: string;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
model?: string;
|
||||
}, dependencies: { fetch?: typeof fetch; readText?: (path: string) => Promise<string> } = {}): Promise<string> {
|
||||
return requestBoundedTextCompletion({
|
||||
prompt: input.prompt,
|
||||
config: input.config,
|
||||
purpose: "Void moderation heartbeat",
|
||||
model: input.model ?? process.env.VOID_MODERATION_HEARTBEAT_MODEL,
|
||||
maxCompletionTokens: Number(process.env.VOID_MODERATION_HEARTBEAT_MAX_COMPLETION_TOKENS) || 2048,
|
||||
}, dependencies);
|
||||
}
|
||||
|
|
@ -137,6 +137,9 @@ const envSchema = z.object({
|
|||
BIFROST_CULTMESH_COMMAND_PUMP_ENABLED: booleanFromEnv.default(true),
|
||||
REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false),
|
||||
VOIDBOT_MODERATION_HEARTBEAT_ENABLED: booleanFromEnv.default(false),
|
||||
VOID_MODERATION_DAEMON_ENABLED: booleanFromEnv.default(false),
|
||||
VOID_MODERATION_DAEMON_INTERVAL_MINUTES: z.coerce.number().int().min(5).default(15),
|
||||
VOID_MODERATION_ENFORCEMENT_MODE: z.string().trim().min(1).default("log_only"),
|
||||
REPO_FACE_HEARTBEAT_STATE_PATH: z.string().min(1).default(".voidbot/status/repo-face-heartbeats.cc"),
|
||||
REPO_FACE_HEARTBEAT_TASK_NAME: z.string().min(1).default("VoidBot Repo Face Heartbeats"),
|
||||
REPO_FACE_HEARTBEAT_INTERVAL_MINUTES: z.coerce.number().int().min(1).default(5),
|
||||
|
|
@ -248,6 +251,7 @@ export interface AppConfig {
|
|||
pumpEnabled: boolean;
|
||||
};
|
||||
voidModerationHeartbeatEnabled: boolean;
|
||||
voidModerationDaemon: { enabled: boolean; intervalMinutes: number; enforcementMode: string };
|
||||
repoFaceHeartbeats: {
|
||||
enabled: boolean;
|
||||
statePath: string;
|
||||
|
|
@ -538,6 +542,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
pumpEnabled: parsed.BIFROST_CULTMESH_COMMAND_PUMP_ENABLED,
|
||||
},
|
||||
voidModerationHeartbeatEnabled: parsed.VOIDBOT_MODERATION_HEARTBEAT_ENABLED,
|
||||
voidModerationDaemon: {
|
||||
enabled: parsed.VOID_MODERATION_DAEMON_ENABLED,
|
||||
intervalMinutes: parsed.VOID_MODERATION_DAEMON_INTERVAL_MINUTES,
|
||||
enforcementMode: parsed.VOID_MODERATION_ENFORCEMENT_MODE,
|
||||
},
|
||||
repoFaceHeartbeats: {
|
||||
enabled: parsed.REPO_FACE_HEARTBEATS_ENABLED,
|
||||
statePath: canonicalSchedulerStatePath(resolve(parsed.REPO_FACE_HEARTBEAT_STATE_PATH)),
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ import { runVoidMemoryMaintenance } from "../apps/persona-scheduler/dist/void-me
|
|||
import { projectVoidMemoryOperations } from "../apps/persona-scheduler/dist/void-memory-text-actuator.js";
|
||||
import { buildVoidModerationHeartbeatPrompt, parseVoidModerationHeartbeatOperations, projectVoidModerationHeartbeatContext } from "../apps/persona-scheduler/dist/void-moderation-heartbeat-projector.js";
|
||||
import { readVoidModerationEvidence } from "../apps/persona-scheduler/dist/void-moderation-evidence-source.js";
|
||||
import { runVoidModerationHeartbeat } from "../apps/persona-scheduler/dist/void-moderation-heartbeat-organ.js";
|
||||
import { projectVoidModerationOperations } from "../apps/persona-scheduler/dist/void-moderation-text-actuator.js";
|
||||
import { runVoidModerationEnforcement } from "../apps/persona-scheduler/dist/void-moderation-enforcement-organ.js";
|
||||
import { projectGamecultPersonaState } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js";
|
||||
import { projectPersonaStatePacket } from "../apps/persona-scheduler/dist/persona-state-packet-projector.js";
|
||||
import { extractLastPersonaProjectionMessage, isRetryablePersonaProjectionFailure } from "../apps/persona-scheduler/dist/persona-text-projection-actuator.js";
|
||||
|
|
@ -844,23 +847,28 @@ const moderationContext = projectVoidModerationHeartbeatContext({
|
|||
});
|
||||
assert.match(JSON.stringify(moderationContext.recentHistory), /5 minutes ago/, "moderation evidence chronology is projected without surrendering exact operation time ownership");
|
||||
assert.doesNotMatch(buildVoidModerationHeartbeatPrompt(moderationContext), /OPERATION_OUTPUT_PATH|apply-operation/, "portable moderation prompts return operations directly and cannot preserve the filesystem tool-writing contract");
|
||||
const parsedModeration = parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify([{
|
||||
operation: "upsert_open_case",
|
||||
case: {
|
||||
sourceMessageId: "message-1", status: "pending", summary: "Concrete fixture evidence.", authorId: "human-1", authorName: "Human",
|
||||
channelId: "room-1", whyItMatters: "The fixture crosses the safety boundary.", createdAt: physiologyObservedAt.toISOString(),
|
||||
lastTouchedAt: physiologyObservedAt.toISOString(), tags: ["infringement:safety_threat", "moderation:instaban"],
|
||||
},
|
||||
}]), state: physiologyState });
|
||||
assert.equal(parsedModeration[0]?.operation, "upsert_open_case", "moderation output parsing crosses the canonical operation schema and classification invariant");
|
||||
assert.throws(() => parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify([{
|
||||
operation: "upsert_open_case",
|
||||
case: {
|
||||
sourceMessageId: "message-2", status: "watching", summary: "Ambiguous fixture evidence.", createdAt: physiologyObservedAt.toISOString(),
|
||||
lastTouchedAt: physiologyObservedAt.toISOString(), tags: ["infringement:safety_threat", "infringement:weaponized_intimidation", "moderation:case_only"],
|
||||
},
|
||||
}]), state: physiologyState }), /exactly one infringement/, "moderation classification cannot multiply infringement authority for one message");
|
||||
assert.throws(() => parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify([{ operation: "update_sleep_cycle", sleepCycle: physiology.sleepCycle }]), state: physiologyState }), /not allowed/, "moderation cannot annex physiology even when the operation is schema-valid");
|
||||
const parsedModeration = parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify({
|
||||
reviewedMessageIds: ["message-1"], urgentMessageIds: ["message-1"], operations: [{
|
||||
operation: "upsert_open_case",
|
||||
case: {
|
||||
sourceMessageId: "message-1", status: "pending", summary: "Concrete fixture evidence.", authorId: "human-1", authorName: "Human",
|
||||
channelId: "room-1", whyItMatters: "The fixture crosses the safety boundary.", createdAt: physiologyObservedAt.toISOString(),
|
||||
lastTouchedAt: physiologyObservedAt.toISOString(), tags: ["infringement:safety_threat", "moderation:instaban"],
|
||||
},
|
||||
}],
|
||||
}), state: physiologyState, observedMessageIds: ["message-1"] });
|
||||
assert.equal(parsedModeration.operations[0]?.operation, "upsert_open_case", "moderation output parsing crosses the canonical operation schema and classification invariant");
|
||||
assert.throws(() => parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify({ reviewedMessageIds: ["message-2"], urgentMessageIds: [], operations: [{
|
||||
operation: "upsert_open_case",
|
||||
case: {
|
||||
sourceMessageId: "message-2", status: "watching", summary: "Ambiguous fixture evidence.", createdAt: physiologyObservedAt.toISOString(),
|
||||
lastTouchedAt: physiologyObservedAt.toISOString(), tags: ["infringement:safety_threat", "infringement:weaponized_intimidation", "moderation:case_only"],
|
||||
},
|
||||
}],
|
||||
}), state: physiologyState, observedMessageIds: ["message-2"] }), /exactly one infringement/, "moderation classification cannot multiply infringement authority for one message");
|
||||
assert.throws(() => parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify({ reviewedMessageIds: ["message-1"], urgentMessageIds: [], operations: [{ operation: "update_sleep_cycle", sleepCycle: physiology.sleepCycle }] }), state: physiologyState, observedMessageIds: ["message-1"] }), /not allowed/, "moderation cannot annex physiology even when the operation is schema-valid");
|
||||
assert.throws(() => parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify({ reviewedMessageIds: [], urgentMessageIds: [], operations: [] }), state: physiologyState, observedMessageIds: ["message-1"] }), /every observed message/, "moderation cannot advance over an omitted message");
|
||||
assert.throws(() => parseVoidModerationHeartbeatOperations({ outputText: JSON.stringify({ reviewedMessageIds: ["message-1"], urgentMessageIds: ["message-1"], operations: [] }), state: physiologyState, observedMessageIds: ["message-1"] }), /unaccounted/, "urgent classification cannot advance without an existing or proposed case");
|
||||
let moderationExporterArgs: string[] = [];
|
||||
const moderationEvidence = await readVoidModerationEvidence({ priorCursorTimestamp: "2026-07-16T02:00:00.000Z" }, {
|
||||
run: async (_file, args) => {
|
||||
|
|
@ -870,6 +878,52 @@ const moderationEvidence = await readVoidModerationEvidence({ priorCursorTimesta
|
|||
});
|
||||
assert.deepEqual(moderationExporterArgs.slice(-4), ["--after", "2026-07-16T02:00:00.000Z", "--limit", "120"], "moderation evidence acquisition owns cursor-bounded chronological export arguments");
|
||||
assert.deepEqual(moderationEvidence.observedCursor, { lastReviewedMessageId: "message-1", lastReviewedTimestamp: "2026-07-16T02:55:00.000Z" }, "moderation evidence returns the exact parent-owned cursor witness separately from prompt chronology");
|
||||
const moderationState = createEmptyVoidSelfState({ createdAt: "2026-07-15T00:00:00.000Z" });
|
||||
const appliedModerationOperations: string[] = [];
|
||||
let moderationModelCalls = 0;
|
||||
const moderationOrganDependencies = {
|
||||
loadState: async () => moderationState,
|
||||
readEvidence: async () => moderationEvidence,
|
||||
projectText: async () => {
|
||||
moderationModelCalls += 1;
|
||||
return JSON.stringify({ reviewedMessageIds: ["message-1"], urgentMessageIds: ["message-1"], operations: [{
|
||||
operation: "upsert_open_case",
|
||||
case: { sourceMessageId: "message-1", status: "pending", summary: "Concrete fixture evidence.", authorId: "human-1", createdAt: physiologyObservedAt.toISOString(), lastTouchedAt: physiologyObservedAt.toISOString(), tags: ["infringement:safety_threat", "moderation:instaban"] },
|
||||
}] });
|
||||
},
|
||||
applyOperation: async (_store: unknown, operation: { operation: string; run?: { runner: string; ranAt: string; summary: string }; case?: unknown; lastReviewedMessageId?: string; lastReviewedTimestamp?: string }) => {
|
||||
appliedModerationOperations.push(operation.operation);
|
||||
if (operation.operation === "record_scheduled_run" && operation.run) moderationState.scheduledRuntime.lastRuns.push(operation.run);
|
||||
if (operation.operation === "upsert_open_case" && operation.case) moderationState.moderationCursor.openCases.push(operation.case as never);
|
||||
if (operation.operation === "record_reviewed_messages") {
|
||||
moderationState.moderationCursor.lastReviewedMessageId = operation.lastReviewedMessageId;
|
||||
moderationState.moderationCursor.lastReviewedTimestamp = operation.lastReviewedTimestamp;
|
||||
}
|
||||
return moderationState;
|
||||
},
|
||||
};
|
||||
const moderated = await runVoidModerationHeartbeat({ statePath: "void-self-state.cc", rules: "Fixture rules", enforcementMode: "log_only", observedAt: physiologyObservedAt }, moderationOrganDependencies as never);
|
||||
assert.equal(moderated.status, "ok", "portable moderation coordinator completes one reviewed evidence window");
|
||||
assert.deepEqual(appliedModerationOperations, ["record_scheduled_run", "upsert_open_case", "record_reviewed_messages"], "moderation persists attempt and cases before the sole cursor commit");
|
||||
const repeatedModeration = await runVoidModerationHeartbeat({ statePath: "void-self-state.cc", rules: "Fixture rules", enforcementMode: "log_only", observedAt: physiologyObservedAt }, moderationOrganDependencies as never);
|
||||
assert.deepEqual(repeatedModeration, { status: "skipped", reason: "already_attempted_this_window", runnerId: "void-moderation-heartbeat-attempt:message-1" }, "the same evidence window cannot become a recurring inference loop");
|
||||
assert.equal(moderationModelCalls, 1, "moderation makes at most one model call per latest-message evidence window");
|
||||
let bannedUserId: string | undefined;
|
||||
const moderationEnforcement = await runVoidModerationEnforcement({ statePath: "void-self-state.cc", mode: "policy", botToken: "token", guildId: "guild", observedAt: physiologyObservedAt }, {
|
||||
loadState: async () => moderationState,
|
||||
banMember: async ({ userId }) => { bannedUserId = userId; },
|
||||
applyOperation: async (_store, operation) => {
|
||||
if (operation.operation === "close_open_case") {
|
||||
const existing = moderationState.moderationCursor.openCases.find((entry) => entry.sourceMessageId === operation.sourceMessageId);
|
||||
if (existing) Object.assign(existing, { status: operation.status, resolvedAt: operation.resolvedAt, resolutionSummary: operation.resolutionSummary });
|
||||
}
|
||||
return moderationState;
|
||||
},
|
||||
});
|
||||
assert.equal(bannedUserId, "human-1", "policy enforcement lowers an instaban through the injected Discord actuator");
|
||||
assert.equal(moderationEnforcement.actions[0]?.status, "instaban_applied", "successful sanction closes the typed case with an enforcement result");
|
||||
const repeatedEnforcement = await runVoidModerationEnforcement({ statePath: "void-self-state.cc", mode: "policy", botToken: "token", guildId: "guild", observedAt: physiologyObservedAt }, { loadState: async () => moderationState });
|
||||
assert.equal(repeatedEnforcement.evaluatedCaseCount, 0, "resolved moderation debt cannot be sanctioned twice on later cadences");
|
||||
const maintainedState = structuredClone(physiologyState);
|
||||
let memoryModelCalls = 0;
|
||||
const memoryMaintenanceDependencies = {
|
||||
|
|
@ -901,6 +955,16 @@ const memoryModelOutput = await projectVoidMemoryOperations({ prompt: "Maintain
|
|||
assert.equal(memoryModelOutput, "[]", "the portable memory actuator returns only model text to the operation parser");
|
||||
assert.equal(memoryRequestBody?.model, "memory-model", "the memory actuator uses one explicit configured model and bounded completion budget");
|
||||
assert.equal(memoryRequestBody?.tools, undefined, "memory maintenance cannot acquire filesystem or side-effect tools through the model actuator");
|
||||
let moderationRequestBody: Record<string, unknown> | undefined;
|
||||
const moderationModelOutput = await projectVoidModerationOperations({ prompt: "Review moderation evidence.", config: { openAiApi: { baseUrl: "https://model.invalid/v1", apiKey: "secret", model: "moderation-model", timeoutMs: 1000, authHeader: "Authorization", maxCompletionTokens: 512 } } as never }, {
|
||||
fetch: async (_url, init) => {
|
||||
moderationRequestBody = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: '{"reviewedMessageIds":[],"urgentMessageIds":[],"operations":[]}' } }] }), { status: 200 });
|
||||
},
|
||||
});
|
||||
assert.match(moderationModelOutput, /reviewedMessageIds/, "the portable moderation actuator returns only model text to the decision parser");
|
||||
assert.equal(moderationRequestBody?.model, "moderation-model", "the moderation actuator uses one explicit configured model and bounded completion budget");
|
||||
assert.equal(moderationRequestBody?.tools, undefined, "moderation inference cannot acquire filesystem or side-effect tools");
|
||||
const moderationLaunchCommand = buildVoidModerationLaunchCommand({
|
||||
pendingMentionsPath: "C:/Void's state/pending.json",
|
||||
runnerScript: "C:/VoidBot/scripts/run.ps1",
|
||||
|
|
|
|||
Loading…
Reference in a new issue