From ed77f79306830468ebbf00ec779cac6bc7abec50 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 4 Jun 2026 23:37:12 +0100 Subject: [PATCH] Require Bifrost for moderation delivery --- notes/fresh-workspace-handoff.md | 4 +- notes/voidbot-current-system-map.md | 2 +- notes/voidbot-implementation-plan.md | 2 +- scripts/run-void-moderator-rumination.ps1 | 113 ++++++------------ .../smoke-void-moderation-status-fixture.ps1 | 40 +++---- .../smoke-void-rumination-speech-fixture.ps1 | 40 +++---- state/evidence.jsonl | 1 + state/map.yaml | 4 +- 8 files changed, 80 insertions(+), 126 deletions(-) diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 1c81da0..949ffe9 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -39,7 +39,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - The live local pulse is now one hidden scheduled task: `GameCult Local Orchestrator`. It invokes repo Face CTB heartbeats, Void mood drift, Void moderation rumination, and the operations watchdog with one lock, one status surface, per-organ logs, and per-organ timeouts. The Bifrost dispatch organ remains wired but is skipped unless `BIFROST_DISPATCH_ENABLED=true`. The old per-organ tasks are disabled and should remain disabled unless testing one organ in isolation. - `scripts/start-voidbot-stack.ps1` now treats the local Bifrost container stack as a startup dependency. It reads `BIFROST_ROOT` and `BIFROST_HEALTH_URL`, runs `E:\Projects\Bifrost\compose.local.yaml` when the health endpoint is down, and records the result under `runtime-stack.json` as `bifrost`. - `npm run moderation:rumination:fixture` proves the non-skip typed rumination branch without spending a live model call: it seeds a temporary `.cc`, routes the runner through a fake Codex child, and verifies short-term memory, incubation, agency pressure, and candidate intervention proposals are parent-applied typed operations with no exact timestamp leakage in the prompt-facing context. -- `npm run moderation:rumination:bifrost-transport-fixture` proves queued speech and moderation status DMs cross the default Bifrost bridge path and return receipts to typed state. `npm run moderation:rumination:speech-fixture` still proves the explicit direct Discord fallback path for standalone installs. The child prompt forbids direct send-script calls either way. +- `npm run moderation:rumination:bifrost-transport-fixture` proves queued speech and moderation status DMs cross the required Bifrost bridge path and return receipts to typed state. `npm run moderation:rumination:speech-fixture` proves the parent-owned candidate closure path with a fake Bifrost bridge. The child prompt forbids direct send-script calls. - The first live sleep-maintenance gate passed on real state. A manual typed nap ran `scripts/run-void-memory-maintenance.ps1`, consumed the scheduled rumination short-term memory with one `apply_memory_distillation` operation, and left `thoughtMemory.shortTerm` empty while preserving the durable identity seam with target, claim, tension, action implication, and anchors. - A later live-loop inspection found one stale answered-case summary, four stale short-term memories, and an unbounded rumination/memory-maintenance failure mode. The current hardened path now verifies candidate delivery postconditions, normalizes answered-case wording on receipt/closure, uses process-aware locks plus Codex timeouts, and a fresh memory-maintenance pass reduced live short-term memory back to zero. - `npm run moderation:rumination:nap-skip-fixture` proves awake rumination does not keep making fresh short-term residue during sleep: when the typed sleep cycle is napping and there are no new room messages or open cases, the runner writes `[]` and exits before invoking Codex. @@ -105,7 +105,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - The important distinction is outward too, not just inward. Self-directed tensions can ask for prompt/memory/runtime surgery, while world-directed tensions can turn repo/lore/room opinions into real requests or first-person interventions when the same objection survives more than one pass. - The intended shape now is: quiet runs can follow one seam for several passes, connect repo motion to lore or philosophy if the evidence supports it, and only surface with the juiciest version once the incubation queue says the thought has actually grown teeth. - Ambient Discord mentions are no longer a direct reply organ. Role/display-name Face mentions, human replies to a Face's webhook/persona message, and ordinary Void mentions/DMs append pending obligations to `REPO_FACE_HEARTBEAT_STATE_PATH`; the CTB scheduler pulls the addressed agent forward and passes the pending prompt/reply target into the next Face rumination or Void moderation turn. Display-name matching is exact-token matching across the whole message, so group introductions like "Meet Kiko, Huginn and Weksa" queue every named Face instead of requiring the message to begin with one name. Mention detection can happen outside the regular autonomous Face chatter lanes, but public speech still goes through the registered channel/action rails with source-channel transcript attached. Slash commands and explicit owner queue tools may still use provider jobs, but `MessageCreate` no longer falls straight into `handlePrompt`. -- The moderation loop now crosses Discord through Bifrost by default. `scripts/run-void-moderator-rumination.ps1` calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for public candidate posts and user moderation-status DMs, then converts the Bifrost receipt into typed Void state. `node scripts/send-discord-message.mjs` remains an explicit standalone fallback behind `VOID_DISCORD_TRANSPORT=direct`; it should not become the hidden GameCult transport authority again. +- The moderation loop now crosses Discord through Bifrost only. `scripts/run-void-moderator-rumination.ps1` calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for public candidate posts and user moderation-status DMs, then converts the Bifrost receipt into typed Void state. `VOID_DISCORD_TRANSPORT=direct` is rejected in this runner; direct VoidBot-native transport is no longer part of the moderation delivery authority. - Repo identities now have a cleaner address/speech split. A private registry at `REPO_DISCORD_IDENTITIES_PATH` maps identity id/repo/display name/avatar/role/channel allow-list. Discord users can address a Face by role mention or exact display-name mention; Codex agents still speak through `post_repo_identity_message`. - Void still moderates and participates everywhere the normal bot lanes allow. The swarm/repo Faces are scoped to text `#aquarium` (`1501196543150264332`) so their lively heartbeats and role replies do not flood general. The old general channel id is `113786069023064068`; do not reuse it as a Face heartbeat default. - Repo Faces now have topic/channel speech permissions. Aquarium is the deliberately low-threshold general-musing lane for all Faces; they may post compact casual thoughts, jokes, curiosities, and half-formed fascinations there without a work artifact. Domain channels are also allowed when the topic matches: Nibu can use narrative/gamedesign/concepts/development, Aqua can use music/soundtrack/development, Mimir can use development/programming/visual-effects/machine-learning/unity3d, and Epiphany can use development/programming/machine-learning. The CTB scheduler folds per-topic speed multipliers into Face initiative, and the heartbeat prompt includes recent cross-channel context so agents can jump into non-Aquarium chatter when their thoughts line up. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index afa5a12..f4b1baa 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -199,7 +199,7 @@ When present, an ignored local script at `.voidbot/private/check-voidbot-operati ## Verse Service Contract - Owner: - - VoidBot owns Discord ingress, room cognition, moderation judgment, archive/source retrieval, direct owner handoff, typed Void self-state, repo Face scheduling compatibility, and parent-owned speech intent/receipt application. Default Discord egress crosses through Bifrost; direct VoidBot send helpers are standalone fallback. + - VoidBot owns Discord ingress for the current bot gateway body, room cognition, moderation judgment, archive/source retrieval, direct owner handoff, typed Void self-state, repo Face scheduling compatibility, and parent-owned speech intent/receipt application. Moderation Discord egress requires Bifrost; direct VoidBot send helpers are not a standalone deployment fallback. - Huginn owns Persona-state and `.cc` runtime inspection stewardship: schema availability, migration pressure, projection health, access-tool sanity, CultMesh publication, and Eve DSL inspection for typed state. - Bifrost owns governed public crossings for GitHub proposals/articles/comments, work topics, dispatch receipts, current Discord egress for swarm/moderation status, and future Discord-native work interfaces. - Odin owns Verse/provider discovery and interface aggregation, not provider state. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index 0ee1861..51772fd 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -328,7 +328,7 @@ Restart gates: - Landed earlier advocacy release: speech-pressure obligations now start at 0.55 intensity instead of waiting for 0.65, only a live queued candidate satisfies an active pressure, and a previously spoken candidate no longer counts as permanent release while the pressure remains active. Mood drift also weights active advocacy more strongly so speaking pressure rises sooner. - Landed rumination verification: `npm run moderation:rumination:fixture` routes the non-skip rumination branch through a fake Codex child and proves short-term memory, incubation, agency-pressure, and candidate-intervention proposals cross the parent-applied typed operation boundary without timestamp leakage. - Landed controlled live rumination review: the first real-model `-NoPost` pass produced valid but self-orbiting readiness-test residue, which was pruned/retired and folded into a prompt guard; the second real-model `-NoPost` pass sampled non-VoidBot repo pressure, used retrieval, and correctly wrote `[]` when nothing earned persistence. -- Landed parent-owned speech closure: candidate interventions can include `deliveryTarget`, the prompt forbids child-side send scripts, the parent runner delivers at most one newly queued deliverable candidate per pass, records the receipt, and marks the candidate spoken with `mark_candidate_intervention_spoken`. Default delivery goes through Bifrost (`npm run moderation:rumination:bifrost-transport-fixture`); `npm run moderation:rumination:speech-fixture` proves the direct Discord fallback for standalone installs. +- Landed parent-owned speech closure: candidate interventions can include `deliveryTarget`, the prompt forbids child-side send scripts, the parent runner delivers at most one newly queued deliverable candidate per pass through Bifrost, records the receipt, and marks the candidate spoken with `mark_candidate_intervention_spoken`. `npm run moderation:rumination:bifrost-transport-fixture` proves queued speech plus moderation status DMs through Bifrost; `npm run moderation:rumination:speech-fixture` proves parent-owned candidate closure with a fake Bifrost bridge. - Landed receipt-backed duplicate direct-reply guard: before delivery, the parent runner now retires queued candidates whose `channelId + replyToMessageId` already appear in canonical speech receipts, so a later pass cannot answer the same direct ask twice just because the message remains visible. The speech fixture now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. - Landed scheduled rumination re-enable: one observed live scheduled pass completed with Last Result `0`, proposed one anchored short-term memory, delivered no speech, and kept exact timestamps out of the prompt-facing context. - Landed live sleep-maintenance verification: a manual typed nap ran real memory maintenance on live state, promoted the scheduled rumination short-term memory into one durable `identity_seam`, and left `thoughtMemory.shortTerm` empty. diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index e21c0f9..17bca6d 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -43,11 +43,6 @@ $contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination- $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" -$sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DISCORD_SCRIPT)) { - $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_SEND_DISCORD_SCRIPT) -} 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 { @@ -58,13 +53,7 @@ $bifrostBridgeScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_BIFRO } 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" -} +$discordTransportMode = "bifrost" $startedAtUtc = [DateTime]::UtcNow . $contextProjectionScriptPath @@ -839,42 +828,25 @@ function Invoke-CandidateInterventionDeliveryFromIntervention { if ([string]::IsNullOrWhiteSpace($interventionId) -or [string]::IsNullOrWhiteSpace($draft)) { return $null } + Assert-BifrostBridgeAvailable $contentPath = Join-Path $statusDir ("moderation-rumination-speech-{0}.txt" -f ([Guid]::NewGuid().ToString("n"))) $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($contentPath, $draft, $utf8NoBom) $mode = Get-ObjectPropertyString -Value $deliveryTarget -Name "mode" - $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 = @($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) - } + if ($mode -eq "owner_dm") { + throw "Candidate intervention '$interventionId' targets owner_dm, but VoidBot moderation delivery now requires Bifrost Discord transport." } + $channelId = Get-ObjectPropertyString -Value $deliveryTarget -Name "channelId" + if ([string]::IsNullOrWhiteSpace($channelId)) { + throw "Candidate intervention '$interventionId' has deliveryTarget without channelId." + } + $arguments = @($bifrostBridgeScriptPath, "discord-post", "--channel-id", $channelId, "--content-file", $contentPath) $replyToMessageId = Get-ObjectPropertyString -Value $deliveryTarget -Name "replyToMessageId" if (-not [string]::IsNullOrWhiteSpace($replyToMessageId)) { - if ($discordTransportMode -eq "bifrost") { - $arguments += @("--reply-to-message-id", $replyToMessageId) - } else { - $arguments += @("--reply-to", $replyToMessageId) - } + $arguments += @("--reply-to-message-id", $replyToMessageId) } $personaName = Get-ObjectPropertyString -Value $deliveryTarget -Name "personaName" @@ -904,17 +876,8 @@ 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 + $bridgeReceiptText = [string]($sendOutput -join [Environment]::NewLine) + $speech = Convert-BifrostBridgeReceiptToSpeechStatus -Receipt ($bridgeReceiptText | ConvertFrom-Json) -Preview $draft return Convert-LastSpeechToSpokenCandidateOperation -InterventionId $interventionId -Speech $speech } @@ -928,17 +891,35 @@ function Convert-BifrostBridgeReceiptToSpeechStatus { return $null } + $action = Get-ObjectPropertyString -Value $Receipt -Name "action" + $mode = switch ($action) { + "discord-post" { "channel" } + "discord-dm" { "user_dm" } + default { $action } + } + return [pscustomobject]@{ sentAt = [DateTime]::UtcNow.ToString("o") - mode = Get-ObjectPropertyString -Value $Receipt -Name "action" + mode = $mode 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" + replyToMessageId = Get-ObjectPropertyString -Value $Receipt -Name "replyToMessageId" + personaName = Get-ObjectPropertyString -Value $Receipt -Name "personaName" + personaAvatarUrl = Get-ObjectPropertyString -Value $Receipt -Name "personaAvatarUrl" + contentLength = if ($null -ne $Preview) { [int]$Preview.Length } else { $null } + chunkCount = 1 preview = if ([string]::IsNullOrWhiteSpace($Preview)) { $null } else { $Preview.Substring(0, [Math]::Min(280, $Preview.Length)) } } } +function Assert-BifrostBridgeAvailable { + if (-not (Test-Path $bifrostBridgeScriptPath)) { + throw "Bifrost bridge helper is required for VoidBot moderation delivery but is missing at $bifrostBridgeScriptPath" + } +} + function Get-PendingModerationStatusNotices { param($TypedState) @@ -1012,16 +993,13 @@ function Invoke-ModerationStatusNoticeDelivery { ) { return $null } + Assert-BifrostBridgeAvailable $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) - } + $arguments = @($bifrostBridgeScriptPath, "discord-dm", "--recipient-id", $userId, "--content-file", $contentPath) $previousVoidStatusDir = $env:VOID_STATUS_DIR try { $env:VOID_STATUS_DIR = $statusDir @@ -1039,17 +1017,8 @@ function Invoke-ModerationStatusNoticeDelivery { 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 + $bridgeReceiptText = [string]($sendOutput -join [Environment]::NewLine) + $speech = Convert-BifrostBridgeReceiptToSpeechStatus -Receipt ($bridgeReceiptText | ConvertFrom-Json) -Preview $body return Convert-LastSpeechToModerationStatusNoticeSentOperation -UserId $userId -NoticeId $noticeId -Speech $speech } @@ -1212,16 +1181,8 @@ foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPa 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 [string]::IsNullOrWhiteSpace($env:VOID_DISCORD_TRANSPORT) -and $env:VOID_DISCORD_TRANSPORT.Trim().ToLowerInvariant() -ne "bifrost") { + throw "VOID_DISCORD_TRANSPORT=$($env:VOID_DISCORD_TRANSPORT) is no longer supported. VoidBot moderation delivery requires Bifrost." } if (-not (Test-Path $promptTemplatePath)) { throw "Missing rumination prompt template at $promptTemplatePath" diff --git a/scripts/smoke-void-moderation-status-fixture.ps1 b/scripts/smoke-void-moderation-status-fixture.ps1 index 384b593..e757c85 100644 --- a/scripts/smoke-void-moderation-status-fixture.ps1 +++ b/scripts/smoke-void-moderation-status-fixture.ps1 @@ -8,7 +8,7 @@ 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" +$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" @@ -17,38 +17,34 @@ $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"; + $fakeBridgeSource = @' +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; -const statusDir = resolve(process.env.VOID_STATUS_DIR); +const command = process.argv[2]; const contentFileIndex = process.argv.indexOf("--content-file"); -const userDmIndex = process.argv.indexOf("--user-dm"); +const recipientIndex = process.argv.indexOf("--recipient-id"); -if (contentFileIndex < 0 || userDmIndex < 0) { - console.error("fake sender expected --content-file and --user-dm"); +if (command !== "discord-dm" || contentFileIndex < 0 || recipientIndex < 0) { + console.error("fake Bifrost bridge expected discord-dm, --content-file, and --recipient-id"); process.exit(2); } const content = readFileSync(resolve(process.argv[contentFileIndex + 1]), "utf8"); -const recipientId = process.argv[userDmIndex + 1]; +const recipientId = process.argv[recipientIndex + 1]; const payload = { - sentAt: "2026-06-04T12:00:00.000Z", - mode: "user_dm", + action: "discord-dm", + ok: true, transport: "bot", channelId: "fixture-dm-channel", + messageId: "fixture-dm-message", 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"); +process.stdout.write(JSON.stringify(payload) + "\n"); '@ - [System.IO.File]::WriteAllText($fakeSendPath, $fakeSendSource, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($fakeBridgePath, $fakeBridgeSource, [System.Text.UTF8Encoding]::new($false)) $expiredStrikeOperation = @{ operation = "upsert_moderation_user_status" @@ -88,22 +84,22 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "user_dm", channelId: payl $previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR $previousLogDir = $env:VOID_RUMINATION_LOG_DIR - $previousSendScript = $env:VOID_SEND_DISCORD_SCRIPT + $previousBridgeScript = $env:VOID_BIFROST_BRIDGE_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_BIFROST_BRIDGE_SCRIPT = $fakeBridgePath + $env:VOID_DISCORD_TRANSPORT = "bifrost" $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_BIFROST_BRIDGE_SCRIPT = $previousBridgeScript $env:VOID_DISCORD_TRANSPORT = $previousDiscordTransport $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = $previousDisableRepoCursorAdvance } diff --git a/scripts/smoke-void-rumination-speech-fixture.ps1 b/scripts/smoke-void-rumination-speech-fixture.ps1 index e1d1391..11f03e3 100644 --- a/scripts/smoke-void-rumination-speech-fixture.ps1 +++ b/scripts/smoke-void-rumination-speech-fixture.ps1 @@ -8,7 +8,7 @@ Set-Location -LiteralPath $repoRoot $tempRoot = Join-Path $env:TEMP ("void-rumination-speech-fixture-" + [guid]::NewGuid().ToString("n")) $stateFilePath = Join-Path $tempRoot "void-self-state.cc" $fakeCodexPath = Join-Path $tempRoot "fake-codex-rumination-speech.mjs" -$fakeSendPath = Join-Path $tempRoot "fake-send-discord-message.mjs" +$fakeBridgePath = Join-Path $tempRoot "fake-bifrost-bridge.mjs" $cursorOperationPath = Join-Path $tempRoot "cursor.json" $fixtureStatusDir = Join-Path $tempRoot "status" $fixtureLogDir = Join-Path $tempRoot "logs" @@ -131,19 +131,19 @@ process.stdin.on("end", () => { '@ [System.IO.File]::WriteAllText($fakeCodexPath, $fakeCodexSource, [System.Text.UTF8Encoding]::new($false)) - $fakeSendSource = @' -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; + $fakeBridgeSource = @' +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; -const statusDir = resolve(process.env.VOID_STATUS_DIR); +const command = process.argv[2]; const contentFileIndex = process.argv.indexOf("--content-file"); const channelIndex = process.argv.indexOf("--channel-id"); -const replyIndex = process.argv.indexOf("--reply-to"); +const replyIndex = process.argv.indexOf("--reply-to-message-id"); const personaIndex = process.argv.indexOf("--persona-name"); const personaAvatarIndex = process.argv.indexOf("--persona-avatar-url"); -if (contentFileIndex < 0 || channelIndex < 0) { - console.error("fake sender expected --content-file and --channel-id"); +if (command !== "discord-post" || contentFileIndex < 0 || channelIndex < 0) { + console.error("fake Bifrost bridge expected discord-post, --content-file, and --channel-id"); process.exit(2); } @@ -153,24 +153,20 @@ const replyToMessageId = replyIndex >= 0 ? process.argv[replyIndex + 1] : undefi const personaName = personaIndex >= 0 ? process.argv[personaIndex + 1] : undefined; const personaAvatarUrl = personaAvatarIndex >= 0 ? process.argv[personaAvatarIndex + 1] : undefined; const payload = { - sentAt: "2026-05-17T03:01:00.000Z", - mode: "channel", - transport: personaName ? "webhook" : "bot", + action: "discord-post", + ok: true, channelId, + messageId: "fixture-bifrost-speech-message", + transport: personaName ? "webhook" : "bot", replyToMessageId, personaName, personaAvatarUrl, - 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: "channel", channelId }) + "\n"); +process.stdout.write(JSON.stringify(payload) + "\n"); '@ - [System.IO.File]::WriteAllText($fakeSendPath, $fakeSendSource, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($fakeBridgePath, $fakeBridgeSource, [System.Text.UTF8Encoding]::new($false)) $cursorOperation = @{ operation = "record_reviewed_messages" @@ -220,7 +216,7 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + $previousFixtureOutput = $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT $previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR $previousLogDir = $env:VOID_RUMINATION_LOG_DIR - $previousSendScript = $env:VOID_SEND_DISCORD_SCRIPT + $previousBridgeScript = $env:VOID_BIFROST_BRIDGE_SCRIPT $previousDiscordTransport = $env:VOID_DISCORD_TRANSPORT $previousDisableRepoCursorAdvance = $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE $previousVoidAvatarUrl = $env:DISCORD_PERSONA_AVATAR_URL_VOID @@ -231,8 +227,8 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT = $statusOperationPath $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_BIFROST_BRIDGE_SCRIPT = $fakeBridgePath + $env:VOID_DISCORD_TRANSPORT = "bifrost" $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = "1" $env:DISCORD_PERSONA_AVATAR_URL_VOID = "https://example.invalid/void-fixture-avatar.png" @@ -243,7 +239,7 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT = $previousFixtureOutput $env:VOID_RUMINATION_STATUS_DIR = $previousStatusDir $env:VOID_RUMINATION_LOG_DIR = $previousLogDir - $env:VOID_SEND_DISCORD_SCRIPT = $previousSendScript + $env:VOID_BIFROST_BRIDGE_SCRIPT = $previousBridgeScript $env:VOID_DISCORD_TRANSPORT = $previousDiscordTransport $env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE = $previousDisableRepoCursorAdvance $env:DISCORD_PERSONA_AVATAR_URL_VOID = $previousVoidAvatarUrl diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 62292a6..0464252 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -347,3 +347,4 @@ {"ts":"2026-06-04T22:20:09Z","type":"implementation","status":"ok","note":"Repo Face SAY routing now marks parent-Interpreter stale/older-side-thread routes as requiring an explicit reply_to anchor; worker refuses those SAY intents instead of defaulting to an unanchored current_room post. Verified build, agents:speech-boundary-smoke, moderation state-boundary fixture, and restarted worker PID 2488."} {"ts":"2026-06-04T22:27:52Z","type":"implementation","status":"ok","note":"Void moderation candidate delivery now resolves persona avatar URLs at the parent delivery boundary when the model target names a persona but omits personaAvatarUrl. Speech fixture verifies Void delivery receives DISCORD_PERSONA_AVATAR_URL_VOID before direct/Bifrost transport."} {"ts":"2026-06-04T22:29:15Z","type":"decision","status":"ok","note":"Transport doctrine updated by operator: VoidBot should expect GameCult infra and Bifrost/CultMesh transport; standalone native Discord fallback is no longer a product goal. Future cuts should remove direct VoidBot-native transport authority instead of preserving convenience for non-GameCult deployments."} +{"ts":"2026-06-04T22:36:57Z","type":"implementation","status":"ok","note":"Void moderation delivery now requires Bifrost instead of falling back to VoidBot-native Discord transport: run-void-moderator-rumination rejects VOID_DISCORD_TRANSPORT=direct, candidate speech and moderation-status DMs call bifrost-bridge only, and speech/status fixtures use fake Bifrost bridges."} diff --git a/state/map.yaml b/state/map.yaml index 29ef998..b15d76e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -82,7 +82,7 @@ accepted_design: - Moderation review should run as a separate local Codex scheduled loop with tracked rules prompt surfaces plus one ignored structured state file, escalating via `notify_owner` instead of pretending the live Discord bot is a silent auto-punishment machine. - The moderation agent should think of itself as a constructive participant as well as a reviewer, and its ignored structured state should follow a Ghostlight-shaped identity/canonical-state/goals/memories/overlays/runtime split rather than a flat notes bucket. - The moderation agent's runtime cognition should stay split between an analytic room-facing lane, an associative archive/project lane, and a bridge that tracks syntheses plus topic saturation; depth is good, but one rewarding seam must not get to annex the whole mind. - - Unattended moderation speech and user-status DMs should cross through Bifrost by default: `scripts/run-void-moderator-rumination.ps1` uses `BIFROST_ROOT/tools/bifrost-bridge.mjs` for Discord posts/DMs when available and treats `scripts/send-discord-message.mjs` as the explicit standalone fallback (`VOID_DISCORD_TRANSPORT=direct`) for installs without the GameCult infra stack. VoidBot owns moderation judgment and typed state; Bifrost owns the public transport crossing and receipt shape. + - Unattended moderation speech and user-status DMs require Bifrost: `scripts/run-void-moderator-rumination.ps1` uses `BIFROST_ROOT/tools/bifrost-bridge.mjs` for Discord posts/DMs and rejects `VOID_DISCORD_TRANSPORT=direct`. VoidBot owns moderation judgment and typed state; Bifrost owns the Discord transport crossing and receipt shape. Open source does not mean convenient standalone deployment. - Quiet moderation runs should not force novelty theater. If incubation already holds a live, grounded, low-saturation thought, the loop may deepen it directly; random archive excursions and repo sweeps are for hungry, stale, or oversaturated queues, not mandatory throat-clearing before every private thought. - The moderation loop is also Void's only standing cross-project perch. Quiet runs should sweep recent tracked-repo commit activity, record repo-activity passes in state, and treat the broader experiment zoo as fair game for grounded commentary when that wider view would help the room. - The current self-state cache seam now uses typed CultCache `.cc` documents directly; editable JSON working projection and routine legacy mirror writes are rejected as mutation authority. @@ -228,7 +228,7 @@ landed_baseline: - The local `GameCult Local Orchestrator` scheduled task is installed as the hidden one-minute pulse for Bifrost dispatch, repo Face CTB heartbeats, Void mood drift, Void moderation rumination, and the operations watchdog. It writes per-organ logs under `.voidbot/logs/orchestrator`, summary/status under `.voidbot/status/gamecult-orchestrator*.json`, freezes overlap with one orchestrator lock, and enforces per-organ timeouts so a hung child cannot occupy the whole clock forever. - VoidBot stack startup now treats Bifrost's local Docker Compose stack as a dependency. `scripts/start-voidbot-stack.ps1` checks `BIFROST_HEALTH_URL`, runs `BIFROST_ROOT/compose.local.yaml` when needed, and records `runtime-stack.json.bifrost` before starting the bot and worker. - The typed rumination path now has a model-branch fixture: `npm run moderation:rumination:fixture` seeds a temporary `.cc`, routes the non-skip Codex branch through a fake child, and verifies short-term memory, incubation, agency-pressure, and candidate-intervention proposals cross only through parent-applied typed operations without leaking exact timestamps into the prompt-facing context. - - The typed rumination speech path is parent-owned now. Candidate interventions can carry an optional delivery target; the child may queue candidates but must not call Discord send scripts. The parent runner delivers at most one newly queued deliverable candidate per pass, records a delivery receipt, and marks the candidate spoken through `mark_candidate_intervention_spoken`. Default delivery crosses through Bifrost (`npm run moderation:rumination:bifrost-transport-fixture`); the direct helper path remains fixture-covered as standalone fallback (`npm run moderation:rumination:speech-fixture`). + - The typed rumination speech path is parent-owned now. Candidate interventions can carry an optional delivery target; the child may queue candidates but must not call Discord send scripts. The parent runner delivers at most one newly queued deliverable candidate per pass through Bifrost, records a delivery receipt, and marks the candidate spoken through `mark_candidate_intervention_spoken`. `npm run moderation:rumination:bifrost-transport-fixture` covers queued speech plus moderation status DMs, and `npm run moderation:rumination:speech-fixture` covers the parent-owned candidate closure path with a fake Bifrost bridge. - The live sleep-maintenance gate has passed once on real state. A manual typed nap triggered `scripts/run-void-memory-maintenance.ps1`, which consumed the scheduled rumination short-term memory with one `apply_memory_distillation` operation and left `thoughtMemory.shortTerm` empty while preserving the durable `identity_seam` target, claim, tension, action implication, and anchors. - Awake rumination now has a sleep brake. If Void is napping and there are no new room messages or open cases, `scripts/run-void-moderator-rumination.ps1` writes an empty operation array and exits before invoking the model. `npm run moderation:rumination:nap-skip-fixture` verifies the model path is not touched in that case. - Short-term rumination now has a parent-side clustering guard. `record_short_term_memory` merges same target/topic pressure into one provisional memory with merged anchors and a stable source id, while `npm run moderation:short-term-clustering:fixture` proves related synth-boundary memories collapse and unrelated same-repo topics stay separate. The guard should preserve deepening by carrying fresh anchors forward; saturation is a judgment about whether the thought is still changing, not a raw duplicate counter.