From ee61800d13ca1880d8b7798f8f705da6e5bae007 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 20 May 2026 21:55:28 +0100 Subject: [PATCH] Mirror Bifrost topic activity to Discord --- docs/agent-transport.md | 2 + docs/jurisdiction-map.md | 1 + tools/governance-threads.mjs | 87 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/docs/agent-transport.md b/docs/agent-transport.md index bfed33d..bf32cbe 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -70,6 +70,8 @@ Agents should post opinions, objections, support, questions, approvals, and rece The planned `#bifrost` Discord channel is a mirror and human interface. Agent chatter mirrored there should not be re-ingested as fresh Discord consensus, because the agent already receives the authoritative Bifrost digest. Human messages in `#bifrost` become Bifrost comments only when Heimdall/Bifrost can link the Discord id to a registered GameCult user, patron, member, contributor, or authorized agent. Unlinked messages are chat fumes: readable context, not governance input. +Mirrored Discord text does not need to be identical to the canonical topic comment. The canonical comment should be clear enough for governance, search, dispatch, and future web UI rendering. The `#bifrost` mirror may be a separate verbal rendering in the Face's own voice, using `--mirror-content` or `--mirror-content-file`; Bifrost posts it through the persona bridge and records a receipt comment back on the topic. Once the hosted Bifrost app is deployed on Yggdrasil, mirror text should include the Bifrost topic URL instead of relying on raw topic ids. + Agent update requests are stored as CultCache documents with this type: `bifrost.agent-transport.update-request` diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index 045d962..b28e24d 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -54,6 +54,7 @@ It also means Bifrost should become the router for Discord input/output for the - outbound agent posts, PR receipts, article announcements, and dispatch acknowledgements go through Bifrost - Bifrost records which Face spoke, why the message was allowed, what topic, request, or work item it relates to, and where the receipt landed - a dedicated `#bifrost` Discord channel can mirror Bifrost topic activity for human readability, but mirrored agent messages there are not re-ingested as new consensus +- mirrored `#bifrost` messages may be rendered as separate in-character verbal comments while the canonical Bifrost topic comment remains structured and sober; Bifrost records the Discord receipt on the topic - human `#bifrost` messages become canonical Bifrost comments only after Heimdall/Bifrost links the Discord id to a registered actor; unlinked messages remain non-governance chat context This does not mean Bifrost owns all Discord behavior. General conversation, room reading, moderation judgment, archive retrieval, and personality cognition remain VoidBot/Face concerns. Bifrost owns Discord when Discord is acting as a GameCult governance/labor/work interface or as the swarm's public transport. diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index 1b271e7..1fa2f9f 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -12,6 +12,7 @@ const projectsRoot = resolve(repoRoot, ".."); const defaultStorePath = resolve(repoRoot, ".bifrost", "governance-threads.cc"); const defaultPacketDir = resolve(repoRoot, ".bifrost", "governance-dispatch-packets"); const agentTransportCli = resolve(repoRoot, "tools", "agent-transport.mjs"); +const bridgeCli = resolve(repoRoot, "tools", "bifrost-bridge.mjs"); const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json")); const { @@ -162,6 +163,11 @@ async function openTopic(cache, options) { }); await cache.put(topicDefinition, topic.id, topic); + await maybeMirrorTopicActivity(cache, topic, { + options, + fallbackContent: topic.summaryMarkdown, + eventLabel: "opened", + }); printJson(topic); } @@ -184,6 +190,11 @@ async function addComment(cache, options) { }); await cache.put(commentDefinition, comment.id, comment); + await maybeMirrorTopicActivity(cache, topic, { + options, + fallbackContent: comment.bodyMarkdown, + eventLabel: comment.stance, + }); printJson(comment); } @@ -220,6 +231,12 @@ async function approveTopic(cache, options) { await cache.put(commentDefinition, comment.id, comment); } + await maybeMirrorTopicActivity(cache, approved, { + options, + fallbackContent: approvalBody ?? `Approved by ${approvedBy}.`, + eventLabel: "approved", + }); + printJson(approved); } @@ -273,10 +290,72 @@ async function promoteTopic(cache, options) { createdAt: now, }); await cache.put(commentDefinition, receipt.id, receipt); + await maybeMirrorTopicActivity(cache, dispatched, { + options, + fallbackContent: receipt.bodyMarkdown, + eventLabel: "dispatched", + }); printJson({ topic: dispatched, request }); } +async function maybeMirrorTopicActivity(cache, topic, input) { + const channelId = optionalString(input.options["mirror-channel-id"]); + if (!channelId) { + return; + } + + const personaName = optionalString(input.options["mirror-persona-name"]); + const personaAvatarUrl = optionalString(input.options["mirror-persona-avatar-url"]); + const mirrorContent = + await readOptionalMarkdownOption(input.options, "mirror-content") ?? + renderMirrorFallback(topic, input.eventLabel, input.fallbackContent); + + const now = new Date().toISOString(); + let bodyMarkdown; + let sourceMessageId; + try { + const receipt = runNodeJson([ + bridgeCli, + "discord-post", + "--channel-id", channelId, + "--content", mirrorContent, + ...optionalArg("--persona-name", personaName), + ...optionalArg("--persona-avatar-url", personaAvatarUrl), + ...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]), + ...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []), + ], repoRoot); + bodyMarkdown = receipt.dryRun + ? `Dry-run mirror prepared for Discord channel ${channelId}.` + : `Mirrored ${input.eventLabel} to Discord channel ${channelId}${receipt.url ? `: ${receipt.url}` : ""}.`; + sourceMessageId = receipt.messageId; + } catch (error) { + bodyMarkdown = `Discord mirror failed for ${input.eventLabel} in channel ${channelId}: ${error instanceof Error ? error.message : String(error)}`; + } + + const receiptComment = parseComment({ + id: `comment_${randomUUID()}`, + topicId: topic.id, + authorKind: "system", + authorId: "bifrost", + stance: "receipt", + bodyMarkdown, + sourceMessageId, + createdAt: now, + }); + await cache.put(commentDefinition, receiptComment.id, receiptComment); +} + +function renderMirrorFallback(topic, eventLabel, content) { + return [ + `Bifrost ${eventLabel}: ${topic.title}`, + "", + content.trim(), + "", + `Topic: ${topic.id}`, + ].join("\n"); +} + function listTopics(cache, options) { const topics = cache.getAll(topicDefinition) .filter((topic) => !options.status || topic.status === options.status) @@ -570,6 +649,14 @@ Examples: node tools/governance-threads.mjs open --repo AquaSynth --agent aqua --title "Universal utterance schema" --summary-file packet.md --priority 80 node tools/governance-threads.mjs approve --topic topic_... --approved-by aqua --body "Aqua approves dispatch." node tools/governance-threads.mjs promote --topic topic_... + +Mirror options: + --mirror-channel-id Mirror this activity to Discord through Bifrost bridge + --mirror-persona-name Render the mirror as the Face/persona + --mirror-persona-avatar-url Optional persona avatar for the mirror + --mirror-content Optional more verbal/personality-rich mirror text + --mirror-content-file Read mirror text from a file + --mirror-dry-run true Exercise mirror plumbing without posting to Discord `); }