diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index 7bac719..c437537 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -21,6 +21,7 @@ import { findRepoDiscordIdentityByRoleIds, findRepoDiscordIdentitiesByTextMentions, findRepoDiscordIdentityByTextAddress, + isRepoDiscordIdentityAllowedInChannel, loadFaceIdentityRegistry, queueAgentHeartbeatMention, queueRepoFaceMention, @@ -364,10 +365,19 @@ export async function startBot(): Promise { ...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 { : 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(); const result: RepoDiscordIdentity[] = []; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 347b718..c32fa8b 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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, + 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[] { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 4c3ca74..0ffd530 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."}