Wake Faces on broadcast invitations

This commit is contained in:
Meta Vens 2026-05-24 03:55:13 +01:00
commit ae6eabcd4d
3 changed files with 84 additions and 17 deletions

View file

@ -21,6 +21,7 @@ import {
findRepoDiscordIdentityByRoleIds,
findRepoDiscordIdentitiesByTextMentions,
findRepoDiscordIdentityByTextAddress,
isRepoDiscordIdentityAllowedInChannel,
loadFaceIdentityRegistry,
queueAgentHeartbeatMention,
queueRepoFaceMention,
@ -364,10 +365,19 @@ export async function startBot(): Promise<void> {
...textMentionedRepoIdentities,
...(repliedRepoIdentity ? [repliedRepoIdentity] : []),
]);
const broadcastAddressedRepoIdentities = !isDirectMessage && addressedRepoIdentities.length === 0 &&
isRepoFaceBroadcastInvitation(stripBotMention(message.content))
? repoDiscordIdentities.identities.filter((identity) =>
isRepoDiscordIdentityAllowedInChannel(identity, message.channelId),
)
: [];
const pendingRepoIdentities = addressedRepoIdentities.length > 0
? addressedRepoIdentities
: broadcastAddressedRepoIdentities;
const addressedRepoIdentity = roleAddressedRepoIdentity ?? textAddressedRepoIdentity ?? repliedRepoIdentity;
const isBotMentioned = Boolean(client.user && message.mentions.has(client.user));
if (!client.user || (!isDirectMessage && !isBotMentioned && addressedRepoIdentities.length === 0)) {
if (!client.user || (!isDirectMessage && !isBotMentioned && pendingRepoIdentities.length === 0)) {
return;
}
@ -385,15 +395,16 @@ export async function startBot(): Promise<void> {
: stripBotMention(message.content).trim();
try {
if (addressedRepoIdentities.length > 0) {
if (pendingRepoIdentities.length > 0) {
let queuedCount = 0;
for (const identity of addressedRepoIdentities) {
for (const identity of pendingRepoIdentities) {
const identityVisiblePrompt = renderRepoIdentityVisiblePrompt({
message,
identity,
roleAddressed: roleAddressedRepoIdentities.some((entry) => entry.id === identity.id),
textAddressed: textAddressedRepoIdentity?.id === identity.id,
replied: repliedRepoIdentity?.id === identity.id,
broadcastAddressed: broadcastAddressedRepoIdentities.some((entry) => entry.id === identity.id),
});
if (!identityVisiblePrompt) {
console.log(`Ignored empty repo Face mention ${message.id} for ${identity.id}.`);
@ -730,6 +741,7 @@ function renderRepoIdentityVisiblePrompt(input: {
roleAddressed: boolean;
textAddressed: boolean;
replied: boolean;
broadcastAddressed: boolean;
}): string {
const strippedBotMention = stripBotMention(input.message.content);
if (input.roleAddressed || input.textAddressed) {
@ -742,6 +754,10 @@ function renderRepoIdentityVisiblePrompt(input: {
}
}
if (input.broadcastAddressed && strippedBotMention.trim().length > 0) {
return `The room was explicitly invited to answer. For ${input.identity.displayName}, this invitation is live: ${strippedBotMention.trim()}`;
}
if (input.message.content.trim().length > 0) {
return strippedBotMention.trim();
}
@ -753,6 +769,24 @@ function renderRepoIdentityVisiblePrompt(input: {
);
}
function isRepoFaceBroadcastInvitation(content: string): boolean {
const normalized = collapseWhitespace(content).toLowerCase();
if (normalized.length === 0) {
return false;
}
return [
/\b(everyone|everybody|all of you|anyone|anybody|the swarm|you all|y'all)\b/,
/\bhow (are|is) (everyone|everybody|you all|y'all)\b/,
/\bwhat about (your|everyone's|everybody's) (minds?|bodies?|feelings?)\b/,
/\banything i can do to help\b/,
].some((pattern) => pattern.test(normalized));
}
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function uniqueRepoIdentities(identities: RepoDiscordIdentity[]): RepoDiscordIdentity[] {
const seen = new Set<string>();
const result: RepoDiscordIdentity[] = [];

View file

@ -1185,7 +1185,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn
identity.displayName,
...(identity.avatarUrl ? ["--persona-avatar-url", identity.avatarUrl] : []),
...(intent.replyToMessageId ? ["--reply-to-message-id", intent.replyToMessageId] : []),
]);
], { retries: 1 });
if (!posted.messageId || !posted.transport) {
throw new Error(`Bifrost Discord bridge returned no message receipt for job ${job.id}.`);
}
@ -1871,23 +1871,55 @@ function bifrostMirrorArgs(
];
}
function runBifrostBridge(args: string[]): BifrostBridgeReceipt {
function runBifrostBridge(args: string[], options: { retries?: number } = {}): BifrostBridgeReceipt {
const bridgeScript = resolve(config.bifrostRoot, "tools", "bifrost-bridge.mjs");
const result = spawnSync("node", [bridgeScript, ...args], {
cwd: config.bifrostRoot,
encoding: "utf8",
windowsHide: true,
});
const maxAttempts = 1 + Math.max(0, Math.floor(options.retries ?? 0));
let lastFailure = "";
if (result.status !== 0) {
throw new Error(`Bifrost bridge failed: ${result.stderr || result.stdout}`);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const result = spawnSync("node", [bridgeScript, ...args], {
cwd: config.bifrostRoot,
encoding: "utf8",
windowsHide: true,
});
if (result.status !== 0 || result.error) {
lastFailure = renderBridgeFailure(result, attempt, maxAttempts);
if (attempt < maxAttempts) {
console.warn(`Bifrost bridge attempt ${attempt}/${maxAttempts} failed; retrying. ${lastFailure}`);
continue;
}
throw new Error(`Bifrost bridge failed. ${lastFailure}`);
}
try {
return JSON.parse(result.stdout) as BifrostBridgeReceipt;
} catch {
throw new Error(`Bifrost bridge returned non-JSON output: ${result.stdout}`);
}
}
try {
return JSON.parse(result.stdout) as BifrostBridgeReceipt;
} catch {
throw new Error(`Bifrost bridge returned non-JSON output: ${result.stdout}`);
}
throw new Error(`Bifrost bridge failed. ${lastFailure || "No bridge attempt completed."}`);
}
function renderBridgeFailure(
result: ReturnType<typeof spawnSync>,
attempt: number,
maxAttempts: number,
): string {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
const errorMessage = result.error instanceof Error ? result.error.message : undefined;
return [
`attempt=${attempt}/${maxAttempts}`,
`status=${result.status ?? "null"}`,
`signal=${result.signal ?? "none"}`,
errorMessage ? `error=${errorMessage}` : undefined,
stderr ? `stderr=${stderr}` : undefined,
stdout ? `stdout=${stdout}` : undefined,
]
.filter((entry): entry is string => Boolean(entry))
.join(" ");
}
function parseRepoIdentityUpdateRequestIntents(finalResponse: string): RepoIdentityUpdateRequestIntent[] {

View file

@ -311,3 +311,4 @@
{"ts":"2026-05-24T02:14:42Z","type":"ops","status":"ok","note":"Thawed state/agent-swarm-paused.json and manually pulsed GameCult Local Orchestrator for repo-face-heartbeats. The pulse ran successfully, queued Void moderation, resynced CTB wall-clock initiative, and left repo Faces active under idle cooling with Bifrost dispatch still disabled."}
{"ts":"2026-05-24T02:24:32Z","type":"implementation","status":"ok","note":"Repo Face turns now carry Discord attachment metadata through SourceMessage, cache recent image attachments under .voidbot/media/discord-images, render media markers in Face transcripts, and pass cached image files to owner Codex via codex exec --image. Prompt assembly verified the Image awareness section renders and non-image attachments stay metadata-only."}
{"ts":"2026-05-24T02:27:41Z","type":"ops","status":"ok","note":"Rebuilt VoidBot and restarted the live stack after multimodal Face-turn changes. Runtime status is ready with bot PID 11324 and worker PID 16396, so owner-Codex provider changes for codex exec --image are live in dist."}
{"ts":"2026-05-24T02:54:54Z","type":"implementation","status":"ok","note":"Discord repo Face intake now treats explicit room-wide invitations as pending CTB obligations for every eligible Face in the channel, and repo identity Discord bridge posting retries once while reporting status/signal/stdout/stderr instead of failing with an empty message."}