diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 6b45fcda..2ef35812 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -14,6 +14,8 @@ "register-commands": "tsx src/register-commands.ts" }, "dependencies": { + "@copilotkit/channels": "^0.1.1", + "@copilotkit/channels-discord": "^0.0.3", "@copilotkit/outpost": "workspace:*", "discord.js": "^14.16.0" }, diff --git a/docs/superpowers/plans/2026-07-16-discord-channels-sdk-rewire.md b/docs/superpowers/plans/2026-07-16-discord-channels-sdk-rewire.md new file mode 100644 index 00000000..86707fd0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-discord-channels-sdk-rewire.md @@ -0,0 +1,478 @@ +# Discord → Channels SDK Rewire Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `apps/discord-bot`'s hand-rolled discord.js transport, event handling, interactions, and message rendering with `@copilotkit/channels` + `@copilotkit/channels-discord`, while Outpost's ticket/queue/worker/AI pipeline stays intact underneath. + +**Architecture:** The SDK is used as **Discord transport + JSX rendering only** — never as the agent runtime (`thread.runAgent()` is never called). Inbound: SDK gateway turns are translated to Outpost's `InboundMessage` and passed to the existing `InboundHandler`. Outbound: the worker keeps posting via the stateless REST adapter, but message construction moves to the SDK's Discord renderer. Everything is behind `DISCORD_USE_CHANNELS_SDK` so the legacy path stays until parity is verified. + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), Turborepo + pnpm workspaces, `discord.js@^14`, `@copilotkit/channels` / `@copilotkit/channels-discord`, Vitest, Prisma, Postgres job queue. + +## Global Constraints + +- **Package manager:** pnpm workspaces. Add deps with `pnpm --filter add`. Never edit lockfile by hand. +- **ESM import specifiers:** all relative imports use the `.js` extension (e.g. `./config.js`) even for `.ts` sources. Match the existing style. +- **No behavior change when the flag is off.** `DISCORD_USE_CHANNELS_SDK` defaults to `false`; the legacy path (`Client` + `events/*` + `interactions/*`) must remain byte-for-byte reachable and unchanged in behavior until a later delete-old PR. +- **discord.js single version.** `apps/discord-bot` and `@copilotkit/channels-discord` must resolve to one `discord.js@^14` instance (verify with `pnpm --filter @copilotkit/outpost-discord-bot why discord.js`). Two `Client` classes will break `instanceof` checks. +- **Keep the ticket engine untouched.** Do not modify `packages/outpost/shared/src/platforms/inbound.ts` (`InboundHandler`), `packages/outpost/queue`, `apps/worker`'s job loop, or `packages/outpost/ai`. +- **Preserve shadow mode.** When `isShadowMode()` is true, the SDK path must not post anything visible to Discord (no `thread.post`), matching `apps/discord-bot/src/lib/shadow-mode.ts`. +- **GitHub is out of scope.** This plan touches Discord only. + +--- + +### Task 1: Add dependencies and resolve the ingress model (gating spike) + +This task unblocks everything else. The SDK's Discord listener pre-filters ingress to **@-mentions**, but Outpost is **forum/channel-monitored** (a new forum post = new ticket; every non-bot reply in a tracked thread = a ticket reply — no mention). We must confirm how to make the SDK deliver those turns before building the inbound bridge. + +**Files:** +- Modify: `apps/discord-bot/package.json` (add deps) +- Modify: `apps/discord-bot/tsconfig.json` (JSX runtime, only if the bot renders JSX acks) +- Create: `docs/superpowers/plans/2026-07-16-discord-ingress-findings.md` (the decision record) +- Create: `apps/discord-bot/src/channels/__tests__/ingress.spike.test.ts` + +**Interfaces:** +- Produces (recorded in the findings doc, consumed by Tasks 2–6): the exact SDK API surface — the `discord(opts)` option shape, whether `createChannel().onMessage`/`onThreadStarted` deliver monitored-channel + forum-thread-start turns under some `ListenerConfig`, OR the lower-level `attachDiscordListener(client, config, sink)` signature + `ClientLike` if we must attach our own listener; the `IncomingTurn` / `ReplyTarget` field names; and the `renderDiscordMessage(ir)` import path + return shape (`{ components, flags }`). + +- [ ] **Step 1: Add the SDK packages to the Discord bot** + +Run: +```bash +pnpm --filter @copilotkit/outpost-discord-bot add @copilotkit/channels @copilotkit/channels-discord +``` +(Confirm the workspace package name first with `node -p "require('./apps/discord-bot/package.json').name"`; use that exact name in `--filter`.) + +- [ ] **Step 2: Verify a single discord.js version** + +Run: `pnpm --filter @copilotkit/outpost-discord-bot why discord.js` +Expected: a single `discord.js@14.x` resolution shared by the app and `@copilotkit/channels-discord`. If two versions appear, add a `pnpm.overrides` entry pinning `discord.js` to the app's `^14` and re-install. + +- [ ] **Step 3: Read the SDK listener + adapter source and record the ingress mechanism** + +Read these files from the CopilotKit repo (GitHub `CopilotKit/CopilotKit`, `main`): +``` +packages/channels-discord/src/discord-listener.ts # ListenerConfig, mention filter, forum/thread handling +packages/channels-discord/src/adapter.ts # discord() options, intents, start(sink) +packages/channels-discord/src/types.ts # IncomingTurn, ReplyTarget, conversationKeyOf +packages/channels-core/src/create-channel.ts # onMessage/onThreadStarted routing +packages/channels-discord/src/render/components-v2.ts # renderDiscordMessage/renderComponents signatures +``` +Fetch each with: +```bash +gh api "repos/CopilotKit/CopilotKit/contents/packages/" --jq '.content' | base64 -d +``` +Write findings to `docs/superpowers/plans/2026-07-16-discord-ingress-findings.md`, answering: (a) Does `ListenerConfig` (or `discord()` opts) allow non-mention, channel-scoped ingress and a forum-thread-start signal? (b) If yes, which handler/flag delivers it — record the exact option names. (c) If no, record the `attachDiscordListener` / `ClientLike` signature to attach our own listener. (d) Record `IncomingTurn`/`ReplyTarget` field names and the `renderDiscordMessage` import + return shape. + +- [ ] **Step 4: Write the spike test proving forum ingress reaches a handler** + +Using the resolved mechanism, write a test that boots a channel with the Discord adapter against a fake/`ClientLike` client (or the SDK's testing double if one exists), emits a **forum thread create** and a **reply in that thread with no mention**, and asserts both reach a registered handler. + +```ts +// apps/discord-bot/src/channels/__tests__/ingress.spike.test.ts +import { describe, it, expect, vi } from 'vitest'; +// import path + config decided in Step 3 (Branch A: createChannel config; Branch B: attachDiscordListener + ClientLike) + +describe('ingress spike: forum monitoring without mention', () => { + it('delivers a forum thread-start turn to a handler', async () => { + const seen: Array<{ kind: string; threadId: string }> = []; + // ...wire the resolved ingress mechanism, register a handler that pushes to `seen`, + // emit a fake forum ThreadCreate for a monitored parent channel... + expect(seen).toContainEqual(expect.objectContaining({ kind: 'thread-start' })); + }); + + it('delivers a non-mention reply in a tracked thread to a handler', async () => { + const seen: string[] = []; + // ...emit a fake messageCreate (author.bot=false, no bot mention) inside the thread... + expect(seen.length).toBe(1); + }); +}); +``` + +- [ ] **Step 5: Run the spike test** + +Run: `pnpm --filter @copilotkit/outpost-discord-bot test -- ingress.spike` +Expected: PASS (both cases). If Branch A is impossible and Branch B (own listener over `ClientLike`) is required, the test passes against Branch B. If neither works, STOP and escalate — the bridge approach needs revisiting before further tasks. + +- [ ] **Step 6: Commit** + +```bash +git add apps/discord-bot/package.json apps/discord-bot/tsconfig.json pnpm-lock.yaml docs/superpowers/plans/2026-07-16-discord-ingress-findings.md apps/discord-bot/src/channels/__tests__/ingress.spike.test.ts +git commit -m "feat(discord-bot): add channels SDK deps + resolve forum ingress (spike)" +``` + +--- + +### Task 2: Add the flag and dual-boot skeleton + +**Files:** +- Modify: `apps/discord-bot/src/config.ts` (add `useChannelsSdk`) +- Modify: `apps/discord-bot/src/index.ts` (branch on the flag) +- Create: `apps/discord-bot/src/channels/bot.ts` (SDK boot, no handlers yet) +- Test: `apps/discord-bot/src/channels/__tests__/config.test.ts` + +**Interfaces:** +- Consumes: the resolved `discord(opts)` shape from Task 1. +- Produces: `export function createChannelsBot(): { start(): Promise; stop(): Promise }` in `channels/bot.ts`; `config.useChannelsSdk: boolean`. + +- [ ] **Step 1: Write the failing test for the flag** + +```ts +// apps/discord-bot/src/channels/__tests__/config.test.ts +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; + +describe('useChannelsSdk flag', () => { + const prev = process.env.DISCORD_USE_CHANNELS_SDK; + afterEach(() => { process.env.DISCORD_USE_CHANNELS_SDK = prev; }); + + it('defaults to false', async () => { + delete process.env.DISCORD_USE_CHANNELS_SDK; + const { readUseChannelsSdk } = await import('../../config.js'); + expect(readUseChannelsSdk()).toBe(false); + }); + + it('is true only for "true"', async () => { + process.env.DISCORD_USE_CHANNELS_SDK = 'true'; + const { readUseChannelsSdk } = await import('../../config.js'); + expect(readUseChannelsSdk()).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run it — expect FAIL** (`readUseChannelsSdk` not exported). +Run: `pnpm --filter @copilotkit/outpost-discord-bot test -- config` + +- [ ] **Step 3: Add the flag to config.ts** + +Add to `apps/discord-bot/src/config.ts`: +```ts +export function readUseChannelsSdk(): boolean { + return (process.env.DISCORD_USE_CHANNELS_SDK ?? 'false').toLowerCase() === 'true'; +} +``` +And add `useChannelsSdk: readUseChannelsSdk(),` to the `config` object. + +- [ ] **Step 4: Create the SDK boot module (handlers added in later tasks)** + +```ts +// apps/discord-bot/src/channels/bot.ts +import { createChannel } from '@copilotkit/channels'; +import { discord } from '@copilotkit/channels-discord'; +import { config } from '../config.js'; + +/** Boots the Channels-SDK-backed Discord bot. No agent is registered — + * the SDK is transport + rendering only; inbound is bridged to InboundHandler. */ +export function createChannelsBot() { + const bot = createChannel({ + adapters: [ + discord({ + botToken: config.discordToken, + appId: config.clientId, + guildId: config.guildId, + // ingress config from Task 1 findings goes here + }), + ], + }); + // Task 3 registers inbound handlers; Task 4 commands; Task 5 interactions. + return { + async start() { await bot.start(); }, + async stop() { await bot.stop(); }, + _bot: bot, + }; +} +``` + +- [ ] **Step 5: Branch the entrypoint on the flag** + +In `apps/discord-bot/src/index.ts`, wrap the existing legacy boot in `if (!config.useChannelsSdk) { ...legacy... } else { const bot = createChannelsBot(); bot.start().catch(...); }`, and route SIGINT/SIGTERM to `bot.stop()` on the SDK path. Keep the legacy branch unchanged. + +- [ ] **Step 6: Run the test — expect PASS.** Run: `pnpm --filter @copilotkit/outpost-discord-bot test -- config` + +- [ ] **Step 7: Commit** +```bash +git add apps/discord-bot/src/config.ts apps/discord-bot/src/index.ts apps/discord-bot/src/channels/bot.ts apps/discord-bot/src/channels/__tests__/config.test.ts +git commit -m "feat(discord-bot): DISCORD_USE_CHANNELS_SDK flag + SDK boot skeleton" +``` + +--- + +### Task 3: Inbound bridge — turns → InboundHandler + +Translate SDK turns into `InboundMessage` and run the existing `InboundHandler`, preserving monitored-channel filtering and shadow mode. Reuses the legacy `createJobFn` and `isThreadStart` semantics. + +**Files:** +- Create: `apps/discord-bot/src/channels/inbound.ts` +- Modify: `apps/discord-bot/src/channels/bot.ts` (register handlers) +- Test: `apps/discord-bot/src/channels/__tests__/inbound.test.ts` + +**Interfaces:** +- Consumes: `IncomingTurn`/`ReplyTarget` fields (Task 1); `InboundMessage` (`packages/outpost/shared/src/platforms/types.ts` — fields: `platformUserId`, `platformUsername`, `content`, `threadId`, `channelId`, `sourceUrl`, `source`, `isThreadStart`, `rawEvent`); `InboundHandler` and `TicketSource.DISCORD`. +- Produces: `export function toInboundMessage(turn, opts: { isThreadStart: boolean }): InboundMessage`; `export function registerInbound(bot, deps)`. + +- [ ] **Step 1: Write the failing mapping test** + +```ts +// apps/discord-bot/src/channels/__tests__/inbound.test.ts +import { describe, it, expect } from 'vitest'; +import { toInboundMessage } from '../inbound.js'; +import { TicketSource } from '@copilotkit/outpost/shared'; + +it('maps a forum thread-start turn to an isThreadStart InboundMessage', () => { + const turn = { // shape confirmed in Task 1 + user: { id: 'u1', name: 'alice' }, + text: 'help pls', + target: { threadId: 't1', channelId: 'forum1', url: 'https://discord.com/channels/g/t1' }, + }; + const msg = toInboundMessage(turn, { isThreadStart: true }); + expect(msg).toMatchObject({ + platformUserId: 'u1', platformUsername: 'alice', content: 'help pls', + threadId: 't1', channelId: 'forum1', source: TicketSource.DISCORD, isThreadStart: true, + }); +}); + +it('maps a reply turn to isThreadStart:false', () => { + const turn = { user: { id: 'u2', name: 'bob' }, text: 'still broken', target: { threadId: 't1', channelId: 'forum1' } }; + expect(toInboundMessage(turn, { isThreadStart: false }).isThreadStart).toBe(false); +}); +``` +(Replace the `turn` field names with the exact ones from Task 1's findings.) + +- [ ] **Step 2: Run it — expect FAIL** (`toInboundMessage` not defined). + +- [ ] **Step 3: Implement the mapping + handler registration** + +```ts +// apps/discord-bot/src/channels/inbound.ts +import { prisma } from '@copilotkit/outpost/db'; +import { createJob } from '@copilotkit/outpost/queue'; +import { InboundHandler } from '@copilotkit/outpost/shared/platforms'; +import { TicketSource } from '@copilotkit/outpost/shared'; +import type { CreateJobFn, InboundMessage } from '@copilotkit/outpost/shared'; +import { config } from '../config.js'; +import { isShadowMode } from '../lib/shadow-mode.js'; + +const createJobFn: CreateJobFn = async (type, payload) => + createJob(type as Parameters[0], payload as Parameters[1]); + +// Field accessors reflect the Task 1 IncomingTurn/ReplyTarget shape. +export function toInboundMessage(turn: any, opts: { isThreadStart: boolean }): InboundMessage { + return { + platformUserId: turn.user?.id ?? '', + platformUsername: turn.user?.name ?? turn.user?.handle ?? 'Unknown', + content: turn.text ?? '', + threadId: turn.target?.threadId, + channelId: turn.target?.channelId, + sourceUrl: turn.target?.url, + source: TicketSource.DISCORD, + isThreadStart: opts.isThreadStart, + rawEvent: turn, + }; +} + +function isMonitored(channelId: string | undefined): boolean { + if (config.monitoredChannelIds.length === 0) return true; + return !!channelId && config.monitoredChannelIds.includes(channelId); +} + +export function registerInbound(bot: { onMessage: Function; onThreadStarted?: Function }): void { + // Thread-start (new forum post → new ticket). Uses the ingress signal resolved in Task 1 + // (onThreadStarted if the adapter emits it for forum posts; otherwise a first-message flag on the turn). + const handleStart = async ({ thread, message }: any) => { + const channelId = thread?.target?.channelId ?? message?.target?.channelId; + if (!isMonitored(channelId)) return; + const msg = toInboundMessage(message ?? thread, { isThreadStart: true }); + const result = await new InboundHandler({ prisma, createJob: createJobFn }).handle(msg); + if (!isShadowMode()) { + await thread.post(`🎫 Ticket ${result.displayId} created. Our AI assistant is reviewing your question...`); + } + }; + const handleReply = async ({ thread, message }: any) => { + const channelId = message?.target?.channelId; + if (!isMonitored(channelId)) return; + const msg = toInboundMessage(message, { isThreadStart: false }); + await new InboundHandler({ prisma, createJob: createJobFn }).handle(msg); + }; + if (bot.onThreadStarted) bot.onThreadStarted(handleStart); + bot.onMessage(handleReply); + // If Task 1 shows forum-starts arrive via onMessage with a first-message flag, branch inside onMessage instead. +} +``` +Wire `registerInbound(bot._bot)` into `createChannelsBot()`. + +- [ ] **Step 4: Run the mapping test — expect PASS.** + +- [ ] **Step 5: Add an integration test that the handler enqueues a job** + +Use `@copilotkit/channels/testing` (API confirmed in Task 1) to drive a forum-start turn through a booted channel with `prisma`/`createJob` mocked; assert `InboundHandler` created a ticket and enqueued `AI_RESPONSE`. Mock `@copilotkit/outpost/db` and `@copilotkit/outpost/queue` with `vi.mock`. + +- [ ] **Step 6: Run it — expect PASS.** + +- [ ] **Step 7: Commit** +```bash +git add apps/discord-bot/src/channels/inbound.ts apps/discord-bot/src/channels/bot.ts apps/discord-bot/src/channels/__tests__/inbound.test.ts +git commit -m "feat(discord-bot): bridge SDK turns to InboundHandler (thread-start + reply)" +``` + +--- + +### Task 4: Slash commands via onCommand + +Port `escalate`, `assign`, `priority`, `close` to the SDK's `onCommand`. The existing handlers in `commands/*.ts` take a discord.js `ChatInputCommandInteraction`; extract their ticket logic into interaction-agnostic functions so both paths reuse it. + +**Files:** +- Create: `apps/discord-bot/src/channels/commands.ts` (SDK command registration) +- Modify: `apps/discord-bot/src/commands/{escalate,assign,priority,close}.ts` (extract pure logic — only if needed; otherwise call a shared core fn) +- Modify: `apps/discord-bot/src/channels/bot.ts` (register commands) +- Test: `apps/discord-bot/src/channels/__tests__/commands.test.ts` + +**Interfaces:** +- Consumes: the `onCommand`/`CommandContext` shape (Task 1); existing command ticket logic. +- Produces: `export function registerCommands(bot): void`. + +- [ ] **Step 1: Write a failing test** that a registered `escalate` command, given a thread with a known ticket, calls the ticket-escalation core logic (mock the core fn, assert called with the resolved ticket id). + +- [ ] **Step 2: Run it — expect FAIL.** + +- [ ] **Step 3: Extract command core logic** (e.g. `escalateTicket(threadId, actor)`) from `commands/escalate.ts` into a small exported function; have the legacy handler call it (no behavior change). Repeat for the four commands as the tests require. + +- [ ] **Step 4: Implement `registerCommands(bot)`** mapping each `onCommand({ name, description, options, handler })` to the extracted core logic, resolving the ticket via `findTicketByThreadId(thread.target.threadId)` and replying with `thread.post(...)`. + +- [ ] **Step 5: Run tests — expect PASS.** + +- [ ] **Step 6: Commit** +```bash +git add apps/discord-bot/src/channels/commands.ts apps/discord-bot/src/commands apps/discord-bot/src/channels/bot.ts apps/discord-bot/src/channels/__tests__/commands.test.ts +git commit -m "feat(discord-bot): port slash commands to SDK onCommand" +``` + +--- + +### Task 5: Button feedback via onInteraction + +The two feedback buttons use **fixed** `custom_id`s (`issue_solved`, `need_more_help`) — not content-minted `ck:` ids — so they map directly to the SDK's `onInteraction(id, handler)` escape hatch. No durable `ActionStore` is required (Risk 2 mitigated). + +**Files:** +- Create: `apps/discord-bot/src/channels/interactions.ts` +- Modify: `apps/discord-bot/src/interactions/buttons.ts` (extract interaction-agnostic core: `markIssueSolved(threadId, actor)`, `requestMoreHelp(threadId, actor)`) +- Modify: `apps/discord-bot/src/channels/bot.ts` (register) +- Test: `apps/discord-bot/src/channels/__tests__/interactions.test.ts` + +**Interfaces:** +- Consumes: `onInteraction(id, handler)` + interaction ctx (Task 1); the extracted core fns. +- Produces: `export function registerInteractions(bot): void`; `export async function markIssueSolved(threadId: string, actor: string): Promise<{ displayId: string } | null>`; `export async function requestMoreHelp(threadId: string, actor: string): Promise<{ displayId: string } | null>`. + +- [ ] **Step 1: Write failing tests** for `markIssueSolved` (sets ticket `CLOSED`, records `POSITIVE` feedback, writes a SYSTEM message) and `requestMoreHelp` (sets `WAITING_ON_TEAM`, records `NEGATIVE`, enqueues `ESCALATION`, writes a SYSTEM message). Mock `prisma`/`createJob`. + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Extract the core logic** from `interactions/buttons.ts` into `markIssueSolved`/`requestMoreHelp` (moving the bodies of `handleIssueSolved`/`handleNeedMoreHelp` minus the discord.js reply). Have the legacy handlers call them + do their `interaction.reply` (no behavior change). + +- [ ] **Step 4: Implement `registerInteractions(bot)`**: +```ts +// apps/discord-bot/src/channels/interactions.ts +import { markIssueSolved, requestMoreHelp } from '../interactions/buttons.js'; +export function registerInteractions(bot: { onInteraction: Function }): void { + bot.onInteraction('issue_solved', async ({ thread }: any) => { + const r = await markIssueSolved(thread.target.threadId, 'user'); + await thread.post(r ? 'Glad we could help! 🎉' : 'No ticket found for this thread.'); + }); + bot.onInteraction('need_more_help', async ({ thread }: any) => { + const r = await requestMoreHelp(thread.target.threadId, 'user'); + await thread.post(r ? 'A team member has been notified and will follow up shortly.' : 'No ticket found for this thread.'); + }); +} +``` + +- [ ] **Step 5: Run tests — expect PASS.** + +- [ ] **Step 6: Commit** +```bash +git add apps/discord-bot/src/channels/interactions.ts apps/discord-bot/src/interactions/buttons.ts apps/discord-bot/src/channels/bot.ts apps/discord-bot/src/channels/__tests__/interactions.test.ts +git commit -m "feat(discord-bot): port feedback buttons to SDK onInteraction" +``` + +--- + +### Task 6: Outbound rendering via the SDK renderer + +The worker still calls `getAdapter(DISCORD).postResponse(...)`. Replace the adapter's hand-rolled `splitMessage` + manual ActionRow with the SDK's Discord renderer, keeping the stateless REST POST (the worker has no live gateway). Build the render IR from `FormattedResponse` (plain `text` + `buttons`). + +**Files:** +- Modify: `packages/outpost/shared/src/platforms/discord.ts` (`postResponse` render path) +- Modify: `packages/outpost/shared/tsconfig.json` **only if** JSX is used to build the IR (prefer the non-JSX element/`renderComponents` API to avoid changing the shared build) +- Test: `packages/outpost/shared/src/platforms/__tests__/discord-render.test.ts` + +**Interfaces:** +- Consumes: `renderDiscordMessage(ir)` → `{ components, flags }` and the IR element builders (Task 1); `FormattedResponse` (`text`, `buttons?`, `parts?`). +- Produces: unchanged `DiscordAdapter.postResponse` signature (behavior: renders via SDK, posts via REST). + +- [ ] **Step 1: Write a failing render test** + +```ts +// packages/outpost/shared/src/platforms/__tests__/discord-render.test.ts +import { describe, it, expect } from 'vitest'; +import { buildDiscordBody } from '../discord.js'; + +it('renders text into a Components V2 body with the IsComponentsV2 flag', () => { + const body = buildDiscordBody({ text: 'hello' }); + expect(body.flags).toBeDefined(); + expect(Array.isArray(body.components)).toBe(true); +}); + +it('renders feedback buttons with fixed custom_ids', () => { + const body = buildDiscordBody({ text: 'answer', buttons: [ + { label: 'Issue Solved', action: 'issue_solved' }, + { label: 'Need more help', action: 'need_more_help' }, + ]}); + const json = JSON.stringify(body); + expect(json).toContain('issue_solved'); + expect(json).toContain('need_more_help'); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** (`buildDiscordBody` not defined). + +- [ ] **Step 3: Implement `buildDiscordBody(response)`** in `discord.ts` using the SDK IR builders + `renderDiscordMessage` (exact import from Task 1 findings), mapping `response.buttons` to `Button` nodes carrying the fixed `custom_id`s. Keep the length-degradation to the SDK's `DISCORD_LIMITS` budget (no more hand-rolled `splitMessage` for the response path). + +- [ ] **Step 4: Rewrite `postResponse`** to `const body = buildDiscordBody(response); await rest.post(Routes.channelMessages(threadId), { body });` (posting once; the renderer handles chunking/overflow per `DISCORD_LIMITS`). Keep the `sourceId` guard and the return value. + +- [ ] **Step 5: Run tests — expect PASS.** Also run the full shared test suite to catch consumers: `pnpm --filter @copilotkit/outpost test`. + +- [ ] **Step 6: Commit** +```bash +git add packages/outpost/shared/src/platforms/discord.ts packages/outpost/shared/src/platforms/__tests__/discord-render.test.ts +git commit -m "feat(shared): render Discord responses via channels SDK renderer" +``` + +--- + +### Task 7: Parity verification + docs + dashboard + +**Files:** +- Modify: `.env.example` (document `DISCORD_USE_CHANNELS_SDK`, `DISCORD_APP_ID` if newly required) +- Modify: `apps/discord-bot/README.md` (SDK path, flag, cutover steps) — create if absent +- Notion: populate the "Rewiring to Channels SDK" dashboard + +- [ ] **Step 1: Full build + typecheck + tests** + +Run: `pnpm --filter @copilotkit/outpost-discord-bot build && pnpm --filter @copilotkit/outpost-discord-bot test && pnpm --filter @copilotkit/outpost test` +Expected: all green. + +- [ ] **Step 2: Manual smoke against a test guild** (flag on) +New forum post → ticket created + ack; non-mention reply → ticket reply; each of the 4 slash commands; both feedback buttons; trigger an `AI_RESPONSE` and confirm the worker posts the rendered reply. Record results in the PR description. + +- [ ] **Step 3: Document env + cutover** in `.env.example` and the bot README (flag default, per-env rollout order, rollback = unset flag, and that legacy deletion is a fast-follow PR). + +- [ ] **Step 4: Populate the Notion dashboard** with: scope (Discord only, GitHub deferred + why), the ingress decision from Task 1, the risk table + mitigations, and a task checklist mirroring this plan. + +- [ ] **Step 5: Commit** +```bash +git add .env.example apps/discord-bot/README.md +git commit -m "docs(discord-bot): document channels SDK flag + cutover" +``` + +--- + +## Self-Review + +- **Spec coverage:** Goals 1–4 → Tasks 2–6 (transport/render swap behind flag) + Task 7 (rollout). Inbound mapping table → Task 3 (thread-start/reply), Task 4 (commands), Task 5 (buttons), Task 3 (ack + shadow mode). Outbound seam → Task 6. Risks 1–4 → Task 1 (ingress, gating), Task 5 (interaction durability via fixed ids), Task 6 (render-only outbound), Tasks 4/3 (command + shadow parity). GitHub non-goal → untouched. Testing section → per-task unit/integration + Task 7 manual. Deliverables 1–7 → Tasks 1–7. No gaps. +- **Placeholder scan:** The `any`-typed turn/ctx params and the "field names confirmed in Task 1" notes are deliberate — Task 1 is a spike whose *Produces* block pins the exact SDK types the later tasks consume; not TODOs. All code steps show real code. +- **Type consistency:** `toInboundMessage`, `registerInbound`, `registerCommands`, `registerInteractions`, `markIssueSolved`, `requestMoreHelp`, `buildDiscordBody`, `readUseChannelsSdk`, `createChannelsBot` are each defined once and referenced consistently. `InboundMessage` fields match `types.ts`. `FormattedResponse` fields match `types.ts`. diff --git a/docs/superpowers/plans/2026-07-16-discord-ingress-findings.md b/docs/superpowers/plans/2026-07-16-discord-ingress-findings.md new file mode 100644 index 00000000..cc2df1d3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-discord-ingress-findings.md @@ -0,0 +1,249 @@ +# Discord Channels SDK — Ingress Findings + +**Question:** can `@copilotkit/channels-discord` (as of `CopilotKit/CopilotKit@main`) deliver forum-channel-monitored, NON-mention ingress — a new forum post in a monitored parent channel → a "thread-start" turn, and every non-bot reply in a tracked thread → a "reply" turn, scoped to a set of monitored channel IDs, with no `@`-mention required? + +Source read directly from GitHub (`CopilotKit/CopilotKit@main`): +- `packages/channels-discord/src/discord-listener.ts` +- `packages/channels-discord/src/adapter.ts` +- `packages/channels-discord/src/types.ts` +- `packages/channels-discord/src/render/components-v2.ts` +- `packages/channels-core/src/create-channel.ts` +- `packages/channels-core/src/platform-adapter.ts` (pulled in addition — `create-channel.ts` imports `IngressSink`/`IncomingThreadStart`/`IncomingTurn` from here, and Branch B's mechanism can't be specified precisely without it) +- `packages/channels-discord/src/index.ts` + `package.json` (to confirm the public export surface / import path) + +## 1. Verdict + +**(B) — requires attaching our own listener over the exported `ClientLike` (or an equivalent custom `PlatformAdapter`).** The SDK cannot be configured into forum/non-mention ingress through `createChannel`/`discord()` options; the mention gate and the total absence of a `threadCreate` subscription are hardcoded in `discord-listener.ts` with no config knob to disable either. + +Two compounding gaps, both in the load-bearing file: + +1. **No forum/thread-create ingress at all.** `attachDiscordListener` only ever calls `client.on(...)` for `"messageCreate"`, `"interactionCreate"`, `"messageReactionAdd"`, `"messageReactionRemove"`. There is no `"threadCreate"` subscription anywhere in `channels-discord`. A new forum post is invisible to the SDK's ingress path, full stop. +2. **Mention gate is hardcoded, unconditional, and unconfigurable.** Every `messageCreate` is filtered through `shouldAnswer`, which requires a DM or a literal `@bot` mention — there is no monitored-channel-id allowlist and no way to pass "answer without a mention." + +## 2. Evidence + +`packages/channels-discord/src/discord-listener.ts` — the entire ingress registration: + +```ts +export function attachDiscordListener(cfg: ListenerConfig): void { + const { client, botUserId, onTurn, onCommand, onReaction, commandPending } = + cfg; + + client.on("messageCreate", (msg: MessageLike) => { + const botId = typeof botUserId === "function" ? botUserId() : botUserId; + if (!shouldAnswer(msg, botId)) return; + const replyTarget = { + channelId: msg.channelId, + ...(msg.guildId ? { guildId: msg.guildId } : {}), + }; + void Promise.resolve( + onTurn({ + conversationKey: msg.channelId, + replyTarget, + userText: stripMention(msg.content, botId), + senderUserId: msg.author.id, + }), + ).catch((e) => console.error("[bot-discord] onTurn handler failed:", e)); + }); + + client.on("interactionCreate", async (i: ChatInputLike) => { ... }); + + if (onReaction) { + client.on("messageReactionAdd", handleReaction(true)); + client.on("messageReactionRemove", handleReaction(false)); + } +} +``` + +That is the **complete list of Gateway events the file subscribes to** — `messageCreate`, `interactionCreate`, `messageReactionAdd`/`Remove`. No `threadCreate`. Compare with Outpost's own `apps/discord-bot/src/events/thread-create.ts`, whose entire job is to react to a discord.js `threadCreate` event that the Channels SDK never listens for. + +The mention filter, verbatim: + +```ts +/** Answer @-mentions and DMs; skip our own messages and other bots. */ +function shouldAnswer(msg: MessageLike, botUserId: string): boolean { + if (msg.author.id === botUserId) return false; + if (msg.author.bot) return false; + if (msg.channel.isDMBased()) return true; + // Only answer a DIRECT user mention. discord.js `mentions.has()` also returns + // true for role mentions and @everyone/@here that happen to include the bot, + // so narrow to the explicit user-mention set. + return msg.mentions.users?.has?.(botUserId) ?? false; +} +``` + +This is a private, module-level function — **not** part of `ListenerConfig` and not passed in from `adapter.ts`. There is no option on `DiscordAdapterOptions` (`botToken`, `appId`, `guildId`, `interruptEventNames` — the complete list, per `adapter.ts`) that disables it, and no monitored-channel-id list anywhere in the adapter's option surface. A message in a tracked forum thread with no `@mention` is dropped before `onTurn` is ever called — the `shouldAnswer` early-return happens synchronously inside the `messageCreate` handler, upstream of everything else. + +Compare with what Outpost's own bot does (`apps/discord-bot/src/events/message-create.ts`): + +```ts +export async function handleMessageCreate(message: Message): Promise { + // Ignore messages from bots + if (message.author.bot) return; + // Only process messages in threads (forum posts are threads) + if (message.channel.type !== ChannelType.PublicThread && message.channel.type !== ChannelType.PrivateThread) { + return; + } + ... + if (!ticket) return; // this thread isn't tracked as a ticket, ignore it + ... +} +``` + +No mention check anywhere — gating is purely "is this a tracked thread," which is exactly the behavior the SDK's `shouldAnswer` cannot produce. + +`createChannel`'s public `Channel` interface (`packages/channels-core/src/create-channel.ts`) does expose `onThreadStarted(h: ThreadStartHandler)`, and `IngressSink.onThreadStarted` is wired through: + +```ts +async onThreadStarted(evt: IncomingThreadStart) { + const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey); + for (const h of threadStartedHandlers) await h({ thread, user: evt.user }); +}, +``` + +But `DiscordAdapter.start()` (`adapter.ts`) never calls `sink.onThreadStarted(...)` — it only wires `onTurn`, `onCommand`, and `onReaction` through `attachDiscordListener`. `onThreadStarted` exists in the core engine for adapters that model a "conversation surface opened" lifecycle event (the doc comment says explicitly: *"e.g. the Slack assistant pane"*), and Discord's adapter simply doesn't emit it. Even if it did, `IncomingThreadStart` (see `platform-adapter.ts`) only carries `conversationKey`, `replyTarget`, `user`, `platform` — no message text, no starter-message content — so it isn't a drop-in vehicle for "new forum post" ingress the way we need it (we need the starter message's text and author to hand off to the ticket-creation path). + +## 3. The mechanism to use (Branch B) + +Since neither `createChannel` options nor `discord()`'s `DiscordAdapterOptions` expose a way to reconfigure `shouldAnswer` or add `threadCreate`, and `DiscordAdapter`'s internal `discord.js` `Client` is a **private field** (`private readonly client: Client;` in `adapter.ts` — never exposed on the `PlatformAdapter`/`DiscordAdapter` public surface), the only viable path is: + +**Do not call `attachDiscordListener` for ingress.** Instead, own a raw `discord.js` `Client` directly (as Outpost's `apps/discord-bot` already does) and drive the SDK's `IngressSink` contract ourselves, by implementing a custom `PlatformAdapter` (or reusing `DiscordAdapter` for egress only, per Task 6). + +Exact exported surface to build against (`packages/channels-discord/src/index.ts` confirms these are public, importable as `@copilotkit/channels-discord`): + +```ts +export interface ClientLike { + on(event: "messageCreate", cb: (msg: MessageLike) => void): void; + on(event: "interactionCreate", cb: (i: ChatInputLike) => void): void; + on( + event: "messageReactionAdd" | "messageReactionRemove", + cb: (reaction: unknown, user: unknown) => void, + ): void; + on(event: string, cb: (arg: unknown) => void): void; +} + +export interface ListenerConfig { + client: ClientLike; + botUserId: string | (() => string); + onTurn(turn: IncomingTurn): void | Promise; + onCommand(cmd: IncomingCommandRaw): void | Promise; + onReaction?: (evt: IncomingReaction) => void | Promise; + commandPending?: PendingInteractions; +} + +export function attachDiscordListener(cfg: ListenerConfig): void; +``` + +`ClientLike.on(event: string, cb: (arg: unknown) => void): void` is a deliberate escape hatch (the loose overload at the end of the interface) — it means a `discord.js` `Client` satisfies `ClientLike` for **any** event, including `"threadCreate"`, even though the interface only types the three events `attachDiscordListener` itself subscribes to. That confirms the intended pattern: write our OWN attach function (mirroring `attachDiscordListener`'s shape but not calling it) that: + +1. Subscribes to `client.on("threadCreate", (thread, newlyCreated) => ...)` — filter to `newlyCreated`, filter `thread.parentId` against `MONITORED_CHANNEL_IDS`, filter `thread.type` to `PublicThread`/`PrivateThread` (mirrors `apps/discord-bot/src/events/thread-create.ts` exactly) — fetch the starter message, then call the core `IngressSink.onTurn(...)` directly (bypassing `onThreadStarted` entirely, since it carries no message text) for the **thread-start turn**. +2. Subscribes to `client.on("messageCreate", (msg) => ...)` with **no mention check** — only `!msg.author.bot` and "is `msg.channel.id` a thread we're tracking" (mirrors `apps/discord-bot/src/events/message-create.ts`) — then calls `IngressSink.onTurn(...)` for the **reply turn**. + +`IngressSink` (from `@copilotkit/channels-core`, `platform-adapter.ts`) is the contract to satisfy directly: + +```ts +export interface IngressSink { + onTurn(turn: IncomingTurn): void | Promise; + onInteraction(evt: InteractionEvent): void | Promise; + onCommand(cmd: IncomingCommand): void | Promise; + onThreadStarted(evt: IncomingThreadStart): void | Promise; + onReaction(evt: IncomingReaction): void | Promise; + onModalSubmit(evt: IncomingModalSubmit): Promise; + onModalClose(evt: IncomingModalClose): void | Promise; +} +``` + +`createChannel`'s `makeSink(adapter)` (`create-channel.ts`) is the only thing that ever constructs a real `IngressSink`, and it hands that sink to `adapter.start(sink, ctx)` — so to get our custom ingress wired through the same `Channel` (with its lock/dedup/identity/transcript machinery), the concrete mechanism is: **implement `PlatformAdapter.start(sink, ctx)` ourselves** (satisfying the interface in `platform-adapter.ts` — `platform`, `capabilities`, `ackDeadlineMs`, `start`, `stop`, `render`, `post`, `update`, `stream`, `delete`, `createRunRenderer`, `decodeInteraction`, `lookupUser`, `conversationStore`), where `start()` attaches the two custom listeners above and calls `sink.onTurn(...)` for both cases, then pass that adapter to `createChannel({ adapters: [ourAdapter] })` in place of (or alongside, for egress) `discord(opts)`. + +Note there is no "kind" discriminator on `IncomingTurn` to distinguish thread-start from reply at the type level — `create-channel.ts` says so explicitly: *"v1 routing: there is no turn `kind`, so prefer mention handlers; if none are registered, fall back to message handlers."* Our bridge (Task 3) must track the thread-start/reply distinction itself (e.g. "first turn for this `conversationKey`" or an explicit branch in our custom adapter's two listeners), the same way `apps/discord-bot`'s `handleThreadCreate` vs `handleMessageCreate` are two separate call sites today — the SDK gives us nothing for this for free. + +## 4. Turn shapes + +Channels-discord's own local types (`packages/channels-discord/src/types.ts` — used internally by `attachDiscordListener`/`discord-listener.ts`, distinct from channels-core's): + +```ts +export interface ReplyTarget { + channelId: string; + guildId?: string; // present for guild channels/threads; absent for DMs +} + +export interface IncomingTurn { + conversationKey: string; + replyTarget: ReplyTarget; + userText: string; + senderUserId?: string; +} + +export function conversationKeyOf(target: ReplyTarget): string { + return target.channelId; +} +``` + +Channels-core's canonical types (`packages/channels-core/src/platform-adapter.ts` — what a `PlatformAdapter.start(sink)` must actually call `sink.onTurn` with): + +```ts +export interface IngressEventBase { + conversationKey: string; + replyTarget: ReplyTarget; // opaque `unknown` at the core level + user?: PlatformUser; +} +export interface IngressIds { + eventId?: string; + turnId?: string; + deliveryId?: string; +} +export interface IncomingTurn extends IngressEventBase, IngressIds { + userText: string; + contentParts?: AgentContentPart[]; + platform: string; +} +export interface IncomingThreadStart extends IngressEventBase { + platform: string; // no message text — lifecycle-only ("conversation opened") +} +``` + +Field-name summary against the question's requirements: +- **user id** → `senderUserId` (discord-local `IncomingTurn`) → resolved to `user: PlatformUser { id, name, handle }` (core-level, via `DiscordAdapter.resolveUser`). +- **username** → `PlatformUser.name` / `PlatformUser.handle` (resolved from Discord's `globalName`/`username`), not carried on the raw turn itself. +- **text** → `userText`. +- **threadId / channelId** → both collapse to `ReplyTarget.channelId` (Discord addresses channels and threads by the same id space) and `conversationKeyOf(target) === target.channelId`. There is no separate `threadId` field — `conversationKey` **is** the thread id for a forum-thread conversation. +- **url** → not present on any turn/reply-target type. Not modeled anywhere in this SDK slice — would need to be constructed by the bridge (`https://discord.com/channels//`) if needed downstream. +- **thread-start vs reply distinction** → **not modeled**. As noted in §3, there is no `kind` discriminator on `IncomingTurn`; both a thread-start and a reply are the same shape. This has to be tracked by our own custom adapter/bridge logic (Task 3), not the SDK. + +## 5. Outbound render + +Import path (public, confirmed via `packages/channels-discord/src/index.ts` + `package.json`'s single `"."` export): + +```ts +import { renderDiscordMessage, renderComponents } from "@copilotkit/channels-discord"; +``` + +Signatures (`packages/channels-discord/src/render/components-v2.ts`): + +```ts +export function renderComponents(ir: ChannelNode[]): ContainerBuilder; + +/** Ready-to-send payload for channel.send / message.edit. */ +export function renderDiscordMessage(ir: ChannelNode[]): { + components: ContainerBuilder[]; + flags: number; // MessageFlags.IsComponentsV2 +}; +``` + +Confirmed: `renderDiscordMessage(ir)` returns exactly `{ components, flags }` — a single-element `components` array wrapping one top-level `ContainerBuilder`, `flags` fixed to `MessageFlags.IsComponentsV2`. `DiscordAdapter.post`/`.update`/`.postEphemeral` all destructure it identically: + +```ts +const { components, flags } = renderDiscordMessage(ir); +const msg = await channel.send({ components, flags }); +``` + +This is usable as-is for Task 6 regardless of the ingress verdict — rendering/egress is orthogonal to the ingress gap found here, and `DiscordAdapter` (or `discord()`) can still be used for `post`/`update`/`stream`/`render` even if a custom adapter/listener handles ingress. + +## 6. Implications for the bridge + +- **Ingress must bypass `attachDiscordListener` and `DiscordAdapter.start()` entirely** for the inbound path. Neither `createChannel`'s options nor `DiscordAdapterOptions` expose a monitored-channel allowlist or a way to disable the mention gate — the gap is structural (hardcoded `shouldAnswer`, no `threadCreate` subscription), not a missing config flag we can pass around. +- **Reuse Outpost's existing filter logic wholesale.** `apps/discord-bot/src/events/thread-create.ts` (monitored-parent + `PublicThread`/`PrivateThread` check) and `message-create.ts` (bot-check + tracked-thread check, no mention) already implement exactly the semantics the SDK lacks — port them into the custom `PlatformAdapter`'s `start()` rather than re-deriving them. +- **The bridge owns the thread-start/reply distinction**, since `IncomingTurn` has no `kind` field and `IncomingThreadStart` carries no message text. Two separate call sites (mirroring today's two event handlers) each construct and dispatch their own `IngressSink.onTurn(...)`, rather than relying on any SDK-level routing. +- **Egress can still use the real `DiscordAdapter`/`discord()`** (`post`, `update`, `stream`, `render` via `renderDiscordMessage`) — the finding here only blocks the *ingress* half. A split design (custom adapter/listener for inbound, `discord()`-backed adapter — or the same custom adapter delegating to `DiscordAdapter`'s internals — for outbound) is workable and keeps Task 6's rendering work unaffected. +- **Risk:** building a custom `PlatformAdapter` from scratch means re-implementing (or vendoring) parts of `DiscordAdapter` we still want (conversation history via `fetchHistory`, `resolveUser`, reaction/interaction handling) — there's no supported "extend `DiscordAdapter` and only override ingress" seam; its `client`, `pending`, `commandPending` fields are all private. Wave 2's spike should verify whether composing two adapter instances (one custom for `onTurn`, one real `DiscordAdapter` registered only for post/update, never started against the same client) is viable, or whether full duplication is required. +- **Risk:** no upstream issue exists yet for "forum/non-mention ingress" as a first-class SDK feature (not checked against CopilotKit's issue tracker in this pass) — if Outpost wants this to eventually be Branch A (native SDK support), that would need to be filed upstream; today's workaround is entirely bridge-side. diff --git a/docs/superpowers/specs/2026-07-16-discord-channels-sdk-rewire-design.md b/docs/superpowers/specs/2026-07-16-discord-channels-sdk-rewire-design.md new file mode 100644 index 00000000..d054fcae --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-discord-channels-sdk-rewire-design.md @@ -0,0 +1,132 @@ +# Rewire the Discord Layer to `@copilotkit/channels` (P0) + +## Context + +Outpost's channel integrations are hand-rolled today. The Discord bot (`apps/discord-bot`) +owns a stateful `discord.js` gateway `Client`, a set of event handlers +(`events/thread-create.ts`, `events/message-create.ts`, `events/interaction-create.ts`, +`events/ready.ts`, `events/guild-member-add.ts`), slash commands (`commands/{escalate,assign,priority,close}.ts` +registered via `register-commands.ts`), and button interactions (`interactions/buttons.ts`). +Outbound AI replies are posted **from the worker**, not the bot: `apps/worker` processes +`AI_RESPONSE` jobs and calls `getAdapter(DISCORD).postResponse(...)`, which is the +stateless REST adapter in `packages/outpost/shared/src/platforms/discord.ts` (hand-rolled +2000-char splitting + ActionRow building). + +CopilotKit now ships [`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels) +(umbrella over `@copilotkit/channels-core` + per-platform adapters incl. `@copilotkit/channels-discord`). +It is an **agent-runtime + transport + rendering** framework: `createChannel({ adapters, agent, tools, context })`, +`bot.onMention(({ thread }) => thread.runAgent())`. The SDK owns the Discord gateway, JSX→Components V2 +rendering, streaming, interactions/HITL, and slash-command registration. + +Tracking dashboard: [Rewiring to Channels SDK](https://www.notion.so/copilotkit/Rewiring-to-Channels-SDK-39f3aa3818528004b3a0c3ee79282ff7) +(Notion, child of the Outpost page). To be populated by this work. + +### Key finding that shapes scope + +**The Channels SDK has no GitHub adapter.** It covers live chat surfaces only — Slack, Teams, +Discord, Telegram, WhatsApp. GitHub is webhooks + reaction polling, not a gateway surface, so it +cannot sit on the SDK the way Discord can. **GitHub is therefore out of scope for P0** and tracked +as a follow-up (either leave it on the current custom adapter or author a GitHub `PlatformAdapter` +against `channels-core`'s interface later). + +## Goals + +1. Replace the Discord-facing I/O layer of the engine (gateway transport, event handling, + interactions, message rendering) with `@copilotkit/channels` + `@copilotkit/channels-discord`. +2. Keep Outpost's ticket/queue/worker/AI pipeline **unchanged** underneath — the SDK is used as + Discord transport + JSX rendering, **not** as the agent runtime (`thread.runAgent()` is never called). +3. Preserve full behavior parity: forum-post → ticket, thread reply → ticket reply, the four slash + commands, button feedback, shadow mode, and worker-posted AI replies. +4. Ship behind an env flag so cutover is per-environment and reversible. + +## Non-goals + +- **GitHub** — deferred to a follow-up (SDK ships no GitHub adapter; see above). +- **Slack / Teams / Email** — not touched in P0. +- **Using the SDK's agent runtime.** We deliberately do NOT wrap Outpost's AI pipeline as an + AG-UI agent or stream replies live via `thread.runAgent()`. Outbound stays async through the + ticket → queue → worker path. (Native-agent-runtime is a possible future, not this PR.) +- **Durable `ActionStore` as a general feature** — only what's needed for Discord button parity + (see Risk 2). + +## Design + +### Architecture — the "bridge" + +Two seams change; everything else stays. + +**Seam 1 — Inbound (`apps/discord-bot`).** Replace the hand-rolled `discord.js Client` + +`events/*` + `interactions/*` + `commands/*` with `createChannel({ adapters: [discord({...})] })`. +No `agent` is registered. SDK handlers translate each turn into Outpost's `InboundMessage` +(`packages/outpost/shared/src/platforms/types.ts`) and call the **existing** +`InboundHandler.handle()` (`packages/outpost/shared/src/platforms/inbound.ts`) — identical ticket +creation, find-or-create user, team-member detection, and `AI_RESPONSE` job enqueue as today. +The ticket ack is posted via `thread.post()`. + +**Seam 2 — Outbound (`apps/worker` + `shared/platforms/discord.ts`).** The worker still processes +`AI_RESPONSE` jobs and calls `getAdapter(DISCORD).postResponse(...)`. We swap the adapter's +hand-rolled splitting / ActionRow construction for the SDK render path +(`renderDiscordMessage(ir)` → `{ components, flags }` from `@copilotkit/channels-discord`) posted +via `discord.js` REST. The worker has no live gateway, so this stays **render-only + stateless REST**. + +**Unchanged:** Ticket/Message/User model, `InboundHandler`, `packages/outpost/queue`, +`apps/worker` job loop, AI pipeline ("Pathfinder" in `packages/outpost/ai`), Linear/GitHub sync +(`shared/src/sync`), SLA, dispatch/on-call routing. + +### Inbound behavior mapping + +| Outpost today | SDK hook | Bridge action | +|---|---|---| +| New forum post → new ticket (`isThreadStart: true`) | `onThreadStarted` / `onMessage` (first msg) | build `InboundMessage(isThreadStart: true)` → `InboundHandler.handle` | +| Reply in tracked thread → ticket reply | `onMessage` | `InboundMessage(isThreadStart: false)` → `InboundHandler.handle` | +| Slash cmds: `escalate` / `assign` / `priority` / `close` | `onCommand` | map each to the existing command logic in `commands/*` | +| Button feedback ("Issue Solved" / "Need more help") | `onInteraction` + `ActionStore` | map to existing `interactions/buttons.ts` logic | +| Ticket-created ack | `thread.post(...)` | replaces bot-side `postSystemMessage` | +| Shadow mode (record silently, no visible posts) | suppress all `thread.post` | preserve `lib/shadow-mode.ts` flag | + +### Rollout & safety + +- Behind env flag `DISCORD_USE_CHANNELS_SDK` (default `false`). When set, `apps/discord-bot` + boots the SDK path; otherwise the legacy path. Cut over per-environment (dev → staging → prod) + and roll back by unsetting. +- The legacy path stays in-tree until parity is verified in staging. Deleting it (completing the + "full cutover") may be a fast-follow PR to keep this one reviewable. +- Align `discord.js` versions: Outpost uses `^14`, SDK uses `^14` — pin to one to avoid a + duplicate install / two `Client` classes. + +## Risks — verified first in the PR (spike step, before bulk work) + +1. **Ingress model mismatch (top risk, gating).** The SDK's Discord listener pre-filters ingress + to **@-mentions** (guild channels and DMs). Outpost is **forum/channel-monitored** + (`MONITORED_CHANNEL_IDS`): a new forum post is a new ticket and every reply in a tracked thread + is a ticket reply — no mention required. First task: confirm whether `ListenerConfig` / + `attachDiscordListener` / the exported `ClientLike` primitive can broaden ingress to + monitored-channel + forum-thread-start turns. If not configurable, fall back to attaching our + own listener over the SDK's `ClientLike` (and file an upstream ask). **Resolve before bulk work.** +2. **Interaction durability.** The SDK's `ActionStore` is in-memory — inline button handlers + expire on restart ("this action expired"). Outpost's feedback buttons must survive restarts. + Mitigation: supply a durable `ActionStore` (DB-backed) or keep self-describing button ids that + don't rely on the in-memory snapshot. +3. **Outbound from a separate process.** The worker has no live gateway `Thread`. Confirm + `renderDiscordMessage` / `renderComponents` are usable standalone (render-only), then POST via + REST from the worker — no `createChannel` in the worker. +4. **Slash-command + shadow-mode parity.** All four commands must map to `onCommand`; shadow mode + must suppress every outbound post on the SDK path. + +## Testing + +- **Unit:** turn → `InboundMessage` mapping; adapter render snapshot (`renderDiscordMessage` output). +- **Integration:** use `@copilotkit/channels/testing` to drive turns with no live gateway; assert + `InboundHandler` is called with the correct `InboundMessage` and the `AI_RESPONSE` job is enqueued. +- **Manual:** run the bot against a test guild/forum with the flag on — new post → ticket, reply → + ticket reply, each slash command, button feedback, and worker-posted AI reply. + +## Deliverables in this PR + +1. Add deps (`@copilotkit/channels`, `@copilotkit/channels-discord`) and align `discord.js`. +2. Resolve Risk 1 (ingress spike) and record the outcome. +3. SDK-backed inbound in `apps/discord-bot` behind `DISCORD_USE_CHANNELS_SDK`. +4. Outbound rendering swapped in `shared/platforms/discord.ts` to the SDK render path. +5. Port the four slash commands + button feedback; preserve shadow mode. +6. Tests (unit + integration). +7. Populate the Notion dashboard with scope, risks, and a checklist. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a28953..63ac8e34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: apps/discord-bot: dependencies: + '@copilotkit/channels': + specifier: ^0.1.1 + version: 0.1.1(vitest@4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(zod@4.3.6) + '@copilotkit/channels-discord': + specifier: ^0.0.3 + version: 0.0.3(@ag-ui/core@0.0.57)(vitest@4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))) '@copilotkit/outpost': specifier: workspace:* version: link:../../packages/outpost @@ -361,6 +367,18 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ag-ui/client@0.0.57': + resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} + + '@ag-ui/core@0.0.57': + resolution: {integrity: sha512-gho1OWjNE6E3Rl7ZEZ1wr2CEpUHjLFU0FqzCZZk439TicLu+BfLCMkMokB07bMGlRmbJ60hM6LW60iOVauCx+Q==} + + '@ag-ui/encoder@0.0.57': + resolution: {integrity: sha512-ifD9NctR4xyPDR58xF9GK1bj/S8oECFkTeDfuYD8tXdbcOstIJ2TOqU2zhiCKnw7Vw+zR9Qv3TbsM9E7Gi9X3Q==} + + '@ag-ui/proto@0.0.57': + resolution: {integrity: sha512-pPENOZt0P6ibH8sCTgq05wLYXi5t3P9B5r/1bWYehXjUxtyOdnukSlWM++SsCIwUXsQdm/b3aBgGjEeTF7RenA==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -519,11 +537,40 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + '@copilotkit/aimock@1.14.0': resolution: {integrity: sha512-1NqwWEameArC7HWT7UHBlkq3pNlCA0eHBocaeL6mS5CULolT9XFL27tC9jJ+OSmREzLwkKbFYaAl2SssaXexVA==} engines: {node: '>=20.15.0'} hasBin: true + '@copilotkit/channels-discord@0.0.3': + resolution: {integrity: sha512-UXkWHcQJ9kLDkkGMXC3NyHuYwdVxWW09uyn1wm9er5tcL5/ZK12gvqgnj7G5j5pF7VI0qWXQ+ZvO3uZENo7o0A==} + + '@copilotkit/channels-ui@0.1.1': + resolution: {integrity: sha512-wQ099E62DEhIuKz8O9Iqy1VP4qCyLtS8d5EwfF2MK8m8amWtuyyTUxV8vC1q9ecJMgAwCtKmeW/fYQR8NsLiXQ==} + + '@copilotkit/channels@0.1.1': + resolution: {integrity: sha512-v/cOFfRZWKXstrj06YZmR5kikE9XhXluuJa81hZEhZ4I9C3U+Lnao1K5pL5DtmhUbowynMvBedVUNqKwGFLCvQ==} + peerDependencies: + vitest: ^4.0.0 + peerDependenciesMeta: + vitest: + optional: true + + '@copilotkit/core@1.62.3': + resolution: {integrity: sha512-Iw0XTJDylh4DTL1qqqoDSvMeL4lXGWfn8UAunnvYtVZ2BF3gs6Lw6K/yOQ0gGQKMqIxeQmgLVclErxwBcaJ8KQ==} + engines: {node: '>=18'} + + '@copilotkit/license-verifier@0.5.0': + resolution: {integrity: sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ==} + + '@copilotkit/shared@1.62.3': + resolution: {integrity: sha512-dfqaYjfJzTIjMBUMOHGZGoZ+v9pumsBuM1FMi5Sb66WkzxbtIKN0CCbYXXIHd0xsEo1pa8u1vZSN30sBtJuQ7g==} + peerDependencies: + '@ag-ui/core': '>=0.0.48' + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -1004,6 +1051,14 @@ packages: resolution: {integrity: sha512-9WYd4eRbFTFNLlWU625/aKLzSu5QfOZ7cYuoxkGZbCB44/8aEOQyCzjOifeSWvYgSMCoO0jF4+XnVtZjC5bf8g==} engines: {node: '>=18.x'} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/uuid@2.0.1': + resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} + engines: {node: '>=8'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1268,6 +1323,10 @@ packages: '@prisma/get-platform@6.19.3': resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@protobuf-ts/protoc@2.11.1': + resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==} + hasBin: true + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -1402,6 +1461,16 @@ packages: resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@segment/analytics-core@1.8.2': + resolution: {integrity: sha512-5FDy6l8chpzUfJcNlIcyqYQq4+JTUynlVoCeCUuVz+l+6W0PXg+ljKp34R4yLVCcY5VVZohuW+HH0VLWdwYVAg==} + + '@segment/analytics-generic-utils@1.2.0': + resolution: {integrity: sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==} + + '@segment/analytics-node@2.3.0': + resolution: {integrity: sha512-fOXLL8uY0uAWw/sTLmezze80hj8YGgXXlAfvSS6TUmivk4D/SP0C0sxnbpFdkUzWg2zT64qWIZj26afEtSnxUA==} + engines: {node: '>=20'} + '@slack/bolt@4.7.0': resolution: {integrity: sha512-Xpf+gKegNvkHpft1z4YiuqZdciJ3tUp1bIRQxylW30Ovf+hzjb0M1zTHVtJsRw9jsjPxHTPoyanEXVvG6qVE1g==} engines: {node: '>=18', npm: '>=8.6.0'} @@ -1437,6 +1506,18 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/pacer@0.20.1': + resolution: {integrity: sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ==} + engines: {node: '>=18'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1619,6 +1700,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/ws@6.0.4': resolution: {integrity: sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==} @@ -2165,6 +2249,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2408,6 +2495,10 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2692,6 +2783,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-patch@3.1.1: + resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3136,6 +3230,9 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -3758,6 +3855,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3778,6 +3878,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + phoenix@1.8.9: + resolution: {integrity: sha512-/2qzAZB3P2s08fFAYaG65lqaNFmVXUSlXdY4/JDdDKIC81y2cFWkPwI8gycy4VLpv197JwZ5PpBf3VhoG32yGA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4070,6 +4173,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -4442,6 +4548,9 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + untruncate-json@0.0.1: + resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4463,6 +4572,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -4688,6 +4801,34 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ag-ui/client@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/encoder': 0.0.57 + '@ag-ui/proto': 0.0.57 + '@types/uuid': 10.0.0 + compare-versions: 6.1.1 + fast-json-patch: 3.1.1 + rxjs: 7.8.1 + untruncate-json: 0.0.1 + uuid: 11.1.1 + zod: 3.25.76 + + '@ag-ui/core@0.0.57': + dependencies: + zod: 3.25.76 + + '@ag-ui/encoder@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/proto': 0.0.57 + + '@ag-ui/proto@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@bufbuild/protobuf': 2.12.1 + '@protobuf-ts/protoc': 2.11.1 + '@alloc/quick-lru@5.2.0': {} '@anthropic-ai/sdk@0.89.0(zod@4.3.6)': @@ -4915,8 +5056,103 @@ snapshots: dependencies: css-tree: 3.2.1 + '@bufbuild/protobuf@2.12.1': {} + '@copilotkit/aimock@1.14.0': {} + '@copilotkit/channels-discord@0.0.3(@ag-ui/core@0.0.57)(vitest@4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))': + dependencies: + '@ag-ui/client': 0.0.57 + '@copilotkit/channels': 0.1.1(vitest@4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + discord.js: 14.26.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@ag-ui/core' + - bufferutil + - encoding + - utf-8-validate + - vitest + + '@copilotkit/channels-ui@0.1.1(@ag-ui/core@0.0.57)': + dependencies: + '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + transitivePeerDependencies: + - '@ag-ui/core' + - encoding + + '@copilotkit/channels@0.1.1(vitest@4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(zod@3.25.76)': + dependencies: + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + vitest: 4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + transitivePeerDependencies: + - encoding + - zod + + '@copilotkit/channels@0.1.1(vitest@4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(zod@4.3.6)': + dependencies: + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/channels-ui': 0.1.1(@ag-ui/core@0.0.57) + '@copilotkit/core': 1.62.3(@ag-ui/core@0.0.57)(zod@4.3.6) + '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + zod-to-json-schema: 3.25.2(zod@4.3.6) + optionalDependencies: + vitest: 4.1.4(@types/node@22.19.17)(jsdom@29.0.2)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + transitivePeerDependencies: + - encoding + - zod + + '@copilotkit/core@1.62.3(@ag-ui/core@0.0.57)(zod@3.25.76)': + dependencies: + '@ag-ui/client': 0.0.57 + '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@tanstack/pacer': 0.20.1 + phoenix: 1.8.9 + rxjs: 7.8.1 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@ag-ui/core' + - encoding + - zod + + '@copilotkit/core@1.62.3(@ag-ui/core@0.0.57)(zod@4.3.6)': + dependencies: + '@ag-ui/client': 0.0.57 + '@copilotkit/shared': 1.62.3(@ag-ui/core@0.0.57) + '@tanstack/pacer': 0.20.1 + phoenix: 1.8.9 + rxjs: 7.8.1 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - '@ag-ui/core' + - encoding + - zod + + '@copilotkit/license-verifier@0.5.0': {} + + '@copilotkit/shared@1.62.3(@ag-ui/core@0.0.57)': + dependencies: + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/license-verifier': 0.5.0 + '@segment/analytics-node': 2.3.0 + '@standard-schema/spec': 1.1.0 + chalk: 4.1.2 + graphql: 16.13.2 + partial-json: 0.1.7 + uuid: 11.1.1 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - encoding + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -5290,6 +5526,12 @@ snapshots: transitivePeerDependencies: - graphql + '@lukeed/csprng@1.1.0': {} + + '@lukeed/uuid@2.0.1': + dependencies: + '@lukeed/csprng': 1.1.0 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.24) @@ -5583,6 +5825,8 @@ snapshots: dependencies: '@prisma/debug': 6.19.3 + '@protobuf-ts/protoc@2.11.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': dependencies: '@standard-schema/spec': 1.1.0 @@ -5663,6 +5907,29 @@ snapshots: '@sapphire/snowflake@3.5.5': {} + '@segment/analytics-core@1.8.2': + dependencies: + '@lukeed/uuid': 2.0.1 + '@segment/analytics-generic-utils': 1.2.0 + dset: 3.1.4 + tslib: 2.8.1 + + '@segment/analytics-generic-utils@1.2.0': + dependencies: + tslib: 2.8.1 + + '@segment/analytics-node@2.3.0': + dependencies: + '@lukeed/uuid': 2.0.1 + '@segment/analytics-core': 1.8.2 + '@segment/analytics-generic-utils': 1.2.0 + buffer: 6.0.3 + jose: 5.10.0 + node-fetch: 2.7.0 + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + '@slack/bolt@4.7.0(@types/express@5.0.6)': dependencies: '@slack/logger': 4.0.1 @@ -5736,6 +6003,15 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/pacer@0.20.1': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/store': 0.9.3 + + '@tanstack/store@0.9.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -5923,6 +6199,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@10.0.0': {} + '@types/ws@6.0.4': dependencies: '@types/node': 22.19.17 @@ -6616,6 +6894,8 @@ snapshots: commander@4.1.1: {} + compare-versions@6.1.1: {} + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -6839,6 +7119,8 @@ snapshots: dotenv@16.6.1: {} + dset@3.1.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7348,6 +7630,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-patch@3.1.1: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -7799,6 +8083,8 @@ snapshots: jose@4.15.9: {} + jose@5.10.0: {} + jose@6.2.3: {} js-tokens@4.0.0: {} @@ -8626,6 +8912,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -8638,6 +8926,8 @@ snapshots: perfect-debounce@1.0.0: {} + phoenix@1.8.9: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -8975,6 +9265,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.9 @@ -9481,6 +9775,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + untruncate-json@0.0.1: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -9499,6 +9795,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.1: {} + uuid@8.3.2: {} vary@1.1.2: {} @@ -9713,13 +10011,16 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@3.25.76): dependencies: zod: 3.25.76 zod@3.25.76: {} - zod@4.3.6: - optional: true + zod@4.3.6: {} zwitch@2.0.4: {}