diff --git a/.env.example b/.env.example index cbf602b..2c45de6 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/persona-scheduler/src/bounded-text-completion-actuator.ts b/apps/persona-scheduler/src/bounded-text-completion-actuator.ts new file mode 100644 index 0000000..4ebb6aa --- /dev/null +++ b/apps/persona-scheduler/src/bounded-text-completion-actuator.ts @@ -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; + purpose: string; + model?: string; + maxCompletionTokens: number; +}, dependencies: { fetch?: typeof fetch; readText?: (path: string) => Promise } = {}): Promise { + 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; } diff --git a/apps/persona-scheduler/src/index.ts b/apps/persona-scheduler/src/index.ts index 746c694..d05c52e 100644 --- a/apps/persona-scheduler/src/index.ts +++ b/apps/persona-scheduler/src/index.ts @@ -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 | 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 { if (activeTick) { @@ -43,6 +49,30 @@ function runTick(): Promise { 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; }); diff --git a/apps/persona-scheduler/src/void-memory-text-actuator.ts b/apps/persona-scheduler/src/void-memory-text-actuator.ts index e3f4a33..c4421b5 100644 --- a/apps/persona-scheduler/src/void-memory-text-actuator.ts +++ b/apps/persona-scheduler/src/void-memory-text-actuator.ts @@ -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; model?: string; }, dependencies: { fetch?: typeof fetch; readText?: (path: string) => Promise } = {}): Promise { - 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; } diff --git a/apps/persona-scheduler/src/void-moderation-enforcement-organ.ts b/apps/persona-scheduler/src/void-moderation-enforcement-organ.ts new file mode 100644 index 0000000..2a4e847 --- /dev/null +++ b/apps/persona-scheduler/src/void-moderation-enforcement-organ.ts @@ -0,0 +1,86 @@ +import { applyVoidSelfStateOperation, loadVoidSelfStateTypedDocuments } from "@voidbot/core"; + +const STRIKE_EXPIRY_DAYS: Record = { + 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; +} = {}): Promise { + 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 { + 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 { + 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)}`); +} diff --git a/apps/persona-scheduler/src/void-moderation-heartbeat-organ.ts b/apps/persona-scheduler/src/void-moderation-heartbeat-organ.ts new file mode 100644 index 0000000..351777b --- /dev/null +++ b/apps/persona-scheduler/src/void-moderation-heartbeat-organ.ts @@ -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; + projectText: (prompt: string) => Promise; +}): Promise { + 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"), + }; +} diff --git a/apps/persona-scheduler/src/void-moderation-heartbeat-projector.ts b/apps/persona-scheduler/src/void-moderation-heartbeat-projector.ts index 4df19ea..94e8dfe 100644 --- a/apps/persona-scheduler/src/void-moderation-heartbeat-projector.ts +++ b/apps/persona-scheduler/src/void-moderation-heartbeat-projector.ts @@ -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: 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 { diff --git a/apps/persona-scheduler/src/void-moderation-text-actuator.ts b/apps/persona-scheduler/src/void-moderation-text-actuator.ts new file mode 100644 index 0000000..713ae9b --- /dev/null +++ b/apps/persona-scheduler/src/void-moderation-text-actuator.ts @@ -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; + model?: string; +}, dependencies: { fetch?: typeof fetch; readText?: (path: string) => Promise } = {}): Promise { + 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); +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 84c5830..06fb887 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -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)), diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index 6db5aad..9fd0bc6 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -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 | 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",