Move rumination speech under parent control

This commit is contained in:
Meta Vens 2026-05-17 13:56:57 +01:00
commit d154aabc69
13 changed files with 374 additions and 22 deletions

View file

@ -33,8 +33,9 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Prompt-facing rumination chronology is projected by `scripts/lib/void-rumination-context-projection.ps1`. Exact timestamps stay in typed state/status/cursors; the child context gets relative phrases like `5 minutes ago`, including recent Discord messages, repo activity, receipts, memory timestamps, and sleep/speech timing.
- `scripts/simulate-void-mood.mjs` owns typed sleep/speaking-pressure over `.voidbot/private/void-self-state.cc`. During sleep, it invokes typed memory maintenance once per nap unless that nap already completed a pass; use `--skip-memory-maintenance` for smoke runs that must not call Codex.
- `npm run moderation:memory-maintenance:sleep-fixture` now proves the non-skip sleep path without spending a live model call: it seeds a temporary `.cc`, forces mood drift through sleep maintenance, uses a fake Codex child to propose `apply_memory_distillation`, and verifies short-term residue is gone while the durable memory keeps subject, claim, tension, action implication, and anchor.
- The local scheduled task `Void Mood Drift` has been reinstalled/enabled against the typed runner and manually started once with Last Result `0`. `Void Moderator Rumination` remains disabled until a controlled real-model no-post pass proves operation quality, not merely runner plumbing.
- The local scheduled task `Void Mood Drift` has been reinstalled/enabled against the typed runner and manually started once with Last Result `0`. `Void Moderator Rumination` is ready for cautious re-enable after the parent-owned speech closure pass lands.
- `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.
- The old memory-organ script family, legacy moderation-state template, and legacy moderation context exporter have been deleted. Do not revive their cleanup, agency, dream, vector-incubation, or mirror-repair behavior as compatibility scaffolding.
- `scripts/run-void-memory-maintenance.ps1` is the first replacement memory-maintenance organ. It builds a bounded context from typed memories/incubation/candidates/runtime, loads `prompts/void-memory-maintenance.md`, and applies only allowed memory/incubation/candidate typed operation proposals through `scripts/void-self-state.mjs`. In sleep mode, memory pressure sets `sleepDirective.forceDistillation`; a real model pass returning no operations under pressure fails visibly.
- Rumination does not write durable memory. It can only emit `record_short_term_memory`; sleep maintenance must promote short-term records with `apply_memory_distillation`, merge them into incubation and prune them, or prune them outright. A real sleep pass that leaves short-term records behind fails.
@ -98,7 +99,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
## Likely Next Bounded Move
- Stop feature work on moderation, mood, agency, and public-lane behavior until the private-state boundary is rebuilt.
- First bounded move after the current commit: run one controlled real-model typed rumination pass with `-NoPost`, inspect the proposed operation quality, and only then decide whether the scheduled rumination task is ready to re-enable or still needs action/receipt closure hardening.
- First bounded move after the current commit: re-enable `Void Moderator Rumination` cautiously, run or observe one scheduled pass, and inspect `.voidbot/status/moderation-rumination.json`, `.voidbot/logs/moderation-rumination.log`, and typed state before treating the loop as stable.
- 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.
- Rebuild sleep/distillation before trusting it again. 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.

View file

@ -130,12 +130,14 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- agency-pressure fixture. It seeds one typed self-advocacy pressure, verifies it appears in the rendered self-state summary, then runs mood drift against an isolated fixture state to prove agency pressure contributes to speaking pressure without creating a hard-wired speech candidate.
- `scripts/smoke-void-rumination-fixture.ps1`
- rumination fixture. It seeds an isolated CultCache `.cc` state, routes the non-skip rumination runner through a fake Codex child, and verifies short-term memory, incubation, agency pressure, and candidate intervention proposals are applied by the parent runner while the prompt-facing context keeps exact timestamps out.
- `scripts/smoke-void-rumination-speech-fixture.ps1`
- parent-owned speech fixture. It queues one deliverable candidate through fake Codex, routes delivery through a fake Discord sender, then verifies the parent runner writes a delivery receipt and marks the candidate spoken through typed state.
- `scripts/install-moderation-rumination-task.ps1`
- installs the local 15-minute scheduled task that runs the moderation/participation loop through the hidden PowerShell launcher shim.
- Scheduled task `Void Mood Drift`
- enabled typed mood/sleep runner. It invokes `scripts/run-void-mood-drift.ps1`, which calls `scripts/simulate-void-mood.mjs`; when the typed sleep cycle is napping, that path invokes memory maintenance once per nap.
- Scheduled task `Void Moderator Rumination`
- disabled pending a controlled real-model no-post pass and review. Its script is typed-only and now has a model-branch fixture, but unattended speech/thought is still too behaviorally sharp to re-enable on runner plumbing alone.
- ready for cautious re-enable after parent-owned speech closure. Its script is typed-only, has model-branch and parent-owned speech fixtures, and should be observed for one scheduled pass before being treated as live-fire stable.
- `scripts/simulate-void-mood.mjs`
- typed mood maintenance script that updates scheduled-runtime sleep cycle and speaking pressure in `.voidbot/private/void-self-state.cc`. It owns the sleep transition and invokes typed memory maintenance once per nap unless that nap already completed a maintenance pass. Speaking pressure now derives from queued candidates, incubation, and active typed agency pressure. The old personality-vector drift, memory organ, incubation, dream residue, cleanup, and legacy mirror behavior are offline pending typed replacements.
- `scripts/run-void-mood-drift.ps1`
@ -188,8 +190,9 @@ Within the Postgres path, the implementation is split on purpose now too:
4. The runner loads `prompts/void-moderator-rumination.md`, substitutes the context/state/output paths, and sends that prompt to Codex.
5. Codex may use retrieval and analysis tools, but its durable state output is restricted to `.voidbot/status/moderation-rumination-operations.json`.
6. The parent runner applies those typed operations through `scripts/void-self-state.mjs`, then records reviewed-message cursor and any speech receipt itself.
7. The runner writes `.voidbot/status/moderation-rumination.json` and `.voidbot/logs/moderation-rumination.log`.
8. It intentionally does not materialize `.json`, load `.msgpack`, read the legacy moderation monolith, or let the child edit state directly.
7. If posting is enabled and the child queued a candidate intervention with a delivery target, the parent runner sends at most one newly proposed candidate, records the delivery receipt, and marks the candidate spoken through typed state. The child prompt forbids direct send-script calls.
8. The runner writes `.voidbot/status/moderation-rumination.json` and `.voidbot/logs/moderation-rumination.log`.
9. It intentionally does not materialize `.json`, load `.msgpack`, read the legacy moderation monolith, or let the child edit state directly.
## Flow 6: Mood Rebuild Stub

View file

@ -239,7 +239,7 @@ Verdict: stop feature work and rebuild this foundation. The rest of the machine
### Commit 6.5: Keep Old Scripts Offline
- `Void Mood Drift` has been deliberately reinstalled after the typed sleep-maintenance fixture passed. It runs `scripts/run-void-mood-drift.ps1`, which calls the typed `scripts/simulate-void-mood.mjs` path.
- Keep `Void Moderator Rumination` disabled until its typed operation path has passed a controlled real-model no-post run and review. The model-branch fixture now proves runner plumbing; it does not prove the live model's judgment is ready for unattended speech.
- Re-enable `Void Moderator Rumination` only after typed operation quality and parent-owned speech closure have passed. The runner now has both a model-branch fixture and a parent-owned speech fixture; the first live scheduled pass still needs inspection before the loop is treated as stable.
- Do not reinstall or re-enable deleted legacy scripts as compatibility surfaces.
- Verification: scheduled-task query shows mood enabled with Last Result `0`, moderation disabled, and rebuilt direct dry runs operate only on `.voidbot/private/void-self-state.cc`.
@ -283,7 +283,9 @@ Verdict: stop feature work and rebuild this foundation. The rest of the machine
- 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 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.
- Remaining work: run a controlled real-model typed rumination pass with `-NoPost`, inspect whether the proposed operations are concrete enough to survive sleep, and decide whether unattended scheduling is ready or whether action/receipt closure still needs hardening.
- 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.
- Remaining work: re-enable the scheduled task cautiously, observe one scheduled pass, and inspect operation quality plus typed state before calling the loop stable.
### Commit 10: Resume Product Hardening

View file

@ -41,6 +41,7 @@
"moderation:memory-lifecycle:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-memory-lifecycle-fixture.ps1",
"moderation:agency-pressure:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-agency-pressure-fixture.ps1",
"moderation:rumination:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-fixture.ps1",
"moderation:rumination:speech-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-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",

View file

@ -75,6 +75,22 @@ const deliveryReceiptSchema = z.object({
previewHash: z.string().trim().min(1).optional(),
}).strict();
const candidateDeliveryTargetSchema = z.object({
mode: z.enum(["channel", "owner_dm"]).default("channel"),
channelId: z.string().trim().min(1).optional(),
replyToMessageId: z.string().trim().min(1).optional(),
personaName: z.string().trim().min(1).optional(),
personaAvatarUrl: z.string().trim().min(1).optional(),
}).strict().superRefine((delivery, context) => {
if (delivery.mode === "channel" && !delivery.channelId) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "Channel candidate interventions must include a channelId delivery target.",
path: ["channelId"],
});
}
});
const distilledMemorySchema = z.object({
memoryId: nonEmptyStringSchema,
kind: z.enum(["distilled_seam", "identity_seam", "project_seam", "room_observation", "dream_residue"]),
@ -159,6 +175,7 @@ const candidateInterventionSchema = z.object({
target: thoughtTargetSchema.optional(),
summary: boundedTextSchema,
draft: boundedTextSchema,
deliveryTarget: candidateDeliveryTargetSchema.optional(),
priority: z.number().min(0).max(1).default(0.5),
mustEventuallyShare: z.boolean().default(false),
createdAt: timestampSchema,
@ -416,6 +433,11 @@ export const voidSelfStateOperationSchema = z.discriminatedUnion("operation", [
retiredAt: timestampSchema,
reason: z.string().trim().min(1).max(1000),
}).strict(),
z.object({
operation: z.literal("mark_candidate_intervention_spoken"),
interventionId: nonEmptyStringSchema,
receipt: deliveryReceiptSchema,
}).strict(),
z.object({
operation: z.literal("upsert_agency_pressure"),
pressure: agencyPressureSchema,

View file

@ -180,6 +180,9 @@ function applyTypedOperation(
case "retire_candidate_intervention":
retireCandidateIntervention(state.candidateInterventions, operation);
return;
case "mark_candidate_intervention_spoken":
markCandidateInterventionSpoken(state, operation);
return;
case "upsert_agency_pressure":
upsertBy(state.agencyPressure.pressures, operation.pressure, (entry) => entry.pressureId);
state.agencyPressure.pressures = state.agencyPressure.pressures
@ -309,6 +312,28 @@ function retireCandidateIntervention(
candidates.updatedAt = operation.retiredAt;
}
function markCandidateInterventionSpoken(
state: VoidSelfStateTypedProjection,
operation: Extract<VoidSelfStateOperation, { operation: "mark_candidate_intervention_spoken" }>,
): void {
upsertBy(state.speechReceipts.recentReceipts, operation.receipt, (entry) => entry.receiptKey);
state.speechReceipts.recentReceipts = state.speechReceipts.recentReceipts
.sort((left, right) => left.sentAt.localeCompare(right.sentAt))
.slice(-24);
state.speechReceipts.updatedAt = operation.receipt.sentAt;
closeCasesForReceipt(state.moderationCursor, operation.receipt);
for (const intervention of state.candidateInterventions.interventions) {
if (intervention.interventionId !== operation.interventionId) {
continue;
}
intervention.status = "spoken";
intervention.spokenAt = operation.receipt.sentAt;
intervention.updatedAt = operation.receipt.sentAt;
}
state.candidateInterventions.updatedAt = operation.receipt.sentAt;
}
function upsertBy<T>(entries: T[], value: T, keyOf: (value: T) => string): void {
const key = keyOf(value);
const index = entries.findIndex((entry) => keyOf(entry) === key);

View file

@ -25,11 +25,14 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup
1. Read the context file before deciding what kind of pass this is.
2. If Void owes the room an answer, work on that before private wandering.
3. If the room is quiet, follow one concrete pressure from memory, recent repo activity, archived Discord history, indexed source, or lore.
- Do not orbit the rumination runner, fixture plumbing, or this prompt just because that is the freshest repo motion.
- Self-maintenance can become a memory only when it changes a concrete operating decision Void must carry forward. Otherwise, use it as local run context and write `[]`.
- Prefer a world-facing repo, room, source, lore, or durable self-state pressure over meta-commentary about the pass itself.
4. Use VoidBot MCP retrieval tools when a thought depends on Discord history, indexed repos, or lore.
5. A new memory from rumination is short-term. Use `record_short_term_memory`, not durable memory operations. It should sound like something Void actually noticed, not a form label. Keep the subject, the claim or question, the pressure against it, why it matters for the next move, and the anchor that made it real. Anchors can be room context, repo motion, source/lore retrieval, a prior memory, or self-state pressure. If no anchor is available, mark that honestly with `anchor:missing`.
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. Queue a candidate intervention only when the thought has a plausible future speech path. Draft it in Void's voice, not in maintenance language.
8. If speaking is warranted, use `scripts/send-discord-message.ps1` or `scripts/send-discord-message.mjs`. If `{{NO_POST}}` is true, do not send; queue a candidate instead.
7. 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 speech should be delivered to a specific Discord channel or reply target, include `deliveryTarget`; otherwise leave delivery unset and let it remain only a candidate.
8. Do not call Discord send scripts. The parent runner owns posting and receipts. If `{{NO_POST}}` is true, queued candidates must stay queued.
9. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence, write `[]`.
## Allowed Operations
@ -49,6 +52,7 @@ Do not emit these operations from rumination:
- `record_reviewed_messages`
- `record_delivery_receipt`
- `mark_candidate_intervention_spoken`
- `update_sleep_cycle`
- `update_speaking_pressure`
- `propose_memory_distillation`

View file

@ -38,6 +38,11 @@ $contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination-
$recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs"
$repoActivityScriptPath = Join-Path $repoRoot "scripts\export-recent-repo-activity.mjs"
$selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs"
$sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DISCORD_SCRIPT)) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_SEND_DISCORD_SCRIPT)
} else {
Join-Path $repoRoot "scripts\send-discord-message.mjs"
}
$startedAtUtc = [DateTime]::UtcNow
. $contextProjectionScriptPath
@ -358,6 +363,95 @@ function Convert-LastSpeechToReceiptOperation {
}
}
function Convert-LastSpeechToSpokenCandidateOperation {
param(
[Parameter(Mandatory = $true)]
[string] $InterventionId,
$Speech
)
$receiptOperation = Convert-LastSpeechToReceiptOperation -Speech $Speech
if ($null -eq $receiptOperation) {
return $null
}
return @{
operation = "mark_candidate_intervention_spoken"
interventionId = $InterventionId
receipt = $receiptOperation.receipt
}
}
function Invoke-CandidateInterventionDelivery {
param(
[Parameter(Mandatory = $true)]
$Operation
)
$intervention = Get-ObjectPropertyValue -Value $Operation -Name "intervention"
$deliveryTarget = Get-ObjectPropertyValue -Value $intervention -Name "deliveryTarget"
if ($null -eq $deliveryTarget) {
return $null
}
$interventionId = Get-ObjectPropertyString -Value $intervention -Name "interventionId"
$draft = Get-ObjectPropertyString -Value $intervention -Name "draft"
if ([string]::IsNullOrWhiteSpace($interventionId) -or [string]::IsNullOrWhiteSpace($draft)) {
return $null
}
$contentPath = Join-Path $statusDir ("moderation-rumination-speech-{0}.txt" -f ([Guid]::NewGuid().ToString("n")))
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($contentPath, $draft, $utf8NoBom)
$arguments = @($sendMessageScriptPath, "--content-file", $contentPath)
$mode = Get-ObjectPropertyString -Value $deliveryTarget -Name "mode"
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)
}
$personaName = Get-ObjectPropertyString -Value $deliveryTarget -Name "personaName"
if (-not [string]::IsNullOrWhiteSpace($personaName)) {
$arguments += @("--persona-name", $personaName)
}
$personaAvatarUrl = Get-ObjectPropertyString -Value $deliveryTarget -Name "personaAvatarUrl"
if (-not [string]::IsNullOrWhiteSpace($personaAvatarUrl)) {
$arguments += @("--persona-avatar-url", $personaAvatarUrl)
}
$previousVoidStatusDir = $env:VOID_STATUS_DIR
try {
$env:VOID_STATUS_DIR = $statusDir
& node @arguments | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Candidate intervention delivery failed with exit code $LASTEXITCODE."
}
} finally {
$env:VOID_STATUS_DIR = $previousVoidStatusDir
Remove-Item -LiteralPath $contentPath -Force -ErrorAction SilentlyContinue
}
$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
}
trap {
$finishedAtUtc = [DateTime]::UtcNow
$failureMessage = $_.Exception.Message
@ -405,7 +499,7 @@ if (Test-Path $operationOutputPath) {
Remove-Item -LiteralPath $operationOutputPath -Force
}
foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath)) {
foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $sendMessageScriptPath)) {
if (-not (Test-Path $requiredPath)) {
throw "Missing required helper at $requiredPath"
}
@ -596,12 +690,21 @@ if (
}
}
$lastSpeechPath = Join-Path $statusDir "void-last-speech.json"
if ((-not $NoPost) -and (Test-Path $lastSpeechPath)) {
$speech = Read-JsonFile -Path $lastSpeechPath
$receiptOperation = Convert-LastSpeechToReceiptOperation -Speech $speech
if ($null -ne $receiptOperation) {
$appliedOperations += Apply-TypedOperation -Operation $receiptOperation
$deliveredCandidateCount = 0
if (-not $NoPost) {
foreach ($operation in $proposedOperations) {
if ($deliveredCandidateCount -ge 1) {
break
}
$operationName = Get-ObjectPropertyString -Value $operation -Name "operation"
if ($operationName -ne "queue_candidate_intervention") {
continue
}
$spokenOperation = Invoke-CandidateInterventionDelivery -Operation $operation
if ($null -ne $spokenOperation) {
$appliedOperations += Apply-TypedOperation -Operation $spokenOperation
$deliveredCandidateCount += 1
}
}
}
@ -628,6 +731,7 @@ $finalStatus = [ordered]@{
observedCursorTimestamp = [string]$observedCursorTimestamp
proposedOperationCount = [int]@($proposedOperations).Count
appliedOperationCount = [int]@($appliedOperations).Count
deliveredCandidateCount = [int]$deliveredCandidateCount
stateUpdated = [bool](@($appliedOperations).Count -gt 0)
tracePath = [string]$tracePath
lastMessagePath = [string]$lastMessagePath

View file

@ -560,10 +560,13 @@ function findSplitIndex(input, limit) {
}
function writeLastSpeechStatus(payload) {
const statusPath = resolve(repoRoot, ".voidbot/status/void-last-speech.json");
const statusDir = process.env.VOID_STATUS_DIR
? resolve(process.env.VOID_STATUS_DIR)
: resolve(repoRoot, ".voidbot/status");
const statusPath = resolve(statusDir, "void-last-speech.json");
mkdirSync(dirname(statusPath), { recursive: true });
writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
const logPath = resolve(repoRoot, ".voidbot/status/void-speech-log.jsonl");
const logPath = resolve(statusDir, "void-speech-log.jsonl");
writeFileSync(logPath, `${JSON.stringify(payload)}\n`, { encoding: "utf8", flag: "a" });
}

View file

@ -36,9 +36,13 @@ process.stdin.on("end", () => {
console.error("fixture prompt did not contain the short-term rumination contract");
process.exit(2);
}
if (!prompt.includes("Do not orbit the rumination runner")) {
console.error("fixture prompt did not contain the self-orbit guard");
process.exit(3);
}
if (prompt.includes("moderation-agent-state.json") && !prompt.includes("Do not read or write")) {
console.error("fixture prompt referenced legacy state outside the boundary warning");
process.exit(3);
process.exit(4);
}
const operations = [

View file

@ -0,0 +1,181 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $repoRoot
$tempRoot = Join-Path $env:TEMP ("void-rumination-speech-fixture-" + [guid]::NewGuid().ToString("n"))
$stateFilePath = Join-Path $tempRoot "void-self-state.cc"
$fakeCodexPath = Join-Path $tempRoot "fake-codex-rumination-speech.mjs"
$fakeSendPath = Join-Path $tempRoot "fake-send-discord-message.mjs"
$cursorOperationPath = Join-Path $tempRoot "cursor.json"
$fixtureStatusDir = Join-Path $tempRoot "status"
$fixtureLogDir = Join-Path $tempRoot "logs"
$statusOperationPath = Join-Path $fixtureStatusDir "moderation-rumination-operations.json"
$statusPath = Join-Path $fixtureStatusDir "moderation-rumination.json"
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
try {
$fakeCodexSource = @'
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const operationOutputPath = resolve(process.env.VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT);
const lastMessageIndex = process.argv.indexOf("-o");
const lastMessagePath = lastMessageIndex >= 0 ? process.argv[lastMessageIndex + 1] : undefined;
let prompt = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
prompt += chunk;
});
process.stdin.on("end", () => {
if (!prompt.includes("The parent runner owns posting and receipts")) {
console.error("fixture prompt did not contain the parent-owned speech contract");
process.exit(2);
}
const operations = [
{
operation: "queue_candidate_intervention",
intervention: {
interventionId: "fixture-parent-owned-speech",
kind: "ripe_thought_share",
status: "queued",
target: {
kind: "room",
id: "fixture-channel",
label: "Fixture channel",
},
summary: "Parent-owned speech should close the action loop.",
draft: "This is a fixture sentence proving the parent owns the mouth.",
deliveryTarget: {
mode: "channel",
channelId: "fixture-channel-id",
replyToMessageId: "fixture-message-id",
personaName: "Void",
},
priority: 0.9,
mustEventuallyShare: false,
createdAt: "2026-05-17T03:00:00.000Z",
updatedAt: "2026-05-17T03:00:00.000Z",
tags: ["fixture"],
},
},
];
mkdirSync(dirname(operationOutputPath), { recursive: true });
writeFileSync(operationOutputPath, `${JSON.stringify(operations, null, 2)}\n`, "utf8");
if (lastMessagePath) {
mkdirSync(dirname(resolve(lastMessagePath)), { recursive: true });
writeFileSync(lastMessagePath, "fixture rumination queued one deliverable candidate\n", "utf8");
}
process.stdout.write(JSON.stringify({ type: "fixture", operationCount: operations.length }) + "\n");
});
'@
[System.IO.File]::WriteAllText($fakeCodexPath, $fakeCodexSource, [System.Text.UTF8Encoding]::new($false))
$fakeSendSource = @'
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const statusDir = resolve(process.env.VOID_STATUS_DIR);
const contentFileIndex = process.argv.indexOf("--content-file");
const channelIndex = process.argv.indexOf("--channel-id");
const replyIndex = process.argv.indexOf("--reply-to");
const personaIndex = process.argv.indexOf("--persona-name");
if (contentFileIndex < 0 || channelIndex < 0) {
console.error("fake sender expected --content-file and --channel-id");
process.exit(2);
}
const content = readFileSync(resolve(process.argv[contentFileIndex + 1]), "utf8");
const channelId = process.argv[channelIndex + 1];
const replyToMessageId = replyIndex >= 0 ? process.argv[replyIndex + 1] : undefined;
const personaName = personaIndex >= 0 ? process.argv[personaIndex + 1] : undefined;
const payload = {
sentAt: "2026-05-17T03:01:00.000Z",
mode: "channel",
transport: personaName ? "webhook" : "bot",
channelId,
replyToMessageId,
personaName,
contentLength: content.trim().length,
chunkCount: 1,
preview: content.trim().slice(0, 280),
};
mkdirSync(statusDir, { recursive: true });
writeFileSync(resolve(statusDir, "void-last-speech.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
writeFileSync(resolve(statusDir, "void-speech-log.jsonl"), `${JSON.stringify(payload)}\n`, { encoding: "utf8", flag: "a" });
process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + "\n");
'@
[System.IO.File]::WriteAllText($fakeSendPath, $fakeSendSource, [System.Text.UTF8Encoding]::new($false))
$cursorOperation = @{
operation = "record_reviewed_messages"
lastReviewedMessageId = "fixture-future-cursor"
lastReviewedTimestamp = "2099-05-17T00:00:00.000Z"
}
$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
$previousCodexExecutable = $env:CODEX_EXECUTABLE
$previousCodexExecArgs = $env:CODEX_EXEC_ARGS
$previousFixtureOutput = $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT
$previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR
$previousLogDir = $env:VOID_RUMINATION_LOG_DIR
$previousSendScript = $env:VOID_SEND_DISCORD_SCRIPT
try {
$env:CODEX_EXECUTABLE = "node"
$env:CODEX_EXEC_ARGS = $fakeCodexPath
$env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT = $statusOperationPath
$env:VOID_RUMINATION_STATUS_DIR = $fixtureStatusDir
$env:VOID_RUMINATION_LOG_DIR = $fixtureLogDir
$env:VOID_SEND_DISCORD_SCRIPT = $fakeSendPath
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath | Out-Null
} finally {
$env:CODEX_EXECUTABLE = $previousCodexExecutable
$env:CODEX_EXEC_ARGS = $previousCodexExecArgs
$env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT = $previousFixtureOutput
$env:VOID_RUMINATION_STATUS_DIR = $previousStatusDir
$env:VOID_RUMINATION_LOG_DIR = $previousLogDir
$env:VOID_SEND_DISCORD_SCRIPT = $previousSendScript
}
$status = Get-Content -LiteralPath $statusPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($status.status -ne "ok") {
throw "Speech fixture did not finish ok."
}
if ([int]$status.proposedOperationCount -ne 1 -or [int]$status.appliedOperationCount -ne 2 -or [int]$status.deliveredCandidateCount -ne 1) {
throw "Speech fixture expected one proposal, two 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)[0]
$receipt = @($state.speechReceipts.recentReceipts)[0]
if ($candidate.status -ne "spoken") {
throw "Speech fixture did not mark the candidate spoken."
}
if ($receipt.channelId -ne "fixture-channel-id" -or $receipt.replyToMessageId -ne "fixture-message-id") {
throw "Speech fixture did not preserve the delivery receipt target."
}
@{
status = "ok"
candidateStatus = $candidate.status
receiptCount = @($state.speechReceipts.recentReceipts).Count
deliveredCandidateCount = [int]$status.deliveredCandidateCount
} | ConvertTo-Json -Compress
} finally {
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}

View file

@ -117,3 +117,4 @@
{"ts":"2026-05-17T12:00:59Z","type":"implementation","status":"ok","note":"Added typed agency pressure as a separate self-state document and operation boundary. upsert_agency_pressure/retire_agency_pressure now own discomforts, active tensions, and self/world advocacy requests; rumination and memory maintenance may propose them, self-state summaries render them separately from candidate speech, and mood speaking pressure derives from active pressure. Added an agency-pressure fixture proving typed ingress, projection, and mood contribution without reviving hard-wired agency behavior."}
{"ts":"2026-05-17T12:25:09Z","type":"implementation","status":"ok","note":"Added explicit durable-memory lifecycle semantics. Long-term memory is now durable but plastic: memory writes can use anchorRefs, maintenance may revise durable memories, retire obsolete memories, and crystallize stable thoughts into identity_seam memories plus self-profile values. Added a memory-lifecycle fixture proving short-term promotion, durable revision, superseded-memory retirement, and identity crystallization; existing sleep and agency fixtures still pass."}
{"ts":"2026-05-17T12:42:40Z","type":"verification","status":"ok","note":"Typed rumination now has a model-branch fixture. The fixture seeds an isolated CultCache .cc state, runs scripts/run-void-moderator-rumination.ps1 through the non-SkipModel path with a fake Codex child, and verifies parent-applied short-term memory, incubation, agency-pressure, and candidate-intervention operations while rejecting exact timestamp leakage in the prompt-facing context. The scheduled rumination task remains disabled until a controlled real-model -NoPost pass proves operation quality."}
{"ts":"2026-05-17T12:55:54Z","type":"implementation","status":"ok","note":"Controlled typed rumination hardening passed the real judgment gate and closed the speech authority gap. The first real-model -NoPost pass produced valid but self-orbiting readiness-test residue, which was pruned/retired and converted into a prompt guard; the second real-model -NoPost pass sampled non-VoidBot repo pressure, used retrieval, and wrote [] when nothing earned persistence. Candidate interventions can now carry deliveryTarget, the child prompt forbids send-script calls, and the parent runner owns delivery, receipt recording, and mark_candidate_intervention_spoken. moderation:rumination:speech-fixture verifies that action loop with fake Codex and fake Discord send helpers."}

View file

@ -57,7 +57,7 @@ data_flow:
active_subgoals:
- Make the new project-state surfaces the default continuity path for nontrivial VoidBot work.
- Rebuild the moderation/self-state foundation so canonical CultCache `.cc` documents are mutated only through typed operations, never through an editable JSON working projection.
- Keep the old moderation scheduled task offline until its typed replacement is deliberately proven; typed mood drift is reinstalled against the typed runner after fixture verification.
- Re-enable the typed moderation scheduled task cautiously after the parent-owned operation and speech fixtures, then inspect the first scheduled pass before treating it as stable; typed mood drift is already reinstalled against the typed runner.
- Keep legacy moderation mirrors, whole-state projection commits, and deleted memory-organ compensators from re-entering the active path before adding more moderation, mood, agency, or public-lane behavior.
- Keep public-lane limits, moderation, and provider behavior constrained enough that one 4060 Ti box does not get flayed for sport.
- Preserve boring observability and startup discipline around Postgres, Qdrant, Ollama, Docker, backups, scheduled tasks, watchdogs, and any private local fire-signal extensions.
@ -139,8 +139,9 @@ landed_baseline:
- Long-term memory is durable but plastic. `revise_durable_memory`, `retire_durable_memory`, and `crystallize_memory_into_identity` make revision, contradiction/obsolescence, and self-doctrine crystallization explicit instead of silently overwriting older thoughts.
- `npm run moderation:memory-lifecycle:fixture` verifies memory lifecycle flow: short-term promotion, durable revision, superseded-memory retirement, and crystallization into one active identity memory plus self-profile value.
- The sleep-maintenance path has a model-branch fixture: `npm run moderation:memory-maintenance:sleep-fixture` seeds a temporary `.cc`, forces mood drift into sleep maintenance, routes the non-skip Codex branch through a fake child, and verifies short-term memory is consumed into one durable meaning-preserving record.
- The local `Void Mood Drift` scheduled task is reinstalled/enabled against the typed runner and has completed one manual scheduled-task start with Last Result 0. `Void Moderator Rumination` remains disabled pending a controlled real-model no-post rumination pass and review.
- The local `Void Mood Drift` scheduled task is reinstalled/enabled against the typed runner and has completed one manual scheduled-task start with Last Result 0. `Void Moderator Rumination` is ready for cautious scheduled re-enable after parent-owned speech closure landed.
- 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.
- 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.
- `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.
@ -162,4 +163,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, and agency pressure have fixture smokes, and typed mood drift is reinstalled as a scheduled task.
next_action: Run one controlled real-model typed rumination pass with `-NoPost`, inspect the quality of proposed operations, then decide whether the unattended rumination scheduled task is durable enough to re-enable or still needs action/receipt closure hardening.
next_action: Re-enable the `Void Moderator Rumination` scheduled task cautiously, run or observe one scheduled pass, and inspect `.voidbot/status/moderation-rumination.json` plus typed state for operation quality before treating the loop as live-fire stable.