From e8acb67141fc6e1f681e078d513383caa209554c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 09:47:17 +0100 Subject: [PATCH] Tighten Void speech pressure and reply guards --- notes/fresh-workspace-handoff.md | 7 +- notes/voidbot-current-system-map.md | 9 +- notes/voidbot-implementation-plan.md | 6 +- prompts/void-moderator-rumination.md | 4 +- scripts/run-void-moderator-rumination.ps1 | 88 +++++++++++++++++-- scripts/simulate-void-mood.mjs | 8 +- .../smoke-void-rumination-speech-fixture.ps1 | 61 ++++++++++++- ...oid-rumination-speech-pressure-fixture.ps1 | 63 ++++++++++++- state/evidence.jsonl | 2 + state/map.yaml | 7 +- 10 files changed, 225 insertions(+), 30 deletions(-) diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ae90122..f13cbcb 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -44,8 +44,9 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Short-term rumination memory is clustered at the typed service boundary now. When a new `record_short_term_memory` operation repeats the same target/topic pressure, the service keeps one provisional memory, merges anchors/tags, preserves a stable source id for sleep, and updates the thought instead of stacking duplicates. This is meant to protect deepening, not suppress it: low-saturation or newly anchored thoughts should keep developing inside the cluster until they stop changing. `npm run moderation:short-term-clustering:fixture` proves the guard. - 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` for high-intensity active self/world advocacy pressures with no matching candidate. A pass may queue/defer a candidate or cool/retire the pressure, but silent `[]` fails visibly. `npm run moderation:rumination:speech-pressure-fixture` covers the guard. +- 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. +- 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. `scripts/smoke-void-rumination-speech-fixture.ps1` now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. - 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. - The desired agency shape is typed state operations plus derived/model-owned policy, not a permanent sermon generator in maintenance code. - Sleep/distillation now has a runner boundary, prompt contract, fixture coverage, and one live-state pass. The invariant is fixed: concrete subject, claim/question, anchors, live tension, and future-action implication survive compression. Bulk can die. Meaning does not get put through the office shredder and called a dream. @@ -104,8 +105,8 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou ## Likely Next Bounded Move -- First bounded move after the current commit: let the enabled loop run under ordinary scheduling after the short-term clustering and queued-speech guards, then inspect the next unattended awake rumination and sleep cycle for repeated thought clusters, stale short-term residue, muted advocacy pressure, stranded candidates, or ungrounded memory writes before calling the system broadly stable. -- The immediate duplicate-rumination follow-up is to inspect the next ordinary rumination after the clustering guard and confirm repeated AquaSynth/Aquarium pressure folds into the existing cluster or redirects to underworked terrain instead of producing another short-term variant. +- First bounded move after the current commit: let the enabled loop run under ordinary scheduling after the receipt-backed direct-reply guard and the earlier advocacy-release change, then inspect the next unattended direct reply, next advocacy pass, and next sleep cycle for duplicate answers, fresh queued advocacy candidates, stale short-term residue, stranded candidates, or ungrounded memory writes before calling the system broadly stable. +- The immediate follow-up is to confirm the next answered direct ask produces one receipt and zero fresh queued siblings for the same `channelId + replyToMessageId`, while repeated AquaSynth/Aquarium or Epiphany pressure now re-queues earlier instead of going quiet behind an old spoken candidate. - Next cuts should delete unstable foundations early: do not revive `legacyJsonPath`, working projections, top-level mirror fields, or cleanup code that only compensated for those broken boundaries. - Keep sleep/distillation under watch. Fixture check: a memory like `AquariumSynthCSharp: Workflow cannot own the body` must survive sleep with its concrete subject, claim, anchor, tension, and implication intact. - Do not build adapters around the JSON projection. That is how the heap learned to stand upright and ask for snacks. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 4c1aeea..17ece78 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -200,15 +200,16 @@ Within the Postgres path, the implementation is split on purpose now too: 6. Codex may use retrieval and analysis tools, but its durable state output is restricted to `.voidbot/status/moderation-rumination-operations.json`. 7. The parent runner applies those typed operations through `scripts/void-self-state.mjs`, then records reviewed-message cursor and, on successful normal posting-enabled passes, advances repo-activity cursors from the observed repo weather through typed `update_repo_activity_cursor` operations. 8. If posting is enabled and typed state contains a queued candidate intervention with a delivery target, the parent runner sends at most one candidate, records the delivery receipt, and marks the candidate spoken through typed state. The candidate may have been proposed in the current pass or already queued from an earlier pass. The child prompt forbids direct send-script calls. -9. The runner writes `.voidbot/status/moderation-rumination.json` and `.voidbot/logs/moderation-rumination.log`. -10. It intentionally does not materialize `.json`, load `.msgpack`, read the legacy moderation monolith, or let the child edit state directly. -11. Before applying model output, the runner enforces speech-pressure accountability. High-intensity active self/world advocacy pressures with no matching queued/spoken candidate are projected as `speechPressureObligations`; model output must queue a candidate or cool/retire the pressure instead of returning silent `[]`. Deferred candidates do not satisfy that pressure by themselves. +9. Before that delivery step, the parent runner now retires queued candidates whose `channelId + replyToMessageId` already appear in canonical typed speech receipts, so a later pass cannot answer an already-answered direct question just because the same message is still visible or the seam still feels warm. +10. The runner writes `.voidbot/status/moderation-rumination.json` and `.voidbot/logs/moderation-rumination.log`. +11. It intentionally does not materialize `.json`, load `.msgpack`, read the legacy moderation monolith, or let the child edit state directly. +12. Before applying model output, the runner enforces speech-pressure accountability. Active self/world advocacy pressures at 0.55 intensity or higher with no matching live queued candidate are projected as `speechPressureObligations`; model output must queue a candidate or cool/retire the pressure instead of returning silent `[]`. Deferred candidates do not satisfy that pressure by themselves, and an older spoken candidate does not keep satisfying a still-active pressure forever. ## Flow 6: Mood And Sleep Runtime 1. The Windows scheduled task `Void Mood Drift` is enabled. 2. `scripts/simulate-void-mood.mjs` reads typed documents from `.voidbot/private/void-self-state.cc`. -3. It updates `void.scheduled_runtime.sleepCycle` and `void.scheduled_runtime.speakingPressure` through `update_sleep_cycle` and `update_speaking_pressure` operations. +3. It updates `void.scheduled_runtime.sleepCycle` and `void.scheduled_runtime.speakingPressure` through `update_sleep_cycle` and `update_speaking_pressure` operations, with active advocacy pressure weighted strongly enough to raise speaking pressure earlier instead of staying a private sulk until the seam is already overripe. 4. It writes `.voidbot/status/void-mood-drift.json`. 5. When sleep is active, the script invokes `scripts/run-void-memory-maintenance.ps1` once per nap to force typed distillation/pruning. 6. The old memory-organ script family, legacy state template, and legacy context exporter have been deleted. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index 8a7358b..5026c65 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -4,7 +4,7 @@ This is the current forward plan for the next larger organs. It is not a changel ## Current Aim -The typed moderation, mood, sleep, agency, and private self-state boundary is live. The current goal is to let ordinary scheduling run, inspect the next unattended awake rumination and sleep cycle, and harden only the seams that show concrete failure. Void should answer Discord, remember useful social/project context, retrieve GameCult history/source/lore, and run an unattended moderation/participation loop without reviving JSON projection edits, legacy mirrors, or the swollen memory organ. +The typed moderation, mood, sleep, agency, and private self-state boundary is live. The current goal is to let ordinary scheduling run, inspect the next unattended direct reply and sleep cycle, and harden only the seams that show concrete failure. Void should answer Discord, remember useful social/project context, retrieve GameCult history/source/lore, and run an unattended moderation/participation loop without reviving JSON projection edits, legacy mirrors, or the swollen memory organ. The next priority is observational hardening of the enabled typed loop. Agents and scheduled workers mutate typed CultCache-backed state through explicit operations, not by editing a whole-state JSON working copy. Sleep and memory distillation now preserve meaning-bearing claims/anchors/tensions in fixture and live-state passes, and short-term rumination has a clustering guard so repeated same-target/topic thoughts update one provisional memory instead of stacking duplicates. Keep checking ordinary cycles for abstraction sludge, duplicate speech, stale residue, or self-orbiting memories. @@ -287,14 +287,16 @@ Verdict: the foundation is coherent enough for cautious live operation. Do not a - Landed agency ingress: `upsert_agency_pressure` / `retire_agency_pressure` store discomforts, active tensions, self-advocacy requests, and world-advocacy requests separately from candidate speech. - Landed agency verification: `npm run moderation:agency-pressure:fixture` proves agency pressure enters typed state, appears in the self-state summary, and contributes to mood speaking pressure. - Landed agency-to-speech accountability: strong active self/world advocacy pressure now appears as `speechPressureObligations` in rumination context. The parent runner rejects silent `[]` unless the model queues a candidate intervention or cools/retires the pressure. Deferred candidates do not satisfy the pressure by themselves, and existing queued candidates with delivery targets can be delivered by later runner passes instead of only during the pass that proposed them. +- 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 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. - Landed sleep brake: scheduled rumination now skips before model invocation during naps when there are no new room messages or open cases. `npm run moderation:rumination:nap-skip-fixture` proves the model path is not touched and no operation proposals are written. - Landed short-term clustering guard: `record_short_term_memory` folds repeated same-target/topic pressure into one provisional memory with merged anchors, and `npm run moderation:short-term-clustering:fixture` proves the synth-boundary duplicate case without merging unrelated same-repo thoughts. The prompt now states explicitly that underexplored, low-saturation, newly anchored thoughts may keep deepening inside the cluster. -- Remaining work: let ordinary scheduling run and inspect the next unattended awake rumination and sleep cycle for repeated thought clusters, stale short-term residue, or ungrounded memory writes before calling the system broadly stable. +- Remaining work: let ordinary scheduling run and inspect the next unattended direct reply, next advocacy pass, and next sleep cycle for duplicate answers, fresh queued advocacy candidates, stale short-term residue, or ungrounded memory writes before calling the system broadly stable. ### Commit 10: Resume Product Hardening diff --git a/prompts/void-moderator-rumination.md b/prompts/void-moderator-rumination.md index 1c607fb..0ca119e 100644 --- a/prompts/void-moderator-rumination.md +++ b/prompts/void-moderator-rumination.md @@ -37,8 +37,8 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup - This is not a ban on depth. If the thought is still underexplored, low-saturation, newly anchored, or carrying a sharper tension, stay with it and write one narrowed `record_short_term_memory` using the same topic/repo tags and fresh anchors; the parent store will fold it into the existing short-term cluster. - Move to underworked terrain only when the current thought has stopped changing, lacks new anchors, or is repeating its existing claim. Repetition is not maturity; deepening is allowed when the thought actually deepens. 6. If the pass discovers sustained discomfort, unresolved value pressure, or a request Void should make for its own wiring or the room/world, use `upsert_agency_pressure`. This is not a speech draft. It is the pressure that may later justify speech or action. -7. If `speechPressureObligations` is non-empty, do not write `[]`. For each obligation, either queue a candidate intervention tagged `source_pressure:` or cool/retire the pressure with an explicit reason. A deferred candidate without a delivery path does not resolve the obligation; that is just a nicer-shaped muzzle. -8. Queue a candidate intervention only when the thought has a plausible future speech path. Draft it in Void's voice, not in maintenance language. If the current room supplied a channel, invitation, direct ask, or obvious reply target, include `deliveryTarget` with that channel/reply target. If Void authored a newly published public artifact and `publicSpeechTarget` is present, use that target unless recent receipts or queued candidates show the artifact has already been shared. Tag the candidate with `topic:public-artifact` and a stable artifact/source tag such as `source_ref:` or `artifact:` when available. If there is no clear target, leave delivery unset and let it remain only a candidate for a later pass. +7. If `speechPressureObligations` is non-empty, do not write `[]`. For each obligation, either queue a candidate intervention tagged `source_pressure:` or cool/retire the pressure with an explicit reason. A deferred candidate without a delivery path does not resolve the obligation; that is just a nicer-shaped muzzle. An older spoken line also does not excuse a still-active pressure. If the pressure is still live, decide again. +8. Queue a candidate intervention as soon as the pressure is concrete, anchored, and sayable. Do not wait for theatrical ripeness if the real job is simple advocacy. Draft it in Void's voice, not in maintenance language. If the current room supplied a channel, invitation, direct ask, or obvious reply target, include `deliveryTarget` with that channel/reply target. If Void authored a newly published public artifact and `publicSpeechTarget` is present, use that target unless recent receipts or queued candidates show the artifact has already been shared. Tag the candidate with `topic:public-artifact` and a stable artifact/source tag such as `source_ref:` or `artifact:` when available. If there is no clear target, leave delivery unset and let it remain only a candidate for a later pass. 9. Do not call Discord send scripts. The parent runner owns posting and receipts. If `{{NO_POST}}` is true, queued candidates must stay queued. 10. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence and there are no speech pressure obligations, write `[]`. diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index c45a408..c981b5f 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -379,11 +379,12 @@ function Test-CandidateMatchesPressure { $Candidate, [Parameter(Mandatory = $true)] [string] $PressureId, - [string] $PressureTargetKey + [string] $PressureTargetKey, + [string[]] $AllowedStatuses = @("queued", "spoken") ) $status = Get-ObjectPropertyString -Value $Candidate -Name "status" - if ($null -eq $status -or -not @("queued", "spoken").Contains($status)) { + if ($null -eq $status -or -not $AllowedStatuses.Contains($status)) { return $false } @@ -405,12 +406,78 @@ function Get-DeliverableCandidateInterventions { $status = Get-ObjectPropertyString -Value $_ -Name "status" $deliveryTarget = Get-ObjectPropertyValue -Value $_ -Name "deliveryTarget" $spokenAt = Get-ObjectPropertyString -Value $_ -Name "spokenAt" - $status -eq "queued" -and $null -ne $deliveryTarget -and [string]::IsNullOrWhiteSpace($spokenAt) + $status -eq "queued" -and + $null -ne $deliveryTarget -and + [string]::IsNullOrWhiteSpace($spokenAt) -and + -not (Test-QueuedCandidateAlreadyAnswered -Candidate $_ -Receipts $TypedState.speechReceipts.recentReceipts) } | Sort-Object -Property @{ Expression = { [double](Get-ObjectPropertyValue -Value $_ -Name "priority") }; Descending = $true }, @{ Expression = { Get-ObjectPropertyString -Value $_ -Name "updatedAt" }; Descending = $false } ) } +function Test-CandidateDeliveryTargetMatchesReceipt { + param( + $DeliveryTarget, + $Receipt + ) + + $channelId = Get-ObjectPropertyString -Value $DeliveryTarget -Name "channelId" + $replyToMessageId = Get-ObjectPropertyString -Value $DeliveryTarget -Name "replyToMessageId" + if ([string]::IsNullOrWhiteSpace($channelId) -or [string]::IsNullOrWhiteSpace($replyToMessageId)) { + return $false + } + + return ( + (Get-ObjectPropertyString -Value $Receipt -Name "channelId") -eq $channelId -and + (Get-ObjectPropertyString -Value $Receipt -Name "replyToMessageId") -eq $replyToMessageId + ) +} + +function Test-QueuedCandidateAlreadyAnswered { + param( + $Candidate, + $Receipts + ) + + $deliveryTarget = Get-ObjectPropertyValue -Value $Candidate -Name "deliveryTarget" + if ($null -eq $deliveryTarget) { + return $false + } + + return @( + @(Convert-ToValueArray -Value $Receipts) | + Where-Object { Test-CandidateDeliveryTargetMatchesReceipt -DeliveryTarget $deliveryTarget -Receipt $_ } + ).Count -gt 0 +} + +function Get-AlreadyAnsweredCandidateRetireOperations { + param( + $TypedState, + [Parameter(Mandatory = $true)] + [DateTime] $RetiredAt + ) + + $receipts = @(Convert-ToValueArray -Value $TypedState.speechReceipts.recentReceipts) + return @( + @(Convert-ToValueArray -Value $TypedState.candidateInterventions.interventions) | + Where-Object { + $status = Get-ObjectPropertyString -Value $_ -Name "status" + $spokenAt = Get-ObjectPropertyString -Value $_ -Name "spokenAt" + $status -eq "queued" -and + [string]::IsNullOrWhiteSpace($spokenAt) -and + (Test-QueuedCandidateAlreadyAnswered -Candidate $_ -Receipts $receipts) + } | + ForEach-Object { + @{ + operation = "retire_candidate_intervention" + interventionId = Get-ObjectPropertyString -Value $_ -Name "interventionId" + retiredAt = $RetiredAt.ToString("o") + reason = "duplicate reply target already answered" + } + } + ) +} + function Get-SpeechPressureObligations { param($TypedState) @@ -423,13 +490,13 @@ function Get-SpeechPressureObligations { $intensity = Get-ObjectPropertyValue -Value $_ -Name "intensity" $status -in @("active", "ready_to_act") -and $kind -in @("self_advocacy_request", "world_advocacy_request") -and - $null -ne $intensity -and [double]$intensity -ge 0.65 + $null -ne $intensity -and [double]$intensity -ge 0.55 } | Where-Object { $pressureId = Get-ObjectPropertyString -Value $_ -Name "pressureId" $pressureTargetKey = Get-ThoughtTargetKey -Target (Get-ObjectPropertyValue -Value $_ -Name "target") -not ($candidateInterventions | Where-Object { - Test-CandidateMatchesPressure -Candidate $_ -PressureId $pressureId -PressureTargetKey $pressureTargetKey + Test-CandidateMatchesPressure -Candidate $_ -PressureId $pressureId -PressureTargetKey $pressureTargetKey -AllowedStatuses @("queued") } | Select-Object -First 1) } | ForEach-Object { @@ -446,7 +513,7 @@ function Get-SpeechPressureObligations { intensity = Get-ObjectPropertyValue -Value $_ -Name "intensity" sourceMemoryIds = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "sourceMemoryIds")) tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $_ -Name "tags")) - requiredResolution = "Queue a candidate intervention with tag source_pressure:$((Get-ObjectPropertyString -Value $_ -Name "pressureId")) or cool/retire this pressure with an explicit reason." + requiredResolution = "Queue a live candidate intervention with tag source_pressure:$((Get-ObjectPropertyString -Value $_ -Name "pressureId")) or cool/retire this pressure with an explicit reason. An old spoken candidate does not satisfy a still-active pressure." } } ) @@ -983,6 +1050,15 @@ if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) { $deliveredCandidateCount = 0 if (-not $NoPost) { $refreshedTypedContext = Get-TypedSelfState + $alreadyAnsweredCandidateOperations = @(Get-AlreadyAnsweredCandidateRetireOperations -TypedState $refreshedTypedContext.typedState -RetiredAt ([DateTime]::UtcNow)) + if ($alreadyAnsweredCandidateOperations.Count -gt 0) { + Append-RunLog ("retiring already-answered queued candidates: {0}" -f $alreadyAnsweredCandidateOperations.Count) + foreach ($operation in $alreadyAnsweredCandidateOperations) { + $appliedOperations += Apply-TypedOperation -Operation $operation + } + $refreshedTypedContext = Get-TypedSelfState + } + $deliverableCandidates = @(Get-DeliverableCandidateInterventions -TypedState $refreshedTypedContext.typedState) foreach ($candidate in $deliverableCandidates) { if ($deliveredCandidateCount -ge 1) { diff --git a/scripts/simulate-void-mood.mjs b/scripts/simulate-void-mood.mjs index c4874c2..2be2c97 100644 --- a/scripts/simulate-void-mood.mjs +++ b/scripts/simulate-void-mood.mjs @@ -144,8 +144,8 @@ function updateSpeakingPressure({ typedState, previous, sleepCycle, now }) { .filter((entry) => entry.status === "active" || entry.status === "ready_to_act") .reduce((sum, entry) => { const advocacyWeight = - entry.kind === "self_advocacy_request" || entry.kind === "world_advocacy_request" ? 0.22 : 0.12; - const readinessWeight = entry.status === "ready_to_act" ? 1.2 : 1; + entry.kind === "self_advocacy_request" || entry.kind === "world_advocacy_request" ? 0.3 : 0.14; + const readinessWeight = entry.status === "ready_to_act" ? 1.25 : 1.08; return sum + entry.intensity * advocacyWeight * readinessWeight; }, 0); const topThreadPressure = typedState.thoughtMemory.incubation @@ -157,13 +157,13 @@ function updateSpeakingPressure({ typedState, previous, sleepCycle, now }) { const sleepiness = sleepCycle.isNapping ? 1 : 0; const priorNeedToSpeak = clamp(previous.needToSpeak ?? 0.35, 0, 1); const targetNeedToSpeak = clamp( - 0.16 + silencePressure * 0.32 + queuedInterventionPressure + topThreadPressure + agencySpeechPressure - recentSpeechDamping * 0.42 - sleepiness * 0.26, + 0.18 + silencePressure * 0.32 + queuedInterventionPressure + topThreadPressure + agencySpeechPressure - recentSpeechDamping * 0.38 - sleepiness * 0.26, 0, 1, ); const needToSpeak = round3(priorNeedToSpeak + (targetNeedToSpeak - priorNeedToSpeak) * 0.42); const noveltyPressure = round3( - clamp((previous.noveltyPressure ?? 0.35) * 0.62 + topThreadPressure * 0.72 + queuedInterventionPressure * 0.28 + agencySpeechPressure * 0.35, 0, 1), + clamp((previous.noveltyPressure ?? 0.35) * 0.6 + topThreadPressure * 0.72 + queuedInterventionPressure * 0.3 + agencySpeechPressure * 0.44, 0, 1), ); const confessionPressure = round3( clamp((previous.confessionPressure ?? 0.25) * 0.68 + silencePressure * 0.12 + sleepiness * 0.1, 0, 1), diff --git a/scripts/smoke-void-rumination-speech-fixture.ps1 b/scripts/smoke-void-rumination-speech-fixture.ps1 index 000a13d..e6c7824 100644 --- a/scripts/smoke-void-rumination-speech-fixture.ps1 +++ b/scripts/smoke-void-rumination-speech-fixture.ps1 @@ -90,6 +90,32 @@ process.stdin.on("end", () => { tags: ["fixture", "duplicate"], }, }, + { + operation: "queue_candidate_intervention", + intervention: { + interventionId: "fixture-already-answered-reply-target", + kind: "direct_reply", + status: "queued", + target: { + kind: "room", + id: "fixture-channel", + label: "Fixture channel", + }, + summary: "Already-answered reply target should retire before delivery.", + draft: "This should retire because the reply target already has a receipt.", + deliveryTarget: { + mode: "channel", + channelId: "fixture-channel-id", + replyToMessageId: "fixture-answered-message-id", + personaName: "Void", + }, + priority: 0.95, + mustEventuallyShare: false, + createdAt: "2026-05-17T03:00:02.000Z", + updatedAt: "2026-05-17T03:00:02.000Z", + tags: ["fixture", "already-answered"], + }, + }, ]; mkdirSync(dirname(operationOutputPath), { recursive: true }); @@ -151,6 +177,24 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + $cursorOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $cursorOperationPath -Encoding UTF8 node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $cursorOperationPath | Out-Null + $priorReceiptOperation = @{ + operation = "record_delivery_receipt" + receipt = @{ + receiptKey = "fixture-previous-answer" + sentAt = "2026-05-17T02:58:00.000Z" + mode = "channel" + transport = "webhook" + channelId = "fixture-channel-id" + replyToMessageId = "fixture-answered-message-id" + personaName = "Void" + preview = "An earlier answer already closed this exact reply target." + contentLength = 56 + chunkCount = 1 + } + } + $priorReceiptOperation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $cursorOperationPath -Encoding UTF8 + node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $cursorOperationPath | Out-Null + $previousCodexExecutable = $env:CODEX_EXECUTABLE $previousCodexExecArgs = $env:CODEX_EXEC_ARGS $previousFixtureOutput = $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT @@ -183,15 +227,22 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + if ($status.status -ne "ok") { throw "Speech fixture did not finish ok." } - if ([int]$status.proposedOperationCount -ne 2 -or [int]$status.appliedOperationCount -ne 3 -or [int]$status.deliveredCandidateCount -ne 1) { - throw "Speech fixture expected two proposals, three applied operations, and one delivered candidate." + if ([int]$status.proposedOperationCount -ne 3 -or [int]$status.appliedOperationCount -ne 5 -or [int]$status.deliveredCandidateCount -ne 1) { + throw "Speech fixture expected three proposals, five applied operations, and one delivered candidate." } $stateJson = node -e "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1]}).then((state)=>console.log(JSON.stringify(state))).catch((error)=>{ console.error(error); process.exit(1); })" $stateFilePath $state = $stateJson | ConvertFrom-Json $candidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-parent-owned-speech" })[0] $duplicateCandidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-duplicate-same-reply-target" })[0] - $receipt = @($state.speechReceipts.recentReceipts)[0] + $alreadyAnsweredCandidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-already-answered-reply-target" })[0] + $receipt = @( + $state.speechReceipts.recentReceipts | + Where-Object { + $property = $_.PSObject.Properties["candidateInterventionId"] + $null -ne $property -and $property.Value -eq "fixture-parent-owned-speech" + } + )[0] if ($candidate.status -ne "spoken") { throw "Speech fixture did not mark the candidate spoken." @@ -199,6 +250,9 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + if ($duplicateCandidate.status -ne "retired") { throw "Speech fixture did not retire the duplicate same-target candidate." } + if ($alreadyAnsweredCandidate.status -ne "retired") { + throw "Speech fixture did not retire the already-answered reply target candidate." + } if ($receipt.channelId -ne "fixture-channel-id" -or $receipt.replyToMessageId -ne "fixture-message-id") { throw "Speech fixture did not preserve the delivery receipt target." } @@ -213,6 +267,7 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + @{ status = "ok" candidateStatus = $candidate.status + staleDuplicateStatus = $alreadyAnsweredCandidate.status receiptCount = @($state.speechReceipts.recentReceipts).Count deliveredCandidateCount = [int]$status.deliveredCandidateCount } | ConvertTo-Json -Compress diff --git a/scripts/smoke-void-rumination-speech-pressure-fixture.ps1 b/scripts/smoke-void-rumination-speech-pressure-fixture.ps1 index 778e3a0..586cc9b 100644 --- a/scripts/smoke-void-rumination-speech-pressure-fixture.ps1 +++ b/scripts/smoke-void-rumination-speech-pressure-fixture.ps1 @@ -28,7 +28,10 @@ function Write-JsonFixture { } function Initialize-State { - param([Parameter(Mandatory = $true)][string] $StateFilePath) + param( + [Parameter(Mandatory = $true)][string] $StateFilePath, + [switch] $SeedSpokenCandidate + ) $cursorPath = Join-Path (Split-Path -Parent $StateFilePath) "cursor.json" Write-JsonFixture -Path $cursorPath -Value @{ @@ -66,6 +69,48 @@ function Initialize-State { } } Invoke-NodeChecked -Arguments @(".\scripts\void-self-state.mjs", "apply-operation", "--canonical", $StateFilePath, "--operation-file", $pressurePath) | Out-Null + + if ($SeedSpokenCandidate) { + $spokenCandidatePath = Join-Path (Split-Path -Parent $StateFilePath) "spoken-candidate.json" + Write-JsonFixture -Path $spokenCandidatePath -Value @( + @{ + operation = "queue_candidate_intervention" + intervention = @{ + interventionId = "fixture-pressure-spoken-history" + kind = "world_advocacy" + status = "queued" + target = @{ kind = "repo"; id = "Aquarium-Engine"; label = "Aquarium-Engine" } + summary = "A previously spoken pressure candidate." + draft = "I already said this once, but the pressure stayed active." + priority = 0.7 + mustEventuallyShare = $true + createdAt = "2026-05-17T04:02:00.000Z" + updatedAt = "2026-05-17T04:02:00.000Z" + tags = @("fixture", "source_pressure:fixture-pressure-wants-a-mouth") + } + }, + @{ + operation = "mark_candidate_intervention_spoken" + interventionId = "fixture-pressure-spoken-history" + receipt = @{ + receiptKey = "fixture-pressure-spoken-history-receipt" + candidateInterventionId = "fixture-pressure-spoken-history" + sentAt = "2026-05-17T04:03:00.000Z" + mode = "channel" + transport = "webhook" + channelId = "fixture-channel-id" + preview = "A previously spoken pressure candidate." + contentLength = 42 + chunkCount = 1 + } + } + ) + foreach ($operation in (Get-Content -LiteralPath $spokenCandidatePath -Raw -Encoding UTF8 | ConvertFrom-Json)) { + $operationPath = Join-Path (Split-Path -Parent $StateFilePath) ("spoken-candidate-operation-" + [guid]::NewGuid().ToString("n") + ".json") + Write-JsonFixture -Path $operationPath -Value $operation + Invoke-NodeChecked -Arguments @(".\scripts\void-self-state.mjs", "apply-operation", "--canonical", $StateFilePath, "--operation-file", $operationPath) | Out-Null + } + } } function Write-FakeCodex { @@ -138,7 +183,8 @@ process.stdin.on("end", () => { function Invoke-RuminationFixture { param( [Parameter(Mandatory = $true)][string] $CaseName, - [Parameter(Mandatory = $true)][string] $Mode + [Parameter(Mandatory = $true)][string] $Mode, + [switch] $SeedSpokenCandidate ) $caseRoot = Join-Path $tempRoot $CaseName @@ -149,7 +195,7 @@ function Invoke-RuminationFixture { $statusOperationPath = Join-Path $fixtureStatusDir "moderation-rumination-operations.json" New-Item -ItemType Directory -Force -Path $caseRoot | Out-Null - Initialize-State -StateFilePath $stateFilePath + Initialize-State -StateFilePath $stateFilePath -SeedSpokenCandidate:$SeedSpokenCandidate Write-FakeCodex -Path $fakeCodexPath -Mode $Mode $previousCodexExecutable = $env:CODEX_EXECUTABLE @@ -204,6 +250,16 @@ try { throw "Silent speech-pressure fixture failed for the wrong reason: $($silentStatus.failureMessage)" } + $spokenHistory = Invoke-RuminationFixture -CaseName "spoken-history" -Mode "silent" -SeedSpokenCandidate + if ($spokenHistory.exitCode -eq 0) { + throw "Spoken-history speech-pressure fixture unexpectedly passed." + } + + $spokenHistoryStatus = Get-Content -LiteralPath $spokenHistory.statusPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($spokenHistoryStatus.failureMessage -notmatch "requires a candidate intervention") { + throw "Spoken-history speech-pressure fixture failed for the wrong reason: $($spokenHistoryStatus.failureMessage)" + } + $candidate = Invoke-RuminationFixture -CaseName "candidate" -Mode "candidate" if ($candidate.exitCode -ne 0) { $candidateStatus = Get-Content -LiteralPath $candidate.statusPath -Raw -Encoding UTF8 | ConvertFrom-Json @@ -220,6 +276,7 @@ try { @{ status = "ok" silentExitCode = $silent.exitCode + spokenHistoryExitCode = $spokenHistory.exitCode candidateStatus = $queued.status candidatePriority = $queued.priority } | ConvertTo-Json -Compress diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f6e0748..aac4c85 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -132,3 +132,5 @@ {"ts":"2026-05-18T02:46:08Z","type":"implementation","status":"ok","note":"Rumination prompt now treats repo changes that attribute public text, decisions, or artifacts to Void as a self-model salience cue: read more through source retrieval, ask what the attribution commits Void to, and only remember or speak if a concrete pressure emerges. This keeps attribution introspection inside the existing repoActivity/contentHints path instead of adding a side channel."} {"ts":"2026-05-18T03:10:14Z","type":"implementation","status":"ok","note":"Public self-authored artifacts now use the ordinary repo-weather/retrieval/candidate path: rumination receives a configured publicSpeechTarget, verifies Void attribution before speech, and the parent runner advances repo-activity cursors after successful normal passes so old commits do not keep reappearing as fresh weather."} {"ts":"2026-05-18T03:26:45Z","type":"verification","status":"ok","note":"Live rumination verified public artifact sharing: after projecting Void-authored artifacts to the top of repo weather and rewinding gamecult-site once, the model queued Epiphany and fractal-domain article share candidates with publicSpeechTarget delivery; the parent posted both and recorded typed speech receipts."} +{"ts":"2026-05-18T05:03:00Z","type":"implementation","status":"ok","note":"Live-loop inspection found a duplicate direct reply: the logo question at Discord message 1505651526151176412 received receipts at 2026-05-17T23:41:40.434Z and 2026-05-17T23:56:12.783Z because the parent delivery step would still deliver a new queued candidate even when canonical typed speech receipts already proved that reply target was answered. The rumination runner now retires queued candidates whose channelId + replyToMessageId already appear in typed receipts, and the speech fixture seeds a prior receipt to prove that guard plus same-pass sibling retirement."} +{"ts":"2026-05-18T08:45:30Z","type":"implementation","status":"ok","note":"Advocacy release valve opened: active self/world advocacy pressure now becomes a speech obligation at intensity 0.55 instead of 0.65, only a live queued candidate satisfies that obligation, older spoken candidates no longer count as ongoing release, and mood speaking-pressure weights now bias active advocacy earlier. speech-pressure, speech, and agency fixtures passed after the change."} diff --git a/state/map.yaml b/state/map.yaml index 041881a..e2f8557 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -105,7 +105,7 @@ accepted_design: - Resonance clusters and incubating thoughts should be named as actual claims, questions, or fascination targets with explicit target kinds. Keyword-slash residue is debug lint, not a valid thought surface. - Curiosity should prefer branches most likely to change Void's map: concrete machines, contradictions, cross-domain bridges, and underworked terrain. Settled identity seams should stop eating novelty budget unless a new anchor reopens them. - Values and personality activations should not stop at tone and salience. The moderation runtime now needs explicit `discomforts`, `active_tensions`, `self_advocacy_requests`, and `world_advocacy_requests` so sustained value-backed pressure can turn into opinions, interventions, and requests for change about both Void's own wiring and the wider repo/room/lore world. - - Strong active advocacy pressure is no longer allowed to vanish into quiet `[]`. If a rumination pass sees high-intensity self/world advocacy with no matching candidate, it must queue/defer a candidate intervention or cool/retire the pressure explicitly; otherwise the parent runner fails the pass as a muzzle. + - Strong active advocacy pressure is no longer allowed to vanish into quiet `[]`. Once self/world advocacy reaches meaningful intensity, the runner should force a live queued candidate or an explicit cool/retire decision; stale spoken history is not enough to count as a release valve for a still-active pressure. - When the thought surface changes shape, legacy memories may need narrow migrations, but the old broad cleanup organ is gone. New memory writes should enter through typed meaning-preserving operations so routine repair bureaucracy is unnecessary. - Over-chewed private seams should eventually crystallize into identity instead of living forever as “active thoughts.” Once a thought has clearly stopped being live curiosity and become self-doctrine, retire it from incubation, store it as one `identity_seam`, and let the identity/value surface carry it from then on. - Identity crystallization should usually have a mouth. When a seam becomes part of Void's doctrine, queue a warmer candidate intervention and let the room hear it unless novelty or timing gives a real reason to hold back. @@ -150,8 +150,9 @@ landed_baseline: - 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. - Agency pressure is typed state now. `upsert_agency_pressure` / `retire_agency_pressure` own discomforts, active tensions, self-advocacy requests, and world-advocacy requests; mood speaking pressure can derive from them, but candidate interventions remain separate possible speech/actions. - - The agency-to-speech seam now has an accountability gate. `scripts/run-void-moderator-rumination.ps1` projects `speechPressureObligations` for strong active advocacy pressures and rejects silent operation output unless those pressures become candidate interventions or are explicitly cooled/retired. `npm run moderation:rumination:speech-pressure-fixture` proves both the silent-failure path and the deferred-candidate path. + - The agency-to-speech seam now has an accountability gate. `scripts/run-void-moderator-rumination.ps1` projects `speechPressureObligations` once active self/world advocacy reaches 0.55 intensity, rejects silent operation output unless those pressures become live queued candidate interventions or are explicitly cooled/retired, and does not treat old spoken candidates as ongoing satisfaction. `npm run moderation:rumination:speech-pressure-fixture` proves the silent-failure path, the spoken-history failure path, and the queued-candidate path. - Deferred candidate interventions are not speech. A pressure is not resolved until it becomes a queued/spoken candidate or is explicitly cooled/retired, and the rumination runner now delivers already-queued candidates with delivery targets instead of only delivering candidates proposed in the current pass. + - Parent-owned candidate delivery must trust canonical speech receipts more than fresh enthusiasm. If a queued candidate points at a Discord reply target that already has a typed receipt, the runner should retire it before delivery instead of answering the same message twice with a slightly different haircut. - `npm run moderation:agency-pressure:fixture` verifies typed agency pressure enters CultCache, renders in the self-state summary, and contributes to mood speaking pressure without reviving hard-wired agency behavior. - Memory cleanup now has to be meaningfully destructive. Sleep-side consolidation should translate old thought fossils into the live claim/question/fascination surface, drop templated slogan-slop, trim repo/archive/runtime residue down to a small recent anchor surface, and keep the freshest surviving records instead of accidentally embalming the oldest ones after a sort. - Durably weird semantic kinds are guilty until proven useful. `recent-preoccupation`, `quiet-room-status`, person-singleton labels, and `live seam` boilerplate are state poison, not doctrine; typed memory ingress should reject or avoid them instead of relying on cleanup to mop later. @@ -172,4 +173,4 @@ rejected_paths: current_status: summary: VoidBot's ordinary bot/worker/provider/RAG/Postgres/Qdrant structure is still conceptually sound, and the private self-state path has been cut to a typed CultCache `.cc` store at `.voidbot/private/void-self-state.cc`. The active typed service no longer loads or mirrors the legacy moderation monolith. The old memory-organ compensator stack has been deleted. Typed rumination and typed memory maintenance now exist as reviewed prompt runners that return operation payloads for the parent to validate/apply. Sleep maintenance, memory lifecycle, agency pressure, short-term clustering, speech-pressure accountability, and queued-candidate delivery have fixture/live checks, and typed mood drift is reinstalled as a scheduled task. - next_action: Let the enabled loop run under ordinary scheduling after the short-term clustering and queued-speech guards, then inspect the next unattended awake rumination and sleep cycle for repeated thought clusters, stale short-term residue, muted advocacy pressure, stranded candidates, or ungrounded memory writes before calling the system broadly stable. + next_action: Let the enabled loop run under ordinary scheduling after the receipt-backed direct-reply guard and the earlier advocacy-release change, then inspect the next unattended direct reply, next advocacy pass, and next sleep cycle for duplicate answers, fresh queued advocacy candidates, stale short-term residue, stranded candidates, or ungrounded memory writes before calling the system broadly stable.