From e5eca5d954fe6c65e8eba82dbb0f890ce6c692dc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 4 Jun 2026 17:19:03 +0100 Subject: [PATCH] Fix repo Face collaboration speech routing --- apps/bot/src/discord-bot.ts | 73 ++++++++++++++++++++++ apps/worker/src/index.ts | 69 ++++++++++++++++++++ prompts/repo-face-turn.prompt.md | 2 +- scripts/smoke-repo-face-speech-boundary.ts | 4 +- state/evidence.jsonl | 1 + 5 files changed, 146 insertions(+), 3 deletions(-) diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index e8d94a3..f3b4e80 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -314,6 +314,17 @@ export async function startBot(): Promise { client.on(Events.MessageCreate, async (message) => { if (message.author.bot) { + await queueRepoFaceCollaborationHooksFromBotMessage({ + message, + registry: repoDiscordIdentities, + statePath: config.repoFaceHeartbeats.statePath, + storageRoot: config.storageRoot, + sourceRepoRoot: config.sourceRepoRoot, + epiphanyAgentRoot: config.epiphanyAgentRoot, + workspaceRoot: process.cwd(), + birthMode: config.repoFaceBirthMode, + birthExecutor: config.repoFaceBirthExecutor, + }); return; } @@ -801,6 +812,68 @@ function uniqueRepoIdentities(identities: RepoDiscordIdentity[]): RepoDiscordIde return result; } +async function queueRepoFaceCollaborationHooksFromBotMessage(input: { + message: Message; + registry: { identities: RepoDiscordIdentity[] }; + statePath: string; + storageRoot: string; + sourceRepoRoot?: string; + epiphanyAgentRoot: string; + workspaceRoot: string; + birthMode: Parameters[0]["birthMode"]; + birthExecutor: Parameters[0]["birthExecutor"]; +}): Promise { + if (!input.message.inGuild() || !isRepoFaceCollaborationHook(input.message.content)) { + return; + } + + const addressed = uniqueRepoIdentities(findRepoDiscordIdentitiesByTextMentions( + input.registry, + stripBotMention(input.message.content), + input.message.channelId, + )); + if (addressed.length === 0) { + return; + } + + const speakerIdentity = findRepoDiscordIdentityByPersonaName( + input.registry, + input.message.author.username, + input.message.channelId, + ); + for (const identity of addressed.filter((entry) => entry.id !== speakerIdentity?.id)) { + const faceInitialization = await ensureRepoFaceInitialized({ + identity, + storageRoot: input.storageRoot, + sourceRepoRoot: input.sourceRepoRoot, + epiphanyAgentRoot: input.epiphanyAgentRoot, + workspaceRoot: input.workspaceRoot, + birthMode: input.birthMode, + birthExecutor: input.birthExecutor, + }); + const queuedMention = await queueRepoFaceMention({ + statePath: input.statePath, + identity, + channelId: input.message.channelId, + messageId: input.message.id, + authorId: input.message.author.id, + authorName: input.message.author.username, + content: input.message.content, + visiblePrompt: + `A public collaboration hook named ${identity.displayName}. Treat this as a live consultation or coauthorship obligation, not passive background: ${input.message.content}`, + }); + console.log( + `Queued repo Face collaboration hook ${input.message.id} for ${identity.id} via CTB turn queue (${queuedMention.pendingCount} pending). Birth status: ${ + faceInitialization.birthStatusPath ?? faceInitialization.skippedReason ?? "unknown" + }`, + ); + } +} + +function isRepoFaceCollaborationHook(content: string): boolean { + return /\b(consult(?:ation|ed|ing)?|co[- ]?author(?:ship|ed|ing)?|collab(?:oration|orate|orating)?|parallel byline|byline|stewardship|consensus check|ask(?:ed|ing)?|invite(?:d|s|ing)?)\b/i.test(content); +} + async function ensureRepoIdentityRoles(options: { botToken: string; guildId?: string; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index a2da9b8..62b1e80 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -574,6 +574,14 @@ async function executeRepoFaceJobWithInterpreter( }); } + const firstObligationRoute = routeUnparsedWouldSay(firstResponse, firstInterpretation, { + job, + faceOutputText: firstText, + }); + if (firstObligationRoute) { + return firstObligationRoute; + } + if (firstInterpretation.decision === "drop") { return dropRepoFaceActionBlocks(firstResponse, firstInterpretation); } @@ -627,6 +635,14 @@ async function executeRepoFaceJobWithInterpreter( }); } + const retryObligationRoute = routeUnparsedWouldSay(retryResponse, retryInterpretation, { + job, + faceOutputText: retryText, + }); + if (retryObligationRoute) { + return retryObligationRoute; + } + await auditLog.record({ type: "repo_face.parent_interpreter_drop", actorId: job.requester.id, @@ -770,6 +786,59 @@ function routeRepoFaceInterpretedOutput( }; } +function routeUnparsedWouldSay( + response: ProviderResponse, + interpretation: RepoFaceParentInterpretation, + input: { job: JobRecord; faceOutputText: string }, +): ProviderResponse | undefined { + if (input.job.command !== "repo-face-rumination") { + return undefined; + } + if (!interpretation.reasons.some((reason) => /parseable reason|did not provide/i.test(reason))) { + return undefined; + } + + const content = extractUnconditionalWouldSay(input.faceOutputText); + if (!content || isNonPublicRepoIdentitySpeech(content)) { + return undefined; + } + + return routeRepoFaceInterpretedOutput( + response, + { + decision: "route", + reasons: [ + "Face produced an unconditional Would say and the parent interpreter failed to parse a routing reason.", + ], + routedOutput: [ + "Parent interpreter fallback: routed unconditional Face Would say.", + "", + "SAY", + "identity: current_face_id", + "channel: current_room", + "reply_to:", + "content:", + ...content.split(/\r?\n/).map((line) => ` ${line}`), + "END", + ].join("\n"), + }, + input, + ); +} + +function extractUnconditionalWouldSay(outputText: string): string | undefined { + const match = outputText.match(/(?:^|\n)Would say:\s*([\s\S]*?)(?=\n\n[A-Z][A-Za-z /-]{2,}:|\n\nWhat should stick:|\n\nWork\/proposal:|\n\nPrivate thought:|$)/i); + const content = match?.[1]?.trim(); + if (!content) { + return undefined; + } + const normalized = content.toLowerCase().replace(/^["'`]+|["'`]+$/g, "").trim(); + if (/^(nothing|nothing public|nothing right now|stay private|no public speech)\b/.test(normalized)) { + return undefined; + } + return content; +} + function normalizeInterpretedRepoFaceSpeechDestinations( outputText: string, input: { job: JobRecord; faceOutputText: string }, diff --git a/prompts/repo-face-turn.prompt.md b/prompts/repo-face-turn.prompt.md index 73ce017..8522dba 100644 --- a/prompts/repo-face-turn.prompt.md +++ b/prompts/repo-face-turn.prompt.md @@ -137,7 +137,7 @@ Write as one projection of the Colossus speaking to another. Keep the myth alive Use this friendly shape when it helps, but do not turn it into a form: - Private thought: what you learned, felt, or decided. -- Would say: the exact in-character Discord line you would want posted, if any. +- Would say: the exact in-character Discord line you want posted now. If you have nothing public to say, omit this line; do not draft a fake would-say or write a placeholder such as "nothing public." - Work/proposal: the review, change request, or article idea you want remembered or openly discussed, if any. - Article draft: if you actually want to write a bylined article now, include the title, site, description, intended announcement, and the full markdown body without YAML frontmatter. - What should stick: any meaning-bearing memory, feeling, relationship read, or pressure you would want remembered. diff --git a/scripts/smoke-repo-face-speech-boundary.ts b/scripts/smoke-repo-face-speech-boundary.ts index 9f81731..ec8254f 100644 --- a/scripts/smoke-repo-face-speech-boundary.ts +++ b/scripts/smoke-repo-face-speech-boundary.ts @@ -12,13 +12,13 @@ type Case = { const cases: Case[] = [ { - name: "raw Would say fallback stays private", + name: "raw silence Would say stays private", run: () => parseRepoIdentityPostIntents([ "Private thought: I should stay quiet.", "Would say: `nothing right now.`", "What should stick: silence is correct.", ].join("\n")).length === 0, - detail: "A child Face draft must not create public speech unless the parent Interpreter emits SAY.", + detail: "A silence marker in a child Face draft must not create public speech.", }, { name: "structured silence SAY rejected", diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 3e5d4f5..889960f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -335,3 +335,4 @@ {"ts":"2026-06-04T13:37:20Z","type":"implementation","status":"ok","note":"VoidBot worker now drains gamecult.operator_dm_request.v1 documents from the provider-owned CultMesh store and delivers pending owner.dm.send requests through the existing Discord owner-DM path, marking requests sent or failed in place. Eve provider documents are keyed, not global, so one store can hold both voidbot.swarm and voidbot.operator-dm."} {"ts":"2026-06-04T13:42:16Z","type":"correction","status":"ok","note":"Corrected VoidBot operator-DM exposure: voidbot.operator-dm is a provider advertisement and command inbox, not an Eve dashboard/interface slot. Odin lists the capability in /eve/deck/providers while the Odin surface only lowers real provider interfaces such as voidbot.swarm."} {"ts":"2026-06-04T15:55:31Z","type":"implementation","status":"ok","note":"Registered Discord #pics (758854103035674624) as a named media/pictures channel for all 16 repo Faces that have #general access, so Faces can see the channel and target SAY channel: pics for picture/screenshot/image/media talk instead of defaulting to #general."} +{"ts":"2026-06-04T16:18:39Z","type":"bugfix","status":"ok","note":"Fixed repo Face collaboration/speech ownership: bot-authored collaboration hooks that name registered Faces now queue CTB obligations instead of being discarded as bot chatter; parent worker fallback routes an unconditional Face 'Would say' when the Interpreter fails to parse, while silence markers still stay private. Replayed the missed Void->Nibu consultation hook; Nibu consumed it and explicitly chose no public speech after the fix."}