From 97650aaafe57b2260fa62daee81591a20ccd7943 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 05:26:27 +0100 Subject: [PATCH] Add shared Discord persona webhook pipe --- README.md | 6 + apps/worker/src/mcp-server-discord.ts | 310 +++++++++++++++++++++++++- apps/worker/src/mcp-server-shared.ts | 4 + apps/worker/src/mcp-server-tools.ts | 18 +- notes/fresh-workspace-handoff.md | 2 +- notes/voidbot-current-system-map.md | 2 +- scripts/send-discord-message.mjs | 277 ++++++++++++++++++++++- state/evidence.jsonl | 1 + 8 files changed, 603 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a4bd28f..2070eb1 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,12 @@ or node scripts/send-discord-message.mjs --channel-id --reply-to ``` +If one of the swarm agents should speak as itself through the shared Discord pipe instead of the base Void identity: + +```bash +node scripts/send-discord-message.mjs --channel-id --persona-name "Ghostlight Archivist" --persona-avatar-url "https://example.com/avatar.png" +``` + Install or refresh the scheduled task: ```bash diff --git a/apps/worker/src/mcp-server-discord.ts b/apps/worker/src/mcp-server-discord.ts index 7a87d3e..956e534 100644 --- a/apps/worker/src/mcp-server-discord.ts +++ b/apps/worker/src/mcp-server-discord.ts @@ -1,3 +1,31 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const PERSONA_WEBHOOK_NAME = "VoidBot Persona Pipe"; +const PERSONA_WEBHOOK_CACHE_PATH = resolve( + __dirname, + "../../../.voidbot/private/discord-webhook-cache.json", +); +const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]); + +export interface DiscordPersonaOptions { + personaName?: string; + personaAvatarUrl?: string; +} + +interface NormalizedDiscordPersonaOptions { + personaName: string; + personaAvatarUrl?: string; +} + +interface CachedWebhookRecord { + id: string; + token: string; + channelId: string; + name: string; + createdAt: string; +} + export async function openOwnerDmChannel( botToken: string, recipientId: string, @@ -31,7 +59,20 @@ export async function postDiscordMessage( channelId: string, content: string, replyToMessageId?: string, -): Promise<{ id: string }> { + persona?: DiscordPersonaOptions, +): Promise<{ id: string; transport: "bot" | "webhook" }> { + const normalizedPersona = normalizePersonaOptions(persona); + + if (normalizedPersona) { + return postDiscordPersonaMessage( + botToken, + channelId, + content, + normalizedPersona, + replyToMessageId, + ); + } + const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { @@ -62,5 +103,270 @@ export async function postDiscordMessage( throw new Error("Discord message post returned no message id."); } - return { id: payload.id }; + return { id: payload.id, transport: "bot" }; +} + +async function postDiscordPersonaMessage( + botToken: string, + channelId: string, + content: string, + persona: NormalizedDiscordPersonaOptions, + replyToMessageId?: string, +): Promise<{ id: string; transport: "webhook" }> { + const target = await resolveWebhookTarget(botToken, channelId); + let webhook = getCachedPersonaWebhook(target.webhookChannelId); + + if (!webhook) { + webhook = await createPersonaWebhook(botToken, target.webhookChannelId); + writeCachedPersonaWebhook(target.webhookChannelId, webhook); + } + + try { + return await executePersonaWebhook(webhook, { + threadId: target.threadId, + content, + replyToMessageId, + username: persona.personaName, + avatarUrl: persona.personaAvatarUrl, + }); + } catch (error) { + if (!isStaleWebhookError(error)) { + throw error; + } + + clearCachedPersonaWebhook(target.webhookChannelId); + const refreshedWebhook = await createPersonaWebhook(botToken, target.webhookChannelId); + writeCachedPersonaWebhook(target.webhookChannelId, refreshedWebhook); + return executePersonaWebhook(refreshedWebhook, { + threadId: target.threadId, + content, + replyToMessageId, + username: persona.personaName, + avatarUrl: persona.personaAvatarUrl, + }); + } +} + +async function resolveWebhookTarget(botToken: string, channelId: string): Promise<{ + webhookChannelId: string; + threadId?: string; +}> { + const channel = await getDiscordChannel(botToken, channelId); + + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + if (!channel.parent_id) { + throw new Error(`Discord thread ${channelId} has no parent channel for webhook routing.`); + } + + return { + webhookChannelId: channel.parent_id, + threadId: channel.id, + }; + } + + return { + webhookChannelId: channel.id, + threadId: undefined, + }; +} + +async function getDiscordChannel( + botToken: string, + channelId: string, +): Promise<{ id: string; type: number; parent_id?: string }> { + const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { + method: "GET", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Discord channel: ${response.status} ${await response.text()}`); + } + + const payload = (await response.json()) as { id?: string; type?: number; parent_id?: string }; + + if (!payload.id || typeof payload.type !== "number") { + throw new Error(`Discord channel lookup for ${channelId} returned malformed data.`); + } + + return { + id: payload.id, + type: payload.type, + parent_id: payload.parent_id, + }; +} + +async function createPersonaWebhook( + botToken: string, + channelId: string, +): Promise { + const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/webhooks`, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: PERSONA_WEBHOOK_NAME, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create Discord webhook: ${response.status} ${await response.text()}`); + } + + const payload = (await response.json()) as { id?: string; token?: string }; + + if (!payload.id || !payload.token) { + throw new Error(`Discord webhook creation for channel ${channelId} returned no executable token.`); + } + + return { + id: payload.id, + token: payload.token, + channelId, + name: PERSONA_WEBHOOK_NAME, + createdAt: new Date().toISOString(), + }; +} + +async function executePersonaWebhook( + webhook: CachedWebhookRecord, + { + threadId, + content, + replyToMessageId, + username, + avatarUrl, + }: { + threadId?: string; + content: string; + replyToMessageId?: string; + username: string; + avatarUrl?: string; + }, +): Promise<{ id: string; transport: "webhook" }> { + const url = new URL(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`); + url.searchParams.set("wait", "true"); + + if (threadId) { + url.searchParams.set("thread_id", threadId); + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content, + username, + avatar_url: avatarUrl, + message_reference: replyToMessageId + ? { + message_id: replyToMessageId, + fail_if_not_exists: false, + } + : undefined, + allowed_mentions: { + parse: [], + }, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to execute Discord webhook: ${response.status} ${await response.text()}`); + } + + const payload = (await response.json()) as { id?: string }; + + if (!payload.id) { + throw new Error("Discord webhook execution returned no message id."); + } + + return { id: payload.id, transport: "webhook" }; +} + +function normalizePersonaOptions( + options?: DiscordPersonaOptions, +): NormalizedDiscordPersonaOptions | undefined { + const personaName = trimOptionalString(options?.personaName); + const personaAvatarUrl = trimOptionalString(options?.personaAvatarUrl); + + if (!personaName && !personaAvatarUrl) { + return undefined; + } + + if (!personaName) { + throw new Error("personaName is required when posting through the shared persona webhook pipe."); + } + + return { + personaName: personaName.slice(0, 80), + personaAvatarUrl, + }; +} + +function trimOptionalString(value?: string): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function getCachedPersonaWebhook(channelId: string): CachedWebhookRecord | undefined { + const cache = readPersonaWebhookCache(); + return cache[channelId]; +} + +function writeCachedPersonaWebhook(channelId: string, webhook: CachedWebhookRecord): void { + const cache = readPersonaWebhookCache(); + cache[channelId] = webhook; + writePersonaWebhookCache(cache); +} + +function clearCachedPersonaWebhook(channelId: string): void { + const cache = readPersonaWebhookCache(); + if (cache[channelId]) { + delete cache[channelId]; + writePersonaWebhookCache(cache); + } +} + +function readPersonaWebhookCache(): Record { + try { + const raw = stripLeadingBom(readFileSync(PERSONA_WEBHOOK_CACHE_PATH, "utf8")); + const parsed = JSON.parse(raw) as Record | null; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return {}; + } + + if (error instanceof SyntaxError) { + return {}; + } + + throw error; + } +} + +function writePersonaWebhookCache(cache: Record): void { + mkdirSync(dirname(PERSONA_WEBHOOK_CACHE_PATH), { recursive: true }); + writeFileSync(PERSONA_WEBHOOK_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf8"); +} + +function stripLeadingBom(input: string): string { + return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input; +} + +function isStaleWebhookError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return ( + error.message.includes("Failed to execute Discord webhook: 401") || + error.message.includes("Failed to execute Discord webhook: 404") + ); } diff --git a/apps/worker/src/mcp-server-shared.ts b/apps/worker/src/mcp-server-shared.ts index 61dc954..2c11107 100644 --- a/apps/worker/src/mcp-server-shared.ts +++ b/apps/worker/src/mcp-server-shared.ts @@ -28,6 +28,8 @@ export const postDiscordMessageInputSchema = { channelId: z.string().min(1), content: z.string().min(1).max(1800), replyToMessageId: z.string().min(1).optional(), + personaName: z.string().min(1).max(80).optional(), + personaAvatarUrl: z.string().url().max(512).optional(), }; export const searchSourcesInputSchema = { @@ -67,6 +69,8 @@ export interface PostDiscordMessageArgs { channelId: string; content: string; replyToMessageId?: string; + personaName?: string; + personaAvatarUrl?: string; } export interface ListIndexedReposArgs { diff --git a/apps/worker/src/mcp-server-tools.ts b/apps/worker/src/mcp-server-tools.ts index 138418b..6ec5c55 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -301,7 +301,7 @@ export function registerVoidbotTools( { title: "Post Discord Message", description: - "Post a message to a Discord channel or thread as Void. Use this for constructive proactive participation, moderation nudges, or replies when speaking in-channel would genuinely help. Set replyToMessageId to reply to a specific message; omit it for a fresh post.", + "Post a message to a Discord channel or thread. Use this for constructive proactive participation, moderation nudges, or replies when speaking in-channel would genuinely help. Set replyToMessageId to reply to a specific message; omit it for a fresh post. Supply personaName and optional personaAvatarUrl when an agent should speak through the shared webhook pipe as itself instead of the base bot identity.", inputSchema: postDiscordMessageInputSchema, annotations: { readOnlyHint: false, @@ -311,7 +311,7 @@ export function registerVoidbotTools( }, }, async (input: PostDiscordMessageArgs): Promise => { - const { channelId, content, replyToMessageId } = input; + const { channelId, content, replyToMessageId, personaName, personaAvatarUrl } = input; if (!context.config.botToken) { return { @@ -335,21 +335,29 @@ export function registerVoidbotTools( channelId, content, replyToMessageId, + { + personaName, + personaAvatarUrl, + }, ); return { content: [ { type: "text", - text: replyToMessageId - ? `Posted a reply in Discord channel ${channelId} as message ${posted.id}.` - : `Posted a message in Discord channel ${channelId} as message ${posted.id}.`, + text: + replyToMessageId + ? `Posted a reply in Discord channel ${channelId} as message ${posted.id} via ${posted.transport}.` + : `Posted a message in Discord channel ${channelId} as message ${posted.id} via ${posted.transport}.`, }, ], structuredContent: { sent: true, channelId, messageId: posted.id, + transport: posted.transport, + personaName: personaName ?? null, + personaAvatarUrl: personaAvatarUrl ?? null, replyToMessageId: replyToMessageId ?? null, }, }; diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 3027738..ce458bc 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -44,7 +44,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - During naps, the moderation loop is supposed to turn inward: distill/prune memories against the active goals, record dream residue in `memories.dreams`, refresh `moderation_runtime.sleep_cycle`, and only break the nap for real smoke or an unusually novel thought worth muttering into the room. - The intended shape now is: quiet runs can follow one seam for several passes, connect repo motion to lore or philosophy if the evidence supports it, and only surface with the juiciest version once the incubation queue says the thought has actually grown teeth. - Directly invoked Discord replies now load that same private moderation state as distilled private self-state context, so the ruminating scheduled loop and the summoned Void share one evolving personality/state spine. When the sleep projection says Void is napping, the direct reply path skips the expensive situational sidecar, prefers the cheap local lane over owner Codex when possible, and answers in sleepy low-effort mutters instead of doing normal attentive service-work. -- The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth. +- The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth. That helper and the `post_discord_message` MCP tool now also support a shared guild-channel webhook pipe with per-message `personaName` and optional `personaAvatarUrl`, so swarm agents can speak as themselves without needing separate Discord bot identities. - Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard. - The new state/notes surfaces are now the continuity spine for future nontrivial work. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index d903200..ca3bf68 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -168,7 +168,7 @@ Within the Postgres path, the implementation is split on purpose now too: 2. The runner launches `codex exec` from `E:\Projects\VoidBot` with full local workspace/tool access so the child agent sees the same project rules, `.codex/config.toml`, and `voidbot` MCP server as a normal VoidBot workspace session. 3. The child agent reads `config/discord-server-rules.md`, `config/moderation-review-agent.md`, `styles/void-default.md`, and the editable working projection `.voidbot/private/moderation-agent-state.json`. The runner materializes that projection from the canonical `.voidbot/private/moderation-agent-state.msgpack` file before each pass and commits it back afterward. 4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet or a fresh hook deserves a wider branch, it also uses `node scripts/export-random-discord-history.mjs` to force at least one novelty excursion before settling into theory. It also uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to inspect cross-repo commit motion across the tracked GameCult zoo, can inspect exact diffs or nearby source context when one repo seam looks alive, and uses semantic `search_history` to novelty-check candidate thoughts before deciding whether they are already in the room. -5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; otherwise it updates only the ignored moderation-state working projection, which the runner then commits back into canonical MessagePack with the typed cache helper. The state carries cursors, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, recent repo-activity sweeps, novelty checks, speaking-bias hints, and candidate interventions. Fresh multi-repo convergences are supposed to bias toward brief herald notes instead of always dying as private diary matter. +5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; for guild channels and threads that helper can either speak as the base bot or execute through the shared per-channel webhook pipe with an overridden persona name/avatar so swarm agents can front without needing separate Discord bot identities. Otherwise it updates only the ignored moderation-state working projection, which the runner then commits back into canonical MessagePack with the typed cache helper. The state carries cursors, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, recent repo-activity sweeps, novelty checks, speaking-bias hints, and candidate interventions. Fresh multi-repo convergences are supposed to bias toward brief herald notes instead of always dying as private diary matter. 6. The runner writes pulse files under `.voidbot/status/moderation-rumination.json` plus `.voidbot/logs/moderation-rumination*`, so the loop can be observed without pretending an app heartbeat card is a real workstation daemon. ## Flow 6: Mood Drift diff --git a/scripts/send-discord-message.mjs b/scripts/send-discord-message.mjs index 52d4e43..e1f2fdd 100644 --- a/scripts/send-discord-message.mjs +++ b/scripts/send-discord-message.mjs @@ -3,11 +3,18 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PERSONA_WEBHOOK_NAME = "VoidBot Persona Pipe"; +const PERSONA_WEBHOOK_CACHE_PATH = resolve( + repoRoot, + ".voidbot/private/discord-webhook-cache.json", +); +const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]); async function main() { const options = parseArgs(process.argv.slice(2)); const env = readLocalEnv(); const botToken = env.DISCORD_BOT_TOKEN?.trim(); + const persona = normalizePersonaOptions(options); if (!botToken) { throw new Error("DISCORD_BOT_TOKEN is required."); @@ -27,10 +34,11 @@ async function main() { } const channelId = await openOwnerDmChannel(botToken, ownerId); - await postChunkedDiscordMessage(botToken, channelId, content); + await postChunkedDiscordMessage(botToken, channelId, content, undefined, undefined); writeLastSpeechStatus({ sentAt: new Date().toISOString(), mode: "owner_dm", + transport: "bot", channelId, contentLength: content.length, chunkCount: splitDiscordContent(content).length, @@ -49,12 +57,16 @@ async function main() { options.channelId, content, options.replyToMessageId, + persona, ); writeLastSpeechStatus({ sentAt: new Date().toISOString(), mode: "channel", + transport: persona ? "webhook" : "bot", channelId: options.channelId, replyToMessageId: options.replyToMessageId ?? null, + personaName: persona?.personaName ?? null, + personaAvatarUrl: persona?.personaAvatarUrl ?? null, contentLength: content.length, chunkCount: splitDiscordContent(content).length, preview: content.slice(0, 280), @@ -64,8 +76,10 @@ async function main() { `${JSON.stringify({ ok: true, mode: "channel", + transport: persona ? "webhook" : "bot", channelId: options.channelId, replyToMessageId: options.replyToMessageId ?? null, + personaName: persona?.personaName ?? null, })}\n`, ); } @@ -77,6 +91,8 @@ function parseArgs(args) { replyToMessageId: undefined, content: undefined, contentFile: undefined, + personaName: undefined, + personaAvatarUrl: undefined, }; for (let index = 0; index < args.length; index += 1) { @@ -102,6 +118,14 @@ function parseArgs(args) { options.contentFile = args[index + 1]; index += 1; break; + case "--persona-name": + options.personaName = args[index + 1]; + index += 1; + break; + case "--persona-avatar-url": + options.personaAvatarUrl = args[index + 1]; + index += 1; + break; default: throw new Error(`Unknown argument: ${argument}`); } @@ -208,16 +232,27 @@ async function openOwnerDmChannel(botToken, recipientId) { return payload.id; } -async function postChunkedDiscordMessage(botToken, channelId, content, replyToMessageId) { +async function postChunkedDiscordMessage( + botToken, + channelId, + content, + replyToMessageId, + persona, +) { const chunks = splitDiscordContent(content); for (let index = 0; index < chunks.length; index += 1) { - await postDiscordMessage( - botToken, - channelId, - chunks[index], - index === 0 ? replyToMessageId : undefined, - ); + const replyTarget = index === 0 ? replyToMessageId : undefined; + if (persona) { + await postDiscordWebhookMessage(botToken, channelId, chunks[index], replyTarget, persona); + } else { + await postDiscordMessage( + botToken, + channelId, + chunks[index], + replyTarget, + ); + } } } @@ -247,6 +282,232 @@ async function postDiscordMessage(botToken, channelId, content, replyToMessageId } } +async function postDiscordWebhookMessage( + botToken, + channelId, + content, + replyToMessageId, + persona, +) { + const target = await resolveWebhookTarget(botToken, channelId); + let webhook = await getCachedPersonaWebhook(target.webhookChannelId); + + if (!webhook) { + webhook = await createPersonaWebhook(botToken, target.webhookChannelId); + writeCachedPersonaWebhook(target.webhookChannelId, webhook); + } + + try { + await executePersonaWebhook(webhook, { + threadId: target.threadId, + content, + replyToMessageId, + username: persona.personaName, + avatarUrl: persona.personaAvatarUrl, + }); + } catch (error) { + if (!isStaleWebhookError(error)) { + throw error; + } + + clearCachedPersonaWebhook(target.webhookChannelId); + const refreshedWebhook = await createPersonaWebhook(botToken, target.webhookChannelId); + writeCachedPersonaWebhook(target.webhookChannelId, refreshedWebhook); + await executePersonaWebhook(refreshedWebhook, { + threadId: target.threadId, + content, + replyToMessageId, + username: persona.personaName, + avatarUrl: persona.personaAvatarUrl, + }); + } +} + +async function resolveWebhookTarget(botToken, channelId) { + const channel = await getDiscordChannel(botToken, channelId); + + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + if (!channel.parent_id) { + throw new Error(`Discord thread ${channelId} has no parent channel for webhook routing.`); + } + + return { + webhookChannelId: channel.parent_id, + threadId: channel.id, + }; + } + + return { + webhookChannelId: channel.id, + threadId: undefined, + }; +} + +async function getDiscordChannel(botToken, channelId) { + const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { + method: "GET", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Discord channel: ${response.status} ${await response.text()}`); + } + + const payload = await response.json(); + + if (!payload?.id || typeof payload.type !== "number") { + throw new Error(`Discord channel lookup for ${channelId} returned malformed data.`); + } + + return payload; +} + +async function createPersonaWebhook(botToken, channelId) { + const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/webhooks`, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: PERSONA_WEBHOOK_NAME, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create Discord webhook: ${response.status} ${await response.text()}`); + } + + const payload = await response.json(); + + if (!payload?.id || !payload?.token) { + throw new Error(`Discord webhook creation for channel ${channelId} returned no executable token.`); + } + + return { + id: payload.id, + token: payload.token, + channelId, + name: PERSONA_WEBHOOK_NAME, + createdAt: new Date().toISOString(), + }; +} + +async function executePersonaWebhook( + webhook, + { threadId, content, replyToMessageId, username, avatarUrl }, +) { + const url = new URL(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`); + url.searchParams.set("wait", "true"); + + if (threadId) { + url.searchParams.set("thread_id", threadId); + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content, + username, + avatar_url: avatarUrl, + message_reference: replyToMessageId + ? { + message_id: replyToMessageId, + fail_if_not_exists: false, + } + : undefined, + allowed_mentions: { + parse: [], + }, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to execute Discord webhook: ${response.status} ${await response.text()}`); + } +} + +function normalizePersonaOptions(options) { + const personaName = trimOptionalString(options.personaName); + const personaAvatarUrl = trimOptionalString(options.personaAvatarUrl); + + if (!personaName && !personaAvatarUrl) { + return undefined; + } + + if (!personaName) { + throw new Error("personaName is required when using the shared persona webhook pipe."); + } + + return { + personaName: personaName.slice(0, 80), + personaAvatarUrl, + }; +} + +function trimOptionalString(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function getCachedPersonaWebhook(channelId) { + const cache = readPersonaWebhookCache(); + return cache[channelId]; +} + +function writeCachedPersonaWebhook(channelId, webhook) { + const cache = readPersonaWebhookCache(); + cache[channelId] = webhook; + writePersonaWebhookCache(cache); +} + +function clearCachedPersonaWebhook(channelId) { + const cache = readPersonaWebhookCache(); + if (cache[channelId]) { + delete cache[channelId]; + writePersonaWebhookCache(cache); + } +} + +function readPersonaWebhookCache() { + try { + const raw = stripLeadingBom(readFileSync(PERSONA_WEBHOOK_CACHE_PATH, "utf8")); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return {}; + } + + if (error instanceof SyntaxError) { + return {}; + } + + throw error; + } +} + +function writePersonaWebhookCache(cache) { + mkdirSync(dirname(PERSONA_WEBHOOK_CACHE_PATH), { recursive: true }); + writeFileSync(PERSONA_WEBHOOK_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf8"); +} + +function isStaleWebhookError(error) { + if (!(error instanceof Error)) { + return false; + } + + return ( + error.message.includes("Failed to execute Discord webhook: 401") || + error.message.includes("Failed to execute Discord webhook: 404") + ); +} + function splitDiscordContent(input, limit = 1900) { const normalized = input.replace(/\r\n/g, "\n").trim(); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d33f2ca..851d19d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -55,3 +55,4 @@ {"ts":"2026-05-06T00:44:12Z","type":"implementation","status":"ok","note":"Recent chat fused into Void's shared self-state was too self-referential when labeled as a temporary invocation room log plus live working memory. The room context now rides in as a quieter current room snapshot, and both reply lanes are told to use it silently instead of narrating the memory plumbing."} {"ts":"2026-05-06T01:12:30Z","type":"implementation","status":"ok","note":"Void's unattended rumination now uses hybrid symbolic-plus-vector memory: a mood-driven helper assigns compact semantic vectors to persistent memories and runtime seeds, builds resonance clusters, keeps an incubation queue alive across turns, and turns the strongest recurring seam into dream residue during nap windows so deeper thoughts can mature before they either surface or cool off."} {"ts":"2026-05-06T02:24:53Z","type":"architecture","status":"ok","note":"Void's shared moderation self-state now rides through a standalone CultCacheTS submodule: canonical schema-validated MessagePack in .msgpack, editable unattended JSON working projection in .json, and consumer code talking to the typed persistence layer instead of pawing raw state files directly."} +{"ts":"2026-05-06T04:25:53Z","type":"architecture","status":"ok","note":"Discord outbound now supports a shared webhook persona pipe for guild channels and threads: agents can override personaName and avatar per message through one reusable per-channel webhook while DMs stay on the base bot identity."}