Keep Face speech in its conversation channel

This commit is contained in:
Meta Vens 2026-05-22 23:03:29 +01:00
commit 4c2a9108ed
5 changed files with 103 additions and 8 deletions

View file

@ -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<string, string> {
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(

View file

@ -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.

View file

@ -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.

View file

@ -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."}

View file

@ -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.