Fix repo Face live channel routing
This commit is contained in:
parent
7daefd7c67
commit
0e2fc95cee
4 changed files with 115 additions and 30 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface GuildContext {
|
|||
channelId: string;
|
||||
channelName?: string;
|
||||
threadId?: string;
|
||||
replyToMessageId?: string;
|
||||
}
|
||||
|
||||
export interface SourceMessage {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue