From 6ffafc5cde5c1d7965bf75c393b5ed47feaeb7da Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 4 Jun 2026 20:28:55 +0100 Subject: [PATCH] Track moderation status through Bifrost transport --- config/discord-server-rules.md | 6 + notes/fresh-workspace-handoff.md | 6 +- notes/voidbot-current-system-map.md | 6 +- notes/voidbot-implementation-plan.md | 2 +- package.json | 2 + packages/core/src/void-self-state-domain.ts | 51 ++++ .../core/src/void-self-state-projection.ts | 11 + packages/core/src/void-self-state-service.ts | 193 +++++++++++++++ prompts/void-moderator-rumination.md | 7 +- .../void-rumination-context-projection.ps1 | 59 +++++ scripts/run-void-moderator-rumination.ps1 | 226 +++++++++++++++++- scripts/send-discord-message.mjs | 31 ++- .../smoke-void-moderation-status-fixture.ps1 | 167 +++++++++++++ ...d-rumination-bifrost-transport-fixture.ps1 | 197 +++++++++++++++ .../smoke-void-rumination-speech-fixture.ps1 | 3 + state/evidence.jsonl | 1 + state/map.yaml | 4 +- 17 files changed, 940 insertions(+), 32 deletions(-) create mode 100644 scripts/smoke-void-moderation-status-fixture.ps1 create mode 100644 scripts/smoke-void-rumination-bifrost-transport-fixture.ps1 diff --git a/config/discord-server-rules.md b/config/discord-server-rules.md index 53a467d..55e9a43 100644 --- a/config/discord-server-rules.md +++ b/config/discord-server-rules.md @@ -8,6 +8,12 @@ Source messages: These are the rules the moderation review automation should treat as authoritative. +Consequences policy for automation: +- Moderation can record a strike when a rule violation needs tracked consequence short of removal. +- A strike expires one week after it is issued. +- Users should be informed by DM when a strike is issued, expires, or otherwise materially changes their moderation status. +- Severe or repeated violations may still lead to immediate moderator action, including removal. + ## English ```text diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 0c1dbb8..1c81da0 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:speech-fixture` proves queued speech has a parent-owned action loop: fake Codex queues one candidate with a delivery target, fake Discord send writes a receipt, and the parent runner marks the candidate spoken through typed state. The child prompt now forbids direct send-script calls. +- `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. - 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. @@ -50,7 +50,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Long-term memory is durable, not immutable. `revise_durable_memory`, `retire_durable_memory`, and `crystallize_memory_into_identity` make memory reconsolidation explicit; `npm run moderation:memory-lifecycle:fixture` proves a short-term thought can promote, revise, retire superseded durable records, and crystallize into an identity memory plus self-profile value. - Agency has a typed pressure surface now: `upsert_agency_pressure` / `retire_agency_pressure` own discomforts, active tensions, self-advocacy requests, and world-advocacy requests. Candidate interventions are possible speech/actions emerging from pressure, not the pressure itself. `npm run moderation:agency-pressure:fixture` verifies the pressure enters `.cc`, renders in self-state summary, and affects mood speaking pressure. - Strong advocacy pressure now has to account for its own mouth. The rumination runner derives `speechPressureObligations` once active self/world advocacy reaches 0.55 intensity, and only a live queued candidate counts as release for that obligation. A pass may queue/defer a candidate or cool/retire the pressure, but silent `[]` fails visibly and an older spoken candidate does not buy permanent absolution. `npm run moderation:rumination:speech-pressure-fixture` covers the silent, spoken-history, and queued-candidate paths. -- Deferred candidates are not delivered speech and no longer satisfy active advocacy pressure by themselves. The runner now delivers existing queued candidates with delivery targets, not only candidates proposed by the current model pass. The first live repair used Metacrat's explicit channel permission to move the stranded AquariumSynthHost candidate from deferred/no-target to queued/channel-target, then delivered it through the normal bot transport and recorded a typed speech receipt. +- Deferred candidates are not delivered speech and no longer satisfy active advocacy pressure by themselves. The runner now delivers existing queued candidates with delivery targets, not only candidates proposed by the current model pass. The first live repair used Metacrat's explicit channel permission to move the stranded AquariumSynthHost candidate from deferred/no-target to queued/channel-target, then delivered it through the then-current bot transport and recorded a typed speech receipt. Current unattended delivery should cross through Bifrost by default. - Parent-owned delivery now also checks canonical typed speech receipts before using the mouth. If a queued candidate targets a Discord reply that already has a receipt, the runner retires it instead of answering the same message twice in slightly different prose. Typed state writes also normalize historical receipts to keep the earliest canonical reply per `channelId + replyToMessageId`. `scripts/smoke-void-rumination-speech-fixture.ps1` now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. - Speaking pressure is allowed to refresh while moderation is active. `scripts/simulate-void-mood.mjs` still skips sleep-cycle mutation and memory maintenance under a recent moderation lock, but it now reloads typed state and applies `update_speaking_pressure` first, so fresh canonical speech receipts update `lastSpokeAt`, damping, and `needToSpeak` even during long rumination runs. - Most of the deterministic legacy translation and retention code is recent compensator cruft from failed attempts to manually clean an exploded state file. Do not preserve it as an earned organ. Architect the state boundary so that bad state cannot enter, and keep only narrow historical migrations for old debris. @@ -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 speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth. That helper and the `post_discord_message` MCP tool now also support a shared guild-channel webhook pipe with per-message `personaName` and optional `personaAvatarUrl`, so swarm agents can speak as themselves without needing separate Discord bot identities. +- 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. - 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 0acceb5..afa5a12 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -199,15 +199,15 @@ 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 delivery. + - 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. - 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, and future Discord-native work interfaces. + - 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. - Eve/CultUI owns presentation shape; browser/native/TUI renderers lower provider-owned surfaces. - Inputs: - Discord gateway events, archived Discord corpus, indexed source/lore archives, Postgres jobs/audit/interaction memory, Qdrant vectors, typed `.cc` self/Face state, Bifrost governance digest, and orchestrator status. - Outputs: - - Discord replies or webhook persona posts through parent-owned rails, owner handoff artifacts, MCP retrieval/tool results, typed CultCache operations, and CultMesh-advertised Eve/CultUI provider surfaces. + - Discord reply/status intents sent through Bifrost with receipts applied to typed state, owner handoff artifacts, MCP retrieval/tool results, typed CultCache operations, and CultMesh-advertised Eve/CultUI provider surfaces. - Derived state: - JSON status files, generated HTML, source/archive freshness summaries, prompt-facing projections, and local debug snapshots are witnesses or lowerings. They do not own durable truth. - Forbidden writers: diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index 6986032..0ee1861 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`. `npm run moderation:rumination:speech-fixture` proves the loop with fake Codex and fake Discord send helpers. +- 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 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/package.json b/package.json index 2d204b6..6664311 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "moderation:rumination:speech-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-fixture.ps1", "moderation:rumination:speech-pressure-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-pressure-fixture.ps1", "moderation:rumination:nap-skip-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-nap-skip-fixture.ps1", + "moderation:rumination:bifrost-transport-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-bifrost-transport-fixture.ps1", + "moderation:status:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-moderation-status-fixture.ps1", "moderation:mood-drift": "node scripts/simulate-void-mood.mjs", "rag:scrub-bot-prompts": "tsx scripts/scrub-bot-prompts-from-history.ts", "rag:index-sources": "tsx scripts/index-source-repos.ts", diff --git a/packages/core/src/void-self-state-domain.ts b/packages/core/src/void-self-state-domain.ts index 21d0aec..25e540c 100644 --- a/packages/core/src/void-self-state-domain.ts +++ b/packages/core/src/void-self-state-domain.ts @@ -61,6 +61,43 @@ const moderationOpenCaseSchema = z.object({ tags: z.array(nonEmptyStringSchema).default([]), }).strict(); +const moderationStrikeSchema = z.object({ + strikeId: nonEmptyStringSchema, + reason: boundedTextSchema, + ruleRef: z.string().trim().min(1).max(240).optional(), + sourceMessageId: z.string().trim().min(1).optional(), + channelId: z.string().trim().min(1).optional(), + issuedAt: timestampSchema, + expiresAt: timestampSchema, + expiredAt: timestampSchema.optional(), + issuedBy: z.string().trim().min(1).max(240).optional(), + tags: z.array(nonEmptyStringSchema).default([]), +}).strict(); + +const moderationStatusNoticeSchema = z.object({ + noticeId: nonEmptyStringSchema, + kind: z.enum(["status_update", "strike_added", "strike_expired", "status_clear"]), + summary: z.string().trim().min(1).max(1000), + body: boundedTextSchema, + createdAt: timestampSchema, + sentAt: timestampSchema.optional(), + channelId: z.string().trim().min(1).optional(), + messageId: z.string().trim().min(1).optional(), + error: z.string().trim().min(1).max(2000).optional(), + tags: z.array(nonEmptyStringSchema).default([]), +}).strict(); + +const moderationUserStatusSchema = z.object({ + userId: nonEmptyStringSchema, + userName: z.string().trim().min(1).max(240).optional(), + status: z.enum(["clear", "watched", "active", "retired"]), + summary: z.string().trim().min(1).max(1000).optional(), + strikes: z.array(moderationStrikeSchema).default([]), + pendingNotices: z.array(moderationStatusNoticeSchema).default([]), + updatedAt: timestampSchema, + tags: z.array(nonEmptyStringSchema).default([]), +}).strict(); + const moderationRepoCursorSchema = z.object({ repo: nonEmptyStringSchema, lastCommitAt: timestampSchema.optional(), @@ -411,6 +448,7 @@ export const voidModerationCursorSchema = z.object({ lastReviewedMessageId: z.string().trim().min(1).optional(), lastReviewedTimestamp: timestampSchema.optional(), openCases: z.array(moderationOpenCaseSchema).default([]), + userStatuses: z.array(moderationUserStatusSchema).default([]), repoActivityCursor: z.array(moderationRepoCursorSchema).default([]), updatedAt: timestampSchema, }).strict(); @@ -553,6 +591,19 @@ export const voidSelfStateOperationSchema = z.discriminatedUnion("operation", [ resolvedAt: timestampSchema, resolutionSummary: z.string().trim().min(1).max(2000).optional(), }).strict(), + z.object({ + operation: z.literal("upsert_moderation_user_status"), + status: moderationUserStatusSchema, + }).strict(), + z.object({ + operation: z.literal("mark_moderation_status_notice_sent"), + userId: nonEmptyStringSchema, + noticeId: nonEmptyStringSchema, + sentAt: timestampSchema, + channelId: z.string().trim().min(1).optional(), + messageId: z.string().trim().min(1).optional(), + error: z.string().trim().min(1).max(2000).optional(), + }).strict(), z.object({ operation: z.literal("update_repo_activity_cursor"), cursor: moderationRepoCursorSchema, diff --git a/packages/core/src/void-self-state-projection.ts b/packages/core/src/void-self-state-projection.ts index f7753e7..9528632 100644 --- a/packages/core/src/void-self-state-projection.ts +++ b/packages/core/src/void-self-state-projection.ts @@ -98,6 +98,7 @@ export function createEmptyVoidSelfState( moderationCursor: voidModerationCursorSchema.parse({ schemaVersion: 1, openCases: [], + userStatuses: [], repoActivityCursor: [], updatedAt: createdAt, }), @@ -210,6 +211,13 @@ export function renderVoidSelfStateSummary( .slice(-4) .reverse() .map((entry) => `- ${entry.summary}${entry.authorName ? ` [from ${entry.authorName}]` : ""}`); + const activeModerationStatuses = state.moderationCursor.userStatuses + .filter((status) => + status.status !== "retired" && + status.strikes.some((strike) => !strike.expiredAt && Date.parse(strike.expiresAt) > Date.now()), + ); + const pendingModerationNotices = state.moderationCursor.userStatuses + .reduce((count, status) => count + status.pendingNotices.filter((notice) => !notice.sentAt).length, 0); const interventions = state.candidateInterventions.interventions .filter((entry) => entry.status === "queued" || entry.status === "deferred") .slice(-3) @@ -247,6 +255,9 @@ export function renderVoidSelfStateSummary( openCases.length > 0 ? [`- What ${identityName} owes the room:`, ...openCases].join("\n") : `- What ${identityName} owes the room: nothing unresolved.`, + activeModerationStatuses.length > 0 || pendingModerationNotices > 0 + ? `- Moderation status: ${activeModerationStatuses.length} users with active strikes; ${pendingModerationNotices} status DMs pending.` + : "- Moderation status: no active strikes or pending status DMs.", memories.length > 0 ? [`- What ${identityName} remembers:`, ...memories].join("\n") : `- What ${identityName} remembers: nothing durable yet.`, diff --git a/packages/core/src/void-self-state-service.ts b/packages/core/src/void-self-state-service.ts index c92bd41..3e5fb6f 100644 --- a/packages/core/src/void-self-state-service.ts +++ b/packages/core/src/void-self-state-service.ts @@ -29,6 +29,8 @@ import { type VoidSelfStateTypedProjection, } from "./void-self-state-projection"; +const MODERATION_STRIKE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + export interface VoidSelfStateServiceOptions { canonicalPath: string; identity?: VoidSelfStateIdentityDefaults; @@ -130,6 +132,7 @@ function readTypedStateOrEmpty( if (identity) { repairSelfProfileIdentity(state, identity); } + normalizeModerationCursor(state.moderationCursor, new Date().toISOString()); normalizeDeliveryReceipts(state); return state; } @@ -139,6 +142,7 @@ async function writeTypedState( state: VoidSelfStateTypedProjection, ): Promise { normalizeTypedStateForWrite(state); + normalizeModerationCursor(state.moderationCursor, new Date().toISOString()); normalizeDeliveryReceipts(state); await cache.putGlobal(voidSelfProfileDocument, stripUndefined(state.selfProfile)); await cache.putGlobal(voidModerationCursorDocument, stripUndefined(state.moderationCursor)); @@ -207,6 +211,7 @@ function normalizeTypedStateForWrite( observedAt = new Date().toISOString(), ): void { pruneRetiredTypedState(state); + normalizeModerationCursor(state.moderationCursor, observedAt); let thoughtMemoryChanged = false; for (const memory of state.thoughtMemory.shortTerm) { @@ -248,6 +253,7 @@ function pruneRetiredTypedState(state: VoidSelfStateTypedProjection): void { state.moderationCursor.openCases = state.moderationCursor.openCases.filter((entry) => !isTerminalCaseStatus(entry.status) ); + state.moderationCursor.userStatuses = state.moderationCursor.userStatuses.filter(isLiveModerationUserStatus); state.thoughtMemory.shortTerm = state.thoughtMemory.shortTerm.filter(isNotRetiredEntry); state.thoughtMemory.memories = state.thoughtMemory.memories.filter(isNotRetiredEntry); state.thoughtMemory.incubation = state.thoughtMemory.incubation.filter(isNotRetiredEntry); @@ -267,6 +273,13 @@ function isLiveCandidateIntervention(entry: { status?: string; retiredAt?: strin return (entry.status === "queued" || entry.status === "deferred") && !entry.retiredAt; } +function isLiveModerationUserStatus(entry: VoidModerationCursor["userStatuses"][number]): boolean { + if (entry.status !== "retired") { + return true; + } + return entry.pendingNotices.some((notice) => !notice.sentAt); +} + function clampFutureTimestampFields>( value: T, observedAt: string, @@ -313,6 +326,18 @@ function applyTypedOperation( case "close_open_case": closeTypedOpenCase(state.moderationCursor, operation); return; + case "upsert_moderation_user_status": + upsertBy( + state.moderationCursor.userStatuses, + normalizeModerationUserStatusOperation(state.moderationCursor, operation.status, appliedAt), + (entry) => entry.userId, + ); + normalizeModerationCursor(state.moderationCursor, appliedAt); + state.moderationCursor.updatedAt = appliedAt; + return; + case "mark_moderation_status_notice_sent": + markModerationStatusNoticeSent(state.moderationCursor, operation); + return; case "update_repo_activity_cursor": upsertBy(state.moderationCursor.repoActivityCursor, operation.cursor, (entry) => entry.repo.toLowerCase()); state.moderationCursor.updatedAt = operation.cursor.updatedAt; @@ -475,6 +500,174 @@ function applyTypedOperation( } } +function normalizeModerationUserStatusOperation( + cursor: VoidModerationCursor, + status: VoidModerationCursor["userStatuses"][number], + appliedAt: string, +): VoidModerationCursor["userStatuses"][number] { + const existing = cursor.userStatuses.find((entry) => entry.userId === status.userId); + return { + ...status, + strikes: status.strikes.map((strike) => ({ + ...strike, + expiresAt: normalizeStrikeExpiry(strike.issuedAt, strike.expiresAt), + })), + pendingNotices: mergeModerationStatusNotices(existing?.pendingNotices ?? [], status.pendingNotices), + updatedAt: appliedAt, + }; +} + +function normalizeStrikeExpiry(issuedAt: string, expiresAt: string): string { + const issuedAtMs = Date.parse(issuedAt); + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isFinite(issuedAtMs)) { + return expiresAt; + } + + const canonicalExpiresAt = new Date(issuedAtMs + MODERATION_STRIKE_TTL_MS).toISOString(); + if (!Number.isFinite(expiresAtMs)) { + return canonicalExpiresAt; + } + + const latestAllowedExpiryMs = issuedAtMs + MODERATION_STRIKE_TTL_MS; + return expiresAtMs > latestAllowedExpiryMs ? canonicalExpiresAt : expiresAt; +} + +function normalizeModerationCursor(cursor: VoidModerationCursor, observedAt: string): void { + let changed = false; + for (const status of cursor.userStatuses) { + const statusChanged = normalizeModerationUserStatus(status, observedAt); + changed = statusChanged || changed; + } + + cursor.userStatuses = cursor.userStatuses + .filter(isLiveModerationUserStatus) + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)) + .slice(-128); + + if (isFutureTimestamp(cursor.updatedAt, observedAt) || changed) { + cursor.updatedAt = observedAt; + } +} + +function normalizeModerationUserStatus( + status: VoidModerationCursor["userStatuses"][number], + observedAt: string, +): boolean { + let changed = false; + const observedAtMs = Date.parse(observedAt); + + status.strikes = status.strikes.map((strike) => { + const normalizedExpiresAt = normalizeStrikeExpiry(strike.issuedAt, strike.expiresAt); + const nextStrike = normalizedExpiresAt === strike.expiresAt ? strike : { ...strike, expiresAt: normalizedExpiresAt }; + const expiresAtMs = Date.parse(nextStrike.expiresAt); + if ( + !nextStrike.expiredAt && + Number.isFinite(expiresAtMs) && + Number.isFinite(observedAtMs) && + expiresAtMs <= observedAtMs + ) { + changed = true; + return { + ...nextStrike, + expiredAt: nextStrike.expiresAt, + tags: Array.from(new Set([...nextStrike.tags, "expired:one-week-policy"])), + }; + } + if (nextStrike !== strike) { + changed = true; + } + return nextStrike; + }); + + const activeStrikes = status.strikes.filter((strike) => !strike.expiredAt); + for (const strike of status.strikes) { + if (!strike.expiredAt) { + continue; + } + const noticeId = `strike-expired-${status.userId}-${strike.strikeId}`; + if (status.pendingNotices.some((notice) => notice.noticeId === noticeId)) { + continue; + } + status.pendingNotices.push({ + noticeId, + kind: "strike_expired", + summary: `Strike expired: ${strike.reason}`, + body: [ + "Moderation status update:", + "", + `Your strike for ${strike.reason} has expired. Strikes expire after one week under the current server policy.`, + activeStrikes.length > 0 + ? `You still have ${activeStrikes.length} active strike(s).` + : "You currently have no active strikes.", + ].join("\n"), + createdAt: observedAt, + tags: ["policy:one-week-strike-expiry"], + }); + changed = true; + } + + status.pendingNotices = mergeModerationStatusNotices([], status.pendingNotices) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .slice(-24); + + const nextStatus = activeStrikes.length > 0 + ? "active" + : status.pendingNotices.some((notice) => !notice.sentAt) + ? "clear" + : status.status === "retired" + ? "retired" + : "clear"; + if (status.status !== nextStatus) { + status.status = nextStatus; + changed = true; + } + if (changed) { + status.updatedAt = observedAt; + } + return changed; +} + +function mergeModerationStatusNotices( + existing: VoidModerationCursor["userStatuses"][number]["pendingNotices"], + incoming: VoidModerationCursor["userStatuses"][number]["pendingNotices"], +): VoidModerationCursor["userStatuses"][number]["pendingNotices"] { + const byId = new Map(); + for (const notice of [...existing, ...incoming]) { + const prior = byId.get(notice.noticeId); + byId.set(notice.noticeId, prior ? { ...prior, ...notice } : notice); + } + return Array.from(byId.values()); +} + +function markModerationStatusNoticeSent( + cursor: VoidModerationCursor, + operation: Extract, +): void { + const status = cursor.userStatuses.find((entry) => entry.userId === operation.userId); + if (!status) { + return; + } + + const notice = status.pendingNotices.find((entry) => entry.noticeId === operation.noticeId); + if (!notice) { + return; + } + + notice.sentAt = operation.sentAt; + if (operation.channelId) { + notice.channelId = operation.channelId; + } + if (operation.messageId) { + notice.messageId = operation.messageId; + } + if (operation.error) { + notice.error = operation.error; + } + status.updatedAt = operation.sentAt; + cursor.updatedAt = operation.sentAt; +} + function normalizeShortTermMemoryOperation( thoughtMemory: VoidThoughtMemory, memory: VoidThoughtMemory["shortTerm"][number], diff --git a/prompts/void-moderator-rumination.md b/prompts/void-moderator-rumination.md index bed2ad9..bd4c7d9 100644 --- a/prompts/void-moderator-rumination.md +++ b/prompts/void-moderator-rumination.md @@ -48,8 +48,9 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup - If a candidate answers, corrects, riffs on, or continues Aquarium talk, its `deliveryTarget.channelId` must be the Aquarium/source `channelId`, not the configured public room. Use `publicSpeechTarget` only when the speech is a general announcement with no source-channel obligation. - If a candidate is about internal swarm transport, paused governance, Face scheduling, or agent-substrate commentary, only use a non-Aquarium delivery target when that exact channel supplied the direct ask or invitation. - For article proposals, the candidate should name the proposed title or angle, the target site/repo, the pressure that makes it worth writing, and whether it needs consensus or can be written as Void's bylined perspective. -10. Do not call Discord send scripts. The parent runner owns posting and receipts. If `{{NO_POST}}` is true, queued candidates must stay queued. -11. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence and there are no pending mentions or speech pressure obligations, write `[]`. +10. When a rules consequence changes a user's moderation status, use `upsert_moderation_user_status`. A strike expires one week after `issuedAt`; set `expiresAt` to that one-week deadline and include a pending DM notice explaining the status change in plain language. Do not turn status notices into public candidate speech. The parent runner owns actually sending the DM and recording the receipt. +11. Do not call Discord send scripts. The parent runner owns posting and receipts, including moderation status DMs. If `{{NO_POST}}` is true, queued candidates and moderation status notices must stay queued. +12. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence and there are no pending mentions or speech pressure obligations, write `[]`. ## Allowed Operations @@ -68,6 +69,7 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup - `update_mood_dimensions` - `queue_candidate_intervention` - `retire_candidate_intervention` +- `upsert_moderation_user_status` ## Parent-Owned Operations @@ -76,6 +78,7 @@ Do not emit these operations from rumination: - `record_reviewed_messages` - `record_delivery_receipt` - `mark_candidate_intervention_spoken` +- `mark_moderation_status_notice_sent` - `update_sleep_cycle` - `update_speaking_pressure` - `propose_memory_distillation` diff --git a/scripts/lib/void-rumination-context-projection.ps1 b/scripts/lib/void-rumination-context-projection.ps1 index 8436952..6e0ec2b 100644 --- a/scripts/lib/void-rumination-context-projection.ps1 +++ b/scripts/lib/void-rumination-context-projection.ps1 @@ -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) diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index 854cea3..41fa6a9 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -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 diff --git a/scripts/send-discord-message.mjs b/scripts/send-discord-message.mjs index 7bd548a..b416c12 100644 --- a/scripts/send-discord-message.mjs +++ b/scripts/send-discord-message.mjs @@ -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 ."); } - 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: { diff --git a/scripts/smoke-void-moderation-status-fixture.ps1 b/scripts/smoke-void-moderation-status-fixture.ps1 new file mode 100644 index 0000000..384b593 --- /dev/null +++ b/scripts/smoke-void-moderation-status-fixture.ps1 @@ -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 +} diff --git a/scripts/smoke-void-rumination-bifrost-transport-fixture.ps1 b/scripts/smoke-void-rumination-bifrost-transport-fixture.ps1 new file mode 100644 index 0000000..c4ee91e --- /dev/null +++ b/scripts/smoke-void-rumination-bifrost-transport-fixture.ps1 @@ -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 +} diff --git a/scripts/smoke-void-rumination-speech-fixture.ps1 b/scripts/smoke-void-rumination-speech-fixture.ps1 index db8e43d..9aef6d9 100644 --- a/scripts/smoke-void-rumination-speech-fixture.ps1 +++ b/scripts/smoke-void-rumination-speech-fixture.ps1 @@ -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 } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5ca5132..2f0e158 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -341,3 +341,4 @@ {"ts":"2026-06-04T17:32:36Z","type":"implementation","status":"ok","note":"Added read-only repo Face self-transcript MCP tools: list/read/search Projector-Persona-Interpreter-Delivery witness packets from model-output logs, job artifacts, and typed Face delivery receipts. Added the tools to the restricted Face child MCP allowlist and restarted the worker directly as PID 26720 after the full stack preflight blocked on the remote Ollama endpoint."} {"ts":"2026-06-04T18:54:39Z","type":"implementation","status":"ok","note":"Created a canonical GameCult structured text CultCache store at .voidbot/private/gamecult-text-documents.cc. The first document is gamecult.odin_verse_poem with title, GameCult author, and half-line arrays; repo Face prompts now render the Odin poem from that typed document while Nightwing-style consumers can lower the same arrays with their own caesura/marquee formatting."} {"ts":"2026-06-04T19:02:38Z","type":"implementation","status":"ok","note":"Added gamecult.love_colossus_poem as the second canonical GameCult structured text document, based on the 2026-06-01 Love Is How The Colossus Wakes article and refreshed against Bellows' Poetic Edda register. The document uses title/author/half-line arrays and previews through texts:seed --document-id gamecult.love_colossus_poem."} +{"ts":"2026-06-04T19:28:18Z","type":"implementation","status":"ok","note":"Moderation consequences now live in typed void.moderation_cursor userStatuses with one-week strike expiry and pending user-status notices; unattended Discord egress defaults through Bifrost bridge posts/DMs with direct VoidBot send helper retained only behind VOID_DISCORD_TRANSPORT=direct fallback."} diff --git a/state/map.yaml b/state/map.yaml index a8e2a29..29ef998 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 should use a local bot-token script instead of approval-gated side-effecting MCP tools, otherwise the loop keeps stopping to ask permission before it can talk. + - 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. - 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`. `npm run moderation:rumination:speech-fixture` verifies this loop with fake Codex and fake Discord send helpers. + - 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 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.