Track moderation status through Bifrost transport

This commit is contained in:
Meta Vens 2026-06-04 20:28:55 +01:00
commit 6ffafc5cde
17 changed files with 940 additions and 32 deletions

View file

@ -223,6 +223,65 @@ function Project-OpenCasesForRumination {
)
}
function Project-ModerationUserStatusesForRumination {
param($Statuses, [DateTime] $Now)
return @(
@(Convert-ToValueArray -Value $Statuses) |
Where-Object {
(Get-ObjectPropertyString -Value $_ -Name "status") -ne "retired" -or
@(
@(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "pendingNotices")) |
Where-Object { [string]::IsNullOrWhiteSpace((Get-ObjectPropertyString -Value $_ -Name "sentAt")) }
).Count -gt 0
} |
ForEach-Object {
$strikes = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "strikes"))
$activeStrikes = @(
$strikes | Where-Object { [string]::IsNullOrWhiteSpace((Get-ObjectPropertyString -Value $_ -Name "expiredAt")) }
)
$pendingNotices = @(
@(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "pendingNotices")) |
Where-Object { [string]::IsNullOrWhiteSpace((Get-ObjectPropertyString -Value $_ -Name "sentAt")) }
)
@{
userId = Get-ObjectPropertyString -Value $_ -Name "userId"
userName = Get-ObjectPropertyString -Value $_ -Name "userName"
status = Get-ObjectPropertyString -Value $_ -Name "status"
summary = Get-ObjectPropertyString -Value $_ -Name "summary"
activeStrikeCount = [int]$activeStrikes.Count
pendingNoticeCount = [int]$pendingNotices.Count
activeStrikes = @(
$activeStrikes | ForEach-Object {
@{
strikeId = Get-ObjectPropertyString -Value $_ -Name "strikeId"
reason = Get-ObjectPropertyString -Value $_ -Name "reason"
ruleRef = Get-ObjectPropertyString -Value $_ -Name "ruleRef"
sourceMessageId = Get-ObjectPropertyString -Value $_ -Name "sourceMessageId"
channelId = Get-ObjectPropertyString -Value $_ -Name "channelId"
issued = Project-RelativeTimestamp -Value $_ -Name "issuedAt" -Now $Now
expires = Project-RelativeTimestamp -Value $_ -Name "expiresAt" -Now $Now
tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "tags"))
}
}
)
pendingNotices = @(
$pendingNotices | ForEach-Object {
@{
noticeId = Get-ObjectPropertyString -Value $_ -Name "noticeId"
kind = Get-ObjectPropertyString -Value $_ -Name "kind"
summary = Get-ObjectPropertyString -Value $_ -Name "summary"
created = Project-RelativeTimestamp -Value $_ -Name "createdAt" -Now $Now
tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "tags"))
}
}
)
tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "tags"))
}
}
)
}
function Test-RuminationCaseTerminal {
param([string] $Status)

View file

@ -48,6 +48,23 @@ $sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DI
} else {
Join-Path $repoRoot "scripts\send-discord-message.mjs"
}
$bifrostRoot = if (-not [string]::IsNullOrWhiteSpace($env:BIFROST_ROOT)) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:BIFROST_ROOT)
} else {
"E:\Projects\Bifrost"
}
$bifrostBridgeScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_BIFROST_BRIDGE_SCRIPT)) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_BIFROST_BRIDGE_SCRIPT)
} else {
Join-Path $bifrostRoot "tools\bifrost-bridge.mjs"
}
$discordTransportMode = if (-not [string]::IsNullOrWhiteSpace($env:VOID_DISCORD_TRANSPORT)) {
$env:VOID_DISCORD_TRANSPORT.Trim().ToLowerInvariant()
} elseif (Test-Path $bifrostBridgeScriptPath) {
"bifrost"
} else {
"direct"
}
$startedAtUtc = [DateTime]::UtcNow
. $contextProjectionScriptPath
@ -611,7 +628,8 @@ function Assert-AllowedRuminationOperation {
"retire_status_read",
"update_mood_dimensions",
"queue_candidate_intervention",
"retire_candidate_intervention"
"retire_candidate_intervention",
"upsert_moderation_user_status"
)
if ($null -eq $operationName -or -not $allowed.Contains($operationName)) {
@ -726,21 +744,37 @@ function Invoke-CandidateInterventionDeliveryFromIntervention {
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($contentPath, $draft, $utf8NoBom)
$arguments = @($sendMessageScriptPath, "--content-file", $contentPath)
$mode = Get-ObjectPropertyString -Value $deliveryTarget -Name "mode"
if ($mode -eq "owner_dm") {
$arguments += "--owner-dm"
} else {
$arguments = @()
if ($discordTransportMode -eq "bifrost") {
if ($mode -eq "owner_dm") {
throw "Candidate intervention '$interventionId' targets owner_dm. Bifrost is the default public transport; use VOID_DISCORD_TRANSPORT=direct only for standalone owner-private installs."
}
$channelId = Get-ObjectPropertyString -Value $deliveryTarget -Name "channelId"
if ([string]::IsNullOrWhiteSpace($channelId)) {
throw "Candidate intervention '$interventionId' has deliveryTarget without channelId."
}
$arguments += @("--channel-id", $channelId)
$arguments = @($bifrostBridgeScriptPath, "discord-post", "--channel-id", $channelId, "--content-file", $contentPath)
} else {
$arguments = @($sendMessageScriptPath, "--content-file", $contentPath)
if ($mode -eq "owner_dm") {
$arguments += "--owner-dm"
} else {
$channelId = Get-ObjectPropertyString -Value $deliveryTarget -Name "channelId"
if ([string]::IsNullOrWhiteSpace($channelId)) {
throw "Candidate intervention '$interventionId' has deliveryTarget without channelId."
}
$arguments += @("--channel-id", $channelId)
}
}
$replyToMessageId = Get-ObjectPropertyString -Value $deliveryTarget -Name "replyToMessageId"
if (-not [string]::IsNullOrWhiteSpace($replyToMessageId)) {
$arguments += @("--reply-to", $replyToMessageId)
if ($discordTransportMode -eq "bifrost") {
$arguments += @("--reply-to-message-id", $replyToMessageId)
} else {
$arguments += @("--reply-to", $replyToMessageId)
}
}
$personaName = Get-ObjectPropertyString -Value $deliveryTarget -Name "personaName"
@ -770,15 +804,155 @@ function Invoke-CandidateInterventionDeliveryFromIntervention {
Remove-Item -LiteralPath $contentPath -Force -ErrorAction SilentlyContinue
}
if ($discordTransportMode -eq "bifrost") {
$bridgeReceiptText = [string]($sendOutput -join [Environment]::NewLine)
$speech = Convert-BifrostBridgeReceiptToSpeechStatus -Receipt ($bridgeReceiptText | ConvertFrom-Json) -Preview $draft
return Convert-LastSpeechToSpokenCandidateOperation -InterventionId $interventionId -Speech $speech
}
$lastSpeechPath = Join-Path $statusDir "void-last-speech.json"
if (-not (Test-Path $lastSpeechPath)) {
throw "Candidate intervention delivery did not write last speech status."
}
$speech = Read-JsonFile -Path $lastSpeechPath
return Convert-LastSpeechToSpokenCandidateOperation -InterventionId $interventionId -Speech $speech
}
function Convert-BifrostBridgeReceiptToSpeechStatus {
param(
$Receipt,
[string] $Preview
)
if ($null -eq $Receipt) {
return $null
}
return [pscustomobject]@{
sentAt = [DateTime]::UtcNow.ToString("o")
mode = Get-ObjectPropertyString -Value $Receipt -Name "action"
transport = Get-ObjectPropertyString -Value $Receipt -Name "transport"
channelId = Get-ObjectPropertyString -Value $Receipt -Name "channelId"
messageId = Get-ObjectPropertyString -Value $Receipt -Name "messageId"
recipientId = Get-ObjectPropertyString -Value $Receipt -Name "recipientId"
preview = if ([string]::IsNullOrWhiteSpace($Preview)) { $null } else { $Preview.Substring(0, [Math]::Min(280, $Preview.Length)) }
}
}
function Get-PendingModerationStatusNotices {
param($TypedState)
$statuses = @(Convert-ToValueArray -Value $TypedState.moderationCursor.userStatuses)
return @(
$statuses | ForEach-Object {
$status = $_
$userId = Get-ObjectPropertyString -Value $status -Name "userId"
if ([string]::IsNullOrWhiteSpace($userId)) {
return
}
@(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $status -Name "pendingNotices")) |
Where-Object { [string]::IsNullOrWhiteSpace((Get-ObjectPropertyString -Value $_ -Name "sentAt")) } |
ForEach-Object {
[pscustomobject]@{
userId = $userId
userName = Get-ObjectPropertyString -Value $status -Name "userName"
notice = $_
}
}
}
)
}
function Convert-LastSpeechToModerationStatusNoticeSentOperation {
param(
[Parameter(Mandatory = $true)]
[string] $UserId,
[Parameter(Mandatory = $true)]
[string] $NoticeId,
$Speech
)
$sentAt = Get-ObjectPropertyString -Value $Speech -Name "sentAt"
if ($null -eq $Speech -or [string]::IsNullOrWhiteSpace($sentAt)) {
return $null
}
$operation = @{
operation = "mark_moderation_status_notice_sent"
userId = $UserId
noticeId = $NoticeId
sentAt = $sentAt
}
foreach ($key in @("channelId", "messageId")) {
$propertyValue = Get-ObjectPropertyString -Value $Speech -Name $key
if ($null -ne $propertyValue) {
$operation[$key] = $propertyValue
}
}
return $operation
}
function Invoke-ModerationStatusNoticeDelivery {
param(
[Parameter(Mandatory = $true)]
$PendingNotice
)
$userId = Get-ObjectPropertyString -Value $PendingNotice -Name "userId"
$notice = Get-ObjectPropertyValue -Value $PendingNotice -Name "notice"
$noticeId = Get-ObjectPropertyString -Value $notice -Name "noticeId"
$body = Get-ObjectPropertyString -Value $notice -Name "body"
if (
[string]::IsNullOrWhiteSpace($userId) -or
[string]::IsNullOrWhiteSpace($noticeId) -or
[string]::IsNullOrWhiteSpace($body)
) {
return $null
}
$contentPath = Join-Path $statusDir ("moderation-status-dm-{0}.txt" -f ([Guid]::NewGuid().ToString("n")))
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($contentPath, $body, $utf8NoBom)
$arguments = if ($discordTransportMode -eq "bifrost") {
@($bifrostBridgeScriptPath, "discord-dm", "--recipient-id", $userId, "--content-file", $contentPath)
} else {
@($sendMessageScriptPath, "--content-file", $contentPath, "--user-dm", $userId)
}
$previousVoidStatusDir = $env:VOID_STATUS_DIR
try {
$env:VOID_STATUS_DIR = $statusDir
$sendOutput = & node @arguments 2>&1
if ($LASTEXITCODE -ne 0) {
$sendDetail = [string]($sendOutput -join [Environment]::NewLine)
if ([string]::IsNullOrWhiteSpace($sendDetail)) {
throw "Moderation status DM '$noticeId' failed with exit code $LASTEXITCODE."
}
throw "Moderation status DM '$noticeId' failed with exit code $LASTEXITCODE`: $($sendDetail.Trim())"
}
} finally {
$env:VOID_STATUS_DIR = $previousVoidStatusDir
Remove-Item -LiteralPath $contentPath -Force -ErrorAction SilentlyContinue
}
if ($discordTransportMode -eq "bifrost") {
$bridgeReceiptText = [string]($sendOutput -join [Environment]::NewLine)
$speech = Convert-BifrostBridgeReceiptToSpeechStatus -Receipt ($bridgeReceiptText | ConvertFrom-Json) -Preview $body
return Convert-LastSpeechToModerationStatusNoticeSentOperation -UserId $userId -NoticeId $noticeId -Speech $speech
}
$lastSpeechPath = Join-Path $statusDir "void-last-speech.json"
if (-not (Test-Path $lastSpeechPath)) {
throw "Moderation status DM '$noticeId' did not write last speech status."
}
$speech = Read-JsonFile -Path $lastSpeechPath
return Convert-LastSpeechToModerationStatusNoticeSentOperation -UserId $userId -NoticeId $noticeId -Speech $speech
}
function Test-LockProcessAlive {
param($LockRecord)
@ -933,11 +1107,22 @@ if (Test-Path $operationOutputPath) {
Remove-Item -LiteralPath $operationOutputPath -Force
}
foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $sendMessageScriptPath)) {
foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath)) {
if (-not (Test-Path $requiredPath)) {
throw "Missing required helper at $requiredPath"
}
}
if ($discordTransportMode -eq "bifrost") {
if (-not (Test-Path $bifrostBridgeScriptPath)) {
throw "VOID_DISCORD_TRANSPORT=bifrost but Bifrost bridge helper is missing at $bifrostBridgeScriptPath"
}
} elseif ($discordTransportMode -eq "direct") {
if (-not (Test-Path $sendMessageScriptPath)) {
throw "VOID_DISCORD_TRANSPORT=direct but direct Discord helper is missing at $sendMessageScriptPath"
}
} else {
throw "Unsupported VOID_DISCORD_TRANSPORT '$discordTransportMode'. Use bifrost or direct."
}
if (-not (Test-Path $promptTemplatePath)) {
throw "Missing rumination prompt template at $promptTemplatePath"
}
@ -987,6 +1172,7 @@ $typedState = $typedContext.typedState
$priorCursor = $typedState.moderationCursor
$speechPressureObligations = @(Get-SpeechPressureObligations -TypedState $typedState)
$deliverableCandidates = @(Get-DeliverableCandidateInterventions -TypedState $typedState)
$pendingModerationStatusNotices = @(Get-PendingModerationStatusNotices -TypedState $typedState)
$historyArgs = @($recentHistoryScriptPath)
$priorCursorTimestamp = Get-ObjectPropertyString -Value $priorCursor -Name "lastReviewedTimestamp"
@ -1044,6 +1230,7 @@ Write-JsonFile -Path $contextPath -Data @{
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)
moderationUserStatuses = @(Project-ModerationUserStatusesForRumination -Statuses $typedState.moderationCursor.userStatuses -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)
@ -1067,7 +1254,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 ($isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0 -and $pendingModerationStatusNotices.Count -eq 0) {
Append-RunLog "napping: no new room messages or open cases; skipping awake rumination."
Write-JsonFile -Path $operationOutputPath -Data @()
$finishedAtUtc = [DateTime]::UtcNow
@ -1085,6 +1272,7 @@ if ($isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $delivera
observedMessageCount = [int]$messageCount
openCaseCount = [int]$openCaseCount
deliverableCandidateCount = [int]$deliverableCandidates.Count
pendingModerationStatusNoticeCount = [int]$pendingModerationStatusNotices.Count
tracePath = [string]$tracePath
lastMessagePath = [string]$lastMessagePath
}
@ -1201,6 +1389,7 @@ if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
}
$deliveredCandidateCount = 0
$deliveredModerationStatusNoticeCount = 0
if (-not $NoPost) {
$refreshedTypedContext = Get-TypedSelfState
$alreadyAnsweredCandidateOperations = @(Get-AlreadyAnsweredCandidateRetireOperations -TypedState $refreshedTypedContext.typedState -RetiredAt ([DateTime]::UtcNow))
@ -1224,6 +1413,19 @@ if (-not $NoPost) {
$deliveredCandidateCount += 1
}
}
$refreshedTypedContext = Get-TypedSelfState
$pendingModerationStatusNotices = @(Get-PendingModerationStatusNotices -TypedState $refreshedTypedContext.typedState)
foreach ($pendingNotice in $pendingModerationStatusNotices) {
if ($deliveredModerationStatusNoticeCount -ge 3) {
break
}
$noticeOperation = Invoke-ModerationStatusNoticeDelivery -PendingNotice $pendingNotice
if ($null -ne $noticeOperation) {
$appliedOperations += Apply-TypedOperation -Operation $noticeOperation
$deliveredModerationStatusNoticeCount += 1
}
}
}
Append-RunLog "writing final rumination status."
@ -1235,6 +1437,8 @@ $lastMessageSummary = @(
"proposed=" + [string]@($proposedOperations).Count
"applied=" + [string]@($appliedOperations).Count
"deliveredCandidates=" + [string]$deliveredCandidateCount
"deliveredModerationStatusNotices=" + [string]$deliveredModerationStatusNoticeCount
"discordTransport=" + [string]$discordTransportMode
"finishedAt=" + $finishedAtUtc.ToString("o")
) -join [Environment]::NewLine
[System.IO.File]::WriteAllText($lastMessagePath, $lastMessageSummary + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
@ -1260,6 +1464,8 @@ $finalStatus = [ordered]@{
appliedOperationCount = [int]@($appliedOperations).Count
repoCursorOperationCount = [int]@($repoCursorOperations).Count
deliveredCandidateCount = [int]$deliveredCandidateCount
deliveredModerationStatusNoticeCount = [int]$deliveredModerationStatusNoticeCount
discordTransport = [string]$discordTransportMode
stateUpdated = [bool](@($appliedOperations).Count -gt 0)
tracePath = [string]$tracePath
lastMessagePath = [string]$lastMessagePath

View file

@ -27,30 +27,32 @@ async function main() {
throw new Error("Message content is empty.");
}
if (options.ownerDm) {
const ownerId = env.DISCORD_OWNER_ID?.trim();
if (options.ownerDm || options.userDm) {
const recipientId = options.userDm ?? env.DISCORD_OWNER_ID?.trim();
if (!ownerId) {
throw new Error("DISCORD_OWNER_ID is required for --owner-dm.");
if (!recipientId) {
throw new Error("DISCORD_OWNER_ID is required for --owner-dm, or provide --user-dm <recipientId>.");
}
const channelId = await openOwnerDmChannel(botToken, ownerId);
const mode = options.userDm ? "user_dm" : "owner_dm";
const channelId = await openDiscordDmChannel(botToken, recipientId);
await postChunkedDiscordMessage(botToken, channelId, content, undefined, undefined);
writeLastSpeechStatus({
sentAt: new Date().toISOString(),
mode: "owner_dm",
mode,
transport: "bot",
channelId,
recipientId,
contentLength: content.length,
chunkCount: splitDiscordContent(content).length,
preview: content.slice(0, 280),
});
process.stdout.write(`${JSON.stringify({ ok: true, mode: "owner_dm", channelId })}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, mode, channelId, recipientId })}\n`);
return;
}
if (!options.channelId) {
throw new Error("Provide --owner-dm or --channel-id.");
throw new Error("Provide --owner-dm, --user-dm, or --channel-id.");
}
await postChunkedDiscordMessage(
@ -89,6 +91,7 @@ async function main() {
function parseArgs(args) {
const options = {
ownerDm: false,
userDm: undefined,
channelId: undefined,
replyToMessageId: undefined,
content: undefined,
@ -104,6 +107,10 @@ function parseArgs(args) {
case "--owner-dm":
options.ownerDm = true;
break;
case "--user-dm":
options.userDm = args[index + 1];
index += 1;
break;
case "--channel-id":
options.channelId = args[index + 1];
index += 1;
@ -133,8 +140,10 @@ function parseArgs(args) {
}
}
if (options.ownerDm && options.channelId) {
throw new Error("Use either --owner-dm or --channel-id, not both.");
const targetCount = [options.ownerDm, Boolean(options.userDm), Boolean(options.channelId)]
.filter(Boolean).length;
if (targetCount > 1) {
throw new Error("Use only one of --owner-dm, --user-dm, or --channel-id.");
}
return options;
@ -209,7 +218,7 @@ async function readContent(options) {
});
}
async function openOwnerDmChannel(botToken, recipientId) {
async function openDiscordDmChannel(botToken, recipientId) {
const response = await fetch("https://discord.com/api/v10/users/@me/channels", {
method: "POST",
headers: {

View file

@ -0,0 +1,167 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $repoRoot
$tempRoot = Join-Path $env:TEMP ("void-moderation-status-fixture-" + [guid]::NewGuid().ToString("n"))
$stateFilePath = Join-Path $tempRoot "void-self-state.cc"
$operationPath = Join-Path $tempRoot "operation.json"
$fakeSendPath = Join-Path $tempRoot "fake-send-discord-message.mjs"
$fixtureStatusDir = Join-Path $tempRoot "status"
$fixtureLogDir = Join-Path $tempRoot "logs"
$statusPath = Join-Path $fixtureStatusDir "moderation-rumination.json"
$checkPath = Join-Path $tempRoot "check-moderation-status.mjs"
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
try {
$fakeSendSource = @'
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const statusDir = resolve(process.env.VOID_STATUS_DIR);
const contentFileIndex = process.argv.indexOf("--content-file");
const userDmIndex = process.argv.indexOf("--user-dm");
if (contentFileIndex < 0 || userDmIndex < 0) {
console.error("fake sender expected --content-file and --user-dm");
process.exit(2);
}
const content = readFileSync(resolve(process.argv[contentFileIndex + 1]), "utf8");
const recipientId = process.argv[userDmIndex + 1];
const payload = {
sentAt: "2026-06-04T12:00:00.000Z",
mode: "user_dm",
transport: "bot",
channelId: "fixture-dm-channel",
recipientId,
contentLength: content.trim().length,
chunkCount: 1,
preview: content.trim().slice(0, 280),
};
mkdirSync(statusDir, { recursive: true });
writeFileSync(resolve(statusDir, "void-last-speech.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
writeFileSync(resolve(statusDir, "void-speech-log.jsonl"), `${JSON.stringify(payload)}\n`, { encoding: "utf8", flag: "a" });
process.stdout.write(JSON.stringify({ ok: true, mode: "user_dm", channelId: payload.channelId, recipientId }) + "\n");
'@
[System.IO.File]::WriteAllText($fakeSendPath, $fakeSendSource, [System.Text.UTF8Encoding]::new($false))
$expiredStrikeOperation = @{
operation = "upsert_moderation_user_status"
status = @{
userId = "fixture-user-id"
userName = "Fixture User"
status = "active"
summary = "Fixture user had one expired strike."
strikes = @(
@{
strikeId = "fixture-expired-strike"
reason = "posting a fixture rule violation"
ruleRef = "fixture-rule"
sourceMessageId = "fixture-message-id"
channelId = "fixture-channel-id"
issuedAt = "2026-05-20T00:00:00.000Z"
expiresAt = "2099-01-01T00:00:00.000Z"
issuedBy = "Void"
tags = @("fixture")
}
)
pendingNotices = @()
updatedAt = "2026-05-20T00:00:00.000Z"
tags = @("fixture")
}
}
$expiredStrikeOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $operationPath -Encoding UTF8
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $operationPath | Out-Null
$cursorOperation = @{
operation = "record_reviewed_messages"
lastReviewedMessageId = "fixture-future-cursor"
lastReviewedTimestamp = "2099-06-04T00:00:00.000Z"
}
$cursorOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $operationPath -Encoding UTF8
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $operationPath | Out-Null
$previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR
$previousLogDir = $env:VOID_RUMINATION_LOG_DIR
$previousSendScript = $env:VOID_SEND_DISCORD_SCRIPT
$previousDiscordTransport = $env:VOID_DISCORD_TRANSPORT
$previousDisableRepoCursorAdvance = $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE
try {
$env:VOID_RUMINATION_STATUS_DIR = $fixtureStatusDir
$env:VOID_RUMINATION_LOG_DIR = $fixtureLogDir
$env:VOID_SEND_DISCORD_SCRIPT = $fakeSendPath
$env:VOID_DISCORD_TRANSPORT = "direct"
$env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = "1"
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath -SkipModel | Out-Null
} finally {
$env:VOID_RUMINATION_STATUS_DIR = $previousStatusDir
$env:VOID_RUMINATION_LOG_DIR = $previousLogDir
$env:VOID_SEND_DISCORD_SCRIPT = $previousSendScript
$env:VOID_DISCORD_TRANSPORT = $previousDiscordTransport
$env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = $previousDisableRepoCursorAdvance
}
if (-not (Test-Path $statusPath)) {
throw "Rumination status was not written."
}
$status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json
if ($status.status -ne "ok") {
throw "Rumination did not finish ok: $($status.status)"
}
if ([int]$status.deliveredModerationStatusNoticeCount -ne 1) {
throw "Expected one delivered moderation status notice, got $($status.deliveredModerationStatusNoticeCount)."
}
$checkSource = @'
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
const repoRoot = resolve(process.cwd());
const require = createRequire(import.meta.url);
const coreDistPath = resolve(repoRoot, "packages/core/dist/index.js");
if (!existsSync(coreDistPath)) {
throw new Error(`Missing built core package at ${coreDistPath}. Run npm run build first.`);
}
const { loadVoidSelfStateTypedDocuments } = require(coreDistPath);
const state = await loadVoidSelfStateTypedDocuments({ canonicalPath: process.argv[2] });
const userStatus = state.moderationCursor.userStatuses.find((entry) => entry.userId === "fixture-user-id");
if (!userStatus) {
throw new Error("Fixture moderation user status was not retained.");
}
const strike = userStatus.strikes.find((entry) => entry.strikeId === "fixture-expired-strike");
if (!strike?.expiredAt) {
throw new Error("Expired strike was not structurally expired.");
}
if (strike.expiresAt !== "2026-05-27T00:00:00.000Z") {
throw new Error(`Expected canonical one-week expiry, got ${strike.expiresAt}.`);
}
const notice = userStatus.pendingNotices.find((entry) => entry.noticeId === "strike-expired-fixture-user-id-fixture-expired-strike");
if (!notice?.sentAt) {
throw new Error("Strike expiry notice was not marked sent.");
}
if (notice.channelId !== "fixture-dm-channel") {
throw new Error(`Expected fixture DM channel, got ${notice.channelId}.`);
}
process.stdout.write(JSON.stringify({
ok: true,
userId: userStatus.userId,
strikeExpiredAt: strike.expiredAt,
noticeSentAt: notice.sentAt,
}) + "\n");
'@
[System.IO.File]::WriteAllText($checkPath, $checkSource, [System.Text.UTF8Encoding]::new($false))
node $checkPath $stateFilePath | Out-Null
Write-Host "Void moderation status fixture passed: expired strike generated and delivered a user DM status notice."
} finally {
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}

View file

@ -0,0 +1,197 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $repoRoot
$tempRoot = Join-Path $env:TEMP ("void-rumination-bifrost-transport-fixture-" + [guid]::NewGuid().ToString("n"))
$stateFilePath = Join-Path $tempRoot "void-self-state.cc"
$operationPath = Join-Path $tempRoot "operation.json"
$fakeBridgePath = Join-Path $tempRoot "fake-bifrost-bridge.mjs"
$fixtureStatusDir = Join-Path $tempRoot "status"
$fixtureLogDir = Join-Path $tempRoot "logs"
$statusPath = Join-Path $fixtureStatusDir "moderation-rumination.json"
$checkPath = Join-Path $tempRoot "check-bifrost-transport.mjs"
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
try {
$fakeBridgeSource = @'
import { readFileSync } from "node:fs";
const command = process.argv[2];
const contentFileIndex = process.argv.indexOf("--content-file");
const content = contentFileIndex >= 0 ? readFileSync(process.argv[contentFileIndex + 1], "utf8").trim() : "";
if (command === "discord-post") {
const channelIndex = process.argv.indexOf("--channel-id");
const replyIndex = process.argv.indexOf("--reply-to-message-id");
process.stdout.write(JSON.stringify({
action: "discord-post",
ok: true,
channelId: process.argv[channelIndex + 1],
messageId: "fixture-bifrost-message",
transport: "webhook",
replyToMessageId: replyIndex >= 0 ? process.argv[replyIndex + 1] : undefined,
preview: content.slice(0, 280),
}) + "\n");
} else if (command === "discord-dm") {
const recipientIndex = process.argv.indexOf("--recipient-id");
process.stdout.write(JSON.stringify({
action: "discord-dm",
ok: true,
recipientId: process.argv[recipientIndex + 1],
channelId: "fixture-bifrost-dm-channel",
messageId: "fixture-bifrost-dm-message",
transport: "bot",
preview: content.slice(0, 280),
}) + "\n");
} else {
console.error(`unexpected fake bridge command ${command}`);
process.exit(2);
}
'@
[System.IO.File]::WriteAllText($fakeBridgePath, $fakeBridgeSource, [System.Text.UTF8Encoding]::new($false))
$cursorOperation = @{
operation = "record_reviewed_messages"
lastReviewedMessageId = "fixture-future-cursor"
lastReviewedTimestamp = "2099-06-04T00:00:00.000Z"
}
$cursorOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $operationPath -Encoding UTF8
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $operationPath | Out-Null
$candidateOperation = @{
operation = "queue_candidate_intervention"
intervention = @{
interventionId = "fixture-bifrost-candidate"
kind = "direct_reply"
status = "queued"
target = @{
kind = "room"
id = "fixture-channel"
label = "Fixture channel"
}
summary = "Bifrost should own public Discord transport."
draft = "Bifrost owns this public crossing; Void just brought the sentence."
deliveryTarget = @{
mode = "channel"
channelId = "fixture-channel-id"
replyToMessageId = "fixture-reply-id"
personaName = "Void"
}
priority = 0.9
mustEventuallyShare = $false
createdAt = "2026-06-04T10:00:00.000Z"
updatedAt = "2026-06-04T10:00:00.000Z"
tags = @("fixture")
}
}
$candidateOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $operationPath -Encoding UTF8
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $operationPath | Out-Null
$statusOperation = @{
operation = "upsert_moderation_user_status"
status = @{
userId = "fixture-user-id"
userName = "Fixture User"
status = "active"
summary = "Fixture user needs a status notice."
strikes = @(
@{
strikeId = "fixture-active-strike"
reason = "posting a fixture rule violation"
ruleRef = "fixture-rule"
issuedAt = "2026-06-04T10:00:00.000Z"
expiresAt = "2026-06-11T10:00:00.000Z"
tags = @("fixture")
}
)
pendingNotices = @(
@{
noticeId = "fixture-status-notice"
kind = "strike_added"
summary = "Strike added"
body = "Moderation status update: fixture strike added."
createdAt = "2026-06-04T10:00:00.000Z"
tags = @("fixture")
}
)
updatedAt = "2026-06-04T10:00:00.000Z"
tags = @("fixture")
}
}
$statusOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $operationPath -Encoding UTF8
node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $operationPath | Out-Null
$previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR
$previousLogDir = $env:VOID_RUMINATION_LOG_DIR
$previousTransport = $env:VOID_DISCORD_TRANSPORT
$previousBridgeScript = $env:VOID_BIFROST_BRIDGE_SCRIPT
$previousDisableRepoCursorAdvance = $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE
try {
$env:VOID_RUMINATION_STATUS_DIR = $fixtureStatusDir
$env:VOID_RUMINATION_LOG_DIR = $fixtureLogDir
$env:VOID_DISCORD_TRANSPORT = "bifrost"
$env:VOID_BIFROST_BRIDGE_SCRIPT = $fakeBridgePath
$env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = "1"
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath -SkipModel | Out-Null
} finally {
$env:VOID_RUMINATION_STATUS_DIR = $previousStatusDir
$env:VOID_RUMINATION_LOG_DIR = $previousLogDir
$env:VOID_DISCORD_TRANSPORT = $previousTransport
$env:VOID_BIFROST_BRIDGE_SCRIPT = $previousBridgeScript
$env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = $previousDisableRepoCursorAdvance
}
$status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json
if ($status.status -ne "ok") {
throw "Rumination did not finish ok: $($status.status)"
}
if ($status.discordTransport -ne "bifrost") {
throw "Expected bifrost transport, got $($status.discordTransport)."
}
if ([int]$status.deliveredCandidateCount -ne 1) {
throw "Expected one delivered candidate, got $($status.deliveredCandidateCount)."
}
if ([int]$status.deliveredModerationStatusNoticeCount -ne 1) {
throw "Expected one delivered moderation status notice, got $($status.deliveredModerationStatusNoticeCount)."
}
$checkSource = @'
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
const require = createRequire(import.meta.url);
const coreDistPath = resolve(process.cwd(), "packages/core/dist/index.js");
if (!existsSync(coreDistPath)) {
throw new Error(`Missing built core package at ${coreDistPath}. Run npm run build first.`);
}
const { loadVoidSelfStateTypedDocuments } = require(coreDistPath);
const state = await loadVoidSelfStateTypedDocuments({ canonicalPath: process.argv[2] });
const candidate = state.candidateInterventions.interventions.find((entry) => entry.interventionId === "fixture-bifrost-candidate");
if (candidate) {
throw new Error("Bifrost-delivered candidate stayed live.");
}
const receipt = state.speechReceipts.recentReceipts.find((entry) => entry.candidateInterventionId === "fixture-bifrost-candidate");
if (!receipt || receipt.transport !== "webhook" || receipt.channelId !== "fixture-channel-id") {
throw new Error("Bifrost candidate receipt was not recorded correctly.");
}
const status = state.moderationCursor.userStatuses.find((entry) => entry.userId === "fixture-user-id");
const notice = status?.pendingNotices.find((entry) => entry.noticeId === "fixture-status-notice");
if (!notice?.sentAt || notice.channelId !== "fixture-bifrost-dm-channel" || notice.messageId !== "fixture-bifrost-dm-message") {
throw new Error("Bifrost moderation notice receipt was not recorded correctly.");
}
process.stdout.write(JSON.stringify({ ok: true, receiptTransport: receipt.transport, noticeChannelId: notice.channelId }) + "\n");
'@
[System.IO.File]::WriteAllText($checkPath, $checkSource, [System.Text.UTF8Encoding]::new($false))
node $checkPath $stateFilePath | Out-Null
Write-Host "Void Bifrost transport fixture passed: public speech and moderation DM receipts came from Bifrost."
} finally {
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}

View file

@ -218,6 +218,7 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) +
$previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR
$previousLogDir = $env:VOID_RUMINATION_LOG_DIR
$previousSendScript = $env:VOID_SEND_DISCORD_SCRIPT
$previousDiscordTransport = $env:VOID_DISCORD_TRANSPORT
$previousDisableRepoCursorAdvance = $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE
try {
@ -227,6 +228,7 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) +
$env:VOID_RUMINATION_STATUS_DIR = $fixtureStatusDir
$env:VOID_RUMINATION_LOG_DIR = $fixtureLogDir
$env:VOID_SEND_DISCORD_SCRIPT = $fakeSendPath
$env:VOID_DISCORD_TRANSPORT = "direct"
$env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = "1"
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath | Out-Null
@ -237,6 +239,7 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) +
$env:VOID_RUMINATION_STATUS_DIR = $previousStatusDir
$env:VOID_RUMINATION_LOG_DIR = $previousLogDir
$env:VOID_SEND_DISCORD_SCRIPT = $previousSendScript
$env:VOID_DISCORD_TRANSPORT = $previousDiscordTransport
$env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = $previousDisableRepoCursorAdvance
}