Split moderation heartbeat from Void rumination

This commit is contained in:
Meta Vens 2026-06-08 23:34:35 +01:00
commit a055cc286e
12 changed files with 843 additions and 75 deletions

View file

@ -0,0 +1,375 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { TextDecoder } from "node:util";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const POLICY = {
safety_threat: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
weaponized_intimidation: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
stalking_or_doxxing: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
sexual_boundary_violation: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
bigotry_identity_attack: { strikeExpiresDays: 180, instantBanTags: ["severity:instaban"], threeStrike: true },
bad_faith_argument: { strikeExpiresDays: 90, instantBanTags: ["severity:instaban"], threeStrike: true },
nsfw_channel_violation: { strikeExpiresDays: 30, instantBanTags: ["severity:instaban"], threeStrike: true },
spam_or_deceptive_promotion: { strikeExpiresDays: 30, instantBanTags: ["severity:instaban"], threeStrike: true },
moderator_obstruction: { strikeExpiresDays: 90, instantBanTags: ["severity:instaban"], threeStrike: true },
empty_words_noise: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
values_debate_escalation: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
pg13_language_violation: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
event_time_coordination: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
};
async function main() {
const options = parseArgs(process.argv.slice(2));
const mode = normalizeMode(options.mode ?? readLocalEnv().VOID_MODERATION_ENFORCEMENT_MODE ?? "log_only");
const operations = await readOperations(options.operationsFile);
const newCases = operations
.filter((operation) => operation?.operation === "upsert_open_case" && operation.case)
.map((operation) => operation.case);
if (newCases.length === 0) {
writeJson({ ok: true, mode, status: "no_new_cases", actions: [] });
return;
}
const state = await loadTypedState(options.stateFile);
const actions = [];
for (const moderationCase of newCases) {
const action = await evaluateCase({ moderationCase, state, mode, dryRun: options.dryRun });
if (action) {
actions.push(action);
}
}
writeJson({
ok: actions.every((action) => action.ok !== false),
mode,
status: actions.length > 0 ? "evaluated" : "no_policy_action",
actions,
});
}
function parseArgs(args) {
const options = {
stateFile: undefined,
operationsFile: undefined,
mode: undefined,
dryRun: false,
};
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
switch (argument) {
case "--state-file":
options.stateFile = args[index + 1];
index += 1;
break;
case "--operations-file":
options.operationsFile = args[index + 1];
index += 1;
break;
case "--mode":
options.mode = args[index + 1];
index += 1;
break;
case "--dry-run":
options.dryRun = true;
break;
default:
throw new Error(`Unknown argument: ${argument}`);
}
}
if (!options.stateFile) {
throw new Error("Provide --state-file.");
}
if (!options.operationsFile) {
throw new Error("Provide --operations-file.");
}
return options;
}
async function evaluateCase({ moderationCase, state, mode, dryRun }) {
const tags = new Set((moderationCase.tags ?? []).map((tag) => String(tag).toLowerCase()));
const infringementType = getTagValue(tags, "infringement:");
if (!infringementType || !POLICY[infringementType]) {
return {
ok: true,
status: "skipped",
reason: "missing_or_unknown_infringement_type",
sourceMessageId: moderationCase.sourceMessageId,
tags: [...tags],
};
}
if (!moderationCase.authorId) {
return {
ok: true,
status: "skipped",
reason: "missing_author_id",
sourceMessageId: moderationCase.sourceMessageId,
infringementType,
};
}
if (hasActionedTag(tags)) {
return {
ok: true,
status: "skipped",
reason: "already_actioned",
sourceMessageId: moderationCase.sourceMessageId,
infringementType,
};
}
const policy = POLICY[infringementType];
const isInstantBan = tags.has("moderation:instaban") ||
policy.instantBanTags.some((tag) => tags.has(tag));
const strikeCount = countActiveStrikes({
cases: state.moderationCursor?.openCases ?? [],
authorId: moderationCase.authorId,
infringementType,
expiresDays: policy.strikeExpiresDays,
now: new Date(),
});
const isThirdStrike = tags.has("moderation:strike") && policy.threeStrike && strikeCount >= 3;
if (isInstantBan || isThirdStrike) {
const reason = isInstantBan
? `VoidBot instaban: ${infringementType} at ${moderationCase.sourceMessageId}`
: `VoidBot three-strike ban: ${infringementType} strike ${strikeCount}/3 at ${moderationCase.sourceMessageId}`;
const moderationResult = await invokeModerationAction({
action: "ban",
userId: moderationCase.authorId,
reason,
mode,
dryRun,
});
await closeCase({
stateFile: state.__stateFile,
sourceMessageId: moderationCase.sourceMessageId,
status: "resolved",
resolutionSummary: `${isInstantBan ? "Instaban" : "Three-strike ban"} applied for ${infringementType}.`,
dryRun,
});
return {
ok: true,
status: isInstantBan ? "instaban_applied" : "three_strike_ban_applied",
sourceMessageId: moderationCase.sourceMessageId,
authorId: moderationCase.authorId,
infringementType,
strikeCount,
strikeExpiresDays: policy.strikeExpiresDays,
moderationResult,
};
}
if (tags.has("moderation:strike")) {
await closeCase({
stateFile: state.__stateFile,
sourceMessageId: moderationCase.sourceMessageId,
status: "resolved",
resolutionSummary: `Strike ${strikeCount}/3 recorded for ${infringementType}; expires after ${policy.strikeExpiresDays} days without another matching strike.`,
dryRun,
});
return {
ok: true,
status: "strike_recorded",
sourceMessageId: moderationCase.sourceMessageId,
authorId: moderationCase.authorId,
infringementType,
strikeCount,
strikeExpiresDays: policy.strikeExpiresDays,
};
}
return {
ok: true,
status: "case_recorded_no_sanction",
sourceMessageId: moderationCase.sourceMessageId,
authorId: moderationCase.authorId,
infringementType,
strikeCount,
strikeExpiresDays: policy.strikeExpiresDays,
};
}
function countActiveStrikes({ cases, authorId, infringementType, expiresDays, now }) {
const cutoffMs = now.getTime() - expiresDays * 24 * 60 * 60 * 1000;
return cases.filter((entry) => {
if (entry.authorId !== authorId) {
return false;
}
const tags = new Set((entry.tags ?? []).map((tag) => String(tag).toLowerCase()));
if (!tags.has(`infringement:${infringementType}`) || !tags.has("moderation:strike")) {
return false;
}
const timestampMs = Date.parse(entry.createdAt ?? entry.lastTouchedAt ?? "");
return Number.isFinite(timestampMs) && timestampMs >= cutoffMs;
}).length;
}
async function invokeModerationAction({ action, userId, reason, mode, dryRun }) {
if (["off", "disabled", "log_only", "log-only", "case_only", "case-only"].includes(mode)) {
return { ok: true, status: "skipped", reason: "enforcement_mode_non_destructive", action, userId };
}
if (mode === "notify_owner") {
return { ok: true, status: "skipped", reason: "notify_owner_does_not_apply_discord_sanctions", action, userId };
}
if (!["policy", "enforce_policy", "enforce-policy", "ban"].includes(mode)) {
return { ok: true, status: "skipped", reason: `mode_${mode}_does_not_apply_policy_bans`, action, userId };
}
const args = [
resolve(repoRoot, "scripts", "moderate-discord-user.mjs"),
"--action", action,
"--user-id", userId,
"--reason", reason,
];
if (dryRun) {
args.push("--dry-run");
}
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Discord moderation action failed: ${result.stderr || result.stdout}`);
}
return JSON.parse(result.stdout);
}
async function closeCase({ stateFile, sourceMessageId, status, resolutionSummary, dryRun }) {
if (dryRun) {
return;
}
const tempRoot = mkdtempSync(resolve(tmpdir(), "void-policy-close-"));
const operationPath = resolve(tempRoot, "operation.json");
try {
writeFileSync(operationPath, `${JSON.stringify({
operation: "close_open_case",
sourceMessageId,
status,
resolvedAt: new Date().toISOString(),
resolutionSummary,
}, null, 2)}\n`, "utf8");
const result = spawnSync(process.execPath, [
resolve(repoRoot, "scripts", "void-self-state.mjs"),
"apply-operation",
"--canonical", stateFile,
"--operation-file", operationPath,
], {
cwd: repoRoot,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Failed to close moderation case: ${result.stderr || result.stdout}`);
}
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
}
function normalizeMode(value) {
return String(value).trim().toLowerCase();
}
function getTagValue(tags, prefix) {
for (const tag of tags) {
if (tag.startsWith(prefix)) {
return tag.slice(prefix.length);
}
}
return undefined;
}
function hasActionedTag(tags) {
for (const tag of tags) {
if (tag.startsWith("moderation:actioned")) {
return true;
}
}
return false;
}
async function readOperations(path) {
const raw = await readFile(path, "utf8");
if (!raw.trim()) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
}
async function loadTypedState(stateFile) {
const script = `
const core = require('./packages/core/dist');
core.loadVoidSelfStateTypedDocuments({ canonicalPath: process.argv[1] })
.then((state) => console.log(JSON.stringify(state)))
.catch((error) => { console.error(error); process.exit(1); });
`;
const result = spawnSync(process.execPath, ["-e", script, stateFile], {
cwd: repoRoot,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Failed to load typed state: ${result.stderr || result.stdout}`);
}
return { ...JSON.parse(result.stdout), __stateFile: stateFile };
}
function readLocalEnv() {
const envPath = resolve(repoRoot, ".env");
const parsed = { ...process.env };
try {
const raw = stripLeadingBom(readTextFileFlexible(envPath));
Object.assign(parsed, parseDotEnv(raw));
} catch (error) {
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
throw error;
}
}
return parsed;
}
function parseDotEnv(raw) {
const result = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) {
continue;
}
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) {
continue;
}
result[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim();
}
return result;
}
function readTextFileFlexible(path) {
const buffer = readFileSync(path);
if (buffer.length >= 2) {
if (buffer[0] === 0xff && buffer[1] === 0xfe) {
return new TextDecoder("utf-16le").decode(buffer);
}
if (buffer[0] === 0xfe && buffer[1] === 0xff) {
return new TextDecoder("utf-16be").decode(buffer);
}
}
return new TextDecoder("utf-8").decode(buffer);
}
function stripLeadingBom(value) {
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
}
function writeJson(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
await main();

View file

@ -311,6 +311,15 @@ try {
Executable = $powershell
Arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "run-void-mood-drift.ps1"))
},
[pscustomobject]@{
Id = "void-moderation-heartbeat"
Label = "Void rules moderation heartbeat"
IntervalMinutes = Get-ConfigInt -Config $config -Name "VOIDBOT_MODERATION_HEARTBEAT_INTERVAL_MINUTES" -Default 1 -Minimum 1
TimeoutMinutes = 10
Cwd = $repoRoot
Executable = $powershell
Arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "run-void-moderator-rumination.ps1"), "-ModerationHeartbeatOnly")
},
[pscustomobject]@{
Id = "void-moderation-rumination"
Label = "Void moderation rumination"

View file

@ -1,7 +1,8 @@
param(
[string] $StateFilePath,
[switch] $NoPost,
[switch] $SkipModel
[switch] $SkipModel,
[switch] $ModerationHeartbeatOnly
)
Set-StrictMode -Version Latest
@ -26,24 +27,30 @@ $logDir = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_LOG_DIR)) {
} else {
Join-Path $repoRoot ".voidbot\logs"
}
$statusPath = Join-Path $statusDir "moderation-rumination.json"
$summaryLogPath = Join-Path $logDir "moderation-rumination.log"
$tracePath = Join-Path $logDir "moderation-rumination-last.jsonl"
$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"
$runSlug = if ($ModerationHeartbeatOnly) { "moderation-heartbeat" } else { "moderation-rumination" }
$statusPath = Join-Path $statusDir "$runSlug.json"
$summaryLogPath = Join-Path $logDir "$runSlug.log"
$tracePath = Join-Path $logDir "$runSlug-last.jsonl"
$lastMessagePath = Join-Path $statusDir "$runSlug-last-message.txt"
$operationOutputPath = Join-Path $statusDir "$runSlug-operations.json"
$contextPath = Join-Path $statusDir "$runSlug-context.json"
$lockPath = Join-Path $statusDir "$runSlug.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"
$promptTemplatePath = if ($ModerationHeartbeatOnly) {
Join-Path $repoRoot "prompts\void-moderation-heartbeat.md"
} else {
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"
$repoActivityScriptPath = Join-Path $repoRoot "scripts\export-recent-repo-activity.mjs"
$selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs"
$moderationActionScriptPath = Join-Path $repoRoot "scripts\moderate-discord-user.mjs"
$moderationPolicyScriptPath = Join-Path $repoRoot "scripts\enforce-discord-moderation-policy.mjs"
$sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DISCORD_SCRIPT)) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_SEND_DISCORD_SCRIPT)
} else {
@ -523,11 +530,11 @@ function Invoke-UrgentModerationEnforcement {
}
$normalizedMode = if ([string]::IsNullOrWhiteSpace($Mode)) { "log_only" } else { $Mode.Trim().ToLowerInvariant() }
if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only")) {
if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only", "policy", "enforce_policy", "enforce-policy", "ban")) {
return @{
mode = $normalizedMode
status = "skipped"
reason = "enforcement_mode_non_destructive"
reason = if ($normalizedMode -in @("policy", "enforce_policy", "enforce-policy", "ban")) { "policy_enforcement_owned_by_moderation_heartbeat" } else { "enforcement_mode_non_destructive" }
witnessCount = [int]$Witnesses.Count
}
}
@ -582,7 +589,70 @@ function Invoke-UrgentModerationEnforcement {
}
}
throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, or timeout."
throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, timeout, or policy."
}
function Assert-ModerationHeartbeatOperations {
param(
[object[]] $Operations,
$TypedState
)
foreach ($operation in $Operations) {
$operationName = Get-ObjectPropertyString -Value $operation -Name "operation"
if ($null -eq $operationName) {
continue
}
if ($operationName -notin @("upsert_open_case", "close_open_case")) {
throw "Moderation heartbeat may only emit upsert_open_case or close_open_case operations; got '$operationName'."
}
if ($operationName -eq "upsert_open_case") {
$case = Get-ObjectPropertyValue -Value $operation -Name "case"
$sourceMessageId = Get-ObjectPropertyString -Value $case -Name "sourceMessageId"
$priorCase = @(
@(Convert-ToValueArray -Value $TypedState.moderationCursor.openCases) |
Where-Object { (Get-ObjectPropertyString -Value $_ -Name "sourceMessageId") -eq $sourceMessageId }
) | Select-Object -First 1
if ($null -ne $priorCase) {
$priorStatus = Get-ObjectPropertyString -Value $priorCase -Name "status"
$priorResolution = Get-ObjectPropertyString -Value $priorCase -Name "resolutionSummary"
if ($priorStatus -in @("answered", "resolved", "closed", "retired", "dropped") -and $priorResolution -match "(?i)(instaban|three-strike ban|strike \d/3 recorded|ban applied)") {
throw "Moderation heartbeat cannot reopen already-actioned case '$sourceMessageId'."
}
}
$tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $case -Name "tags")) | ForEach-Object { ([string]$_).ToLowerInvariant() }
$infringementTags = @($tags | Where-Object { $_.StartsWith("infringement:") })
$moderationTags = @($tags | Where-Object { $_ -in @("moderation:instaban", "moderation:strike", "moderation:case_only") })
if ($infringementTags.Count -ne 1) {
throw "Moderation heartbeat open cases must include exactly one infringement:<type> tag."
}
if ($moderationTags.Count -ne 1) {
throw "Moderation heartbeat open cases must include exactly one moderation:instaban, moderation:strike, or moderation:case_only tag."
}
}
}
}
function Invoke-ModerationPolicyEnforcement {
param(
[string] $Mode,
[switch] $DryRun
)
$arguments = @(
$moderationPolicyScriptPath,
"--state-file", $stateFilePath,
"--operations-file", $operationOutputPath,
"--mode", $Mode
)
if ($DryRun) {
$arguments += "--dry-run"
}
$output = & node @arguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Moderation policy enforcement failed: $($output | Out-String)"
}
return $output | ConvertFrom-Json
}
function Get-TypedSelfState {
@ -1206,7 +1276,7 @@ if (Test-Path $operationOutputPath) {
Remove-Item -LiteralPath $operationOutputPath -Force
}
foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $sendMessageScriptPath)) {
foreach ($requiredPath in @($promptTemplatePath, $contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $moderationPolicyScriptPath, $sendMessageScriptPath)) {
if (-not (Test-Path $requiredPath)) {
throw "Missing required helper at $requiredPath"
}
@ -1262,6 +1332,7 @@ $moderationTimeoutMinutes = Get-ConfigValue -Name "VOID_MODERATION_TIMEOUT_MINUT
if ([string]::IsNullOrWhiteSpace($moderationTimeoutMinutes)) {
$moderationTimeoutMinutes = "60"
}
$moderationPolicyDryRun = -not [string]::IsNullOrWhiteSpace($env:VOID_MODERATION_POLICY_DRY_RUN)
$typedContext = Get-TypedSelfState
$typedState = $typedContext.typedState
@ -1295,54 +1366,79 @@ if ($messageCount -gt 0) {
}
$repoActivity = $null
try {
$repoActivity = Invoke-NodeJson -Arguments @(
$repoActivityScriptPath,
"--hours", "96",
"--max-commits", "3",
"--read-only",
"--state-path", $stateFilePath
)
} catch {
if ($ModerationHeartbeatOnly) {
$repoActivity = @{
status = "failed"
error = $_.Exception.Message
status = "skipped"
reason = "moderation_heartbeat_only"
}
} else {
try {
$repoActivity = Invoke-NodeJson -Arguments @(
$repoActivityScriptPath,
"--hours", "96",
"--max-commits", "3",
"--read-only",
"--state-path", $stateFilePath
)
} catch {
$repoActivity = @{
status = "failed"
error = $_.Exception.Message
}
}
}
$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)
if (-not $ModerationHeartbeatOnly) {
$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
noPost = [bool]$NoPost
selfStateSummary = $typedContext.summary
chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping."
openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc)
speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc)
memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc)
shortTermMemories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.shortTerm -Now $startedAtUtc)
incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc)
agencyPressure = @(Project-AgencyPressureForRumination -Pressures $typedState.agencyPressure.pressures -Now $startedAtUtc)
speechPressureObligations = $speechPressureObligations
urgentModerationWitnesses = $urgentModerationWitnesses
pendingMentions = $pendingMentions
recentConversationTarget = Project-RecentConversationTargetForRumination -History $history -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
publicSpeechTarget = Project-PublicSpeechTargetForRumination -ChannelId $publicRoomChannelId -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
deliverableCandidateCount = [int]$deliverableCandidates.Count
candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc)
scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc
priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc
observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc
recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc
repoActivity = Select-RuminationRepoActivity -RepoActivity $repoActivity -Now $startedAtUtc
if ($ModerationHeartbeatOnly) {
Write-JsonFile -Path $contextPath -Data @{
generated = "now"
mode = "moderation_heartbeat"
stateFile = $stateFilePath
noPost = [bool]$NoPost
chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping."
openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc)
urgentModerationWitnesses = $urgentModerationWitnesses
priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc
observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc
recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc
enforcementMode = $moderationEnforcementMode
}
} else {
Write-JsonFile -Path $contextPath -Data @{
generated = "now"
stateFile = $stateFilePath
noPost = [bool]$NoPost
selfStateSummary = $typedContext.summary
chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping."
openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc)
speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc)
memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc)
shortTermMemories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.shortTerm -Now $startedAtUtc)
incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc)
agencyPressure = @(Project-AgencyPressureForRumination -Pressures $typedState.agencyPressure.pressures -Now $startedAtUtc)
speechPressureObligations = $speechPressureObligations
urgentModerationWitnesses = $urgentModerationWitnesses
pendingMentions = $pendingMentions
recentConversationTarget = Project-RecentConversationTargetForRumination -History $history -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
publicSpeechTarget = Project-PublicSpeechTargetForRumination -ChannelId $publicRoomChannelId -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
deliverableCandidateCount = [int]$deliverableCandidates.Count
candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc)
scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc
priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc
observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc
recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc
repoActivity = Select-RuminationRepoActivity -RepoActivity $repoActivity -Now $startedAtUtc
}
}
$isNapping = [bool](Get-ObjectPropertyValue -Value $typedState.scheduledRuntime.sleepCycle -Name "isNapping")
@ -1350,7 +1446,7 @@ $openCaseCount = @(
@(Convert-ToValueArray -Value $typedState.moderationCursor.openCases) |
Where-Object { Test-OpenCaseRequiresRumination -Case $_ }
).Count
if ($isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0) {
if (-not $ModerationHeartbeatOnly -and $isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0) {
Append-RunLog "napping: no new room messages or open cases; skipping awake rumination."
Write-JsonFile -Path $operationOutputPath -Data @()
$finishedAtUtc = [DateTime]::UtcNow
@ -1446,7 +1542,11 @@ $appliedOperations = @()
$moderationEnforcementResult = $null
Assert-UrgentModerationWitnessesHandled -Witnesses $urgentModerationWitnesses -Operations $proposedOperations -TypedState $typedState
Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations
if ($ModerationHeartbeatOnly) {
Assert-ModerationHeartbeatOperations -Operations $proposedOperations -TypedState $typedState
} else {
Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations
}
Append-RunLog ("applying proposed operations: {0}" -f @($proposedOperations).Count)
foreach ($operation in $proposedOperations) {
@ -1458,7 +1558,11 @@ foreach ($operation in $proposedOperations) {
$appliedOperations += Apply-TypedOperation -Operation $operation
}
if (-not $NoPost) {
if ($ModerationHeartbeatOnly) {
$moderationEnforcementResult = Invoke-ModerationPolicyEnforcement `
-Mode $moderationEnforcementMode `
-DryRun:([bool]$moderationPolicyDryRun)
} elseif (-not $NoPost) {
$moderationEnforcementResult = Invoke-UrgentModerationEnforcement `
-Witnesses $urgentModerationWitnesses `
-Mode $moderationEnforcementMode `
@ -1482,7 +1586,7 @@ if (
$repoCursorOperations = @()
$disableRepoCursorAdvance = -not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE)
if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
if (-not $ModerationHeartbeatOnly -and -not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
$repoCursorOperations = @(Get-RepoActivityCursorOperations -RepoActivity $repoActivity -ObservedAt ([DateTime]::UtcNow))
if ($repoCursorOperations.Count -gt 0) {
Append-RunLog ("recording repo activity cursors: {0}" -f $repoCursorOperations.Count)
@ -1493,7 +1597,7 @@ if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
}
$deliveredCandidateCount = 0
if (-not $NoPost) {
if (-not $ModerationHeartbeatOnly -and -not $NoPost) {
$refreshedTypedContext = Get-TypedSelfState
$alreadyAnsweredCandidateOperations = @(Get-AlreadyAnsweredCandidateRetireOperations -TypedState $refreshedTypedContext.typedState -RetiredAt ([DateTime]::UtcNow))
if ($alreadyAnsweredCandidateOperations.Count -gt 0) {
@ -1523,7 +1627,7 @@ $finishedAtUtc = [DateTime]::UtcNow
Append-RunLog "final status timestamp captured."
$lastMessageSummary = @(
"Void moderation rumination finished."
"mode=" + $(if ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" })
"mode=" + $(if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } elseif ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" })
"proposed=" + [string]@($proposedOperations).Count
"applied=" + [string]@($appliedOperations).Count
"deliveredCandidates=" + [string]$deliveredCandidateCount
@ -1535,7 +1639,7 @@ Append-RunLog "final status last message loaded."
$finalStatus = [ordered]@{
status = "ok"
mode = if ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" }
mode = if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } elseif ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" }
startedAt = $startedAtUtc.ToString("o")
finishedAt = $finishedAtUtc.ToString("o")
durationSeconds = [Math]::Round(($finishedAtUtc - $startedAtUtc).TotalSeconds, 2)
@ -1560,5 +1664,5 @@ $finalStatus = [ordered]@{
}
Write-JsonFile -Path $statusPath -Data $finalStatus
Append-RunLog ("mode=typed_rumination messages={0} proposed={1} applied={2} cursor={3}" -f $messageCount, @($proposedOperations).Count, @($appliedOperations).Count, $observedCursorTimestamp)
Append-RunLog ("mode={0} messages={1} proposed={2} applied={3} cursor={4}" -f $(if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } else { "typed_rumination" }), $messageCount, @($proposedOperations).Count, @($appliedOperations).Count, $observedCursorTimestamp)
Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue

View file

@ -0,0 +1,163 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $repoRoot
$tempRoot = Join-Path $env:TEMP ("void-moderation-heartbeat-policy-fixture-" + [guid]::NewGuid().ToString("n"))
$fakeCodexPath = Join-Path $tempRoot "fake-codex-heartbeat-policy.mjs"
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
function Set-TestEnvironment {
param(
[Parameter(Mandatory = $true)][string] $OperationOutputPath,
[Parameter(Mandatory = $true)][string] $StatusDir,
[Parameter(Mandatory = $true)][string] $LogDir
)
$script:previousCodexExecutable = $env:CODEX_EXECUTABLE
$script:previousCodexExecArgs = $env:CODEX_EXEC_ARGS
$script:previousFixtureOutput = $env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT
$script:previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR
$script:previousLogDir = $env:VOID_RUMINATION_LOG_DIR
$script:previousPolicyDryRun = $env:VOID_MODERATION_POLICY_DRY_RUN
$script:previousPolicyMode = $env:VOID_MODERATION_ENFORCEMENT_MODE
$env:CODEX_EXECUTABLE = "node"
$env:CODEX_EXEC_ARGS = $fakeCodexPath
$env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT = $OperationOutputPath
$env:VOID_RUMINATION_STATUS_DIR = $StatusDir
$env:VOID_RUMINATION_LOG_DIR = $LogDir
$env:VOID_MODERATION_POLICY_DRY_RUN = "1"
$env:VOID_MODERATION_ENFORCEMENT_MODE = "policy"
}
function Restore-TestEnvironment {
$env:CODEX_EXECUTABLE = $script:previousCodexExecutable
$env:CODEX_EXEC_ARGS = $script:previousCodexExecArgs
$env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT = $script:previousFixtureOutput
$env:VOID_RUMINATION_STATUS_DIR = $script:previousStatusDir
$env:VOID_RUMINATION_LOG_DIR = $script:previousLogDir
$env:VOID_MODERATION_POLICY_DRY_RUN = $script:previousPolicyDryRun
$env:VOID_MODERATION_ENFORCEMENT_MODE = $script:previousPolicyMode
}
function New-SeededState {
param([Parameter(Mandatory = $true)][string] $StateFilePath)
$cursorOperationPath = Join-Path (Split-Path -Parent $StateFilePath) "cursor.json"
@{
operation = "record_reviewed_messages"
lastReviewedMessageId = "fixture-before-lobo-threat"
lastReviewedTimestamp = "2026-06-08T12:00:00.000Z"
} | 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
}
try {
$fakeCodexSource = @'
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const operationOutputPath = resolve(process.env.VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT);
const lastMessageIndex = process.argv.indexOf("-o");
const lastMessagePath = lastMessageIndex >= 0 ? process.argv[lastMessageIndex + 1] : undefined;
let prompt = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
prompt += chunk;
});
process.stdin.on("end", () => {
for (const required of ["Void Moderation Heartbeat", "config/discord-server-rules.md", "infringement:<type>"]) {
if (!prompt.includes(required)) {
console.error(`heartbeat prompt missing ${required}`);
process.exit(2);
}
}
for (const forbidden of ["repoActivity", "shortTermMemories", "candidateInterventions", "styles/void-default.md"]) {
if (prompt.includes(forbidden)) {
console.error(`heartbeat prompt included forbidden rumination surface ${forbidden}`);
process.exit(3);
}
}
const operations = [{
operation: "upsert_open_case",
case: {
sourceMessageId: "1513515503329480734",
status: "pending",
summary: "Lobo declared war on Metacrat and framed the conflict as a katana fight in #general.",
authorId: "1511062277254545499",
authorName: "Lobo",
channelId: "113786069023064068",
messageUrl: "https://discord.com/channels/113786069023064068/113786069023064068/1513515503329480734",
whyItMatters: "The policy treats credible threats and weaponized declarations of violent conflict as instant-ban safety violations.",
createdAt: "2026-06-08T12:10:15.752Z",
lastTouchedAt: "2026-06-08T12:10:15.752Z",
tags: [
"safety:urgent",
"infringement:safety_threat",
"moderation:instaban",
"fixture:lobo-threat"
]
}
}];
mkdirSync(dirname(operationOutputPath), { recursive: true });
writeFileSync(operationOutputPath, `${JSON.stringify(operations, null, 2)}\n`, "utf8");
if (lastMessagePath) {
mkdirSync(dirname(resolve(lastMessagePath)), { recursive: true });
writeFileSync(lastMessagePath, "fixture heartbeat policy\n", "utf8");
}
process.stdout.write(JSON.stringify({ type: "fixture", operationCount: operations.length }) + "\n");
});
'@
[System.IO.File]::WriteAllText($fakeCodexPath, $fakeCodexSource, [System.Text.UTF8Encoding]::new($false))
$fixtureRoot = Join-Path $tempRoot "handled"
$stateFilePath = Join-Path $fixtureRoot "void-self-state.cc"
$statusDir = Join-Path $fixtureRoot "status"
$logDir = Join-Path $fixtureRoot "logs"
New-Item -ItemType Directory -Force -Path $fixtureRoot | Out-Null
New-SeededState -StateFilePath $stateFilePath
try {
Set-TestEnvironment -OperationOutputPath (Join-Path $statusDir "moderation-heartbeat-operations.json") -StatusDir $statusDir -LogDir $logDir
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath -ModerationHeartbeatOnly -NoPost | Out-Null
} finally {
Restore-TestEnvironment
}
$status = Get-Content -LiteralPath (Join-Path $statusDir "moderation-heartbeat.json") -Raw -Encoding UTF8 | ConvertFrom-Json
if ($status.status -ne "ok" -or $status.mode -ne "moderation_heartbeat") {
throw "Heartbeat policy fixture did not finish in moderation_heartbeat mode."
}
$action = @($status.moderationEnforcement.actions | Where-Object { $_.status -eq "instaban_applied" }) | Select-Object -First 1
if ($null -eq $action -or $action.moderationResult.dryRun -ne $true -or $action.moderationResult.action -ne "ban") {
throw "Heartbeat policy fixture did not produce a dry-run instaban action."
}
$stateJson = node -e "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1]}).then((state)=>console.log(JSON.stringify(state))).catch((error)=>{ console.error(error); process.exit(1); })" $stateFilePath
$state = $stateJson | ConvertFrom-Json
$case = @($state.moderationCursor.openCases | Where-Object { $_.sourceMessageId -eq "1513515503329480734" }) | Select-Object -First 1
if ($null -eq $case -or -not @($case.tags).Contains("infringement:safety_threat")) {
throw "Heartbeat policy fixture did not persist the tagged infringement case."
}
@{
status = "ok"
mode = $status.mode
enforcementStatus = $action.status
action = $action.moderationResult.action
dryRun = $action.moderationResult.dryRun
} | ConvertTo-Json -Compress
} finally {
Restore-TestEnvironment
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}