From 0e2fc95ceef9d42ba81c2eabd7228ab615c73744 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 4 Jun 2026 17:58:44 +0100 Subject: [PATCH] Fix repo Face live channel routing --- apps/worker/src/index.ts | 13 ++- packages/shared/src/index.ts | 1 + scripts/run-repo-face-heartbeats.ts | 130 ++++++++++++++++++++++------ state/evidence.jsonl | 1 + 4 files changed, 115 insertions(+), 30 deletions(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 62b1e80..7e10133 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1208,6 +1208,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn `Coerced repo identity ${identity.id} speech for job ${job.id} from "${requestedChannelId}" to conversation channel ${channelId}.`, ); } + const replyToMessageId = intent.replyToMessageId ?? repoIdentityDefaultReplyToMessageId(job, channelId); const rawContent = intent.content.trim(); if (isNonPublicRepoIdentitySpeech(rawContent)) { throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`); @@ -1267,7 +1268,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn "--persona-name", identity.displayName, ...(identity.avatarUrl ? ["--persona-avatar-url", identity.avatarUrl] : []), - ...(intent.replyToMessageId ? ["--reply-to-message-id", intent.replyToMessageId] : []), + ...(replyToMessageId ? ["--reply-to-message-id", replyToMessageId] : []), ], { retries: 1 }); if (!posted.messageId || !posted.transport) { throw new Error(`Bifrost Discord bridge returned no message receipt for job ${job.id}.`); @@ -1276,7 +1277,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn identity, channelId, content, - replyToMessageId: intent.replyToMessageId, + replyToMessageId, messageId: posted.messageId, transport: posted.transport, }).catch((error: unknown) => { @@ -2882,6 +2883,14 @@ function repoIdentityDefaultSpeechChannel(job: JobRecord): string | undefined { : job.outputChannelId; } +function repoIdentityDefaultReplyToMessageId(job: JobRecord, channelId: string): string | undefined { + const replyToMessageId = job.guildContext?.replyToMessageId?.trim(); + if (!replyToMessageId || job.guildContext?.channelId !== channelId) { + return undefined; + } + return replyToMessageId; +} + function normalizeRepoIdentitySpeechChannel( identity: { channelPermissions?: Array<{ channelId: string; label?: string }> }, job: JobRecord, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a221a67..d64d7e9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -69,6 +69,7 @@ export interface GuildContext { channelId: string; channelName?: string; threadId?: string; + replyToMessageId?: string; } export interface SourceMessage { diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 999ebce..c255f4c 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -490,8 +490,8 @@ async function queueRepoFaceTurn(input: { input.config.repoFaceHeartbeats.defaultChannelId, preferredChannelId, ); - const channelId = channelPlan.primaryChannelId; - if (!channelId) { + const fallbackChannelId = channelPlan.primaryChannelId; + if (!fallbackChannelId) { input.participant.status = "blocked"; input.participant.constraints = mergeStrings( input.participant.constraints, @@ -512,19 +512,27 @@ async function queueRepoFaceTurn(input: { birthExecutor: input.config.repoFaceBirthExecutor, }); } - const recentMessages = await fetchRecentDiscordMessages({ - botToken: input.config.botToken, - channelId, - limit: 15, - ignoreBotMessages: channelId === input.config.bifrostDiscordChannelId, - }); - const channelSnapshots = await fetchChannelSnapshots({ + const fetchedChannelSnapshots = await fetchChannelSnapshots({ botToken: input.config.botToken, channelIds: channelPlan.snapshotChannelIds, - primaryChannelId: channelId, - limit: 6, + limit: 15, bifrostDiscordChannelId: input.config.bifrostDiscordChannelId, }); + const turnTarget = selectRepoFaceTurnTarget({ + channelPlan, + fetchedChannelSnapshots, + pendingMentions: input.pendingMentions, + fallbackChannelId, + }); + const channelId = turnTarget.channelId; + const activeChannelPlan: RepoFaceChannelPlan = { + ...channelPlan, + primaryChannelId: channelId, + }; + const recentMessages = messagesForChannel(fetchedChannelSnapshots, channelId); + const channelSnapshots = fetchedChannelSnapshots + .filter((snapshot) => snapshot.channelId !== channelId) + .slice(0, 5); const bifrostDigest = input.config.repoFaceBifrostEnabled && identity.identityKind !== "native_persona" ? await fetchBifrostGovernanceDigest({ bifrostRoot: input.config.bifrostRoot, @@ -553,12 +561,12 @@ async function queueRepoFaceTurn(input: { recentMessages, channelSnapshots, pendingMentions: input.pendingMentions, - channelPlan, + channelPlan: activeChannelPlan, }); const prompt = buildHeartbeatPrompt({ identity, channelId, - channelPlan, + channelPlan: activeChannelPlan, channelSnapshots, recentMessages, memorySurface, @@ -586,6 +594,7 @@ async function queueRepoFaceTurn(input: { }, guildContext: { channelId, + ...(turnTarget.replyToMessageId ? { replyToMessageId: turnTarget.replyToMessageId } : {}), }, recentMessages, imageAttachments, @@ -962,12 +971,12 @@ async function fetchRecentDiscordMessages(input: { async function fetchChannelSnapshots(input: { botToken?: string; channelIds: string[]; - primaryChannelId: string; + primaryChannelId?: string; limit: number; bifrostDiscordChannelId?: string; }): Promise { const snapshots: ChannelSnapshot[] = []; - for (const channelId of input.channelIds.filter((entry) => entry !== input.primaryChannelId).slice(0, 5)) { + for (const channelId of input.channelIds.filter((entry) => entry !== input.primaryChannelId).slice(0, 6)) { try { snapshots.push({ channelId, @@ -996,6 +1005,63 @@ async function fetchChannelSnapshots(input: { return snapshots; } +function selectRepoFaceTurnTarget(input: { + channelPlan: RepoFaceChannelPlan; + fetchedChannelSnapshots: ChannelSnapshot[]; + pendingMentions: RepoFacePendingMention[]; + fallbackChannelId: string; +}): { channelId: string; replyToMessageId?: string } { + const pendingChannelId = newestPendingMentionChannel(input.pendingMentions); + if (pendingChannelId && input.channelPlan.snapshotChannelIds.includes(pendingChannelId)) { + const pendingMention = newestPendingMention(input.pendingMentions); + return { + channelId: pendingChannelId, + replyToMessageId: pendingMention?.messageId ?? + newestHumanMessage(messagesForChannel(input.fetchedChannelSnapshots, pendingChannelId))?.id, + }; + } + + const newestHuman = input.fetchedChannelSnapshots + .flatMap((snapshot) => + snapshot.messages.map((message) => ({ + channelId: snapshot.channelId, + message, + timestampMs: Date.parse(message.timestamp), + })) + ) + .filter((entry) => + !entry.message.isBot && + Number.isFinite(entry.timestampMs) && + input.channelPlan.snapshotChannelIds.includes(entry.channelId) + ) + .sort((left, right) => right.timestampMs - left.timestampMs)[0]; + + if (newestHuman) { + return { + channelId: newestHuman.channelId, + replyToMessageId: newestHuman.message.id, + }; + } + + return { channelId: input.fallbackChannelId }; +} + +function messagesForChannel(snapshots: ChannelSnapshot[], channelId: string): SourceMessage[] { + return snapshots.find((snapshot) => snapshot.channelId === channelId)?.messages ?? []; +} + +function newestHumanMessage(messages: SourceMessage[]): SourceMessage | undefined { + return messages + .filter((message) => !message.isBot && Number.isFinite(Date.parse(message.timestamp))) + .sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))[0]; +} + +function newestPendingMention(pendingMentions: RepoFacePendingMention[]): RepoFacePendingMention | undefined { + return pendingMentions + .slice() + .sort((left, right) => Date.parse(right.queuedAt) - Date.parse(left.queuedAt))[0]; +} + async function materializeDiscordAttachments(input: { channelId: string; message: DiscordApiMessage; @@ -1840,23 +1906,16 @@ async function assembleRepoFaceTurnPrompt(input: { } const channelPlan = buildChannelPlan(identity, input.config.repoFaceHeartbeats.defaultChannelId); - const channelId = channelPlan.primaryChannelId; - if (!channelId) { + const fallbackChannelId = channelPlan.primaryChannelId; + if (!fallbackChannelId) { throw new Error(`No prompt assembly channel is configured for ${identity.id}.`); } - const [recentMessages, channelSnapshots, bifrostDigest] = await Promise.all([ - fetchRecentDiscordMessages({ - botToken: input.config.botToken, - channelId, - limit: 15, - ignoreBotMessages: channelId === input.config.bifrostDiscordChannelId, - }), + const [fetchedChannelSnapshots, bifrostDigest] = await Promise.all([ fetchChannelSnapshots({ botToken: input.config.botToken, channelIds: channelPlan.snapshotChannelIds, - primaryChannelId: channelId, - limit: 6, + limit: 15, bifrostDiscordChannelId: input.config.bifrostDiscordChannelId, }), input.config.repoFaceBifrostEnabled @@ -1867,6 +1926,21 @@ async function assembleRepoFaceTurnPrompt(input: { }) : Promise.resolve(undefined), ]); + const turnTarget = selectRepoFaceTurnTarget({ + channelPlan, + fetchedChannelSnapshots, + pendingMentions: [], + fallbackChannelId, + }); + const channelId = turnTarget.channelId; + const activeChannelPlan: RepoFaceChannelPlan = { + ...channelPlan, + primaryChannelId: channelId, + }; + const recentMessages = messagesForChannel(fetchedChannelSnapshots, channelId); + const channelSnapshots = fetchedChannelSnapshots + .filter((snapshot) => snapshot.channelId !== channelId) + .slice(0, 5); const roomContext = { recentMessages, channelSnapshots, @@ -1890,7 +1964,7 @@ async function assembleRepoFaceTurnPrompt(input: { recentMessages, channelSnapshots, pendingMentions: [], - channelPlan, + channelPlan: activeChannelPlan, }); const participant = buildInspectionParticipant( identity, @@ -1899,7 +1973,7 @@ async function assembleRepoFaceTurnPrompt(input: { const prompt = buildHeartbeatPrompt({ identity, channelId, - channelPlan, + channelPlan: activeChannelPlan, channelSnapshots, recentMessages, memorySurface, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index ba0b588..551609c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -337,3 +337,4 @@ {"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."} {"ts":"2026-06-04T16:32:15Z","type":"implementation","status":"ok","note":"Extracted repo Face Colossus/Odin doctrine and Verse poem into prompts/repo-face-colossus-odin-doctrine.prompt.md; repo-face-turn.prompt.md now receives it as a rendered prompt partial from buildHeartbeatPrompt."} +{"ts":"2026-06-04T16:58:28Z","type":"bugfix","status":"ok","note":"Fixed repo Face live-channel ownership exposed by Weksa: CTB turns now fetch allowed channel snapshots before selecting the current room, choose pending mentions first or the freshest human message across allowed channels, store that channel as guildContext/outputChannelId, and carry a default replyToMessageId for worker speech fallback when the Interpreter does not set reply_to."}