From 18561234513ce1899a2b67bd73fab3f7fafa40ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 26 Apr 2026 03:28:21 +0100 Subject: [PATCH] Refactor Discord bot seams --- apps/bot/src/discord-bot-handlers.ts | 609 ++++++++++++++ apps/bot/src/discord-bot-support.ts | 535 ++++++++++++ apps/bot/src/discord-bot.ts | 1123 +------------------------- notes/fresh-workspace-handoff.md | 3 +- notes/voidbot-current-system-map.md | 15 +- notes/voidbot-implementation-plan.md | 2 +- state/evidence.jsonl | 1 + state/map.yaml | 5 +- 8 files changed, 1192 insertions(+), 1101 deletions(-) create mode 100644 apps/bot/src/discord-bot-handlers.ts create mode 100644 apps/bot/src/discord-bot-support.ts diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts new file mode 100644 index 0000000..ec18049 --- /dev/null +++ b/apps/bot/src/discord-bot-handlers.ts @@ -0,0 +1,609 @@ +import { + REST, + Routes, + SlashCommandBuilder, + type CacheType, + type ChatInputCommandInteraction, + type Client, + type TextBasedChannel, +} from "discord.js"; + +import type { AppConfig } from "@voidbot/config"; +import { + ContextBuilder, + PermissionEngine, + type AuditLog, + type InteractionMemoryBank, + type JobQueue, + type SystemMessageCatalog, + type VoidUsageRateLimiter, +} from "@voidbot/core"; +import type { ProviderRegistry } from "@voidbot/providers"; +import { + FileSourceDocumentArchiveRepository, + RagPipeline, + RetrievalService, +} from "@voidbot/rag"; +import { + type Actor, + type CommandName, + type GuildContext, + type JobRecord, + type ProviderName, + type StylePack, + isProviderName, + shouldIndexChannel, +} from "@voidbot/shared"; + +import { + buildChannelIndexingTarget, + canSendMessages, + convertDiscordMessageToArchive, + getRecentMessages, + inferSourceGroundingHint, + renderRateLimitMessage, + renderSystemMessage, + truncate, +} from "./discord-bot-support"; + +export const commandDefinitions = [ + new SlashCommandBuilder() + .setName("ask") + .setDescription("Ask VoidBot a question.") + .addStringOption((option) => + option.setName("question").setDescription("What do you want to ask?").setRequired(true), + ) + .addStringOption((option) => + option + .setName("provider") + .setDescription("Override the provider instead of using auto-selection.") + .setRequired(false) + .addChoices( + { name: "Auto", value: "auto" }, + { name: "Owner Codex", value: "owner_codex" }, + { name: "Local LLM", value: "local_llm" }, + ), + ), + new SlashCommandBuilder() + .setName("queue-codex") + .setDescription("Force a fuller owner-only Codex handoff.") + .addStringOption((option) => + option.setName("prompt").setDescription("The owner workflow prompt.").setRequired(true), + ), + new SlashCommandBuilder() + .setName("approve-job") + .setDescription("Approve a queued job or post its final response.") + .addStringOption((option) => + option.setName("job-id").setDescription("The job id to approve.").setRequired(true), + ) + .addStringOption((option) => + option + .setName("response") + .setDescription("Optional final response to publish when the job is awaiting post approval.") + .setRequired(false), + ), + new SlashCommandBuilder() + .setName("reject-job") + .setDescription("Reject a queued job.") + .addStringOption((option) => + option.setName("job-id").setDescription("The job id to reject.").setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Optional rejection reason.").setRequired(false), + ), + new SlashCommandBuilder() + .setName("provider-status") + .setDescription("Show current provider availability for you."), + new SlashCommandBuilder() + .setName("search-history") + .setDescription("Search indexed channel history.") + .addStringOption((option) => + option.setName("query").setDescription("What to search for.").setRequired(true), + ), + new SlashCommandBuilder() + .setName("summarize-channel") + .setDescription("Summarize recent messages in this channel."), + new SlashCommandBuilder() + .setName("reindex-channel") + .setDescription("Reindex the current indexed channel."), + new SlashCommandBuilder() + .setName("set-style") + .setDescription("Reload the configured style pack from disk."), +]; + +export interface PromptHandlerOptions { + prompt: string; + command: CommandName; + actor: Actor; + roleIds: string[]; + guildContext: GuildContext; + outputChannelId: string; + requestMessageId?: string; + channel: TextBasedChannel | null; + respond: (content: string) => Promise; + config: AppConfig; + permissionEngine: PermissionEngine; + contextBuilder: ContextBuilder; + retrievalService: RetrievalService; + sourceArchiveRepository: FileSourceDocumentArchiveRepository; + jobQueue: JobQueue; + auditLog: AuditLog; + interactionMemory: InteractionMemoryBank; + voidUsageRateLimiter: VoidUsageRateLimiter; + providerRegistry: ProviderRegistry; + stylePack?: StylePack; + systemMessages: SystemMessageCatalog; + forceProvider?: ProviderName; + silentOwnerQueueAck?: boolean; +} + +export async function handlePrompt(options: PromptHandlerOptions): Promise { + const providerName = + options.forceProvider ?? pickProvider(options.actor, options.guildContext, options.providerRegistry); + + if (!providerName) { + await options.respond( + renderSystemMessage(options.systemMessages, "provider.none_available"), + ); + return; + } + + const provider = options.providerRegistry.get(providerName); + + if (!provider) { + await options.respond( + renderSystemMessage(options.systemMessages, "provider.missing_registration", { + providerName, + }), + ); + return; + } + + const access = options.permissionEngine.canUseProvider(options.actor, providerName); + + if (!access.allowed) { + await options.respond( + access.reasonKey + ? renderSystemMessage(options.systemMessages, access.reasonKey) + : renderSystemMessage(options.systemMessages, "provider.access_denied.generic"), + ); + return; + } + + const rateLimitDecision = await options.voidUsageRateLimiter.consume({ + actorId: options.actor.id, + roleIds: options.roleIds, + command: options.command, + provider: providerName, + guildId: options.guildContext.guildId, + channelId: options.guildContext.channelId, + }); + + if (!rateLimitDecision.allowed) { + await options.auditLog.record({ + type: "rate_limit.denied", + actorId: options.actor.id, + provider: providerName, + details: { + command: options.command, + reason: rateLimitDecision.reason, + dailyCount: rateLimitDecision.dailyCount, + dailyLimit: rateLimitDecision.policy.dailyLimit ?? null, + cooldownSeconds: rateLimitDecision.policy.cooldownSeconds ?? null, + retryAfterSeconds: rateLimitDecision.retryAfterSeconds ?? null, + resetsAt: rateLimitDecision.resetsAt ?? null, + modifier: rateLimitDecision.policy.modifier, + matchedSubjects: rateLimitDecision.policy.matchedSubjects, + }, + }); + await options.respond(renderRateLimitMessage(options.systemMessages, rateLimitDecision)); + return; + } + + const rememberedInteraction = await options.interactionMemory.recordInteraction({ + actorId: options.actor.id, + actorName: options.actor.displayName, + sourceKind: "direct_prompt", + guildId: options.guildContext.guildId, + channelId: options.guildContext.channelId, + channelName: options.guildContext.channelName, + command: options.command, + prompt: options.prompt, + eventId: options.requestMessageId, + }); + const interactionMemory = + rememberedInteraction.totalInteractions > 0 ? rememberedInteraction : undefined; + + const recentMessages = await getRecentMessages(options.channel, 10); + const sourceGrounding = inferSourceGroundingHint( + options.prompt, + await options.sourceArchiveRepository.listRepoSummaries(), + ); + const shouldAttachInitialRetrieval = + !provider.getCapabilities().includes("tool_driven_retrieval") && + (shouldIndexChannel(options.config.channelIndexing, buildChannelIndexingTarget(options.channel)) || + providerName !== "owner_codex"); + const retrieval = shouldAttachInitialRetrieval + ? await options.retrievalService.search(options.prompt, 5, { + guildId: options.guildContext.guildId, + corpusKind: "discord_history", + }) + : []; + const contextBundle = options.contextBuilder.build({ + prompt: options.prompt, + actor: options.actor, + guildContext: options.guildContext, + recentMessages, + retrieval, + interactionMemory, + sourceGrounding, + stylePack: options.stylePack, + }); + + if (providerName === "owner_codex") { + const runApprovalRequired = options.config.ownerCodexMode === "manual_package"; + const postApprovalRequired = options.config.ownerCodexMode === "manual_package"; + const { job, created } = await options.jobQueue.createJob({ + command: options.command, + provider: "owner_codex", + runApprovalRequired, + postApprovalRequired, + requester: options.actor, + guildContext: options.guildContext, + prompt: options.prompt, + contextBundle, + outputChannelId: options.outputChannelId, + requestMessageId: options.requestMessageId, + initialState: runApprovalRequired ? "awaiting_approval" : "approved", + }); + + if (!created) { + return; + } + + await options.auditLog.record({ + type: "job.queued", + actorId: options.actor.id, + jobId: job.id, + provider: "owner_codex", + details: { + command: options.command, + channelId: options.outputChannelId, + runApprovalRequired, + postApprovalRequired, + }, + }); + + if (runApprovalRequired) { + await options.respond( + renderSystemMessage(options.systemMessages, "job.owner.awaiting_run_approval", { + jobId: job.id, + }), + ); + return; + } + + if (!options.silentOwnerQueueAck) { + await options.respond( + renderSystemMessage(options.systemMessages, "job.owner.auto_queued", { + jobId: job.id, + }), + ); + } + return; + } + + const response = await provider.execute(provider.buildRequest(contextBundle)); + + await options.auditLog.record({ + type: "provider.responded", + actorId: options.actor.id, + provider: providerName, + details: { + command: options.command, + summary: response.summary, + }, + }); + + await options.respond(response.outputText ?? response.summary); +} + +export async function handleApproveJob(options: { + interaction: ChatInputCommandInteraction; + client: Client; + actor: Actor; + jobQueue: JobQueue; + auditLog: AuditLog; + permissionEngine: PermissionEngine; + systemMessages: SystemMessageCatalog; +}): Promise { + const jobId = options.interaction.options.getString("job-id", true); + const response = options.interaction.options.getString("response", false); + const job = await options.jobQueue.getJob(jobId); + + if (!job) { + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.not_found", { jobId }), + ); + return; + } + + if (!options.permissionEngine.canApproveJob(options.actor, job)) { + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.approval_forbidden"), + ); + return; + } + + if (job.state === "awaiting_approval") { + await options.jobQueue.approveRun(jobId, options.actor.id); + await options.auditLog.record({ + type: "job.run_approved", + actorId: options.actor.id, + jobId, + provider: job.provider, + details: {}, + }); + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.run_approved", { jobId }), + ); + return; + } + + if (job.state === "awaiting_post_approval") { + if (!response) { + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.post_approval.needs_response"), + ); + return; + } + + await postFinalResponse(options.client, job, response); + await options.jobQueue.completeJob(jobId, options.actor.id, response); + await options.auditLog.record({ + type: "job.post_approved", + actorId: options.actor.id, + jobId, + provider: job.provider, + details: { + publishedLength: response.length, + }, + }); + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.post_approved", { jobId }), + ); + return; + } + + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.no_action_for_state", { + jobId, + jobState: job.state, + }), + ); +} + +export async function handleRejectJob(options: { + interaction: ChatInputCommandInteraction; + actor: Actor; + jobQueue: JobQueue; + auditLog: AuditLog; + permissionEngine: PermissionEngine; + systemMessages: SystemMessageCatalog; +}): Promise { + const jobId = options.interaction.options.getString("job-id", true); + const reason = options.interaction.options.getString("reason", false) ?? undefined; + const job = await options.jobQueue.getJob(jobId); + + if (!job) { + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.not_found", { jobId }), + ); + return; + } + + if (!options.permissionEngine.canApproveJob(options.actor, job)) { + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.rejection_forbidden"), + ); + return; + } + + await options.jobQueue.rejectJob(jobId, options.actor.id, reason); + await options.auditLog.record({ + type: "job.rejected", + actorId: options.actor.id, + jobId, + provider: job.provider, + details: { + reason, + }, + }); + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "job.rejected", { jobId }), + ); +} + +export async function handleSearchHistory( + interaction: ChatInputCommandInteraction, + retrievalService: RetrievalService, + systemMessages: SystemMessageCatalog, +): Promise { + const query = interaction.options.getString("query", true); + const results = await retrievalService.searchHistory(query, 5, { + guildId: interaction.guildId ?? undefined, + }); + + if (results.length === 0) { + await replyEphemeral(interaction, renderSystemMessage(systemMessages, "history.no_results")); + return; + } + + const body = results + .map((result, index) => `${index + 1}. (${result.score.toFixed(2)}) ${truncate(result.text, 180)}`) + .join("\n"); + + await replyEphemeral( + interaction, + `${renderSystemMessage(systemMessages, "history.results_intro")}\n${body}`, + ); +} + +export async function handleSummarizeChannel( + interaction: ChatInputCommandInteraction, + systemMessages: SystemMessageCatalog, +): Promise { + const messages = await getRecentMessages(interaction.channel?.isTextBased() ? interaction.channel : null, 15); + + if (messages.length === 0) { + await replyEphemeral( + interaction, + renderSystemMessage(systemMessages, "summarize.no_messages"), + ); + return; + } + + const summary = messages + .slice(-8) + .map((message) => `- ${message.authorName}: ${truncate(message.content, 100)}`) + .join("\n"); + + await replyEphemeral( + interaction, + `${renderSystemMessage(systemMessages, "summarize.results_intro")}\n${summary}`, + ); +} + +export async function handleReindexChannel( + interaction: ChatInputCommandInteraction, + actor: Actor, + channelIndexing: AppConfig["channelIndexing"], + permissionEngine: PermissionEngine, + ragPipeline: RagPipeline, + systemMessages: SystemMessageCatalog, +): Promise { + if (!permissionEngine.canReindex(actor)) { + await replyEphemeral(interaction, renderSystemMessage(systemMessages, "reindex.forbidden")); + return; + } + + if ( + !shouldIndexChannel( + channelIndexing, + buildChannelIndexingTarget(interaction.channel?.isTextBased() ? interaction.channel : null), + ) + ) { + await replyEphemeral( + interaction, + renderSystemMessage(systemMessages, "reindex.excluded"), + ); + return; + } + + if (!interaction.channel?.isTextBased()) { + await replyEphemeral( + interaction, + renderSystemMessage(systemMessages, "reindex.unsupported_channel"), + ); + return; + } + + const fetched = await interaction.channel.messages.fetch({ limit: 100 }); + const archivedMessages = [...fetched.values()].map(convertDiscordMessageToArchive); + const result = await ragPipeline.upsertMessages(archivedMessages); + + await replyEphemeral( + interaction, + renderSystemMessage(systemMessages, "reindex.completed", { + archivedMessages: archivedMessages.length, + createdMessages: result.createdMessages, + updatedMessages: result.updatedMessages, + unchangedMessages: result.unchangedMessages, + indexedChunks: result.indexedChunks, + }), + ); +} + +export function parseProviderOverride(value: string | null): ProviderName | undefined { + if (!value || value === "auto") { + return undefined; + } + + return isProviderName(value) ? value : undefined; +} + +export async function maybeRegisterCommands( + botToken: string, + applicationId?: string, + developmentGuildId?: string, +): Promise { + if (!applicationId || !developmentGuildId) { + console.log("Skipping slash command registration because DISCORD_APPLICATION_ID or DISCORD_GUILD_ID is missing."); + return; + } + + const rest = new REST({ version: "10" }).setToken(botToken); + + await rest.put(Routes.applicationGuildCommands(applicationId, developmentGuildId), { + body: commandDefinitions.map((command) => command.toJSON()), + }); + + console.log(`Registered ${commandDefinitions.length} guild slash commands for ${developmentGuildId}.`); +} + +export async function replyEphemeral( + interaction: ChatInputCommandInteraction, + content: string, +): Promise { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, ephemeral: true }); + return; + } + + await interaction.reply({ content, ephemeral: true }); +} + +function pickProvider( + actor: Actor, + guildContext: GuildContext, + providerRegistry: ProviderRegistry, +): ProviderName | undefined { + const ownerCodex = providerRegistry.get("owner_codex"); + + if (ownerCodex?.isAllowedForActor(actor, guildContext)) { + return "owner_codex"; + } + + const openAi = providerRegistry.get("openai_api"); + + if (openAi?.isAllowedForActor(actor, guildContext)) { + return "openai_api"; + } + + const local = providerRegistry.get("local_llm"); + + if (local?.isAllowedForActor(actor, guildContext)) { + return "local_llm"; + } + + return undefined; +} + +async function postFinalResponse(client: Client, job: JobRecord, finalResponse: string): Promise { + const channel = await client.channels.fetch(job.outputChannelId); + + if (!canSendMessages(channel)) { + throw new Error(`Channel ${job.outputChannelId} is not available for publishing.`); + } + + await channel.send(finalResponse); +} diff --git a/apps/bot/src/discord-bot-support.ts b/apps/bot/src/discord-bot-support.ts new file mode 100644 index 0000000..6d45bf4 --- /dev/null +++ b/apps/bot/src/discord-bot-support.ts @@ -0,0 +1,535 @@ +import { + ChannelType, + Client, + PermissionsBitField, + type CacheType, + type ChatInputCommandInteraction, + type Message, + type PartialMessage, + type TextBasedChannel, +} from "discord.js"; + +import { + type InteractionMemoryBank, + type SystemMessageCatalog, + type VoidUsageRateLimiter, +} from "@voidbot/core"; +import type { ProviderRegistry } from "@voidbot/providers"; +import { + RagPipeline, + RetrievalService, + type ArchivedMessageRecord, +} from "@voidbot/rag"; +import { + type Actor, + type ArchivedMessage, + type ChannelIndexingPolicy, + type ChannelIndexingTarget, + type GuildContext, + type SourceGroundingHint, + type SourceMessage, + shouldIndexChannel, +} from "@voidbot/shared"; + +export function buildActorFromMessage(message: Message): Actor { + return { + id: message.author.id, + displayName: message.author.displayName ?? message.author.username, + isAdmin: message.member?.permissions.has(PermissionsBitField.Flags.Administrator) ?? false, + isBot: message.author.bot, + }; +} + +export function buildActorFromInteraction( + interaction: ChatInputCommandInteraction, +): Actor { + return { + id: interaction.user.id, + displayName: interaction.user.displayName ?? interaction.user.username, + isAdmin: + interaction.memberPermissions?.has(PermissionsBitField.Flags.Administrator) ?? false, + isBot: interaction.user.bot, + }; +} + +export function getRoleIdsFromMessage(message: Message): string[] { + return message.member ? [...message.member.roles.cache.keys()] : []; +} + +export function getRoleIdsFromInteraction( + interaction: ChatInputCommandInteraction, +): string[] { + const member = interaction.member; + + if (!member || !("roles" in member)) { + return []; + } + + const { roles } = member; + + if (Array.isArray(roles)) { + return roles.filter((roleId): roleId is string => typeof roleId === "string"); + } + + if (roles && typeof roles === "object" && "cache" in roles) { + return [...roles.cache.keys()]; + } + + return []; +} + +export function buildGuildContextFromMessage(message: Message): GuildContext { + return { + guildId: message.guildId ?? undefined, + guildName: message.guild?.name, + channelId: message.channelId, + channelName: "name" in message.channel ? message.channel.name ?? undefined : undefined, + threadId: message.channel.type === ChannelType.PublicThread ? message.channel.id : undefined, + }; +} + +export function buildGuildContextFromInteraction( + interaction: ChatInputCommandInteraction, +): GuildContext { + return { + guildId: interaction.guildId ?? undefined, + guildName: interaction.guild?.name, + channelId: interaction.channelId, + channelName: + interaction.channel && "name" in interaction.channel ? interaction.channel.name ?? undefined : undefined, + threadId: + interaction.channel?.type === ChannelType.PublicThread ? interaction.channel.id : undefined, + }; +} + +export async function ingestIfIndexed( + message: Message, + channelIndexing: ChannelIndexingPolicy, + ragPipeline: RagPipeline, +): Promise { + if (!shouldIndexChannel(channelIndexing, buildChannelIndexingTarget(message.channel))) { + return; + } + + await ragPipeline.upsertMessages([convertDiscordMessageToArchive(message)]); +} + +export async function rememberAmbientVoidReference( + message: Message, + botUserId: string | undefined, + interactionMemory: InteractionMemoryBank, +): Promise { + if (!isAmbientVoidReference(message, botUserId)) { + return; + } + + await interactionMemory.recordInteraction({ + actorId: message.author.id, + actorName: message.author.displayName ?? message.author.username, + sourceKind: "ambient_mention", + guildId: message.guildId ?? undefined, + channelId: message.channelId, + channelName: "name" in message.channel ? message.channel.name ?? undefined : undefined, + prompt: message.content, + eventId: message.id, + timestamp: (message.editedAt ?? message.createdAt).toISOString(), + }); +} + +export function buildChannelIndexingTarget( + channel: TextBasedChannel | null | undefined, +): ChannelIndexingTarget { + if (!channel) { + return {}; + } + + const target: ChannelIndexingTarget = { + channelId: channel.id, + channelName: "name" in channel ? channel.name ?? undefined : undefined, + }; + + if (channel.isThread()) { + target.parentChannelId = channel.parentId ?? undefined; + target.parentChannelName = + channel.parent && "name" in channel.parent ? channel.parent.name ?? undefined : undefined; + } + + return target; +} + +export function convertDiscordMessageToArchive(message: Message): ArchivedMessage { + const metadata: Record = { + jumpUrl: message.url, + }; + + if ("name" in message.channel && message.channel.name) { + metadata.channelName = message.channel.name; + } + + if (message.channel.isThread()) { + if (message.channel.parentId) { + metadata.parentChannelId = message.channel.parentId; + } + + if (message.channel.parent && "name" in message.channel.parent && message.channel.parent.name) { + metadata.parentChannelName = message.channel.parent.name; + } + } + + return { + id: message.id, + guildId: message.guildId ?? undefined, + channelId: message.channelId, + authorId: message.author.id, + authorName: message.author.displayName ?? message.author.username, + content: message.content, + timestamp: message.createdAt.toISOString(), + editedAt: message.editedAt?.toISOString(), + threadId: message.channel.isThread() ? message.channel.id : undefined, + attachments: + message.attachments.size > 0 + ? [...message.attachments.values()].map( + (attachment) => attachment.url ?? attachment.proxyURL ?? attachment.name, + ) + : undefined, + metadata, + }; +} + +export function convertDiscordMessageToSource(message: Message): SourceMessage { + return { + id: message.id, + authorId: message.author.id, + authorName: message.author.displayName ?? message.author.username, + content: message.content, + timestamp: message.createdAt.toISOString(), + }; +} + +export async function getRecentMessages( + channel: TextBasedChannel | null, + limit: number, +): Promise { + if (!channel || !("messages" in channel)) { + return []; + } + + const fetched = await channel.messages.fetch({ limit }); + + return [...fetched.values()] + .sort((left, right) => left.createdTimestamp - right.createdTimestamp) + .map(convertDiscordMessageToSource); +} + +export async function materializeMessage( + message: Message | PartialMessage, +): Promise { + if (!message.partial) { + return message; + } + + try { + return await message.fetch(); + } catch { + return undefined; + } +} + +export function stripBotMention(content: string): string { + return content.replace(/<@!?(\d+)>/g, "").trim(); +} + +export function inferSourceGroundingHint( + prompt: string, + repoSummaries: Array<{ repoName: string }>, +): SourceGroundingHint { + const normalized = ` ${normalizeForMatching(prompt)} `; + const reasons = new Set(); + const matchedRepoNames = new Set(); + + for (const repo of repoSummaries) { + for (const alias of buildRepoAliases(repo.repoName)) { + if (alias.length < 4) { + continue; + } + + if (normalized.includes(` ${alias} `)) { + matchedRepoNames.add(repo.repoName); + } + } + } + + if (matchedRepoNames.size > 0) { + reasons.add("matched indexed repo/project name"); + } + + const genericSourceCues = [ + " repo ", + " repository ", + " repositories ", + " codebase ", + " source ", + " sources ", + " lore ", + " vault ", + " canon ", + " file ", + " files ", + " docs ", + " documentation ", + " module ", + " implementation ", + " project ", + " projects ", + ]; + + if (genericSourceCues.some((cue) => normalized.includes(cue))) { + reasons.add("contains source/lore cue"); + } + + return { + required: reasons.size > 0, + reasons: [...reasons], + matchedRepoNames: [...matchedRepoNames].sort(), + }; +} + +export function formatProviderStatuses( + statuses: ReturnType, + systemMessages: SystemMessageCatalog, +): string { + return [ + renderSystemMessage(systemMessages, "provider_status.intro"), + statuses + .map((status) => { + const state = status.enabled ? "enabled" : "disabled"; + const access = status.allowed ? "allowed" : "blocked"; + return `${status.name}: ${state}, ${access}, capabilities=${status.capabilities.join(", ")}`; + }) + .join("\n"), + ].join("\n"); +} + +export function renderSystemMessage( + systemMessages: SystemMessageCatalog, + key: string, + variables: Record = {}, +): string { + return systemMessages.render(key, variables); +} + +export function renderRateLimitMessage( + systemMessages: SystemMessageCatalog, + decision: Awaited>, +): string { + if (decision.reason === "daily_limit") { + return renderSystemMessage(systemMessages, "rate_limit.daily_limit", { + dailyLimit: decision.policy.dailyLimit ?? decision.dailyCount, + resetsAt: decision.resetsAt ? formatUtcTimestamp(decision.resetsAt) : "the next UTC midnight", + }); + } + + return renderSystemMessage(systemMessages, "rate_limit.cooldown", { + retryAfter: formatDurationSeconds(decision.retryAfterSeconds ?? 1), + }); +} + +export function formatHistoryResults( + results: Awaited>, +): Array> { + return results.map((result) => ({ + score: Number(result.score.toFixed(4)), + text: result.text, + sourceId: result.sourceId, + channelId: result.metadata.channelId, + channelName: result.metadata.channelName, + authorId: result.metadata.authorId, + authorName: result.metadata.authorName, + timestamp: result.metadata.timestamp, + jumpUrl: result.metadata.jumpUrl, + threadId: result.metadata.threadId, + })); +} + +export function formatSourceResults( + results: Awaited>, +): Array> { + return results.map((result) => ({ + score: Number(result.score.toFixed(4)), + text: result.text, + sourceId: result.sourceId, + repoName: result.metadata.repoName, + path: result.metadata.path, + language: result.metadata.language, + title: result.metadata.title, + chunkIndex: Number(result.metadata.chunkIndex ?? 0), + lineStart: Number(result.metadata.lineStart ?? 1), + lineEnd: Number(result.metadata.lineEnd ?? 1), + lastModifiedAt: result.metadata.lastModifiedAt, + })); +} + +export function formatArchivedMessageContext( + message: ArchivedMessageRecord, + anchorMessageId: string, +): Record { + return { + id: message.id, + isAnchor: message.id === anchorMessageId, + timestamp: message.timestamp, + authorId: message.authorId, + authorName: message.authorName, + channelId: message.channelId, + channelName: message.metadata?.channelName, + threadId: message.threadId, + content: message.content, + jumpUrl: message.metadata?.jumpUrl, + editedAt: message.editedAt, + }; +} + +export async function notifyOwnerOfBotIssue( + client: Client, + ownerDiscordId: string, + content: string, +): Promise { + try { + const owner = await client.users.fetch(ownerDiscordId); + await owner.send(truncate(content, 1900)); + } catch (error) { + console.error( + `Failed to DM owner about bot-side issue: ${ + error instanceof Error ? error.message : "Unexpected notification failure." + }`, + ); + } +} + +export function truncate(input: string, limit: number): string { + if (input.length <= limit) { + return input; + } + + return `${input.slice(0, limit - 3)}...`; +} + +export function formatDurationSeconds(totalSeconds: number): string { + const seconds = Math.max(1, Math.ceil(totalSeconds)); + + if (seconds < 60) { + return `${seconds} second${seconds === 1 ? "" : "s"}`; + } + + const minutes = Math.floor(seconds / 60); + const remainderSeconds = seconds % 60; + + if (minutes < 60) { + return remainderSeconds === 0 + ? `${minutes} minute${minutes === 1 ? "" : "s"}` + : `${minutes}m ${remainderSeconds}s`; + } + + const hours = Math.floor(minutes / 60); + const remainderMinutes = minutes % 60; + + return remainderMinutes === 0 + ? `${hours} hour${hours === 1 ? "" : "s"}` + : `${hours}h ${remainderMinutes}m`; +} + +export function formatUtcTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const year = date.getUTCFullYear(); + const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); + const day = `${date.getUTCDate()}`.padStart(2, "0"); + const hours = `${date.getUTCHours()}`.padStart(2, "0"); + const minutes = `${date.getUTCMinutes()}`.padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes} UTC`; +} + +export function canSendMessages( + channel: Awaited>, +): channel is TextBasedChannel & { send: (content: string) => Promise } { + return ( + channel !== null && + channel.isTextBased() && + "send" in channel && + typeof channel.send === "function" + ); +} + +function buildRepoAliases(repoName: string): string[] { + const normalizedRepo = repoName.replace(/[_-]+/g, " ").trim(); + const lower = normalizeForMatching(normalizedRepo); + const compact = normalizeForMatching(repoName); + const words = splitPascalCase(normalizedRepo) + .flatMap((part) => part.split(/\s+/)) + .map((part) => normalizeForMatching(part)) + .filter((part) => part.length > 0); + const aliases = new Set([lower, compact, words.join(" ")]); + + for (const word of words) { + if (word.length >= 4) { + aliases.add(word); + } + } + + return [...aliases].filter((alias) => alias.length > 0); +} + +function splitPascalCase(value: string): string[] { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/\s+/) + .filter((part) => part.length > 0); +} + +function normalizeForMatching(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function isAmbientVoidReference( + message: Message, + botUserId: string | undefined, +): boolean { + const content = message.content.trim(); + + if (content.length === 0) { + return false; + } + + if (botUserId && message.mentions.users.has(botUserId)) { + return false; + } + + if (/\bvoidbot\b/i.test(content)) { + return true; + } + + if (!/\bvoid\b/i.test(content)) { + return false; + } + + const normalized = ` ${content + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim()} `; + + if ( + normalized.includes(" the void ") || + normalized.includes(" into the void ") || + normalized.includes(" return void ") || + normalized.includes(" void function ") || + normalized.includes(" void pointer ") || + normalized.includes(" non void ") + ) { + return false; + } + + return true; +} diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index 57e1964..74faa91 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -1,31 +1,17 @@ import { - ChannelType, Client, Events, GatewayIntentBits, - PermissionsBitField, - REST, - Routes, - SlashCommandBuilder, - type CacheType, - type ChatInputCommandInteraction, - type Message, - type PartialMessage, - type TextBasedChannel, } from "discord.js"; import { loadConfig } from "@voidbot/config"; import { - type AuditLog, buildVoidMcpServerConfig, ContextBuilder, PermissionEngine, createStateStorage, - type InteractionMemoryBank, - type JobQueue, loadStylePack, loadSystemMessageCatalog, - type SystemMessageCatalog, VoidUsageRateLimiter, } from "@voidbot/core"; import { @@ -43,88 +29,39 @@ import { RagPipeline, RetrievalService, SourceDocumentIngester, - type ArchivedMessageRecord, } from "@voidbot/rag"; -import { - type Actor, - type ArchivedMessage, - type ChannelIndexingPolicy, - type ChannelIndexingTarget, - type CommandName, - type GuildContext, - type JobRecord, - type ProviderName, - type SourceGroundingHint, - type SourceMessage, - type StylePack, - isProviderName, - shouldIndexChannel, -} from "@voidbot/shared"; +import { shouldIndexChannel } from "@voidbot/shared"; -const commandDefinitions = [ - new SlashCommandBuilder() - .setName("ask") - .setDescription("Ask VoidBot a question.") - .addStringOption((option) => - option.setName("question").setDescription("What do you want to ask?").setRequired(true), - ) - .addStringOption((option) => - option - .setName("provider") - .setDescription("Override the provider instead of using auto-selection.") - .setRequired(false) - .addChoices( - { name: "Auto", value: "auto" }, - { name: "Owner Codex", value: "owner_codex" }, - { name: "Local LLM", value: "local_llm" }, - ), - ), - new SlashCommandBuilder() - .setName("queue-codex") - .setDescription("Force a fuller owner-only Codex handoff.") - .addStringOption((option) => - option.setName("prompt").setDescription("The owner workflow prompt.").setRequired(true), - ), - new SlashCommandBuilder() - .setName("approve-job") - .setDescription("Approve a queued job or post its final response.") - .addStringOption((option) => - option.setName("job-id").setDescription("The job id to approve.").setRequired(true), - ) - .addStringOption((option) => - option - .setName("response") - .setDescription("Optional final response to publish when the job is awaiting post approval.") - .setRequired(false), - ), - new SlashCommandBuilder() - .setName("reject-job") - .setDescription("Reject a queued job.") - .addStringOption((option) => - option.setName("job-id").setDescription("The job id to reject.").setRequired(true), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Optional rejection reason.").setRequired(false), - ), - new SlashCommandBuilder() - .setName("provider-status") - .setDescription("Show current provider availability for you."), - new SlashCommandBuilder() - .setName("search-history") - .setDescription("Search indexed channel history.") - .addStringOption((option) => - option.setName("query").setDescription("What to search for.").setRequired(true), - ), - new SlashCommandBuilder() - .setName("summarize-channel") - .setDescription("Summarize recent messages in this channel."), - new SlashCommandBuilder() - .setName("reindex-channel") - .setDescription("Reindex the current indexed channel."), - new SlashCommandBuilder() - .setName("set-style") - .setDescription("Reload the configured style pack from disk."), -]; +import { + handleApproveJob, + handlePrompt, + handleRejectJob, + handleReindexChannel, + handleSearchHistory, + handleSummarizeChannel, + maybeRegisterCommands, + parseProviderOverride, + replyEphemeral, +} from "./discord-bot-handlers"; +import { + buildActorFromInteraction, + buildActorFromMessage, + buildChannelIndexingTarget, + buildGuildContextFromInteraction, + buildGuildContextFromMessage, + formatArchivedMessageContext, + formatHistoryResults, + formatProviderStatuses, + formatSourceResults, + getRoleIdsFromInteraction, + getRoleIdsFromMessage, + ingestIfIndexed, + materializeMessage, + notifyOwnerOfBotIssue, + rememberAmbientVoidReference, + renderSystemMessage, + stripBotMention, +} from "./discord-bot-support"; export async function startBot(): Promise { const config = loadConfig(); @@ -571,1001 +508,3 @@ export async function startBot(): Promise { await client.login(config.botToken); } - -interface PromptHandlerOptions { - prompt: string; - command: CommandName; - actor: Actor; - roleIds: string[]; - guildContext: GuildContext; - outputChannelId: string; - requestMessageId?: string; - channel: TextBasedChannel | null; - respond: (content: string) => Promise; - config: ReturnType; - permissionEngine: PermissionEngine; - contextBuilder: ContextBuilder; - retrievalService: RetrievalService; - sourceArchiveRepository: FileSourceDocumentArchiveRepository; - jobQueue: JobQueue; - auditLog: AuditLog; - interactionMemory: InteractionMemoryBank; - voidUsageRateLimiter: VoidUsageRateLimiter; - providerRegistry: ProviderRegistry; - stylePack?: StylePack; - systemMessages: SystemMessageCatalog; - forceProvider?: ProviderName; - silentOwnerQueueAck?: boolean; -} - -async function handlePrompt(options: PromptHandlerOptions): Promise { - const providerName = - options.forceProvider ?? pickProvider(options.actor, options.guildContext, options.providerRegistry); - - if (!providerName) { - await options.respond( - renderSystemMessage(options.systemMessages, "provider.none_available"), - ); - return; - } - - const provider = options.providerRegistry.get(providerName); - - if (!provider) { - await options.respond( - renderSystemMessage(options.systemMessages, "provider.missing_registration", { - providerName, - }), - ); - return; - } - - const access = options.permissionEngine.canUseProvider(options.actor, providerName); - - if (!access.allowed) { - await options.respond( - access.reasonKey - ? renderSystemMessage(options.systemMessages, access.reasonKey) - : renderSystemMessage(options.systemMessages, "provider.access_denied.generic"), - ); - return; - } - - const rateLimitDecision = await options.voidUsageRateLimiter.consume({ - actorId: options.actor.id, - roleIds: options.roleIds, - command: options.command, - provider: providerName, - guildId: options.guildContext.guildId, - channelId: options.guildContext.channelId, - }); - - if (!rateLimitDecision.allowed) { - await options.auditLog.record({ - type: "rate_limit.denied", - actorId: options.actor.id, - provider: providerName, - details: { - command: options.command, - reason: rateLimitDecision.reason, - dailyCount: rateLimitDecision.dailyCount, - dailyLimit: rateLimitDecision.policy.dailyLimit ?? null, - cooldownSeconds: rateLimitDecision.policy.cooldownSeconds ?? null, - retryAfterSeconds: rateLimitDecision.retryAfterSeconds ?? null, - resetsAt: rateLimitDecision.resetsAt ?? null, - modifier: rateLimitDecision.policy.modifier, - matchedSubjects: rateLimitDecision.policy.matchedSubjects, - }, - }); - await options.respond(renderRateLimitMessage(options.systemMessages, rateLimitDecision)); - return; - } - - const rememberedInteraction = await options.interactionMemory.recordInteraction({ - actorId: options.actor.id, - actorName: options.actor.displayName, - sourceKind: "direct_prompt", - guildId: options.guildContext.guildId, - channelId: options.guildContext.channelId, - channelName: options.guildContext.channelName, - command: options.command, - prompt: options.prompt, - eventId: options.requestMessageId, - }); - const interactionMemory = - rememberedInteraction.totalInteractions > 0 ? rememberedInteraction : undefined; - - const recentMessages = await getRecentMessages(options.channel, 10); - const sourceGrounding = inferSourceGroundingHint( - options.prompt, - await options.sourceArchiveRepository.listRepoSummaries(), - ); - const shouldAttachInitialRetrieval = - !provider.getCapabilities().includes("tool_driven_retrieval") && - (shouldIndexChannel(options.config.channelIndexing, buildChannelIndexingTarget(options.channel)) || - providerName !== "owner_codex"); - const retrieval = shouldAttachInitialRetrieval - ? await options.retrievalService.search(options.prompt, 5, { - guildId: options.guildContext.guildId, - corpusKind: "discord_history", - }) - : []; - const contextBundle = options.contextBuilder.build({ - prompt: options.prompt, - actor: options.actor, - guildContext: options.guildContext, - recentMessages, - retrieval, - interactionMemory, - sourceGrounding, - stylePack: options.stylePack, - }); - - if (providerName === "owner_codex") { - const runApprovalRequired = options.config.ownerCodexMode === "manual_package"; - const postApprovalRequired = options.config.ownerCodexMode === "manual_package"; - const { job, created } = await options.jobQueue.createJob({ - command: options.command, - provider: "owner_codex", - runApprovalRequired, - postApprovalRequired, - requester: options.actor, - guildContext: options.guildContext, - prompt: options.prompt, - contextBundle, - outputChannelId: options.outputChannelId, - requestMessageId: options.requestMessageId, - initialState: runApprovalRequired ? "awaiting_approval" : "approved", - }); - - if (!created) { - return; - } - - await options.auditLog.record({ - type: "job.queued", - actorId: options.actor.id, - jobId: job.id, - provider: "owner_codex", - details: { - command: options.command, - channelId: options.outputChannelId, - runApprovalRequired, - postApprovalRequired, - }, - }); - - if (runApprovalRequired) { - await options.respond( - renderSystemMessage(options.systemMessages, "job.owner.awaiting_run_approval", { - jobId: job.id, - }), - ); - return; - } - - if (!options.silentOwnerQueueAck) { - await options.respond( - renderSystemMessage(options.systemMessages, "job.owner.auto_queued", { - jobId: job.id, - }), - ); - } - return; - } - - const response = await provider.execute(provider.buildRequest(contextBundle)); - - await options.auditLog.record({ - type: "provider.responded", - actorId: options.actor.id, - provider: providerName, - details: { - command: options.command, - summary: response.summary, - }, - }); - - await options.respond(response.outputText ?? response.summary); -} - -async function handleApproveJob(options: { - interaction: ChatInputCommandInteraction; - client: Client; - actor: Actor; - jobQueue: JobQueue; - auditLog: AuditLog; - permissionEngine: PermissionEngine; - systemMessages: SystemMessageCatalog; -}): Promise { - const jobId = options.interaction.options.getString("job-id", true); - const response = options.interaction.options.getString("response", false); - const job = await options.jobQueue.getJob(jobId); - - if (!job) { - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.not_found", { jobId }), - ); - return; - } - - if (!options.permissionEngine.canApproveJob(options.actor, job)) { - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.approval_forbidden"), - ); - return; - } - - if (job.state === "awaiting_approval") { - await options.jobQueue.approveRun(jobId, options.actor.id); - await options.auditLog.record({ - type: "job.run_approved", - actorId: options.actor.id, - jobId, - provider: job.provider, - details: {}, - }); - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.run_approved", { jobId }), - ); - return; - } - - if (job.state === "awaiting_post_approval") { - if (!response) { - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.post_approval.needs_response"), - ); - return; - } - - await postFinalResponse(options.client, job, response); - await options.jobQueue.completeJob(jobId, options.actor.id, response); - await options.auditLog.record({ - type: "job.post_approved", - actorId: options.actor.id, - jobId, - provider: job.provider, - details: { - publishedLength: response.length, - }, - }); - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.post_approved", { jobId }), - ); - return; - } - - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.no_action_for_state", { - jobId, - jobState: job.state, - }), - ); -} - -async function handleRejectJob(options: { - interaction: ChatInputCommandInteraction; - actor: Actor; - jobQueue: JobQueue; - auditLog: AuditLog; - permissionEngine: PermissionEngine; - systemMessages: SystemMessageCatalog; -}): Promise { - const jobId = options.interaction.options.getString("job-id", true); - const reason = options.interaction.options.getString("reason", false) ?? undefined; - const job = await options.jobQueue.getJob(jobId); - - if (!job) { - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.not_found", { jobId }), - ); - return; - } - - if (!options.permissionEngine.canApproveJob(options.actor, job)) { - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.rejection_forbidden"), - ); - return; - } - - await options.jobQueue.rejectJob(jobId, options.actor.id, reason); - await options.auditLog.record({ - type: "job.rejected", - actorId: options.actor.id, - jobId, - provider: job.provider, - details: { - reason, - }, - }); - await replyEphemeral( - options.interaction, - renderSystemMessage(options.systemMessages, "job.rejected", { jobId }), - ); -} - -async function handleSearchHistory( - interaction: ChatInputCommandInteraction, - retrievalService: RetrievalService, - systemMessages: SystemMessageCatalog, -): Promise { - const query = interaction.options.getString("query", true); - const results = await retrievalService.searchHistory(query, 5, { - guildId: interaction.guildId ?? undefined, - }); - - if (results.length === 0) { - await replyEphemeral(interaction, renderSystemMessage(systemMessages, "history.no_results")); - return; - } - - const body = results - .map((result, index) => `${index + 1}. (${result.score.toFixed(2)}) ${truncate(result.text, 180)}`) - .join("\n"); - - await replyEphemeral( - interaction, - `${renderSystemMessage(systemMessages, "history.results_intro")}\n${body}`, - ); -} - -async function handleSummarizeChannel( - interaction: ChatInputCommandInteraction, - systemMessages: SystemMessageCatalog, -): Promise { - const messages = await getRecentMessages(interaction.channel?.isTextBased() ? interaction.channel : null, 15); - - if (messages.length === 0) { - await replyEphemeral( - interaction, - renderSystemMessage(systemMessages, "summarize.no_messages"), - ); - return; - } - - const summary = messages - .slice(-8) - .map((message) => `- ${message.authorName}: ${truncate(message.content, 100)}`) - .join("\n"); - - await replyEphemeral( - interaction, - `${renderSystemMessage(systemMessages, "summarize.results_intro")}\n${summary}`, - ); -} - -async function handleReindexChannel( - interaction: ChatInputCommandInteraction, - actor: Actor, - channelIndexing: ChannelIndexingPolicy, - permissionEngine: PermissionEngine, - ragPipeline: RagPipeline, - systemMessages: SystemMessageCatalog, -): Promise { - if (!permissionEngine.canReindex(actor)) { - await replyEphemeral(interaction, renderSystemMessage(systemMessages, "reindex.forbidden")); - return; - } - - if ( - !shouldIndexChannel( - channelIndexing, - buildChannelIndexingTarget(interaction.channel?.isTextBased() ? interaction.channel : null), - ) - ) { - await replyEphemeral( - interaction, - renderSystemMessage(systemMessages, "reindex.excluded"), - ); - return; - } - - if (!interaction.channel?.isTextBased()) { - await replyEphemeral( - interaction, - renderSystemMessage(systemMessages, "reindex.unsupported_channel"), - ); - return; - } - - const fetched = await interaction.channel.messages.fetch({ limit: 100 }); - const archivedMessages = [...fetched.values()].map(convertDiscordMessageToArchive); - const result = await ragPipeline.upsertMessages(archivedMessages); - - await replyEphemeral( - interaction, - renderSystemMessage(systemMessages, "reindex.completed", { - archivedMessages: archivedMessages.length, - createdMessages: result.createdMessages, - updatedMessages: result.updatedMessages, - unchangedMessages: result.unchangedMessages, - indexedChunks: result.indexedChunks, - }), - ); -} - -function buildActorFromMessage(message: Message): Actor { - return { - id: message.author.id, - displayName: message.author.displayName ?? message.author.username, - isAdmin: message.member?.permissions.has(PermissionsBitField.Flags.Administrator) ?? false, - isBot: message.author.bot, - }; -} - -function buildActorFromInteraction(interaction: ChatInputCommandInteraction): Actor { - return { - id: interaction.user.id, - displayName: interaction.user.displayName ?? interaction.user.username, - isAdmin: - interaction.memberPermissions?.has(PermissionsBitField.Flags.Administrator) ?? false, - isBot: interaction.user.bot, - }; -} - -function getRoleIdsFromMessage(message: Message): string[] { - return message.member ? [...message.member.roles.cache.keys()] : []; -} - -function getRoleIdsFromInteraction( - interaction: ChatInputCommandInteraction, -): string[] { - const member = interaction.member; - - if (!member || !("roles" in member)) { - return []; - } - - const { roles } = member; - - if (Array.isArray(roles)) { - return roles.filter((roleId): roleId is string => typeof roleId === "string"); - } - - if (roles && typeof roles === "object" && "cache" in roles) { - return [...roles.cache.keys()]; - } - - return []; -} - -function buildGuildContextFromMessage(message: Message): GuildContext { - return { - guildId: message.guildId ?? undefined, - guildName: message.guild?.name, - channelId: message.channelId, - channelName: "name" in message.channel ? message.channel.name ?? undefined : undefined, - threadId: message.channel.type === ChannelType.PublicThread ? message.channel.id : undefined, - }; -} - -function buildGuildContextFromInteraction( - interaction: ChatInputCommandInteraction, -): GuildContext { - return { - guildId: interaction.guildId ?? undefined, - guildName: interaction.guild?.name, - channelId: interaction.channelId, - channelName: - interaction.channel && "name" in interaction.channel ? interaction.channel.name ?? undefined : undefined, - threadId: - interaction.channel?.type === ChannelType.PublicThread ? interaction.channel.id : undefined, - }; -} - -async function ingestIfIndexed( - message: Message, - channelIndexing: ChannelIndexingPolicy, - ragPipeline: RagPipeline, -): Promise { - if (!shouldIndexChannel(channelIndexing, buildChannelIndexingTarget(message.channel))) { - return; - } - - await ragPipeline.upsertMessages([convertDiscordMessageToArchive(message)]); -} - -async function rememberAmbientVoidReference( - message: Message, - botUserId: string | undefined, - interactionMemory: InteractionMemoryBank, -): Promise { - if (!isAmbientVoidReference(message, botUserId)) { - return; - } - - await interactionMemory.recordInteraction({ - actorId: message.author.id, - actorName: message.author.displayName ?? message.author.username, - sourceKind: "ambient_mention", - guildId: message.guildId ?? undefined, - channelId: message.channelId, - channelName: "name" in message.channel ? message.channel.name ?? undefined : undefined, - prompt: message.content, - eventId: message.id, - timestamp: (message.editedAt ?? message.createdAt).toISOString(), - }); -} - -function buildChannelIndexingTarget( - channel: TextBasedChannel | null | undefined, -): ChannelIndexingTarget { - if (!channel) { - return {}; - } - - const target: ChannelIndexingTarget = { - channelId: channel.id, - channelName: "name" in channel ? channel.name ?? undefined : undefined, - }; - - if (channel.isThread()) { - target.parentChannelId = channel.parentId ?? undefined; - target.parentChannelName = - channel.parent && "name" in channel.parent ? channel.parent.name ?? undefined : undefined; - } - - return target; -} - -function convertDiscordMessageToArchive(message: Message): ArchivedMessage { - const metadata: Record = { - jumpUrl: message.url, - }; - - if ("name" in message.channel && message.channel.name) { - metadata.channelName = message.channel.name; - } - - if (message.channel.isThread()) { - if (message.channel.parentId) { - metadata.parentChannelId = message.channel.parentId; - } - - if (message.channel.parent && "name" in message.channel.parent && message.channel.parent.name) { - metadata.parentChannelName = message.channel.parent.name; - } - } - - return { - id: message.id, - guildId: message.guildId ?? undefined, - channelId: message.channelId, - authorId: message.author.id, - authorName: message.author.displayName ?? message.author.username, - content: message.content, - timestamp: message.createdAt.toISOString(), - editedAt: message.editedAt?.toISOString(), - threadId: message.channel.isThread() ? message.channel.id : undefined, - attachments: - message.attachments.size > 0 - ? [...message.attachments.values()].map( - (attachment) => attachment.url ?? attachment.proxyURL ?? attachment.name, - ) - : undefined, - metadata, - }; -} - -function convertDiscordMessageToSource(message: Message): SourceMessage { - return { - id: message.id, - authorId: message.author.id, - authorName: message.author.displayName ?? message.author.username, - content: message.content, - timestamp: message.createdAt.toISOString(), - }; -} - -async function getRecentMessages( - channel: TextBasedChannel | null, - limit: number, -): Promise { - if (!channel || !("messages" in channel)) { - return []; - } - - const fetched = await channel.messages.fetch({ limit }); - - return [...fetched.values()] - .sort((left, right) => left.createdTimestamp - right.createdTimestamp) - .map(convertDiscordMessageToSource); -} - -async function materializeMessage( - message: Message | PartialMessage, -): Promise { - if (!message.partial) { - return message; - } - - try { - return await message.fetch(); - } catch { - return undefined; - } -} - -function pickProvider( - actor: Actor, - guildContext: GuildContext, - providerRegistry: ProviderRegistry, -): ProviderName | undefined { - const ownerCodex = providerRegistry.get("owner_codex"); - - if (ownerCodex?.isAllowedForActor(actor, guildContext)) { - return "owner_codex"; - } - - const openAi = providerRegistry.get("openai_api"); - - if (openAi?.isAllowedForActor(actor, guildContext)) { - return "openai_api"; - } - - const local = providerRegistry.get("local_llm"); - - if (local?.isAllowedForActor(actor, guildContext)) { - return "local_llm"; - } - - return undefined; -} - -function parseProviderOverride(value: string | null): ProviderName | undefined { - if (!value || value === "auto") { - return undefined; - } - - return isProviderName(value) ? value : undefined; -} - -async function maybeRegisterCommands( - botToken: string, - applicationId?: string, - developmentGuildId?: string, -): Promise { - if (!applicationId || !developmentGuildId) { - console.log("Skipping slash command registration because DISCORD_APPLICATION_ID or DISCORD_GUILD_ID is missing."); - return; - } - - const rest = new REST({ version: "10" }).setToken(botToken); - - await rest.put(Routes.applicationGuildCommands(applicationId, developmentGuildId), { - body: commandDefinitions.map((command) => command.toJSON()), - }); - - console.log(`Registered ${commandDefinitions.length} guild slash commands for ${developmentGuildId}.`); -} - -async function replyEphemeral( - interaction: ChatInputCommandInteraction, - content: string, -): Promise { - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content, ephemeral: true }); - return; - } - - await interaction.reply({ content, ephemeral: true }); -} - -function stripBotMention(content: string): string { - return content.replace(/<@!?(\d+)>/g, "").trim(); -} - -function inferSourceGroundingHint( - prompt: string, - repoSummaries: Array<{ repoName: string }>, -): SourceGroundingHint { - const normalized = ` ${normalizeForMatching(prompt)} `; - const reasons = new Set(); - const matchedRepoNames = new Set(); - - for (const repo of repoSummaries) { - for (const alias of buildRepoAliases(repo.repoName)) { - if (alias.length < 4) { - continue; - } - - if (normalized.includes(` ${alias} `)) { - matchedRepoNames.add(repo.repoName); - } - } - } - - if (matchedRepoNames.size > 0) { - reasons.add("matched indexed repo/project name"); - } - - const genericSourceCues = [ - " repo ", - " repository ", - " repositories ", - " codebase ", - " source ", - " sources ", - " lore ", - " vault ", - " canon ", - " file ", - " files ", - " docs ", - " documentation ", - " module ", - " implementation ", - " project ", - " projects ", - ]; - - if (genericSourceCues.some((cue) => normalized.includes(cue))) { - reasons.add("contains source/lore cue"); - } - - return { - required: reasons.size > 0, - reasons: [...reasons], - matchedRepoNames: [...matchedRepoNames].sort(), - }; -} - -function buildRepoAliases(repoName: string): string[] { - const normalizedRepo = repoName.replace(/[_-]+/g, " ").trim(); - const lower = normalizeForMatching(normalizedRepo); - const compact = normalizeForMatching(repoName); - const words = splitPascalCase(normalizedRepo) - .flatMap((part) => part.split(/\s+/)) - .map((part) => normalizeForMatching(part)) - .filter((part) => part.length > 0); - const aliases = new Set([lower, compact, words.join(" ")]); - - for (const word of words) { - if (word.length >= 4) { - aliases.add(word); - } - } - - return [...aliases].filter((alias) => alias.length > 0); -} - -function splitPascalCase(value: string): string[] { - return value - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .split(/\s+/) - .filter((part) => part.length > 0); -} - -function normalizeForMatching(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function isAmbientVoidReference( - message: Message, - botUserId: string | undefined, -): boolean { - const content = message.content.trim(); - - if (content.length === 0) { - return false; - } - - if (botUserId && message.mentions.users.has(botUserId)) { - return false; - } - - if (/\bvoidbot\b/i.test(content)) { - return true; - } - - if (!/\bvoid\b/i.test(content)) { - return false; - } - - const normalized = ` ${content - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .replace(/\s+/g, " ") - .trim()} `; - - if ( - normalized.includes(" the void ") || - normalized.includes(" into the void ") || - normalized.includes(" return void ") || - normalized.includes(" void function ") || - normalized.includes(" void pointer ") || - normalized.includes(" non void ") - ) { - return false; - } - - return true; -} - -function formatProviderStatuses( - statuses: ReturnType, - systemMessages: SystemMessageCatalog, -): string { - return [ - renderSystemMessage(systemMessages, "provider_status.intro"), - statuses - .map((status) => { - const state = status.enabled ? "enabled" : "disabled"; - const access = status.allowed ? "allowed" : "blocked"; - return `${status.name}: ${state}, ${access}, capabilities=${status.capabilities.join(", ")}`; - }) - .join("\n"), - ].join("\n"); -} - -function renderSystemMessage( - systemMessages: SystemMessageCatalog, - key: string, - variables: Record = {}, -): string { - return systemMessages.render(key, variables); -} - -function renderRateLimitMessage( - systemMessages: SystemMessageCatalog, - decision: Awaited>, -): string { - if (decision.reason === "daily_limit") { - return renderSystemMessage(systemMessages, "rate_limit.daily_limit", { - dailyLimit: decision.policy.dailyLimit ?? decision.dailyCount, - resetsAt: decision.resetsAt ? formatUtcTimestamp(decision.resetsAt) : "the next UTC midnight", - }); - } - - return renderSystemMessage(systemMessages, "rate_limit.cooldown", { - retryAfter: formatDurationSeconds(decision.retryAfterSeconds ?? 1), - }); -} - -function formatHistoryResults( - results: Awaited>, -): Array> { - return results.map((result) => ({ - score: Number(result.score.toFixed(4)), - text: result.text, - sourceId: result.sourceId, - channelId: result.metadata.channelId, - channelName: result.metadata.channelName, - authorId: result.metadata.authorId, - authorName: result.metadata.authorName, - timestamp: result.metadata.timestamp, - jumpUrl: result.metadata.jumpUrl, - threadId: result.metadata.threadId, - })); -} - -function formatSourceResults( - results: Awaited>, -): Array> { - return results.map((result) => ({ - score: Number(result.score.toFixed(4)), - text: result.text, - sourceId: result.sourceId, - repoName: result.metadata.repoName, - path: result.metadata.path, - language: result.metadata.language, - title: result.metadata.title, - chunkIndex: Number(result.metadata.chunkIndex ?? 0), - lineStart: Number(result.metadata.lineStart ?? 1), - lineEnd: Number(result.metadata.lineEnd ?? 1), - lastModifiedAt: result.metadata.lastModifiedAt, - })); -} - -function formatArchivedMessageContext( - message: ArchivedMessageRecord, - anchorMessageId: string, -): Record { - return { - id: message.id, - isAnchor: message.id === anchorMessageId, - timestamp: message.timestamp, - authorId: message.authorId, - authorName: message.authorName, - channelId: message.channelId, - channelName: message.metadata?.channelName, - threadId: message.threadId, - content: message.content, - jumpUrl: message.metadata?.jumpUrl, - editedAt: message.editedAt, - }; -} - -async function postFinalResponse(client: Client, job: JobRecord, finalResponse: string): Promise { - const channel = await client.channels.fetch(job.outputChannelId); - - if (!canSendMessages(channel)) { - throw new Error(`Channel ${job.outputChannelId} is not available for publishing.`); - } - - await channel.send(finalResponse); -} - -async function notifyOwnerOfBotIssue( - client: Client, - ownerDiscordId: string, - content: string, -): Promise { - try { - const owner = await client.users.fetch(ownerDiscordId); - await owner.send(truncate(content, 1900)); - } catch (error) { - console.error( - `Failed to DM owner about bot-side issue: ${ - error instanceof Error ? error.message : "Unexpected notification failure." - }`, - ); - } -} - -function truncate(input: string, limit: number): string { - if (input.length <= limit) { - return input; - } - - return `${input.slice(0, limit - 3)}...`; -} - -function formatDurationSeconds(totalSeconds: number): string { - const seconds = Math.max(1, Math.ceil(totalSeconds)); - - if (seconds < 60) { - return `${seconds} second${seconds === 1 ? "" : "s"}`; - } - - const minutes = Math.floor(seconds / 60); - const remainderSeconds = seconds % 60; - - if (minutes < 60) { - return remainderSeconds === 0 - ? `${minutes} minute${minutes === 1 ? "" : "s"}` - : `${minutes}m ${remainderSeconds}s`; - } - - const hours = Math.floor(minutes / 60); - const remainderMinutes = minutes % 60; - - return remainderMinutes === 0 - ? `${hours} hour${hours === 1 ? "" : "s"}` - : `${hours}h ${remainderMinutes}m`; -} - -function formatUtcTimestamp(timestamp: string): string { - const date = new Date(timestamp); - const year = date.getUTCFullYear(); - const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); - const day = `${date.getUTCDate()}`.padStart(2, "0"); - const hours = `${date.getUTCHours()}`.padStart(2, "0"); - const minutes = `${date.getUTCMinutes()}`.padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${minutes} UTC`; -} - -function canSendMessages( - channel: Awaited>, -): channel is TextBasedChannel & { send: (content: string) => Promise } { - return ( - channel !== null && - channel.isTextBased() && - "send" in channel && - typeof channel.send === "function" - ); -} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 47d8ba0..91e7cab 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -22,12 +22,13 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - `.voidbot/` owns archives, artifacts, logs, status files, and backups. - `owner_codex` and `local_llm` are the active reply lanes. - The owner Codex lane is no longer one swollen file; orchestration, runtime/parsing, rendering, and shared helpers are split under `packages/providers/src/owner-codex-*.ts`. +- The Discord bot lane is no longer one swollen entrypoint; gateway wiring remains in `apps/bot/src/discord-bot.ts` while command/prompt handlers and Discord-shape support helpers now live under `apps/bot/src/discord-bot-*.ts`. - Ops health, backup verification, offsite sync, and dashboard surfaces already exist. - The new state/notes surfaces are now the continuity spine for future nontrivial work. ## Likely Next Bounded Move -- The next good structural cut is probably `apps/bot/src/discord-bot.ts` or `packages/core/src/interaction-memory-logic.ts`. +- The next good structural cut is probably `packages/core/src/interaction-memory-logic.ts` or `packages/core/src/state-storage.ts`. - Prefer the seam with the clearest concern split rather than chasing line count for its own sake. - Do not broaden scope into a heroic "refactor everything" binge. One organ at a time. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 83f5687..b618074 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -5,7 +5,11 @@ This note is the source-grounded description of how the live VoidBot stack is sh ## Main Organs - `apps/bot/src/discord-bot.ts` - - Discord gateway, commands, mention handling, permissions, provider selection, rate-limit gating, and context assembly entrypoint. + - Discord gateway shell: client bootstrap, provider wiring, event registration, and top-level orchestration. +- `apps/bot/src/discord-bot-handlers.ts` + - Prompt/command handling, provider dispatch, rate-limit gating, job approval/rejection, reindexing, and slash-command registration helpers. +- `apps/bot/src/discord-bot-support.ts` + - Discord-shape adapters, archive/source conversion helpers, ambient-memory ingestion helpers, source-grounding hint logic, and bot-side message formatting/rendering helpers. - `apps/worker/src/index.ts` - owner-job poller, provider execution, handoff packaging, and MCP wiring for the worker-side lane. - `apps/worker/src/mcp-server.ts` @@ -35,12 +39,13 @@ This note is the source-grounded description of how the live VoidBot stack is sh ## Flow 1: Discord Request To Provider -1. `apps/bot/src/discord-bot.ts` receives a slash command or mention-driven request. +1. `apps/bot/src/discord-bot.ts` receives a slash command or mention-driven request and delegates prompt/command work into `apps/bot/src/discord-bot-handlers.ts`. 2. Permission checks run through `packages/core/src/permission-engine.ts`. 3. Void usage limits are applied through `packages/core/src/void-usage-rate-limiter.ts` backed by `packages/core/src/state-storage.ts`. -4. `packages/core/src/context-builder.ts` assembles request context, including recent interaction profile and any retrieval hints. -5. Provider selection goes through `packages/providers/src/index.ts`. -6. The bot either: +4. `apps/bot/src/discord-bot-support.ts` adapts Discord message/interaction shapes, ambient-memory events, and source-grounding hints. +5. `packages/core/src/context-builder.ts` assembles request context, including recent interaction profile and any retrieval hints. +6. Provider selection goes through `packages/providers/src/index.ts`. +7. The bot either: - answers directly through `local_llm`, or - queues an owner job for the worker / `owner_codex` path. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index 69583a8..ec6d62d 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -28,7 +28,7 @@ Use the persistent project-state surfaces to keep future work source-grounded an ### 4. Legibility Hardening - continue splitting oversized runtime files where concern boundaries are already obvious -- likely next cuts: `apps/bot/src/discord-bot.ts`, `packages/core/src/interaction-memory-logic.ts`, or `packages/core/src/state-storage.ts` +- likely next cuts: `packages/core/src/interaction-memory-logic.ts` or `packages/core/src/state-storage.ts` - keep behavior stable while moving concerns into smaller organs ### 5. The Still-Scaffolded Paths diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c66292b..ac78cfe 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -2,3 +2,4 @@ {"ts":"2026-04-25T13:00:00+01:00","type":"architecture","status":"ok","note":"Tool-driven retrieval is now the default for owner_codex and local_llm; source and lore answers are expected to touch source-side lookup before sounding confident."} {"ts":"2026-04-26T03:05:00+01:00","type":"process","status":"ok","note":"Canonical project-state surfaces were added so future sessions can rehydrate from map, handoff, system map, implementation plan, and distilled evidence instead of relying on transcript memory alone."} {"ts":"2026-04-26T03:40:00+01:00","type":"architecture","status":"ok","note":"The owner Codex provider was split into orchestration, runtime/parsing, rendering, and shared-helper modules; the architecture was sound, but file bloat had become the likelier future failure surface."} +{"ts":"2026-04-26T15:20:00+01:00","type":"architecture","status":"ok","note":"The Discord bot lane was split into a gateway shell plus dedicated command-handler and support-helper modules; behavior stayed stable, but line-count monarchy had become the likelier future failure surface here too."} diff --git a/state/map.yaml b/state/map.yaml index 66a804f..89b13fd 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -73,6 +73,7 @@ landed_baseline: - Interaction memory and behavioral profiling are live. - Source-grounding enforcement and tool-driven retrieval are live. - The owner Codex lane is now split into orchestration, runtime/parsing, shared helpers, and rendering modules instead of one swollen provider file. + - The Discord bot lane is now split into a gateway shell plus dedicated command-handler and support helper modules instead of one swollen entrypoint file. rejected_paths: - name: transcript_only_continuity @@ -83,5 +84,5 @@ rejected_paths: reason: vibes produce plausible slop that sounds smart and lies with a straight face current_status: - summary: VoidBot now has a real project-memory spine plus the first legibility-hardening pass on a major runtime organ. The owner Codex lane was split into smaller seams, which lowers local edit risk without changing the behavioral contract. The stack is still dense, but it no longer depends purely on transcript memory or one-file monarchies to stay coherent. - next_action: Keep using the canonical state files for re-entry, then continue decomposing the next oversized organ with the clearest concern boundaries, likely the Discord bot entrypoint or the interaction-memory policy slab. + summary: VoidBot now has a real project-memory spine plus two legibility-hardening passes on major runtime organs. The owner Codex lane and Discord bot lane were both split into smaller seams, which lowers local edit risk without changing the behavioral contract. The stack is still dense, but it no longer depends purely on transcript memory or one-file monarchies to stay coherent. + next_action: Keep using the canonical state files for re-entry, then continue decomposing the next oversized organ with the clearest concern boundaries, likely the interaction-memory policy slab or the state-storage persistence slab.