Fix repo Face live channel routing

This commit is contained in:
Meta Vens 2026-06-04 17:58:44 +01:00
commit 0e2fc95cee
4 changed files with 115 additions and 30 deletions

View file

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

View file

@ -69,6 +69,7 @@ export interface GuildContext {
channelId: string;
channelName?: string;
threadId?: string;
replyToMessageId?: string;
}
export interface SourceMessage {

View file

@ -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<ChannelSnapshot[]> {
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,

View file

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