diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index ed0fb36..347b718 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -551,7 +551,10 @@ async function executeRepoFaceJobWithInterpreter( attempt: 1, }); if (firstInterpretation.decision === "route") { - return routeRepoFaceInterpretedOutput(firstResponse, firstInterpretation); + return routeRepoFaceInterpretedOutput(firstResponse, firstInterpretation, { + job, + faceOutputText: firstText, + }); } if (firstInterpretation.decision === "drop") { @@ -601,7 +604,10 @@ async function executeRepoFaceJobWithInterpreter( attempt: 2, }); if (retryInterpretation.decision === "route") { - return routeRepoFaceInterpretedOutput(retryResponse, retryInterpretation); + return routeRepoFaceInterpretedOutput(retryResponse, retryInterpretation, { + job, + faceOutputText: retryText, + }); } await auditLog.record({ @@ -729,10 +735,12 @@ function extractRoutedRepoFaceOutput(reviewText: string): string | undefined { function routeRepoFaceInterpretedOutput( response: ProviderResponse, interpretation: RepoFaceParentInterpretation, + input: { job: JobRecord; faceOutputText: string }, ): ProviderResponse { - const outputText = + const rawOutputText = interpretation.routedOutput?.trim() || stripRepoIdentityPostIntents(fitDiscordMessage(response.outputText ?? response.summary)); + const outputText = normalizeInterpretedRepoFaceSpeechDestinations(rawOutputText, input); const summary = outputText || "Repo Face parent interpreter routed no public or governed action."; return { ...response, @@ -747,6 +755,77 @@ function routeRepoFaceInterpretedOutput( }; } +function normalizeInterpretedRepoFaceSpeechDestinations( + outputText: string, + input: { job: JobRecord; faceOutputText: string }, +): string { + if (input.job.command !== "repo-face-rumination") { + return outputText; + } + if (repoFaceOutputHasExplicitSayChannel(input.faceOutputText)) { + return outputText; + } + if (!input.job.guildContext?.channelId) { + return outputText; + } + + return rewriteRepoFaceSayChannels(outputText, "current_room"); +} + +function repoFaceOutputHasExplicitSayChannel(outputText: string): boolean { + return parseRepoFaceActionBlocks(outputText).some((block) => + block.kind === "say" && + Boolean(optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId)) + ); +} + +function rewriteRepoFaceSayChannels(outputText: string, channelSelector: string): string { + const lines = outputText.split(/\r?\n/); + const rewritten: string[] = []; + let insideSay = false; + let sawChannel = false; + + const insertChannelIfNeeded = (): void => { + if (insideSay && !sawChannel) { + rewritten.push(`channel: ${channelSelector}`); + sawChannel = true; + } + }; + + for (const line of lines) { + const kind = parseRepoFaceActionKind(line); + if (kind) { + insertChannelIfNeeded(); + insideSay = kind === "say"; + sawChannel = false; + rewritten.push(line); + continue; + } + + if (insideSay && line.trim() === "END") { + insertChannelIfNeeded(); + insideSay = false; + rewritten.push(line); + continue; + } + + if (insideSay && /^channel(?:Id)?:/i.test(line.trim())) { + rewritten.push(`channel: ${channelSelector}`); + sawChannel = true; + continue; + } + + rewritten.push(line); + if (insideSay && /^identity:/i.test(line.trim()) && !sawChannel) { + rewritten.push(`channel: ${channelSelector}`); + sawChannel = true; + } + } + + insertChannelIfNeeded(); + return rewritten.join("\n"); +} + function parseInterpretationBlock(interpretationText: string): Record { const lines = interpretationText.split(/\r?\n/); const start = lines.findIndex((line) => @@ -1041,7 +1120,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn return false; } - const requestedChannelId = intent.channelId ?? job.outputChannelId; + const requestedChannelId = intent.channelId ?? repoIdentityDefaultSpeechChannel(job); const channelId = normalizeRepoIdentitySpeechChannel(identity, job, requestedChannelId); if (!channelId) { console.warn(`Rejected repo identity ${identity.id} speech for job ${job.id}: no Discord channel was available.`); @@ -1049,7 +1128,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn } if (requestedChannelId && requestedChannelId !== channelId) { console.warn( - `Coerced repo identity ${identity.id} speech for job ${job.id} from owner DM alias to output channel ${channelId}.`, + `Coerced repo identity ${identity.id} speech for job ${job.id} from "${requestedChannelId}" to conversation channel ${channelId}.`, ); } if (isOwnerDmChannelAlias(channelId)) { @@ -2491,11 +2570,25 @@ function isOwnerDmChannelAlias(channelId: string): boolean { return ["owner", "dm", "owner_dm", "private", "meta"].includes(channelId.trim().toLowerCase()); } +function isCurrentRoomChannelAlias(channelId: string): boolean { + return ["current", "current_room", "room", "here"].includes(channelId.trim().toLowerCase()); +} + +function repoIdentityDefaultSpeechChannel(job: JobRecord): string | undefined { + return job.guildContext?.channelId && !isOwnerDmChannelAlias(job.guildContext.channelId) + ? job.guildContext.channelId + : job.outputChannelId; +} + function normalizeRepoIdentitySpeechChannel( identity: { channelPermissions?: Array<{ channelId: string; label?: string }> }, job: JobRecord, requestedChannelId: string | undefined, ): string | undefined { + if (requestedChannelId && isCurrentRoomChannelAlias(requestedChannelId)) { + return repoIdentityDefaultSpeechChannel(job); + } + const explicitChannelId = resolveRepoIdentityChannelSelector(identity, requestedChannelId); if (explicitChannelId) { return explicitChannelId; @@ -2509,9 +2602,7 @@ function normalizeRepoIdentitySpeechChannel( return requestedChannelId; } - return job.outputChannelId && !isOwnerDmChannelAlias(job.outputChannelId) - ? job.outputChannelId - : undefined; + return repoIdentityDefaultSpeechChannel(job); } function resolveRepoIdentityChannelSelector( diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 7502f0a..deefdc1 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -151,6 +151,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Keep the swarm awake, but watch the logs with suspicion. The restart pass thawed `state/agent-swarm-paused.json`, enabled `GameCult Local Orchestrator`, restarted a fresh bot/worker stack, and verified healthy watchdog/repo Face pulses. The current patch removes repo Face turns from the generic owner-reply wrapper, coerces stray owner-DM aliases back to the live room unless explicitly marked owner-private, pauses Bifrost/governance routing so agents return to open room discussion plus private state pressure, and routes volatile Face state through the actual state projector model before child prompt assembly. - 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. - Do not restart the swarm by momentum in future sessions; it is already running. If it must be stopped again, use `state/agent-swarm-paused.json` and the orchestrator task deliberately, then update this handoff. - 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. diff --git a/prompts/repo-face-turn-interpreter.prompt.md b/prompts/repo-face-turn-interpreter.prompt.md index 5622d68..cf22bbd 100644 --- a/prompts/repo-face-turn-interpreter.prompt.md +++ b/prompts/repo-face-turn-interpreter.prompt.md @@ -83,6 +83,7 @@ Normalized output rules: - If the Face output includes a `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. - 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. - Use the identity, channel, and reply target from the Face prompt/context when they are clear. - Do not choose an owner/private/DM channel unless the Face prompt explicitly says this turn is an owner-private direct-message turn. Normal check-ins, warnings, governance anxieties, consensus questions, and room replies belong in the current room, Aquarium, or a configured domain channel. - Preserve the Face's voice in SAY content. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 64bb325..11548b8 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -289,3 +289,4 @@ {"ts":"2026-05-22T21:34:48Z","type":"fix","status":"ok","note":"Repaired Face Interpreter DSL routing after live silence: parser now ends a block when a new action header starts so unterminated STATE NOTE blocks cannot swallow SAY blocks; current_face_id/face_id aliases resolve to the current repo Face identity. Weksa's missed summons was an interrupted job caused by stack restart between Face output and Interpreter routing, not the swallowed-SAY parser case."} {"ts":"2026-05-22T21:49:44Z","type":"implementation","status":"ok","note":"Mimir loop repair: preserved manual blocked participant status across heartbeat reconciliation, added visible cross-channel chronology to Face prompts, taught the Interpreter to treat later same-Face acknowledgements as resolved repairs, and retired Mimir's duplicated correction-pressure state after isolated Face/Interpreter smoke produced no repeated SAY."} {"ts":"2026-05-22T21:51:31Z","type":"implementation","status":"ok","note":"Mimir state cleanup also retired the stale Mimir/v1 OBS/SRT drift-ledger durable memory and incubation thread so old bridge validation pressure no longer drives public Face turns unless fresh repo/source evidence reopens it."} +{"ts":"2026-05-22T22:02:15Z","type":"fix","status":"ok","note":"Repaired repo Face speech channel ownership after Heimdall posted an Aquarium-shaped reply in development: parent Interpreter can no longer invent a SAY venue when the Face did not explicitly choose one; default and current_room speech now resolve to the job conversation channel before registered-channel allow-list checks. Typecheck/build passed and the local stack was restarted on the new dist."} diff --git a/state/map.yaml b/state/map.yaml index 3fe9fa2..72a1b32 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -147,6 +147,7 @@ accepted_design: - Repo Face heartbeats are not ordinary owner Discord replies. The owner-Codex render wrapper must recognize both legacy `Repo Face identity doctrine` prompts and current `Epiphany Face identity doctrine` heartbeat prompts, forbid generic handoff for Face jobs, and preserve the repo identity action rail as parent-owned output. The preferred rail is now the small Face action DSL; legacy JSON sentinels remain accepted only as a compatibility surface. - Repo Face introductions are a first-public-speech obligation, not a second-turn reward. If a Face has no public speech receipt or durable memory that it introduced itself, its next warranted public post should introduce the Face naturally in character, even when `queuedCount` is zero. - Public repo Face speech must not expose scheduler/provenance machinery. Labels such as `Repo-face heartbeat from ...`, `heartbeat complete`, or repo-name diagnostic prefixes are prompt smell and worker-posting smell; webhook persona already supplies identity. + - Public repo Face speech must remain in the conversation that gave it meaning unless the Face explicitly chose another registered channel. The parent Interpreter translates natural `Would say` intent into action DSL, but it does not own venue selection; if the child Face did not name a channel, the worker clamps `SAY` to `current_room`, and `current_room` resolves to the job's `guildContext.channelId` before channel allow-list checks. Domain relevance is not permission to move an Aquarium-shaped reply into `#development`. - Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. Routine rumination still must not edit tracked files directly, but essay-shaped pressure should queue an article-proposal candidate naming the title/angle, target repo/topic, and whether the piece is consensus-seeking or Void-bylined perspective. - Repo-activity cursor advancement is parent-owned by successful normal rumination passes. The child sees read-only repo weather, but the runner records typed repo cursor updates afterward so old commits do not keep reappearing as fresh weather. - Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human.