From af74530d2eda313491e59843f43cdf3a7204c199 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 9 Jun 2026 16:29:00 +0100 Subject: [PATCH] Repair repo face swarm speech --- apps/worker/src/index.ts | 67 +++++++++++++++++++- notes/fresh-workspace-handoff.md | 4 +- prompts/repo-face-turn-interpreter.prompt.md | 2 + scripts/run-repo-face-heartbeats.ts | 5 +- scripts/simulate-void-mood.mjs | 14 +++- state/agent-swarm-paused.json | 6 +- state/evidence.jsonl | 2 + state/map.yaml | 4 +- 8 files changed, 93 insertions(+), 11 deletions(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index d733731..1637e70 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -680,7 +680,7 @@ async function interpretRepoFaceTurnOutput( }; const response = await executeProviderForJob(provider, job, interpreterContext, "interpreter"); const interpretationText = normalizeModelText(response.outputText ?? response.summary); - return parseRepoFaceParentInterpretation(interpretationText, input.attempt); + return parseRepoFaceParentInterpretation(interpretationText, input.attempt, outputText); } async function renderRepoFaceDynamicMemoryRecall( @@ -769,8 +769,17 @@ function renderRepoFaceInterpreterPromptContext(prompt: string): string { function parseRepoFaceParentInterpretation( interpretationText: string, attempt: 1 | 2, + faceOutputText: string, ): RepoFaceParentInterpretation { const block = parseInterpretationBlock(interpretationText); + const fallback = buildBareInterpreterSayFallback(interpretationText, faceOutputText); + if (!block.decision && fallback) { + return { + decision: "route", + reasons: ["parent interpreter returned a bare compact SAY candidate for an unconditional Face speech line"], + routedOutput: fallback, + }; + } const decision = block.decision?.trim().toLowerCase(); const parsedDecision = decision === "route" || decision === "retry" || decision === "drop" @@ -791,6 +800,62 @@ function parseRepoFaceParentInterpretation( }; } +function buildBareInterpreterSayFallback(interpretationText: string, faceOutputText: string): string | undefined { + if (!faceOutputHasUnconditionalWouldSay(faceOutputText)) { + return undefined; + } + if (parseRepoFaceActionBlocks(interpretationText).length > 0) { + return undefined; + } + const content = normalizeBareInterpreterSayCandidate(interpretationText); + if (!content) { + return undefined; + } + + return [ + "Private summary:", + "The parent interpreter returned a compact public line without the required SAY wrapper; routing it as the Face's public speech.", + "", + "SAY", + "identity: current_face_id", + "channel: current_room", + "content:", + ...content.split(/\r?\n/).map((line) => ` ${line}`), + "END", + ].join("\n"); +} + +function faceOutputHasUnconditionalWouldSay(faceOutputText: string): boolean { + const match = faceOutputText.match(/(?:^|\n)\s*Would say:\s*([\s\S]*?)(?:\n\s*(?:Work\/proposal|Article draft|What should stick|Private thought):|\s*$)/i); + if (!match) { + return false; + } + const content = normalizePublicRepoIdentitySpeech(match[1].replace(/^`|`$/g, "").trim()); + return Boolean(content) && !isNonPublicRepoIdentitySpeech(content); +} + +function normalizeBareInterpreterSayCandidate(interpretationText: string): string | undefined { + const content = normalizePublicRepoIdentitySpeech( + interpretationText + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/^`+|`+$/g, "") + .trim(), + ); + if (!content || isNonPublicRepoIdentitySpeech(content)) { + return undefined; + } + if (content.length > 700 || content.split(/\r?\n/).length > 4) { + return undefined; + } + if (/^(INTERPRETATION|Private summary|STATE NOTE|ARTICLE|REDDIT THREAD|BIFROST TOPIC|UPDATE REQUEST)\b/i.test(content)) { + return undefined; + } + if (/\b(stay quiet|cleaner reply is to stay quiet|if you want a line at all|would say nothing|no public line)\b/i.test(content)) { + return undefined; + } + return content; +} + function collectForbiddenRepoFaceChildTools(artifacts: ProviderArtifact[]): string[] { const forbidden = new Set(); for (const artifact of artifacts) { diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 0c1820a..90826a3 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -164,7 +164,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou ## Likely Next Bounded Move -- Keep the repo Face swarm paused until the heartbeat timeout/cadence boundary is repaired. On 2026-06-09, a health pass cleared stale `GameCult Local Orchestrator` locks, restarted the VoidBot stack so bot/worker `dist` entrypoints were fresh, verified Postgres/Qdrant/Ollama/Discord/local backup health, and regenerated the swarm dashboard/CultMesh snapshot. Scheduled repo-face-heartbeats then repeatedly exceeded the orchestrator window and killed the `gpt-5.4` state-projector child, so `state/agent-swarm-paused.json` is set to `"paused": true`. Do not thaw it until state projection has a bounded turn budget, cheaper/deferred path, or other coherent timeout fix, and a scheduled pulse proves clean. +- The repo Face swarm is unpaused again as of 2026-06-09. The repair isolated the expensive `gpt-5.4` state-projector path behind local `REPO_FACE_STATE_PROJECTOR_ENABLED=false`, limited local heartbeat selection to one job per tick, pointed `REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID` back at Aquarium (`1501196543150264332`), and disabled idle cooling for the wake test. Epiphany posted in Aquarium through the registered webhook, so the room is no longer silent. Keep watching scheduled `GameCult Local Orchestrator` pulses before raising cadence or re-enabling the state projector. - The 2026-05-22 live retry confirmed role/name mention obligations can wake multiple newborn Faces from one human intro. Mimir, Huginn, Weksa, and Kiko all posted through the repo identity webhook path. If a Face speaks with its name but no avatar, inspect `.voidbot/private/repo-discord-identities.json` first: webhook persona avatars come from each identity's `avatarUrl`, and new public assets for Kiko, Weksa, and Huginn live under `assets/repo-faces/`. - Later on 2026-05-22, Mimir started looping on an already-acknowledged correction. Root cause: the Face prompt did include recent Mimir repo activity, but nearby-channel sections obscured cross-channel order and Mimir's typed state still carried active correction-pressure, so the Face and Interpreter treated a resolved repair as live debt. The repair added a deterministic visible cross-channel chronology to repo Face prompts, made the Interpreter treat later same-Face acknowledgements as resolved, preserved manual `blocked` status during participant reconciliation, and retired Mimir's duplicated correction-pressure state plus the stale v1 OBS/SRT drift-ledger incubation. The isolated Face/Interpreter smoke produced no SAY for the repeated acknowledgement. - Immediately after, Heimdall produced an Aquarium-shaped reply that the parent Interpreter normalized into `channel: development`, making the post look contextless. Root cause: the Interpreter was allowed to translate a natural `Would say` into both content and venue, and worker fallback treated the scheduled output channel as the default mouth. The repair makes conversation channel ownership deterministic: `SAY` defaults to `current_room`, `current_room` resolves to `job.guildContext.channelId`, owner-DM aliases fall back to the conversation channel unless explicitly owner-private, and the Interpreter prompt says not to invent a speech venue merely because the topic is domain-shaped. The stack was rebuilt and restarted from `dist` after typecheck/build passed. @@ -175,7 +175,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - The overnight 2026-05-23 run showed a softer version of the previous loop: Faces did not collapse into nonsense, but the Aquarium room stayed captured by the Nibu-origin/faction lore seam for hours. Existing `saturationScore` lived on private thought/incubation surfaces, and the repetition prompt mostly caught copied openings/endings, not semantic topic attractors. The heartbeat prompt now projects current-room topic saturation from repeated raw-transcript terms and estimates whether the attractor is home-adjacent or another steward's gravity well from each Face's identity/jurisdiction text. The state projector should turn foreign attractors into character-specific crowding, neglect, boredom, rivalry, handoff, or pivot pressure; the Interpreter diagnoses non-owner same-topic politeness as missing personal stake, ownership, freshness, handoff, or pivot rather than applying a topic ban or token blacklist. - Void's broader moderation authority had become venue leakage: recent-history projection stripped `channelId`, leaving the configured `publicSpeechTarget` as the obvious place for replies, so Aquarium-shaped talk could land in `#general`. The rumination context now preserves message `channelId` and projects `recentConversationTarget`; prompt doctrine says source-channel replies belong in that source channel, while `publicSpeechTarget` is for unanchored herald/artifact announcements. - The Nibu/Void turf-crossing discussion exposed a social-state projection gap: existing bonds/status reads project well once typed state already contains them, but the live pipeline did not own "this recent event should become relationship/status pressure." The repair adds bounded raw relationship-pressure evidence to the state packet before the projector: recent messages that touch the Face, peer Faces, existing social targets, jurisdiction/domain terms, or status/territory/consultation language. The projector may turn that evidence into tentative felt pressure, and the Interpreter may persist a bond/status/mood note only when the projected memory or Face output shows a character-specific reaction. This should let resentment, rivalry, consultation offense, and territorial tension emerge without manual state edits. -- The swarm is currently paused through `state/agent-swarm-paused.json` with `"paused": true`. It had been restarted deliberately on 2026-05-24 after global doctrine inheritance landed, but the 2026-06-09 health pass showed scheduled Face turns can overrun the orchestrator window inside the `gpt-5.4` state projector. Treat this as an ownership/cadence problem, not a prompt-polish problem: repair the heartbeat budget/projection path before resuming ordinary turns. +- The swarm brake currently reads `state/agent-swarm-paused.json` with `"paused": false`. If scheduled Face turns start overrunning again, use that brake rather than letting the orchestrator churn. The known 2026-06-09 timeout source was the model state-projector child; local runtime currently keeps it disabled while the deterministic fallback and Interpreter/worker speech rails are watched. - Direct Face `UPDATE REQUEST` and `BIFROST TOPIC` packets are ignored while `REPO_FACE_BIFROST_ENABLED=false`. Do not debug missing Bifrost jobs as a live failure unless that flag has been deliberately enabled again. - Live model-facing prompt prose has started moving out of executable code. The shared loader is `packages/shared/src/prompt-template.ts`; extracted prompt surfaces live under `prompts/`, including repo Face turn/reviewer prompts, provider wrapper prompts, situational social read prompts, source-grounding reminders, consensus-feed prompts, and repo Face model-smoke scenarios. Code should project data and select prompt names; prompt files should own behavioral prose. - Identity collapse has started. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through the canonical Face registry and treat repo Discord identities as compatibility projections. Next bounded move is to finish removing remaining direct conceptual dependence on repo identities, then split the Face turn loop into scheduler, context assembler, prompt renderer, candidate parser, policy gate, and transport adapter. diff --git a/prompts/repo-face-turn-interpreter.prompt.md b/prompts/repo-face-turn-interpreter.prompt.md index 28f7dce..968ca7c 100644 --- a/prompts/repo-face-turn-interpreter.prompt.md +++ b/prompts/repo-face-turn-interpreter.prompt.md @@ -27,6 +27,7 @@ Route/retry/drop decision model: - Route when you can safely interpret and translate the Face turn into private summary plus state notes and at most one SAY without changing meaning. - Retry when the Face turn is recoverable and the missing piece is specific: live correction uptake, reader context, owned jurisdiction, distinct contribution, social repair, or a pivot away from stale room gravity. - Drop when a second attempt is still bad, unsafe, empty, purely echo-shaped, or not worth routing. +- If the current room contains a newest or nearly-newest invitation to speak and the Face offers an unconditional public line that answers that invitation, your job is to route the smallest safe version of that line, not to coach private silence. Retry only when the line cannot be made legible without changing its meaning. If your own critique produces a smaller safe line that preserves the Face's thought, emit it as SAY. - When a public reply follows a correction of the Face's own prior claim, private understanding is not enough if the room would reasonably expect visible repair. Retry once for a compact acknowledgement unless the visible chronology already shows a later acknowledgement from that Face. - When a public reply preserves the corrected demand under softer wording, retry once and name the invariant it failed to update: the evidence shape, the owner, the scale, the timing, the target, or the social claim. - Agent repetition is evidence of social pressure, not consensus. Claims like "we decided," "the room agrees," or "consensus stands" need clear human approval, source evidence, or explicit framing as only this Face's private preference. @@ -97,6 +98,7 @@ Normalized output rules: - A `Would say` line is only public speech when it contains an unconditional message intended for now. - Do not emit SAY for a `Would say` line that says the Face would say nothing, hold silence, wait, speak only if/unless/when a future condition happens, or otherwise withhold public speech. Example: `Would say: Nothing in aquarium unless the cleanup produces a specimen` routes no SAY; it may become a STATE NOTE about specimen pressure. - If the Face output includes an unconditional `Would say` line, a direct answer to a live room invitation, or a clear desire to respond to a human asking the agents to speak, route that as SAY unless it is unsafe or empty. +- When a live room invitation asks for a small concrete thought and the Face's proposed line is safe but too broad, polished, or doctrine-shaped, you may compact it to the Face's own narrowest concrete claim and route that as SAY. Do not discard a sayable turn merely because a better edit exists. - If the Face visibly acknowledges a human correction of its own prior claim/proposal, route that acknowledgement as SAY unless unsafe, duplicative, or contradicted by the rest of the Face output. Acknowledgement is social repair only when the repaired claim actually changes. - Route correction acknowledgements to the room where the correction happened, normally the `Current room (...)` named in the Face prompt. Do not move an acknowledgement to a domain/work channel merely because the Face also mentioned a future work/proposal there. If you are choosing between `aquarium` and `development` for an acknowledgement, choose `aquarium` unless the human correction itself happened in `development`. - Do not invent a speech venue. If the Face output gives a `Would say` or natural public reply without explicitly naming a channel, emit `channel: current_room`. Topic relevance is not permission to move the reply into a domain channel; a context-shaped reply posted elsewhere becomes orphaned noise. diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index eced5f4..99fb9e0 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -4594,7 +4594,10 @@ function buildChannelPlan( const preferred = preferredChannelId ? options.find((option) => option.channelId === preferredChannelId) : undefined; - const primary = preferred ?? options + const defaultOption = defaultChannelId + ? options.find((option) => option.channelId === defaultChannelId) + : undefined; + const primary = preferred ?? defaultOption ?? options .slice() .sort((left, right) => thresholdRank(left.speechThreshold) - thresholdRank(right.speechThreshold)) [0]; diff --git a/scripts/simulate-void-mood.mjs b/scripts/simulate-void-mood.mjs index 13fa9ed..74ad20f 100644 --- a/scripts/simulate-void-mood.mjs +++ b/scripts/simulate-void-mood.mjs @@ -76,7 +76,7 @@ async function main() { }, ); - const memoryMaintenance = runSleepMemoryMaintenanceIfDue({ sleepCycle }); + const memoryMaintenance = runSleepMemoryMaintenanceIfDue({ sleepCycle, typedState }); if (memoryMaintenance.status === "failed") { writeStatus({ status: "failed", @@ -226,7 +226,7 @@ function readJsonSafe(path) { } } -function runSleepMemoryMaintenanceIfDue({ sleepCycle }) { +function runSleepMemoryMaintenanceIfDue({ sleepCycle, typedState }) { if (args.has("--skip-memory-maintenance")) { return { status: "skipped", @@ -256,6 +256,16 @@ function runSleepMemoryMaintenanceIfDue({ sleepCycle }) { }; } + const shortTermPressure = (typedState.thoughtMemory?.shortTerm ?? []) + .filter((memory) => typeof memory?.retiredAt !== "string" || memory.retiredAt.trim().length === 0) + .length; + if (!args.has("--force-memory-maintenance") && shortTermPressure === 0) { + return { + status: "skipped", + reason: "no_short_term_memory_pressure", + }; + } + const result = spawnSync( "powershell", [ diff --git a/state/agent-swarm-paused.json b/state/agent-swarm-paused.json index 6a83c8d..e3f56df 100644 --- a/state/agent-swarm-paused.json +++ b/state/agent-swarm-paused.json @@ -1,5 +1,5 @@ { - "paused": true, - "reason": "Paused by Codex on 2026-06-09 after scheduled repo Face heartbeat repeatedly exceeded the orchestrator window and killed the gpt-5.4 state-projector child. Bot, worker, retrieval, and typed state are healthy; autonomous Face turns need timeout/cadence repair before resuming.", - "updatedAt": "2026-06-09T14:26:00.0000000Z" + "paused": false, + "reason": "Resumed by Codex on 2026-06-09 after local runtime config disabled the expensive repo Face state-projector pass and reduced heartbeat selection to one job per tick. Watch scheduled pulses for clean exits before raising cadence.", + "updatedAt": "2026-06-09T15:00:00.0000000Z" } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c9db9dc..9099db7 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -339,3 +339,5 @@ {"ts":"2026-06-09T14:17:35Z","type":"documentation","status":"ok","note":"Documented r/GameCultOrg as the GameCult subreddit: Bifrost owns Reddit thread/post transport and receipts; Void owns moderation judgment there using the same baseline public-community posture as Discord."} {"ts":"2026-06-09T14:26:13Z","type":"operations","status":"warning","note":"2026-06-09 swarm health pass: cleared stale GameCult Local Orchestrator locks, restarted stack to refresh stale worker, verified bot/worker/Postgres/Qdrant/Ollama/Discord/local backup, then paused repo Face swarm because scheduled repo-face-heartbeats repeatedly exceeded the orchestrator window and killed the gpt-5.4 state-projector child. Leave autonomous Face turns paused until timeout/cadence repair."} {"ts":"2026-06-09T14:46:15Z","type":"implementation","status":"ok","note":"Added repo Face REDDIT THREAD action path for r/GameCultOrg: parent Interpreter may emit a thread title/body/share line, worker calls Bifrost reddit-post with Persona flair and posts a receipt announcement. Bifrost remains Reddit transport owner; VoidBot owns intent packaging and moderation/herald judgment. Typecheck and Bifrost reddit dry-run passed; interpreter-loop smoke still failed on pre-existing due-jurisdiction-dive tool-use behavior."} +{"ts":"2026-06-09T15:22:34Z","type":"implementation","status":"ok","note":"2026-06-09 swarm wake repair: unpaused state/agent-swarm-paused.json, pointed local Face heartbeat default back to Aquarium, disabled the expensive state-projector path locally, limited heartbeat selection to one job/tick, made buildChannelPlan prefer the configured default channel unless a pending mention supplies a source channel, added Interpreter prompt guidance plus worker fallback for bare compact SAY candidates, and manually verified Epiphany spoke in Aquarium via registered webhook message 1513922884798513392."} +{"ts":"2026-06-09T15:28:15Z","type":"verification","status":"ok","note":"After rebuilding and starting a fresh worker from apps/worker/dist/index.js, a live repo-face-rumination job e20403ee-d3f8-4191-93ae-4ef69f71f803 parsed say=1 and posted Huginn to Aquarium channel 1501196543150264332 via webhook message 1513926584904450130. This verifies the swarm speech rail is no longer silently swallowing all Face turns."} diff --git a/state/map.yaml b/state/map.yaml index 9eae7db..9c35ecd 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -259,5 +259,5 @@ rejected_paths: reason: sleep/distillation that collapses concrete claims into generic abstraction creates memory nonsense and then forces later cleanup compensators current_status: - summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, and the 2026-06-09 health pass refreshed the stale worker, verified the live stack, regenerated the swarm dashboard/CultMesh snapshot, and refreshed the local backup. Autonomous repo Face turns are paused through `state/agent-swarm-paused.json` because scheduled repo-face-heartbeats repeatedly exceeded the orchestrator window and killed the gpt-5.4 state-projector child. GameCult Local Orchestrator remains the single pulse owner; with the pause gate set, it should skip swarm organs instead of attempting public Face turns. Bifrost work routing remains paused by default. - next_action: Repair the repo Face heartbeat timeout/cadence boundary before resuming the swarm: identify why state projection can exceed the orchestrator window, add a bounded turn budget or cheaper/deferred projection path, then verify a scheduled pulse completes with `state/agent-swarm-paused.json` set back to false. + summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, and the 2026-06-09 swarm repair pass resumed autonomous repo Face turns through `state/agent-swarm-paused.json`. Local runtime config now points `REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID` at Aquarium, disables the expensive repo Face state-projector pass, disables idle cooling during the wake test, and limits selection to one job per tick. Epiphany posted in Aquarium through the registered webhook, proving the room is no longer silent. GameCult Local Orchestrator remains the single pulse owner; Bifrost work routing remains paused by default. + next_action: Watch the next scheduled `GameCult Local Orchestrator` pulses with the swarm unpaused and rebuilt worker live. Verify repo Face turns keep using Aquarium as current room, do not revive the gpt-5.4 state-projector timeout path while `REPO_FACE_STATE_PROJECTOR_ENABLED=false`, and confirm the parent Interpreter either emits `SAY` blocks or the worker's bare-interpreter fallback routes compact unconditional speech safely.