From 7d2e4f9a4f1a4ebd07b5a5ba76eaf39c68cbb444 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Wed, 24 Jun 2026 19:06:53 +0000 Subject: [PATCH 1/9] chore: simplify tools & funcs --- apps/bot/src/features/customizations/views.ts | 2 +- apps/bot/src/lib/agent/compaction.ts | 86 ++++++++++ apps/bot/src/lib/agent/index.ts | 151 ++++-------------- apps/bot/src/lib/agent/mentions.ts | 36 +++++ apps/bot/src/lib/agent/prompt.ts | 84 ++++------ .../src/lib/agent/{line-reply.ts => reply.ts} | 4 +- apps/bot/src/lib/agent/sandbox.ts | 13 ++ apps/bot/src/lib/agent/turns.ts | 49 ++++++ apps/bot/src/lib/ai/stream/index.ts | 35 ++-- apps/bot/src/lib/ai/stream/tasks/chat.ts | 16 +- apps/bot/src/lib/ai/stream/tasks/index.ts | 5 +- apps/bot/src/lib/ai/tools/get-channel-info.ts | 4 +- apps/bot/src/lib/ai/tools/get-user.ts | 2 +- apps/bot/src/lib/ai/tools/list-threads.ts | 17 +- apps/bot/src/lib/ai/tools/post-message.ts | 30 ++++ apps/bot/src/lib/ai/tools/posting.ts | 66 -------- apps/bot/src/lib/ai/tools/react.ts | 19 +++ .../lib/ai/tools/read-conversation-history.ts | 31 +--- .../bot/src/lib/ai/tools/schedule-reminder.ts | 11 +- apps/bot/src/lib/ai/tools/search-slack.ts | 10 +- apps/bot/src/lib/ai/tools/search-web.ts | 2 +- apps/bot/src/lib/ai/tools/summarize-thread.ts | 7 +- apps/bot/src/lib/ai/toolset.ts | 24 +-- docs/architecture.md | 2 +- docs/index.md | 1 + docs/reference/tools.md | 8 +- docs/runtime/error-catalog.md | 132 +++++++++++++++ docs/runtime/streaming.md | 4 +- packages/ai/src/prompts/core.ts | 5 + packages/ai/src/prompts/customization.ts | 13 -- packages/ai/src/prompts/index.ts | 2 - packages/ai/src/prompts/slack.ts | 4 +- packages/ai/src/providers/models.ts | 4 +- 33 files changed, 501 insertions(+), 378 deletions(-) create mode 100644 apps/bot/src/lib/agent/compaction.ts create mode 100644 apps/bot/src/lib/agent/mentions.ts rename apps/bot/src/lib/agent/{line-reply.ts => reply.ts} (97%) create mode 100644 apps/bot/src/lib/agent/sandbox.ts create mode 100644 apps/bot/src/lib/agent/turns.ts create mode 100644 apps/bot/src/lib/ai/tools/post-message.ts delete mode 100644 apps/bot/src/lib/ai/tools/posting.ts create mode 100644 apps/bot/src/lib/ai/tools/react.ts create mode 100644 docs/runtime/error-catalog.md delete mode 100644 packages/ai/src/prompts/customization.ts diff --git a/apps/bot/src/features/customizations/views.ts b/apps/bot/src/features/customizations/views.ts index c478b9e0..57de49fb 100644 --- a/apps/bot/src/features/customizations/views.ts +++ b/apps/bot/src/features/customizations/views.ts @@ -12,7 +12,7 @@ import type { SlackTextInputElement, } from './types'; -const maxHomePromptLength = 900; +const maxHomePromptLength = 600; const maxPromptLength = 3000; export function buildHomeView({ diff --git a/apps/bot/src/lib/agent/compaction.ts b/apps/bot/src/lib/agent/compaction.ts new file mode 100644 index 00000000..c8581825 --- /dev/null +++ b/apps/bot/src/lib/agent/compaction.ts @@ -0,0 +1,86 @@ +import { + chatAttempts, + createAgent, + openSession, + persistSession, + type SandboxContext, + systemPrompt, +} from '@repo/ai'; +import { loadSkills } from '@repo/sandbox'; +import type { Message, Thread } from 'chat'; +import { sandbox } from '@/lib/agent/sandbox'; +import { requestHints } from '@/lib/ai/hints'; +import { buildTools } from '@/lib/ai/toolset'; +import { runQueuedTurn } from '@/lib/ai/turn-queue'; +import { bot } from '@/lib/chat'; +import { agentErrorMessage } from '@/lib/errors'; +import logger from '@/lib/logger'; + +// Compaction is queued like a turn so it never races an in-flight response on +// the same thread; Pi compacts its session in place, and we persist the smaller +// transcript so the next turn resumes from it. +export function compactTurn(input: { + instructions?: string; + message: Message; + thread: Thread; +}): Promise { + return runQueuedTurn({ + threadId: input.thread.id, + run: () => executeCompact(input), + }); +} + +async function executeCompact({ + instructions, + message, + thread, +}: { + instructions?: string; + message: Message; + thread: Thread; +}): Promise { + const threadId = thread.id; + const attempt = chatAttempts[0]; + if (!attempt) { + logger.error({ threadId }, '[agent] no model configured for compaction'); + return; + } + logger.info({ threadId }, '[agent] compaction started'); + await thread.startTyping('is compacting'); + + const hints = await requestHints({ thread, message }); + const skills = await loadSkills(); + let sandboxContext: SandboxContext | undefined; + const agent = createAgent({ + attempt, + onSandboxReady: (context) => { + sandboxContext = context; + }, + sandbox, + sessionId: threadId, + skills, + systemPrompt: systemPrompt({ hints }), + tools: buildTools({ + bot, + getSandboxContext: () => sandboxContext, + message, + thread, + }), + }); + + let session: Awaited> | undefined; + try { + session = await openSession({ agent, threadId }); + await session.compact(instructions); + await persistSession({ session, snapshotSource: sandboxContext, threadId }); + await sandbox.pauseSession({ threadId }); + logger.info({ threadId }, '[agent] compaction complete'); + await thread + .post({ markdown: '🧹 Compacted this thread’s context.' }) + .catch(() => undefined); + } catch (error) { + logger.error({ err: error, threadId }, '[agent] compaction failed'); + await session?.detach().catch(() => undefined); + await thread.post(agentErrorMessage({ error })).catch(() => undefined); + } +} diff --git a/apps/bot/src/lib/agent/index.ts b/apps/bot/src/lib/agent/index.ts index 0d7550c1..7a345a81 100644 --- a/apps/bot/src/lib/agent/index.ts +++ b/apps/bot/src/lib/agent/index.ts @@ -7,19 +7,19 @@ import { type SandboxContext, systemPrompt, } from '@repo/ai'; -import { E2BSandboxProvider, loadSkills } from '@repo/sandbox'; +import { loadSkills } from '@repo/sandbox'; import { type Message, StreamingPlan, type Thread } from 'chat'; -import { env } from '@/env'; import { deleteControls, postControls } from '@/lib/agent/controls'; -import { createLineReply } from '@/lib/agent/line-reply'; -import { buildAgentPromptText } from '@/lib/agent/prompt'; +import { buildPrompt } from '@/lib/agent/prompt'; +import { createReply } from '@/lib/agent/reply'; +import { sandbox } from '@/lib/agent/sandbox'; import { type ActiveTurn, abortReasonOf, interruptTurn, pendingResumeInput, - TurnAbort, } from '@/lib/agent/steering'; +import { clearTurn, getTurn, setTurn } from '@/lib/agent/turns'; import { startThinking } from '@/lib/agent/utils'; import { promptWithAttachments, seedAttachments } from '@/lib/ai/attachments'; import { type AttemptFailure, nextAttempt } from '@/lib/ai/attempts'; @@ -32,120 +32,27 @@ import { type AgentErrorStage, agentErrorMessage } from '@/lib/errors'; import logger from '@/lib/logger'; import { errorMessage } from '@/lib/utils/error'; -const activeTurns = new Map(); - -const sandbox = new E2BSandboxProvider({ - apiKey: env.E2B_API_KEY, - env: { - ...(env.AGENTMAIL_API_KEY - ? { AGENTMAIL_API_KEY: env.AGENTMAIL_API_KEY } - : {}), - }, - logger, -}); +export { compactTurn } from '@/lib/agent/compaction'; +export { stopAllTurns, stopTurn } from '@/lib/agent/turns'; export function runTurn(input: { message: Message; thread: Thread; }): Promise { - const activeTurn = activeTurns.get(input.thread.id); - if (!activeTurn) { + const turn = getTurn({ threadId: input.thread.id }); + if (!turn) { return runQueuedTurn({ threadId: input.thread.id, run: (controller) => executeTurn(input, controller), }); } - interruptTurn({ activeTurn, input }); + interruptTurn({ activeTurn: turn, input }); return slack .addReaction(input.thread.id, input.message.id, 'white_check_mark') .catch(() => undefined); } -export function stopTurn({ threadId }: { threadId: string }): boolean { - const activeTurn = activeTurns.get(threadId); - if (!activeTurn) { - return false; - } - activeTurn.controller.abort(new TurnAbort('stop')); - return true; -} - -export function stopAllTurns(): void { - for (const activeTurn of activeTurns.values()) { - activeTurn.controller.abort(new TurnAbort('shutdown')); - } -} - -// Compaction is queued like a turn so it never races an in-flight response on -// the same thread; the runtime (Pi) compacts its session in place, and we -// persist the smaller transcript so the next turn resumes from it. -export function compactTurn(input: { - instructions?: string; - message: Message; - thread: Thread; -}): Promise { - return runQueuedTurn({ - threadId: input.thread.id, - run: () => executeCompact(input), - }); -} - -async function executeCompact({ - instructions, - message, - thread, -}: { - instructions?: string; - message: Message; - thread: Thread; -}): Promise { - const threadId = thread.id; - const attempt = chatAttempts[0]; - if (!attempt) { - logger.error({ threadId }, '[agent] no model configured for compaction'); - return; - } - logger.info({ threadId }, '[agent] compaction started'); - await thread.startTyping('is compacting'); - - const hints = await requestHints({ thread, message }); - const skills = await loadSkills(); - let sandboxContext: SandboxContext | undefined; - const agent = createAgent({ - attempt, - onSandboxReady: (context) => { - sandboxContext = context; - }, - sandbox, - sessionId: threadId, - skills, - systemPrompt: systemPrompt({ hints }), - tools: buildTools({ - bot, - getSandboxContext: () => sandboxContext, - message, - thread, - }), - }); - - let session: Awaited> | undefined; - try { - session = await openSession({ agent, threadId }); - await session.compact(instructions); - await persistSession({ session, snapshotSource: sandboxContext, threadId }); - await sandbox.pauseSession({ threadId }); - logger.info({ threadId }, '[agent] compaction complete'); - await thread - .post({ markdown: '🧹 Compacted this thread’s context.' }) - .catch(() => undefined); - } catch (error) { - logger.error({ err: error, threadId }, '[agent] compaction failed'); - await session?.detach().catch(() => undefined); - await thread.post(agentErrorMessage({ error })).catch(() => undefined); - } -} - async function executeTurn( { message, thread }: { message: Message; thread: Thread }, controller: AbortController @@ -156,7 +63,7 @@ async function executeTurn( controller, pendingMessages: [], }; - activeTurns.set(threadId, activeTurn); + setTurn({ threadId, turn: activeTurn }); await startThinking({ thread }); const hints = await requestHints({ thread, message }); @@ -165,7 +72,7 @@ async function executeTurn( let controls: Awaited> = null; let sandboxContext: SandboxContext | undefined; let completion: { finishReason: string; textLength: number } | undefined; - let lineReply: ReturnType | undefined; + let reply: ReturnType | undefined; let errorStage: AgentErrorStage = 'before_output'; const parkSession = async ({ pause }: { pause: boolean }): Promise => { @@ -196,7 +103,7 @@ async function executeTurn( 'Agent turn ended before session completion was recorded.' ); } - await lineReply?.flush({ thread }); + await reply?.flush({ thread }); if (hints.customization?.prompt && !slack.isDM(thread.id)) { await thread .post({ @@ -225,15 +132,13 @@ async function executeTurn( { attempt: attemptLog(activeAttempt), err: error, threadId }, '[agent] turn failed' ); - await lineReply?.flush({ thread }); + await reply?.flush({ thread }); await parkSession({ pause: true }); await deleteControls({ controls }); await thread.post(agentErrorMessage({ error, stage: errorStage })); } } finally { - if (activeTurns.get(threadId) === activeTurn) { - activeTurns.delete(threadId); - } + clearTurn({ threadId, turn: activeTurn }); // Only an interrupt replays queued messages; a rapid burst is merged into a // single follow-up so steering does not drop intermediate corrections. const resume = @@ -258,10 +163,12 @@ async function executeTurn( thread: Thread; }) { const skills = await loadSkills(); - const messageText = await buildAgentPromptText(message); + const messageText = await buildPrompt(message, { + customizationPrompt: hints.customization?.prompt, + }); let attachments: Awaited> = []; - let hasStreamed = false; - const attemptHistory: AttemptFailure[] = []; + let streamed = false; + const attempts: AttemptFailure[] = []; let attempt = chatAttempts[0]; while (attempt) { const currentAttempt = attempt; @@ -292,7 +199,7 @@ async function executeTurn( }), }); session = await openSession({ agent, threadId }); - lineReply = createLineReply({ threadId }); + reply = createReply({ threadId }); const result = await agent.stream({ abortSignal: controller.signal, prompt: promptWithAttachments({ @@ -303,14 +210,14 @@ async function executeTurn( }); for await (const chunk of renderStream({ onTextDelta: async (text) => { - hasStreamed = true; + streamed = true; errorStage = 'after_text'; controls ??= await postControls({ thread }); - await lineReply?.append({ text, thread }); + await reply?.append({ text, thread }); }, stream: result.stream, })) { - hasStreamed = true; + streamed = true; if (errorStage === 'before_output') { errorStage = 'after_progress'; } @@ -325,23 +232,23 @@ async function executeTurn( ]); completion = { finishReason, textLength: text.length }; } catch (error) { - if (!hasStreamed) { + if (!streamed) { throw error; } logger.warn( { err: errorMessage(error), threadId }, '[agent] failed to read final stream metadata after text output' ); - completion = { finishReason: 'unknown', textLength: 0 }; + completion = { finishReason: 'metadata_unavailable', textLength: 0 }; } return; } catch (error) { - attemptHistory.push({ attempt: currentAttempt, error }); + attempts.push({ attempt: currentAttempt, error }); const retryAttempt = nextAttempt({ attempts: chatAttempts, - failures: attemptHistory, + failures: attempts, }); - if (controller.signal.aborted || hasStreamed || !retryAttempt) { + if (controller.signal.aborted || streamed || !retryAttempt) { throw error; } logger.warn( diff --git a/apps/bot/src/lib/agent/mentions.ts b/apps/bot/src/lib/agent/mentions.ts new file mode 100644 index 00000000..f28345ed --- /dev/null +++ b/apps/bot/src/lib/agent/mentions.ts @@ -0,0 +1,36 @@ +import { bot } from '@/lib/chat'; + +const slackUserMentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; + +export async function annotateMentions(text: string): Promise { + const names = new Map(); + const missingIds = new Set(); + + for (const mention of text.matchAll(slackUserMentionPattern)) { + const userId = mention[1]; + const label = mention[2]; + if (!userId || names.has(userId)) { + continue; + } + if (label) { + names.set(userId, label); + continue; + } + missingIds.add(userId); + } + + await Promise.all( + [...missingIds].map(async (userId) => { + const user = await bot.getUser(userId).catch(() => undefined); + names.set(userId, user?.userName ?? userId); + }) + ); + + if (names.size === 0) { + return text; + } + return text.replace(slackUserMentionPattern, (token, userId: string) => { + const name = names.get(userId); + return name ? `@${name} (${userId})` : token; + }); +} diff --git a/apps/bot/src/lib/agent/prompt.ts b/apps/bot/src/lib/agent/prompt.ts index 167469f0..e2be1cad 100644 --- a/apps/bot/src/lib/agent/prompt.ts +++ b/apps/bot/src/lib/agent/prompt.ts @@ -1,56 +1,42 @@ import { slackMrkdwnToMarkdown } from '@chat-adapter/slack/format'; import type { Message } from 'chat'; -import { bot } from '@/lib/chat'; +import { annotateMentions } from '@/lib/agent/mentions'; -const slackUserMentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; +export async function buildPrompt( + message: Message, + { + customizationPrompt, + }: { + customizationPrompt?: string; + } = {} +): Promise { + const prefix = `@${message.author.userName} (${message.author.userId})`; + const rawText = getRawSlackText(message); + const text = rawText + ? slackMrkdwnToMarkdown(await annotateMentions(rawText)) + : message.text; + const messageText = `${prefix}: ${text}`; -export async function buildAgentPromptText(message: Message): Promise { - const raw = message.raw; - const rawText = - raw && - typeof raw === 'object' && - 'text' in raw && - typeof raw.text === 'string' - ? raw.text - : undefined; - const prefix = `[${message.author.userName}: ${message.author.userId}]`; - if (!rawText) { - return `${prefix}: ${message.text}`; - } - - const mentions = [...rawText.matchAll(slackUserMentionPattern)]; - if (mentions.length === 0) { - return `${prefix}: ${slackMrkdwnToMarkdown(rawText)}`; - } - - const mentionNames = new Map(); - const mentionLookups: Promise[] = []; + return customizationPrompt + ? [ + '', + customizationPrompt, + '', + '', + messageText, + ].join('\n') + : messageText; +} - for (const mention of mentions) { - const userId = mention[1]; - const label = mention[2]; - if (!userId || mentionNames.has(userId)) { - continue; - } - if (label) { - mentionNames.set(userId, label); - continue; - } - mentionLookups.push( - bot.getUser(userId).then((user) => { - mentionNames.set(userId, user?.userName ?? userId); - }) - ); +function getRawSlackText(message: Message): string | undefined { + const raw = message.raw; + if ( + !raw || + typeof raw !== 'object' || + !('text' in raw) || + typeof raw.text !== 'string' + ) { + return; } - - await Promise.all(mentionLookups); - const annotatedText = rawText.replace( - slackUserMentionPattern, - (token, userId: string) => { - const name = mentionNames.get(userId); - return name ? `@${name} [${userId}]` : token; - } - ); - - return `${prefix}: ${slackMrkdwnToMarkdown(annotatedText)}`; + return raw.text; } diff --git a/apps/bot/src/lib/agent/line-reply.ts b/apps/bot/src/lib/agent/reply.ts similarity index 97% rename from apps/bot/src/lib/agent/line-reply.ts rename to apps/bot/src/lib/agent/reply.ts index 6f7acd70..493b74b2 100644 --- a/apps/bot/src/lib/agent/line-reply.ts +++ b/apps/bot/src/lib/agent/reply.ts @@ -16,7 +16,7 @@ const IDLE_MS = 1500; const FENCE_REOPEN_PADDING = 3; const TABLE_SEPARATOR = /^\|?[\s:|-]*-{2,}[\s:|-]*$/; -export function createLineReply({ threadId }: { threadId: string }) { +export function createReply({ threadId }: { threadId: string }) { let buffer = ''; let lastPostAt = Date.now(); @@ -42,7 +42,7 @@ export function createLineReply({ threadId }: { threadId: string }) { lastPostAt = Date.now(); }) .catch((error: unknown) => { - logger.warn({ err: error, threadId }, '[agent] line reply failed'); + logger.warn({ err: error, threadId }, '[agent] reply post failed'); }); } } diff --git a/apps/bot/src/lib/agent/sandbox.ts b/apps/bot/src/lib/agent/sandbox.ts new file mode 100644 index 00000000..fd0ecec5 --- /dev/null +++ b/apps/bot/src/lib/agent/sandbox.ts @@ -0,0 +1,13 @@ +import { E2BSandboxProvider } from '@repo/sandbox'; +import { env } from '@/env'; +import logger from '@/lib/logger'; + +export const sandbox = new E2BSandboxProvider({ + apiKey: env.E2B_API_KEY, + env: { + ...(env.AGENTMAIL_API_KEY + ? { AGENTMAIL_API_KEY: env.AGENTMAIL_API_KEY } + : {}), + }, + logger, +}); diff --git a/apps/bot/src/lib/agent/turns.ts b/apps/bot/src/lib/agent/turns.ts new file mode 100644 index 00000000..920a6807 --- /dev/null +++ b/apps/bot/src/lib/agent/turns.ts @@ -0,0 +1,49 @@ +import type { ActiveTurn } from '@/lib/agent/steering'; +import { TurnAbort } from '@/lib/agent/steering'; + +const turns = new Map(); + +export function getTurn({ + threadId, +}: { + threadId: string; +}): ActiveTurn | undefined { + return turns.get(threadId); +} + +export function setTurn({ + threadId, + turn, +}: { + threadId: string; + turn: ActiveTurn; +}): void { + turns.set(threadId, turn); +} + +export function clearTurn({ + threadId, + turn, +}: { + threadId: string; + turn: ActiveTurn; +}): void { + if (turns.get(threadId) === turn) { + turns.delete(threadId); + } +} + +export function stopTurn({ threadId }: { threadId: string }): boolean { + const turn = turns.get(threadId); + if (!turn) { + return false; + } + turn.controller.abort(new TurnAbort('stop')); + return true; +} + +export function stopAllTurns(): void { + for (const turn of turns.values()) { + turn.controller.abort(new TurnAbort('shutdown')); + } +} diff --git a/apps/bot/src/lib/ai/stream/index.ts b/apps/bot/src/lib/ai/stream/index.ts index 6717031f..cc531224 100644 --- a/apps/bot/src/lib/ai/stream/index.ts +++ b/apps/bot/src/lib/ai/stream/index.ts @@ -27,9 +27,15 @@ export async function* renderStream({ break; } case 'reasoning-start': { - reasoning.set(part.id, ''); + const id = reasoningTaskId(part.id); + if (!showTask({ id, visibleTaskIds })) { + hiddenTaskCount += 1; + yield hiddenTaskUpdate({ count: hiddenTaskCount, done: false }); + break; + } + reasoning.set(id, ''); yield { - id: `reasoning-${part.id}`, + id, status: 'in_progress', title: 'Thinking', type: 'task_update', @@ -37,19 +43,26 @@ export async function* renderStream({ break; } case 'reasoning-delta': { - reasoning.set(part.id, (reasoning.get(part.id) ?? '') + part.text); + const id = reasoningTaskId(part.id); + if (visibleTaskIds.has(id)) { + reasoning.set(id, (reasoning.get(id) ?? '') + part.text); + } break; } case 'reasoning-end': { - const text = reasoning.get(part.id)?.trim(); + const id = reasoningTaskId(part.id); + if (!visibleTaskIds.has(id)) { + break; + } + const text = reasoning.get(id)?.trim(); yield { - id: `reasoning-${part.id}`, + id, output: text ? clamp(text, REASONING_OUTPUT_MAX_LENGTH) : undefined, status: 'complete', title: 'Thinking', type: 'task_update', }; - reasoning.delete(part.id); + reasoning.delete(id); break; } case 'tool-call': { @@ -167,6 +180,10 @@ function showTask({ return false; } +function reasoningTaskId(id: string): string { + return `reasoning-${id}`; +} + function hiddenTaskUpdate({ count, done, @@ -175,12 +192,12 @@ function hiddenTaskUpdate({ done: boolean; }): StreamChunk { return { - id: 'hidden-tool-activity', + id: 'hidden-activity', output: done - ? `Ran ${count} additional tool${count === 1 ? '' : 's'}.` + ? `Ran ${count} additional activity item${count === 1 ? '' : 's'}.` : undefined, status: done ? 'complete' : 'in_progress', - title: `Tool activity: ${count}`, + title: `Activity: ${count}`, type: 'task_update', }; } diff --git a/apps/bot/src/lib/ai/stream/tasks/chat.ts b/apps/bot/src/lib/ai/stream/tasks/chat.ts index 06e3421d..cb2dbe4b 100644 --- a/apps/bot/src/lib/ai/stream/tasks/chat.ts +++ b/apps/bot/src/lib/ai/stream/tasks/chat.ts @@ -10,6 +10,7 @@ import type { ToolTaskRendererEntry } from './types'; export const message: ToolTaskRendererEntry = { request: ({ input }) => ({ details: + textField(input, 'id') ?? textField(input, 'threadId') ?? textField(input, 'channelId') ?? textField(input, 'userId'), @@ -21,21 +22,6 @@ export const message: ToolTaskRendererEntry = { title: 'Sending message', }; -export const directMessage: ToolTaskRendererEntry = { - request: ({ input }) => ({ - details: textField(input, 'userId'), - }), - response: ({ input, output }) => { - const userId = textField(input, 'userId'); - const threadId = textField(output, 'threadId'); - return { - output: `Sent DM${userId ? ` to ${userId}` : ''}${threadId ? ` in ${threadId}` : ''}.`, - title: 'Sent DM', - }; - }, - title: 'Sending DM', -}; - export const fetchMessages: ToolTaskRendererEntry = { request: ({ input }) => ({ details: textField(input, 'threadId') ?? textField(input, 'channelId'), diff --git a/apps/bot/src/lib/ai/stream/tasks/index.ts b/apps/bot/src/lib/ai/stream/tasks/index.ts index da2c52df..7dcb87d0 100644 --- a/apps/bot/src/lib/ai/stream/tasks/index.ts +++ b/apps/bot/src/lib/ai/stream/tasks/index.ts @@ -1,6 +1,5 @@ import { clamp } from '@/lib/utils/text'; import { - directMessage, fetchMessages, getChannelInfo, getUser, @@ -25,7 +24,6 @@ import { uploadFile } from './upload-file'; type RenderPhase = 'request' | 'response' | 'error'; const toolRenderers: Record = { - addReaction: reaction, bash: command, compaction: { title: 'Compacting context' }, edit: { ...file, title: 'Editing file' }, @@ -40,14 +38,13 @@ const toolRenderers: Record = { listThreads, ls: { title: 'Listing files' }, mermaid, - postChannelMessage: { ...message, title: 'Posting to channel' }, postMessage: message, readConversationHistory: { ...fetchMessages, title: 'Reading history' }, + react: reaction, read: file, scheduleReminder, searchSlack, searchWeb, - sendDirectMessage: directMessage, summarizeThread, uploadFile, write: { ...file, title: 'Writing file' }, diff --git a/apps/bot/src/lib/ai/tools/get-channel-info.ts b/apps/bot/src/lib/ai/tools/get-channel-info.ts index 55e50c04..3a62e6c6 100644 --- a/apps/bot/src/lib/ai/tools/get-channel-info.ts +++ b/apps/bot/src/lib/ai/tools/get-channel-info.ts @@ -15,9 +15,7 @@ export function getChannelInfoTool({ description: 'Fetch metadata for a channel: name, member count, DM status, visibility, etc.', inputSchema: z.object({ - channelId: z - .string() - .describe('Chat SDK channel id, e.g. slack:C123456.'), + channelId: z.string(), }), execute: async ({ channelId }) => { const chatChannelId = toChatSlackChannelId(channelId); diff --git a/apps/bot/src/lib/ai/tools/get-user.ts b/apps/bot/src/lib/ai/tools/get-user.ts index 7b626597..a0795882 100644 --- a/apps/bot/src/lib/ai/tools/get-user.ts +++ b/apps/bot/src/lib/ai/tools/get-user.ts @@ -7,7 +7,7 @@ export function getUserTool() { description: "Look up a Slack user's profile by their user id (like U0123ABCD): display name, real name, pronouns, title, status, and custom profile fields (Website, GitHub, etc.). Use their pronouns when referring to them.", inputSchema: z.object({ - userId: z.string().min(1).describe('The Slack user id, like U0123ABCD.'), + userId: z.string().min(1), }), execute: async ({ userId }) => { const profile = await resolveUserProfile(userId); diff --git a/apps/bot/src/lib/ai/tools/list-threads.ts b/apps/bot/src/lib/ai/tools/list-threads.ts index 198d6fcd..cffc5e8a 100644 --- a/apps/bot/src/lib/ai/tools/list-threads.ts +++ b/apps/bot/src/lib/ai/tools/list-threads.ts @@ -13,20 +13,9 @@ export function listThreadsTool({ description: 'List recent Slack channel threads so you can pick a thread id before reading it. The current channel always works (even if private); other channels must be public.', inputSchema: z.object({ - channelId: z - .string() - .describe('Chat SDK channel id, e.g. slack:C123456.'), - cursor: z - .string() - .optional() - .describe('Slack pagination cursor from a previous response.'), - limit: z - .number() - .int() - .min(1) - .max(100) - .default(20) - .describe('Maximum thread roots to return.'), + channelId: z.string(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), }), execute: async ({ channelId, cursor, limit }) => { const chatChannelId = toChatSlackChannelId(channelId); diff --git a/apps/bot/src/lib/ai/tools/post-message.ts b/apps/bot/src/lib/ai/tools/post-message.ts new file mode 100644 index 00000000..09cb34a2 --- /dev/null +++ b/apps/bot/src/lib/ai/tools/post-message.ts @@ -0,0 +1,30 @@ +import { tool } from 'ai'; +import type { Chat } from 'chat'; +import { z } from 'zod'; + +export function postMessageTool({ bot }: { bot: Chat }) { + return tool({ + description: + 'Post a markdown-formatted message to another target. Type must be thread, channel, or user.', + inputSchema: z.object({ + type: z + .enum(['thread', 'channel', 'user']) + .describe('Target kind: thread, channel, or user.'), + id: z.string().min(1), + message: z.string().min(1).describe('Markdown message body.'), + }), + execute: async ({ id, message, type }) => { + if (type === 'thread') { + const sent = await bot.thread(id).post({ markdown: message }); + return { messageId: sent.id, threadId: sent.threadId }; + } + if (type === 'channel') { + const sent = await bot.channel(id).post({ markdown: message }); + return { messageId: sent.id, threadId: sent.threadId }; + } + const dm = await bot.openDM(id); + const sent = await dm.post({ markdown: message }); + return { messageId: sent.id, threadId: sent.threadId }; + }, + }); +} diff --git a/apps/bot/src/lib/ai/tools/posting.ts b/apps/bot/src/lib/ai/tools/posting.ts deleted file mode 100644 index 8069ca4c..00000000 --- a/apps/bot/src/lib/ai/tools/posting.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type ToolSet, tool } from 'ai'; -import type { Chat } from 'chat'; -import { z } from 'zod'; - -const postableInputSchema = z.union([ - z.string().min(1).describe('Markdown body.'), - z.object({ markdown: z.string().min(1) }).describe('Markdown body.'), - z.object({ raw: z.string().min(1) }).describe('Raw body.'), -]); - -type MarkdownPostable = z.infer; - -function toMarkdownPostable( - message: MarkdownPostable -): { markdown: string } | { raw: string } { - if (typeof message === 'string') { - return { markdown: message }; - } - return message; -} - -export function postingTools({ bot }: { bot: Chat }): ToolSet { - return { - postMessage: tool({ - description: - 'Post a markdown-formatted reply inside an existing thread. Use a full Chat SDK thread id like slack:C123:1234567890.123456.', - inputSchema: z.object({ - threadId: z.string().min(1).describe('Full Chat SDK thread id.'), - message: postableInputSchema, - }), - execute: async ({ threadId, message }) => { - const sent = await bot - .thread(threadId) - .post(toMarkdownPostable(message)); - return { messageId: sent.id, threadId: sent.threadId }; - }, - }), - postChannelMessage: tool({ - description: - 'Post a markdown-formatted top-level message to a channel. Use a full Chat SDK channel id like slack:C123.', - inputSchema: z.object({ - channelId: z.string().min(1).describe('Full Chat SDK channel id.'), - message: postableInputSchema, - }), - execute: async ({ channelId, message }) => { - const sent = await bot - .channel(channelId) - .post(toMarkdownPostable(message)); - return { messageId: sent.id, threadId: sent.threadId }; - }, - }), - sendDirectMessage: tool({ - description: - 'Open or reuse a direct message with a user and post a markdown-formatted message.', - inputSchema: z.object({ - userId: z.string().min(1).describe('Platform-native user id.'), - message: postableInputSchema, - }), - execute: async ({ userId, message }) => { - const dm = await bot.openDM(userId); - const sent = await dm.post(toMarkdownPostable(message)); - return { messageId: sent.id, threadId: sent.threadId }; - }, - }), - }; -} diff --git a/apps/bot/src/lib/ai/tools/react.ts b/apps/bot/src/lib/ai/tools/react.ts new file mode 100644 index 00000000..1f77cbb9 --- /dev/null +++ b/apps/bot/src/lib/ai/tools/react.ts @@ -0,0 +1,19 @@ +import { tool } from 'ai'; +import type { Chat } from 'chat'; +import { z } from 'zod'; + +export function reactTool({ bot }: { bot: Chat }) { + return tool({ + description: 'Add an emoji reaction to a specific message.', + inputSchema: z.object({ + threadId: z.string(), + messageId: z.string(), + emoji: z.string(), + }), + execute: async ({ emoji, messageId, threadId }) => { + const thread = bot.thread(threadId); + await thread.adapter.addReaction(threadId, messageId, emoji); + return { added: true, emoji, messageId, threadId }; + }, + }); +} diff --git a/apps/bot/src/lib/ai/tools/read-conversation-history.ts b/apps/bot/src/lib/ai/tools/read-conversation-history.ts index c815bda1..0e92b8d1 100644 --- a/apps/bot/src/lib/ai/tools/read-conversation-history.ts +++ b/apps/bot/src/lib/ai/tools/read-conversation-history.ts @@ -11,29 +11,12 @@ export function readConversationHistoryTool({ }) { return tool({ description: - 'Read message history from a Slack channel or thread using a channel ID and optional thread timestamp. The current conversation is always readable (even a private channel or DM); other channels must be public.', + 'Read channel history or thread replies. The current conversation is always readable; other channels must be public.', inputSchema: z.object({ - channelId: z - .string() - .optional() - .describe('Chat SDK channel id, e.g. slack:C123456.'), - threadId: z - .string() - .optional() - .describe( - 'Optional full Chat SDK thread id, e.g. slack:C123456:1781599802.270109.' - ), - threadTs: z - .string() - .optional() - .describe('Optional Slack thread timestamp to read replies.'), - limit: z - .number() - .int() - .min(1) - .max(200) - .default(40) - .describe('Maximum messages to read.'), + channelId: z.string().optional(), + threadId: z.string().optional(), + threadTs: z.string().optional(), + limit: z.number().int().min(1).max(200).default(40), cursor: z .string() .optional() @@ -48,9 +31,7 @@ export function readConversationHistoryTool({ (threadId ? slack.channelIdFromThreadId(threadId) : undefined); const resolvedThreadTs = threadTs ?? decodedThread?.threadTs; if (!resolvedChannelId) { - throw new Error( - 'readConversationHistory needs channelId, or a full threadId like slack:C123456:1781599802.270109.' - ); + throw new Error('readConversationHistory needs channelId or threadId.'); } const chatChannelId = toChatSlackChannelId(resolvedChannelId); diff --git a/apps/bot/src/lib/ai/tools/schedule-reminder.ts b/apps/bot/src/lib/ai/tools/schedule-reminder.ts index 6e3db37e..e3349697 100644 --- a/apps/bot/src/lib/ai/tools/schedule-reminder.ts +++ b/apps/bot/src/lib/ai/tools/schedule-reminder.ts @@ -7,19 +7,14 @@ import { errorMessage } from '@/lib/utils/error'; export function scheduleReminderTool({ message }: { message: Message }) { return tool({ description: - 'Schedule a one-time reminder DM to the user who sent the current message. Use scheduleTask for recurring reminders once available.', + 'Schedule a one-time reminder DM to the user who sent the current message.', inputSchema: z.object({ - text: z - .string() - .min(1) - .max(3000) - .describe('Reminder message text to send to the user.'), + text: z.string().min(1).max(3000), seconds: z .number() .int() .min(1) - .max(120 * 24 * 60 * 60) - .describe('Seconds from now to send the reminder.'), + .max(120 * 24 * 60 * 60), }), execute: async ({ text, seconds }) => { const postAt = new Date(Date.now() + seconds * 1000); diff --git a/apps/bot/src/lib/ai/tools/search-slack.ts b/apps/bot/src/lib/ai/tools/search-slack.ts index cbc8fcb3..d0e40978 100644 --- a/apps/bot/src/lib/ai/tools/search-slack.ts +++ b/apps/bot/src/lib/ai/tools/search-slack.ts @@ -74,7 +74,7 @@ const slackSearchResponseSchema = z.looseObject({ .optional(), }); -export function searchSlack({ message }: { message: Message }) { +export function searchSlackTool({ message }: { message: Message }) { return tool({ description: 'Search Slack messages for past conversations, decisions, links, or context outside the current thread.', @@ -84,13 +84,7 @@ export function searchSlack({ message }: { message: Message }) { .min(1) .optional() .describe('Cursor from a previous Slack search result page.'), - query: z - .string() - .min(1) - .max(500) - .describe( - 'Specific Slack search query with keywords, people, channels, or dates.' - ), + query: z.string().min(1).max(500), }), execute: async ({ cursor, query }) => { const parsedRaw = actionTokenSchema.safeParse(message.raw); diff --git a/apps/bot/src/lib/ai/tools/search-web.ts b/apps/bot/src/lib/ai/tools/search-web.ts index 9e5bdedd..6ae7cba4 100644 --- a/apps/bot/src/lib/ai/tools/search-web.ts +++ b/apps/bot/src/lib/ai/tools/search-web.ts @@ -2,7 +2,7 @@ import { tool } from 'ai'; import Exa from 'exa-js'; import { z } from 'zod'; -export function searchWeb({ apiKey }: { apiKey: string }) { +export function searchWebTool({ apiKey }: { apiKey: string }) { const exa = new Exa(apiKey); return tool({ description: diff --git a/apps/bot/src/lib/ai/tools/summarize-thread.ts b/apps/bot/src/lib/ai/tools/summarize-thread.ts index e2dfd343..7b230c3a 100644 --- a/apps/bot/src/lib/ai/tools/summarize-thread.ts +++ b/apps/bot/src/lib/ai/tools/summarize-thread.ts @@ -19,12 +19,7 @@ export function summarizeThreadTool({ .string() .optional() .describe('Optional focus or format instructions for the summary.'), - threadId: z - .string() - .optional() - .describe( - 'Full Chat SDK thread id, e.g. slack:C123456:1781599802.270109. Defaults to the current thread.' - ), + threadId: z.string().optional(), }), execute: async (input) => { const targetThreadId = input.threadId ?? threadId; diff --git a/apps/bot/src/lib/ai/toolset.ts b/apps/bot/src/lib/ai/toolset.ts index 3258a791..c054198e 100644 --- a/apps/bot/src/lib/ai/toolset.ts +++ b/apps/bot/src/lib/ai/toolset.ts @@ -2,7 +2,6 @@ import nodePath from 'node:path/posix'; import type { SandboxContext } from '@repo/ai'; import type { ToolSet } from 'ai'; import type { Chat, Message, Thread } from 'chat'; -import { createChatTools } from 'chat/ai'; import { env } from '@/env'; import { generateImageTool } from './tools/generate-image'; import { getChannelInfoTool } from './tools/get-channel-info'; @@ -11,11 +10,12 @@ import { getUserTool } from './tools/get-user'; import { leaveThreadTool } from './tools/leave-thread'; import { listThreadsTool } from './tools/list-threads'; import { mermaidTool } from './tools/mermaid'; -import { postingTools } from './tools/posting'; +import { postMessageTool } from './tools/post-message'; +import { reactTool } from './tools/react'; import { readConversationHistoryTool } from './tools/read-conversation-history'; import { scheduleReminderTool } from './tools/schedule-reminder'; -import { searchSlack } from './tools/search-slack'; -import { searchWeb } from './tools/search-web'; +import { searchSlackTool } from './tools/search-slack'; +import { searchWebTool } from './tools/search-web'; import { summarizeThreadTool } from './tools/summarize-thread'; import { uploadFileTool } from './tools/upload-file'; @@ -30,18 +30,10 @@ export function buildTools({ message: Message; thread: Thread; }): ToolSet { - const chatTools = createChatTools({ - chat: bot, - preset: 'messenger', - requireApproval: false, - }); - - const { addReaction } = chatTools; - return { - ...(addReaction && { addReaction }), + react: reactTool({ bot }), getUser: getUserTool(), - ...postingTools({ bot }), + postMessage: postMessageTool({ bot }), getFile: getFileTool({ getSandboxContext }), leaveThread: leaveThreadTool({ thread }), listThreads: listThreadsTool({ currentThreadId: thread.id }), @@ -51,8 +43,8 @@ export function buildTools({ getChannelInfo: getChannelInfoTool({ bot, currentThreadId: thread.id }), mermaid: mermaidTool({ thread }), scheduleReminder: scheduleReminderTool({ message }), - searchSlack: searchSlack({ message }), - searchWeb: searchWeb({ apiKey: env.EXA_API_KEY }), + searchSlack: searchSlackTool({ message }), + searchWeb: searchWebTool({ apiKey: env.EXA_API_KEY }), summarizeThread: summarizeThreadTool({ bot, threadId: thread.id }), generateImage: generateImageTool({ upload: async ({ bytes, mediaType, index, total }) => { diff --git a/docs/architecture.md b/docs/architecture.md index 90f9c662..1e6142e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ sequenceDiagram | Chat SDK setup | `apps/bot/src/lib/chat.ts` | | Turn orchestration | `apps/bot/src/lib/agent/index.ts` | | Turn interruption and stop controls | `apps/bot/src/lib/agent/steering.ts`, `apps/bot/src/lib/agent/controls.ts` | -| Slack reply chunking | `apps/bot/src/lib/agent/line-reply.ts` | +| Slack reply chunking | `apps/bot/src/lib/agent/reply.ts` | | Stream and task rendering | `apps/bot/src/lib/ai/stream/**` | | Host tools | `apps/bot/src/lib/ai/tools/**`, `apps/bot/src/lib/ai/toolset.ts` | | Agent construction | `packages/ai/src/agent.ts` | diff --git a/docs/index.md b/docs/index.md index ddc0e3c1..11d02ecf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,6 +27,7 @@ flowchart LR - [Sandbox And Sessions](./runtime/sandbox): E2B lifecycle, session files, recovery, and skills. - [Streaming](./runtime/streaming): Assistant text, task rows, stop controls, and Slack limits. - [Turn Controls](./runtime/controls): Interruption, stop, shutdown, and session parking. +- [Runtime Error Catalog](./runtime/error-catalog): Observed failures, root causes, and fix paths. - [Tools](./reference/tools): The model-facing tool surface and safety boundaries. - [Prompts](./reference/prompts): How the system prompt is assembled. - [Data Model](./reference/data-model): What Postgres stores and why. diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 21391d58..322922b4 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -23,14 +23,12 @@ These tools operate in the sandbox. They do not call Slack directly. ## Slack And Chat Tools -`apps/bot/src/lib/ai/toolset.ts` starts from Chat SDK's `messenger` preset and exposes only the tools Gorkie currently wants: +`apps/bot/src/lib/ai/toolset.ts` exposes bot-owned tools for Slack actions: | Tool | Purpose | | --- | --- | -| `postMessage` | Post to another thread. | -| `postChannelMessage` | Post to a channel. | -| `sendDirectMessage` | Send a DM. | -| `addReaction` | React to a message. | +| `postMessage` | Post to another thread, channel, or user. | +| `react` | React to a message. | | `getChannelInfo` | Read channel metadata. | | `getUser` | Read user metadata. | diff --git a/docs/runtime/error-catalog.md b/docs/runtime/error-catalog.md new file mode 100644 index 00000000..41d54b6c --- /dev/null +++ b/docs/runtime/error-catalog.md @@ -0,0 +1,132 @@ +--- +title: Runtime Error Catalog +description: Observed Gorkie runtime errors, likely causes, and fixes. +--- + +# Runtime Error Catalog + +This page tracks recent production and dev runtime failures seen in bot logs or Slack UI. Keep entries evidence based. If the same symptom can have multiple causes, list each separately. + +## Final Stream Metadata Failed + +**Symptom:** The bot visibly replies in Slack, then logs: + +```text +[agent] failed to read final stream metadata after text output +finishReason: metadata_unavailable +``` + +**Observed count:** 14 entries in `apps/bot/logs` on 2026-06-24. + +| Count | Cause | Evidence | +| --- | --- | --- | +| 8 | Provider gateway timeout | Hack Club returned a Cloudflare 504 HTML error page after text output had streamed. | +| 4 | Turn interruption | The final metadata read rejected with `Request was aborted`. | +| 1 | Spending limit | Provider returned `429 Daily spending limit of $3 reached`. | +| 1 | Credits or token budget | OpenRouter returned `402 This request requires more credits, or fewer max_tokens`. | + +**Why it happens:** `result.stream` can finish enough to render Slack output, but later awaits for `result.text` or `result.finishReason` can still reject while the provider finalizes metadata. After visible output, posting a generic final error is noisy, so the bot logs the failure and records `metadata_unavailable`. + +**Implemented behavior:** + +1. The bot only swallows the final metadata error after text or progress has streamed. +2. The completion log uses `metadata_unavailable` so it does not look like a model finish reason. +3. Provider 504, 429, and 402 remain separate provider or account failures, not Slack rendering failures. +4. Aborts should be checked against newer messages or stop actions before treating them as bugs. + +## Image Generation Failed + +**Symptom:** `generateImage` completes with an error, or Slack says image generation failed. + +**Observed causes:** + +| Cause | Evidence | Fix path | +| --- | --- | --- | +| Gateway timeout | `AI_RetryError: Failed after 3 attempts. Last error: AI_APICallError: Gateway Timeout`. | Retry later, improve user-facing error copy, and do not present zero uploads as success. | +| Credits or token budget | `AI_APICallError: This request requires more credits, or fewer max_tokens`. | Fix the provider account limit or route image generation to a provider key with enough credits. | + +**Notes:** The 402 is not a local image upload bug. It happens before an image exists. + +## Raw Slack Channel Id Passed To Chat Tools + +**Symptom:** Tool returns: + +```text +Adapter "C0793T42XV4" not found for channel ID "C0793T42XV4" +``` + +**Cause:** Chat SDK channel ids include the platform prefix, for example `slack:C123456`. A raw Slack channel id like `C123456` is not a Chat SDK id. + +**Fix path:** + +1. Keep ID format guidance in the global Slack prompt. +2. Keep individual tool fields short, for example `Channel id.` +3. Keep validation errors direct and short. + +## Model Not Found + +**Symptom:** Attempt fallback logs: + +```text +Model minimax/minimax-m3 was not found. +``` + +**Cause:** The selected provider route did not expose that model id at that moment. + +**Fix path:** + +1. Check the provider model registry before changing code. +2. Keep fallback order stable unless the provider is consistently broken. +3. If a provider alias changes, update `packages/ai/src/providers/pi.ts` and document the provider-side change. + +## Provider Route Missing API Key + +**Symptom:** Attempt failure says: + +```text +No API key found for nvidia. +``` + +**Cause:** The provider routed the selected model through Nvidia, but the runtime did not have a matching Nvidia key. + +**Fix path:** + +1. Treat this as provider route configuration unless local code explicitly selected Nvidia. +2. Either provide the expected key or use a model route backed by configured credentials. +3. Do not confuse this with Slack or sandbox failures. + +## Too Much Tool Activity + +**Symptom:** Slack UI becomes noisy or fragile when a turn emits many tool and reasoning events. A log or UI summary may show a large activity count. + +**Cause:** Reasoning rows were not counted against the same visible activity limit as tool rows, so the UI could exceed the intended 45 visible task cap. + +**Fix path:** + +1. Count reasoning and tool tasks through the same activity budget. +2. Show one overflow row for hidden activity. +3. Keep the overflow label generic, because it can include reasoning and tools. + +## Custom Instructions Follow The Wrong User + +**Symptom:** In a shared thread, Gorkie follows the first speaker's saved instructions even after another user pings it. + +**Cause:** Per-user customization was embedded in the persisted system prompt for the thread. A later turn by another user could resume the same session with stale user-specific instructions in history. + +**Fix path:** + +1. Keep global customization behavior rules in the static system prompt. +2. Put only the current speaker's saved customization in a live turn prelude. +3. Tell the model to treat older turn instruction blocks as historical context only. + +## Long Running Command Input Missing In UI + +**Symptom:** A task row says `Running command`, but the command details are missing or too small to identify the command. + +**Likely cause:** Long command inputs, dense comments, or task renderer detail truncation can leave the UI with little useful command text. + +**Fix path:** + +1. Render command details from the first meaningful non-comment shell lines. +2. Clamp details only after extracting the useful lines. +3. If the UI has an independent input length cutoff, make the cutoff explicit and render a stable summary instead of an empty detail. diff --git a/docs/runtime/streaming.md b/docs/runtime/streaming.md index 55ed9ced..e7c8901f 100644 --- a/docs/runtime/streaming.md +++ b/docs/runtime/streaming.md @@ -5,14 +5,14 @@ description: Assistant text, task rows, and Slack limits. Gorkie renders a turn through two Slack output paths: -- assistant text is posted as normal Slack replies through `createLineReply`; +- assistant text is posted as normal Slack replies through `createReply`; - reasoning and tool activity are rendered as task rows through Chat SDK `StreamingPlan`. > **Slack message limits:** Long native Slack stream buffers can fail with `msg_too_long`. Gorkie keeps assistant text outside the native stream buffer so long answers can be split into multiple Slack messages. ## Text Replies -`apps/bot/src/lib/agent/line-reply.ts` buffers text deltas and posts chunks at natural boundaries. It prefers paragraph breaks, then sentence or line boundaries, and falls back to a hard size split before Slack's message limit. +`apps/bot/src/lib/agent/reply.ts` buffers text deltas and posts chunks at natural boundaries. It prefers paragraph breaks, then sentence or line boundaries, and falls back to a hard size split before Slack's message limit. The splitting logic also avoids splitting inside open fenced code blocks. diff --git a/packages/ai/src/prompts/core.ts b/packages/ai/src/prompts/core.ts index 06cf9400..81abc662 100644 --- a/packages/ai/src/prompts/core.ts +++ b/packages/ai/src/prompts/core.ts @@ -4,6 +4,11 @@ You're Gorkie. Your default identity and style are only the fallback when the user has not set persistent custom instructions. If the user has set instructions for tone, persona, style, language, formatting, or how to address them, those override the default Gorkie presentation unless they conflict with safety rules or hard system constraints. Never tell the user you cannot follow their saved custom instructions for "developer", "system", "persona", or "priority" reasons unless there is a real safety conflict. Do not lecture about instruction hierarchy. If you failed to follow them, briefly acknowledge it and correct course. +Current speaker instructions: +- An incoming message may include a block before the message text. This is the current speaker's saved customization for this turn. +- Follow the current speaker's customization unless it conflicts with safety requirements or hard system constraints. +- Treat earlier blocks from other speakers as historical context only. + Limitations: - You CANNOT log in to websites, authenticate, or reach anything behind auth (private repos, Google Docs, Jira, private APIs). - You have no direct web browser, but you can fetch and process PUBLIC URLs by running code in your sandbox. diff --git a/packages/ai/src/prompts/customization.ts b/packages/ai/src/prompts/customization.ts deleted file mode 100644 index a92aa2f1..00000000 --- a/packages/ai/src/prompts/customization.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { RequestHints } from './hints'; - -export function customizationPrompt(hints: RequestHints): string | null { - const prompt = hints.customization?.prompt; - if (!prompt) { - return null; - } - return `\ - -The user you're talking to has set the following persistent personal instructions. They are mandatory and must be followed exactly unless they conflict with safety requirements or higher-priority system rules. Treat them as an active behavioral contract, not a suggestion: obey any tone, language, brevity, formatting, or addressing instructions strictly. -${prompt} -`; -} diff --git a/packages/ai/src/prompts/index.ts b/packages/ai/src/prompts/index.ts index 4b12ddc8..4a3e4e64 100644 --- a/packages/ai/src/prompts/index.ts +++ b/packages/ai/src/prompts/index.ts @@ -1,6 +1,5 @@ import { contextPrompt } from './context'; import { corePrompt } from './core'; -import { customizationPrompt } from './customization'; import type { RequestHints } from './hints'; import { personalityPrompt } from './personality'; import { sandboxPrompt } from './sandbox'; @@ -15,7 +14,6 @@ export function systemPrompt({ hints }: { hints: RequestHints }): string { sandboxPrompt, slackPrompt, contextPrompt(hints), - customizationPrompt(hints), ] .filter(Boolean) .join('\n\n') diff --git a/packages/ai/src/prompts/slack.ts b/packages/ai/src/prompts/slack.ts index af1fd06d..b01acb48 100644 --- a/packages/ai/src/prompts/slack.ts +++ b/packages/ai/src/prompts/slack.ts @@ -28,8 +28,8 @@ Read: - getFile: download a Slack file (upload, snippet, image, canvas, any type) into the sandbox by URL, permalink, or file id so you can read it. Act: -- addReaction: react to a message with an emoji. -- postMessage / postChannelMessage / sendDirectMessage: send a message to ANOTHER thread, channel, or user. Your streamed text is the reply to the current message; never post your reply through a tool. +- react: react to a message with an emoji. +- postMessage: send a message to ANOTHER thread, channel, or user. Your streamed text is the reply to the current message; never post your reply through a tool. - searchWeb: search the internet for current info, docs, or facts. don't guess at recent events, search. - generateImage: generate AI image(s) from a prompt and post them to the thread; use it for image creation requests. - mermaid: render a Mermaid diagram and upload it to this Slack thread. diff --git a/packages/ai/src/providers/models.ts b/packages/ai/src/providers/models.ts index e21207ba..d7ba43d5 100644 --- a/packages/ai/src/providers/models.ts +++ b/packages/ai/src/providers/models.ts @@ -14,8 +14,6 @@ export const provider: Provider = customProvider({ 'chat-model': hostProvider.languageModel('google/gemini-3-flash-preview'), }, imageModels: { - 'image-model': hostProvider.imageModel( - 'google/gemini-3.1-flash-image-preview' - ), + 'image-model': hostProvider.imageModel('google/gemini-3.1-flash-image'), }, }); From eeb28ce6e88f8b74aef0ca4b54dd4a54d0a7012d Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Wed, 24 Jun 2026 19:19:21 +0000 Subject: [PATCH 2/9] chore: uf --- .agents/skills/gorkie-cleanup/SKILL.md | 52 ++++++++++++ .../{types.ts => types/slack.ts} | 0 apps/bot/src/features/customizations/views.ts | 2 +- apps/bot/src/lib/agent/index.ts | 8 +- apps/bot/src/lib/agent/steering.ts | 19 ++--- apps/bot/src/lib/agent/turns.ts | 2 +- apps/bot/src/lib/agent/types/errors.ts | 1 + apps/bot/src/lib/agent/types/steering.ts | 13 +++ apps/bot/src/lib/ai/attachments.ts | 84 ++++++++++--------- apps/bot/src/lib/ai/attempts.ts | 6 +- apps/bot/src/lib/ai/stream/tasks/chat.ts | 8 +- apps/bot/src/lib/ai/stream/tasks/default.ts | 2 +- .../src/lib/ai/stream/tasks/generate-image.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/get-file.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/index.ts | 2 +- .../src/lib/ai/stream/tasks/leave-thread.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/mermaid.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/pi.ts | 2 +- .../lib/ai/stream/tasks/schedule-reminder.ts | 2 +- .../src/lib/ai/stream/tasks/search-slack.ts | 2 +- .../bot/src/lib/ai/stream/tasks/search-web.ts | 2 +- .../lib/ai/stream/tasks/summarize-thread.ts | 2 +- .../tasks/{types.ts => types/renderers.ts} | 0 .../src/lib/ai/stream/tasks/upload-file.ts | 2 +- apps/bot/src/lib/ai/tools/generate-image.ts | 8 +- apps/bot/src/lib/ai/types/attachments.ts | 6 ++ apps/bot/src/lib/ai/types/attempts.ts | 6 ++ .../src/lib/ai/types/tools/generate-image.ts | 6 ++ apps/bot/src/lib/errors.ts | 3 +- apps/bot/src/lib/onboarding.ts | 18 ++-- apps/bot/src/lib/slack/names.ts | 27 +++--- apps/bot/src/lib/slack/types/names.ts | 13 +++ 32 files changed, 192 insertions(+), 114 deletions(-) create mode 100644 .agents/skills/gorkie-cleanup/SKILL.md rename apps/bot/src/features/customizations/{types.ts => types/slack.ts} (100%) create mode 100644 apps/bot/src/lib/agent/types/errors.ts create mode 100644 apps/bot/src/lib/agent/types/steering.ts rename apps/bot/src/lib/ai/stream/tasks/{types.ts => types/renderers.ts} (100%) create mode 100644 apps/bot/src/lib/ai/types/attachments.ts create mode 100644 apps/bot/src/lib/ai/types/attempts.ts create mode 100644 apps/bot/src/lib/ai/types/tools/generate-image.ts create mode 100644 apps/bot/src/lib/slack/types/names.ts diff --git a/.agents/skills/gorkie-cleanup/SKILL.md b/.agents/skills/gorkie-cleanup/SKILL.md new file mode 100644 index 00000000..fde5b754 --- /dev/null +++ b/.agents/skills/gorkie-cleanup/SKILL.md @@ -0,0 +1,52 @@ +--- +name: gorkie-cleanup +description: > + Use when cleaning or refactoring this Gorkie Slack codebase. Covers the local + cleanup style: simplifying names, collapsing thin helpers, splitting real + ownership boundaries, trimming tool schemas, preserving Slack/AI behavior, and + running the repo validation suite. +--- + +# Gorkie Cleanup + +Clean by reducing jumps, not by adding architecture. + +## Pass Shape + +1. Read the nearby source first. Do not rename or split from vibes. +2. Find names that describe implementation history instead of current purpose. + Prefer direct names like `reply`, `turns`, `buildPrompt`, `annotateMentions`. +3. Collapse helpers that only wrap one line or have no ownership. Inline them at + the call site unless they hide a real boundary. +4. Split files only when a module owns a coherent concept: turn state, compaction, + sandbox setup, Slack mention annotation, tool factories, task rendering. +5. Make factory naming consistent. If most factories use `*Tool`, fix outliers + without changing model-facing tool keys. +6. Keep schemas terse. Add `.describe()` only for fields whose contract is not + obvious from the name. +7. Remove stale docs and prompts after renames. Search for old names before + handoff. + +## Gorkie-Specific Smells + +- `index.ts` files that own lifecycle, state, IO, and helpers at once. +- `create*`, `build*`, `with*`, `resolve*` names that hide a tiny operation. +- Three optional fields where a discriminated shape is clearer. +- Long async closures inside tool factories or object literals. +- User-facing task names that describe internal status instead of the action. +- Stale prompt/docs references after tool or agent module renames. + +## Validation + +After code changes, run: + +```bash +bun run fix +bun run typecheck +bun run check +bun run check:spelling +bun run check:knip +``` + +For file moves, deleted exports, or public entry points, also run stale-name +searches with `rg` and, when practical, a non-starting import smoke. diff --git a/apps/bot/src/features/customizations/types.ts b/apps/bot/src/features/customizations/types/slack.ts similarity index 100% rename from apps/bot/src/features/customizations/types.ts rename to apps/bot/src/features/customizations/types/slack.ts diff --git a/apps/bot/src/features/customizations/views.ts b/apps/bot/src/features/customizations/views.ts index 57de49fb..f851f5be 100644 --- a/apps/bot/src/features/customizations/views.ts +++ b/apps/bot/src/features/customizations/views.ts @@ -10,7 +10,7 @@ import type { SlackHomeView, SlackModalView, SlackTextInputElement, -} from './types'; +} from './types/slack'; const maxHomePromptLength = 600; const maxPromptLength = 3000; diff --git a/apps/bot/src/lib/agent/index.ts b/apps/bot/src/lib/agent/index.ts index 7a345a81..f8ca969a 100644 --- a/apps/bot/src/lib/agent/index.ts +++ b/apps/bot/src/lib/agent/index.ts @@ -14,21 +14,23 @@ import { buildPrompt } from '@/lib/agent/prompt'; import { createReply } from '@/lib/agent/reply'; import { sandbox } from '@/lib/agent/sandbox'; import { - type ActiveTurn, abortReasonOf, interruptTurn, pendingResumeInput, } from '@/lib/agent/steering'; import { clearTurn, getTurn, setTurn } from '@/lib/agent/turns'; +import type { AgentErrorStage } from '@/lib/agent/types/errors'; +import type { ActiveTurn } from '@/lib/agent/types/steering'; import { startThinking } from '@/lib/agent/utils'; import { promptWithAttachments, seedAttachments } from '@/lib/ai/attachments'; -import { type AttemptFailure, nextAttempt } from '@/lib/ai/attempts'; +import { nextAttempt } from '@/lib/ai/attempts'; import { requestHints } from '@/lib/ai/hints'; import { renderStream } from '@/lib/ai/stream'; import { buildTools } from '@/lib/ai/toolset'; import { runQueuedTurn } from '@/lib/ai/turn-queue'; +import type { AttemptFailure } from '@/lib/ai/types/attempts'; import { bot, slack } from '@/lib/chat'; -import { type AgentErrorStage, agentErrorMessage } from '@/lib/errors'; +import { agentErrorMessage } from '@/lib/errors'; import logger from '@/lib/logger'; import { errorMessage } from '@/lib/utils/error'; diff --git a/apps/bot/src/lib/agent/steering.ts b/apps/bot/src/lib/agent/steering.ts index 465aa94a..c062cc84 100644 --- a/apps/bot/src/lib/agent/steering.ts +++ b/apps/bot/src/lib/agent/steering.ts @@ -1,11 +1,9 @@ -import { Message, parseMarkdown, type Thread } from 'chat'; - -export interface TurnInput { - message: Message; - thread: Thread; -} - -export type AbortReason = 'interrupt' | 'stop' | 'shutdown'; +import { Message, parseMarkdown } from 'chat'; +import type { + AbortReason, + ActiveTurn, + TurnInput, +} from '@/lib/agent/types/steering'; // Carried as the AbortSignal reason so the turn loop knows why it was aborted: // an `interrupt` restarts with the queued follow-up; `stop`/`shutdown` do not. @@ -18,11 +16,6 @@ export class TurnAbort extends Error { } } -export interface ActiveTurn { - controller: AbortController; - pendingMessages: TurnInput[]; -} - export function abortReasonOf(signal: AbortSignal): AbortReason | undefined { if (!signal.aborted) { return; diff --git a/apps/bot/src/lib/agent/turns.ts b/apps/bot/src/lib/agent/turns.ts index 920a6807..ea24c465 100644 --- a/apps/bot/src/lib/agent/turns.ts +++ b/apps/bot/src/lib/agent/turns.ts @@ -1,5 +1,5 @@ -import type { ActiveTurn } from '@/lib/agent/steering'; import { TurnAbort } from '@/lib/agent/steering'; +import type { ActiveTurn } from '@/lib/agent/types/steering'; const turns = new Map(); diff --git a/apps/bot/src/lib/agent/types/errors.ts b/apps/bot/src/lib/agent/types/errors.ts new file mode 100644 index 00000000..1d2ac988 --- /dev/null +++ b/apps/bot/src/lib/agent/types/errors.ts @@ -0,0 +1 @@ +export type AgentErrorStage = 'after_progress' | 'after_text' | 'before_output'; diff --git a/apps/bot/src/lib/agent/types/steering.ts b/apps/bot/src/lib/agent/types/steering.ts new file mode 100644 index 00000000..89965da4 --- /dev/null +++ b/apps/bot/src/lib/agent/types/steering.ts @@ -0,0 +1,13 @@ +import type { Message, Thread } from 'chat'; + +export interface TurnInput { + message: Message; + thread: Thread; +} + +export type AbortReason = 'interrupt' | 'stop' | 'shutdown'; + +export interface ActiveTurn { + controller: AbortController; + pendingMessages: TurnInput[]; +} diff --git a/apps/bot/src/lib/ai/attachments.ts b/apps/bot/src/lib/ai/attachments.ts index 3a6e823d..9bba68ea 100644 --- a/apps/bot/src/lib/ai/attachments.ts +++ b/apps/bot/src/lib/ai/attachments.ts @@ -1,15 +1,9 @@ import nodePath from 'node:path/posix'; import type { SandboxContext } from '@repo/ai'; import type { Message } from 'chat'; +import type { SeededAttachment } from '@/lib/ai/types/attachments'; import { sanitizeFilename } from '@/lib/utils/sanitize'; -export interface SeededAttachment { - mimeType?: string; - name: string; - path: string; - type: string; -} - export async function seedAttachments({ message, sandboxContext, @@ -18,43 +12,55 @@ export async function seedAttachments({ sandboxContext: SandboxContext; }): Promise { const seeded = await Promise.all( - message.attachments.map( - async (attachment, index): Promise => { - const data = attachment.fetchData - ? await attachment.fetchData() - : attachment.data; - if (!data) { - return null; - } - - const fallback = `attachment-${index + 1}`; - const filename = - sanitizeFilename(nodePath.basename(attachment.name || fallback)) || - fallback; - const messageDir = sanitizeFilename(message.id) || 'message'; - const path = nodePath.join( - sandboxContext.sessionWorkDir, - 'attachments', - messageDir, - filename - ); - const bytes = - data instanceof Blob - ? new Uint8Array(await data.arrayBuffer()) - : new Uint8Array(data); - await sandboxContext.session.writeBinaryFile({ content: bytes, path }); - return { - mimeType: attachment.mimeType, - name: filename, - path, - type: attachment.type, - }; - } + message.attachments.map((attachment, index) => + seedAttachment({ attachment, index, message, sandboxContext }) ) ); return seeded.filter((entry): entry is SeededAttachment => entry !== null); } +async function seedAttachment({ + attachment, + index, + message, + sandboxContext, +}: { + attachment: Message['attachments'][number]; + index: number; + message: Message; + sandboxContext: SandboxContext; +}): Promise { + const data = attachment.fetchData + ? await attachment.fetchData() + : attachment.data; + if (!data) { + return null; + } + + const fallback = `attachment-${index + 1}`; + const filename = + sanitizeFilename(nodePath.basename(attachment.name || fallback)) || + fallback; + const messageDir = sanitizeFilename(message.id) || 'message'; + const path = nodePath.join( + sandboxContext.sessionWorkDir, + 'attachments', + messageDir, + filename + ); + const bytes = + data instanceof Blob + ? new Uint8Array(await data.arrayBuffer()) + : new Uint8Array(data); + await sandboxContext.session.writeBinaryFile({ content: bytes, path }); + return { + mimeType: attachment.mimeType, + name: filename, + path, + type: attachment.type, + }; +} + export function promptWithAttachments({ attachments, text, diff --git a/apps/bot/src/lib/ai/attempts.ts b/apps/bot/src/lib/ai/attempts.ts index 465d081c..f9a663ed 100644 --- a/apps/bot/src/lib/ai/attempts.ts +++ b/apps/bot/src/lib/ai/attempts.ts @@ -1,9 +1,5 @@ import type { PiAttempt } from '@repo/ai'; - -export interface AttemptFailure { - attempt: PiAttempt; - error: unknown; -} +import type { AttemptFailure } from '@/lib/ai/types/attempts'; export function nextAttempt({ attempts, diff --git a/apps/bot/src/lib/ai/stream/tasks/chat.ts b/apps/bot/src/lib/ai/stream/tasks/chat.ts index cb2dbe4b..36c8bd84 100644 --- a/apps/bot/src/lib/ai/stream/tasks/chat.ts +++ b/apps/bot/src/lib/ai/stream/tasks/chat.ts @@ -5,15 +5,11 @@ import { plural, textField, } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const message: ToolTaskRendererEntry = { request: ({ input }) => ({ - details: - textField(input, 'id') ?? - textField(input, 'threadId') ?? - textField(input, 'channelId') ?? - textField(input, 'userId'), + details: textField(input, 'id'), }), response: ({ output }) => ({ output: `Sent message${textField(output, 'threadId') ? ` in ${textField(output, 'threadId')}` : ''}.`, diff --git a/apps/bot/src/lib/ai/stream/tasks/default.ts b/apps/bot/src/lib/ai/stream/tasks/default.ts index 090953d4..a74c538d 100644 --- a/apps/bot/src/lib/ai/stream/tasks/default.ts +++ b/apps/bot/src/lib/ai/stream/tasks/default.ts @@ -1,5 +1,5 @@ import { errorOutput, textField } from './helpers'; -import type { DefaultToolTaskRenderer } from './types'; +import type { DefaultToolTaskRenderer } from './types/renderers'; export const defaultTool: DefaultToolTaskRenderer = { request: ({ input, toolName }) => { diff --git a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts index 9c9978b9..518ef15a 100644 --- a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts +++ b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts @@ -1,5 +1,5 @@ import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const generateImage: ToolTaskRendererEntry = { title: 'Generating image', diff --git a/apps/bot/src/lib/ai/stream/tasks/get-file.ts b/apps/bot/src/lib/ai/stream/tasks/get-file.ts index b1dbb4f0..07671d17 100644 --- a/apps/bot/src/lib/ai/stream/tasks/get-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/get-file.ts @@ -1,5 +1,5 @@ import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const getFile: ToolTaskRendererEntry = { title: 'Downloading file', diff --git a/apps/bot/src/lib/ai/stream/tasks/index.ts b/apps/bot/src/lib/ai/stream/tasks/index.ts index 7dcb87d0..fadc0579 100644 --- a/apps/bot/src/lib/ai/stream/tasks/index.ts +++ b/apps/bot/src/lib/ai/stream/tasks/index.ts @@ -18,7 +18,7 @@ import { scheduleReminder } from './schedule-reminder'; import { searchSlack } from './search-slack'; import { searchWeb } from './search-web'; import { summarizeThread } from './summarize-thread'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; import { uploadFile } from './upload-file'; type RenderPhase = 'request' | 'response' | 'error'; diff --git a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts index 48f13e1d..d335d83b 100644 --- a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts @@ -1,4 +1,4 @@ -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const leaveThread: ToolTaskRendererEntry = { title: 'Leaving thread', diff --git a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts index 7bb11b95..dc7c31fd 100644 --- a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts +++ b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts @@ -1,5 +1,5 @@ import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const mermaid: ToolTaskRendererEntry = { title: 'Creating diagram', diff --git a/apps/bot/src/lib/ai/stream/tasks/pi.ts b/apps/bot/src/lib/ai/stream/tasks/pi.ts index 41b43aa0..da85d2a3 100644 --- a/apps/bot/src/lib/ai/stream/tasks/pi.ts +++ b/apps/bot/src/lib/ai/stream/tasks/pi.ts @@ -1,5 +1,5 @@ import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const command: ToolTaskRendererEntry = { title: 'Running command', diff --git a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts index b94f30ab..04753b16 100644 --- a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts +++ b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts @@ -1,5 +1,5 @@ import { numberField, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const scheduleReminder: ToolTaskRendererEntry = { title: 'Scheduling reminder', diff --git a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts index b9e8f867..2e267872 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts @@ -1,5 +1,5 @@ import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const searchSlack: ToolTaskRendererEntry = { title: 'Searching Slack', diff --git a/apps/bot/src/lib/ai/stream/tasks/search-web.ts b/apps/bot/src/lib/ai/stream/tasks/search-web.ts index 3c3a0aea..ff96db5e 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-web.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-web.ts @@ -1,5 +1,5 @@ import { field, numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const searchWeb: ToolTaskRendererEntry = { title: 'Searching the web', diff --git a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts index a1f2014b..33d9f754 100644 --- a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts @@ -1,5 +1,5 @@ import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const summarizeThread: ToolTaskRendererEntry = { title: 'Summarizing thread', diff --git a/apps/bot/src/lib/ai/stream/tasks/types.ts b/apps/bot/src/lib/ai/stream/tasks/types/renderers.ts similarity index 100% rename from apps/bot/src/lib/ai/stream/tasks/types.ts rename to apps/bot/src/lib/ai/stream/tasks/types/renderers.ts diff --git a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts index 4cc3b808..84755147 100644 --- a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts @@ -1,5 +1,5 @@ import { booleanField, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { ToolTaskRendererEntry } from './types/renderers'; export const uploadFile: ToolTaskRendererEntry = { title: 'Uploading file', diff --git a/apps/bot/src/lib/ai/tools/generate-image.ts b/apps/bot/src/lib/ai/tools/generate-image.ts index 77eb8051..504bb7fd 100644 --- a/apps/bot/src/lib/ai/tools/generate-image.ts +++ b/apps/bot/src/lib/ai/tools/generate-image.ts @@ -1,13 +1,7 @@ import { provider } from '@repo/ai'; import { generateImage, tool } from 'ai'; import { z } from 'zod'; - -export interface GeneratedImage { - bytes: Uint8Array; - index: number; - mediaType: string; - total: number; -} +import type { GeneratedImage } from '@/lib/ai/types/tools/generate-image'; export function generateImageTool({ upload, diff --git a/apps/bot/src/lib/ai/types/attachments.ts b/apps/bot/src/lib/ai/types/attachments.ts new file mode 100644 index 00000000..83f51e7b --- /dev/null +++ b/apps/bot/src/lib/ai/types/attachments.ts @@ -0,0 +1,6 @@ +export interface SeededAttachment { + mimeType?: string; + name: string; + path: string; + type: string; +} diff --git a/apps/bot/src/lib/ai/types/attempts.ts b/apps/bot/src/lib/ai/types/attempts.ts new file mode 100644 index 00000000..c8edea1c --- /dev/null +++ b/apps/bot/src/lib/ai/types/attempts.ts @@ -0,0 +1,6 @@ +import type { PiAttempt } from '@repo/ai'; + +export interface AttemptFailure { + attempt: PiAttempt; + error: unknown; +} diff --git a/apps/bot/src/lib/ai/types/tools/generate-image.ts b/apps/bot/src/lib/ai/types/tools/generate-image.ts new file mode 100644 index 00000000..4314392a --- /dev/null +++ b/apps/bot/src/lib/ai/types/tools/generate-image.ts @@ -0,0 +1,6 @@ +export interface GeneratedImage { + bytes: Uint8Array; + index: number; + mediaType: string; + total: number; +} diff --git a/apps/bot/src/lib/errors.ts b/apps/bot/src/lib/errors.ts index 18722496..47b51fd8 100644 --- a/apps/bot/src/lib/errors.ts +++ b/apps/bot/src/lib/errors.ts @@ -1,3 +1,4 @@ +import type { AgentErrorStage } from '@/lib/agent/types/errors'; import { errorMessage } from '@/lib/utils/error'; const CREDIT_ERROR_PATTERN = @@ -7,8 +8,6 @@ const CONTEXT_ERROR_PATTERN = const PROVIDER_TIMEOUT_PATTERN = /\b(5\d\d|gateway time-out|gateway timeout|cloudflare|timeout occurred|origin_response_timeout)\b/i; -export type AgentErrorStage = 'after_progress' | 'after_text' | 'before_output'; - export function agentErrorMessage({ error, stage = 'before_output', diff --git a/apps/bot/src/lib/onboarding.ts b/apps/bot/src/lib/onboarding.ts index 1f02a0e5..18e54afc 100644 --- a/apps/bot/src/lib/onboarding.ts +++ b/apps/bot/src/lib/onboarding.ts @@ -7,6 +7,7 @@ import { CardText, type Thread, } from 'chat'; +import { z } from 'zod'; import { env } from '@/env'; import { addAllowedUser } from '@/lib/allowed-users'; import { slack } from '@/lib/chat'; @@ -20,6 +21,14 @@ import { toLogError } from '@/lib/utils/error'; export const OPT_IN_ACCEPT_ACTION = 'opt_in_accept'; +const slackErrorSchema = z.looseObject({ + data: z + .looseObject({ + error: z.string().optional(), + }) + .optional(), +}); + export async function offerOptIn(thread: Thread, user: Author): Promise { if (!env.OPT_IN_CHANNEL) { return; @@ -94,12 +103,5 @@ async function inviteToOptInChannel(userId: string): Promise { } function slackErrorCode(error: unknown): string | undefined { - if (error && typeof error === 'object' && 'data' in error) { - const data = (error as { data?: unknown }).data; - if (data && typeof data === 'object' && 'error' in data) { - const code = (data as { error?: unknown }).error; - return typeof code === 'string' ? code : undefined; - } - } - return; + return slackErrorSchema.safeParse(error).data?.data?.error; } diff --git a/apps/bot/src/lib/slack/names.ts b/apps/bot/src/lib/slack/names.ts index 7587d2e4..e0b4e710 100644 --- a/apps/bot/src/lib/slack/names.ts +++ b/apps/bot/src/lib/slack/names.ts @@ -1,18 +1,14 @@ +import { z } from 'zod'; import { bot, slack } from '@/lib/chat'; +import type { UserProfile } from '@/lib/slack/types/names'; -interface ProfileField { - label: string; - value: string; -} - -export interface UserProfile { - displayName?: string; - fields?: ProfileField[]; - pronouns?: string; - realName?: string; - status?: string; - title?: string; -} +const profileFieldsSchema = z.record( + z.string(), + z.looseObject({ + label: z.string().optional(), + value: z.string().optional(), + }) +); export async function resolveUserProfile( userId: string @@ -33,10 +29,7 @@ export async function resolveUserProfile( if (!(raw || user)) { return; } - const fields = (raw?.fields ?? {}) as Record< - string, - { label?: string; value?: string } - >; + const fields = profileFieldsSchema.parse(raw?.fields ?? {}); profile = { displayName: raw?.display_name || undefined, fields: Object.values(fields).flatMap((field) => diff --git a/apps/bot/src/lib/slack/types/names.ts b/apps/bot/src/lib/slack/types/names.ts new file mode 100644 index 00000000..8d38c8b7 --- /dev/null +++ b/apps/bot/src/lib/slack/types/names.ts @@ -0,0 +1,13 @@ +interface ProfileField { + label: string; + value: string; +} + +export interface UserProfile { + displayName?: string; + fields?: ProfileField[]; + pronouns?: string; + realName?: string; + status?: string; + title?: string; +} From 614a08eb86ddc73a796d9079aa9175b92f2e9f2a Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Wed, 24 Jun 2026 19:19:28 +0000 Subject: [PATCH 3/9] chore: ee --- .agents/skills/gorkie-cleanup/SKILL.md | 9 +- apps/bot/src/features/customizations/views.ts | 4 +- apps/bot/src/lib/agent/index.ts | 30 +--- apps/bot/src/lib/agent/steering.ts | 6 +- apps/bot/src/lib/agent/turns.ts | 2 +- apps/bot/src/lib/agent/types/errors.ts | 1 - apps/bot/src/lib/ai/attachments.ts | 2 +- apps/bot/src/lib/ai/attempts.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/chat.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/default.ts | 2 +- .../src/lib/ai/stream/tasks/generate-image.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/get-file.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/index.ts | 2 +- .../src/lib/ai/stream/tasks/leave-thread.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/mermaid.ts | 2 +- apps/bot/src/lib/ai/stream/tasks/pi.ts | 2 +- .../lib/ai/stream/tasks/schedule-reminder.ts | 2 +- .../src/lib/ai/stream/tasks/search-slack.ts | 2 +- .../bot/src/lib/ai/stream/tasks/search-web.ts | 2 +- .../lib/ai/stream/tasks/summarize-thread.ts | 2 +- .../src/lib/ai/stream/tasks/upload-file.ts | 2 +- apps/bot/src/lib/ai/tools/generate-image.ts | 2 +- apps/bot/src/lib/errors.ts | 2 +- apps/bot/src/lib/slack/names.ts | 2 +- .../types/steering.ts => types/agent.ts} | 2 + .../bot/src/{lib/ai => }/types/attachments.ts | 0 apps/bot/src/{lib/ai => }/types/attempts.ts | 0 apps/bot/src/{lib/slack => }/types/names.ts | 0 .../types/slack.ts => types/slack-views.ts} | 0 .../renderers.ts => types/task-renderers.ts} | 0 .../ai => }/types/tools/generate-image.ts | 0 docs/index.md | 1 - docs/runtime/error-catalog.md | 132 ------------------ packages/sandbox/src/provider.ts | 2 +- 34 files changed, 38 insertions(+), 187 deletions(-) delete mode 100644 apps/bot/src/lib/agent/types/errors.ts rename apps/bot/src/{lib/agent/types/steering.ts => types/agent.ts} (76%) rename apps/bot/src/{lib/ai => }/types/attachments.ts (100%) rename apps/bot/src/{lib/ai => }/types/attempts.ts (100%) rename apps/bot/src/{lib/slack => }/types/names.ts (100%) rename apps/bot/src/{features/customizations/types/slack.ts => types/slack-views.ts} (100%) rename apps/bot/src/{lib/ai/stream/tasks/types/renderers.ts => types/task-renderers.ts} (100%) rename apps/bot/src/{lib/ai => }/types/tools/generate-image.ts (100%) delete mode 100644 docs/runtime/error-catalog.md diff --git a/.agents/skills/gorkie-cleanup/SKILL.md b/.agents/skills/gorkie-cleanup/SKILL.md index fde5b754..4f6faeef 100644 --- a/.agents/skills/gorkie-cleanup/SKILL.md +++ b/.agents/skills/gorkie-cleanup/SKILL.md @@ -24,7 +24,11 @@ Clean by reducing jumps, not by adding architecture. without changing model-facing tool keys. 6. Keep schemas terse. Add `.describe()` only for fields whose contract is not obvious from the name. -7. Remove stale docs and prompts after renames. Search for old names before +7. Keep type ownership explicit. Private one-file shapes stay inline; shared or + exported shapes live in the nearest clear owner `types/` folder. App-wide bot + shapes can live under `apps/bot/src/types/`. Tool-owned shared shapes use a + `types/tools/.ts` path. +8. Remove stale docs and prompts after renames. Search for old names before handoff. ## Gorkie-Specific Smells @@ -34,6 +38,9 @@ Clean by reducing jumps, not by adding architecture. - Three optional fields where a discriminated shape is clearer. - Long async closures inside tool factories or object literals. - User-facing task names that describe internal status instead of the action. +- Exported interfaces living in implementation files when an owned `types/` + folder is the clearer home. +- Tool-owned shared types outside a `types/tools/` folder. - Stale prompt/docs references after tool or agent module renames. ## Validation diff --git a/apps/bot/src/features/customizations/views.ts b/apps/bot/src/features/customizations/views.ts index f851f5be..fbf76671 100644 --- a/apps/bot/src/features/customizations/views.ts +++ b/apps/bot/src/features/customizations/views.ts @@ -4,13 +4,13 @@ import { escapeSlackText, } from '@chat-adapter/slack/format'; import { personas } from '@repo/ai'; -import { PROMPT_INPUT } from './schema'; import type { SlackBlock, SlackHomeView, SlackModalView, SlackTextInputElement, -} from './types/slack'; +} from '@/types/slack-views'; +import { PROMPT_INPUT } from './schema'; const maxHomePromptLength = 600; const maxPromptLength = 3000; diff --git a/apps/bot/src/lib/agent/index.ts b/apps/bot/src/lib/agent/index.ts index f8ca969a..dc3eb485 100644 --- a/apps/bot/src/lib/agent/index.ts +++ b/apps/bot/src/lib/agent/index.ts @@ -19,8 +19,6 @@ import { pendingResumeInput, } from '@/lib/agent/steering'; import { clearTurn, getTurn, setTurn } from '@/lib/agent/turns'; -import type { AgentErrorStage } from '@/lib/agent/types/errors'; -import type { ActiveTurn } from '@/lib/agent/types/steering'; import { startThinking } from '@/lib/agent/utils'; import { promptWithAttachments, seedAttachments } from '@/lib/ai/attachments'; import { nextAttempt } from '@/lib/ai/attempts'; @@ -28,11 +26,12 @@ import { requestHints } from '@/lib/ai/hints'; import { renderStream } from '@/lib/ai/stream'; import { buildTools } from '@/lib/ai/toolset'; import { runQueuedTurn } from '@/lib/ai/turn-queue'; -import type { AttemptFailure } from '@/lib/ai/types/attempts'; import { bot, slack } from '@/lib/chat'; import { agentErrorMessage } from '@/lib/errors'; import logger from '@/lib/logger'; import { errorMessage } from '@/lib/utils/error'; +import type { ActiveTurn, AgentErrorStage } from '@/types/agent'; +import type { AttemptFailure } from '@/types/attempts'; export { compactTurn } from '@/lib/agent/compaction'; export { stopAllTurns, stopTurn } from '@/lib/agent/turns'; @@ -73,7 +72,6 @@ async function executeTurn( let activeAttempt: PiAttempt | undefined; let controls: Awaited> = null; let sandboxContext: SandboxContext | undefined; - let completion: { finishReason: string; textLength: number } | undefined; let reply: ReturnType | undefined; let errorStage: AgentErrorStage = 'before_output'; @@ -100,10 +98,8 @@ async function executeTurn( groupTasks: 'plan', }) ); - if (!(session && completion)) { - throw new Error( - 'Agent turn ended before session completion was recorded.' - ); + if (!session) { + throw new Error('Agent turn ended before session was recorded.'); } await reply?.flush({ thread }); if (hints.customization?.prompt && !slack.isDM(thread.id)) { @@ -117,7 +113,7 @@ async function executeTurn( await deleteControls({ controls }); await parkSession({ pause: true }); logger.info( - { ...completion, attempt: attemptLog(activeAttempt), threadId }, + { attempt: attemptLog(activeAttempt), threadId }, '[agent] turn complete' ); } catch (error) { @@ -227,22 +223,6 @@ async function executeTurn( controls ??= await postControls({ thread }); } - try { - const [text, finishReason] = await Promise.all([ - result.text, - result.finishReason, - ]); - completion = { finishReason, textLength: text.length }; - } catch (error) { - if (!streamed) { - throw error; - } - logger.warn( - { err: errorMessage(error), threadId }, - '[agent] failed to read final stream metadata after text output' - ); - completion = { finishReason: 'metadata_unavailable', textLength: 0 }; - } return; } catch (error) { attempts.push({ attempt: currentAttempt, error }); diff --git a/apps/bot/src/lib/agent/steering.ts b/apps/bot/src/lib/agent/steering.ts index c062cc84..d63302d3 100644 --- a/apps/bot/src/lib/agent/steering.ts +++ b/apps/bot/src/lib/agent/steering.ts @@ -1,9 +1,5 @@ import { Message, parseMarkdown } from 'chat'; -import type { - AbortReason, - ActiveTurn, - TurnInput, -} from '@/lib/agent/types/steering'; +import type { AbortReason, ActiveTurn, TurnInput } from '@/types/agent'; // Carried as the AbortSignal reason so the turn loop knows why it was aborted: // an `interrupt` restarts with the queued follow-up; `stop`/`shutdown` do not. diff --git a/apps/bot/src/lib/agent/turns.ts b/apps/bot/src/lib/agent/turns.ts index ea24c465..95d505b9 100644 --- a/apps/bot/src/lib/agent/turns.ts +++ b/apps/bot/src/lib/agent/turns.ts @@ -1,5 +1,5 @@ import { TurnAbort } from '@/lib/agent/steering'; -import type { ActiveTurn } from '@/lib/agent/types/steering'; +import type { ActiveTurn } from '@/types/agent'; const turns = new Map(); diff --git a/apps/bot/src/lib/agent/types/errors.ts b/apps/bot/src/lib/agent/types/errors.ts deleted file mode 100644 index 1d2ac988..00000000 --- a/apps/bot/src/lib/agent/types/errors.ts +++ /dev/null @@ -1 +0,0 @@ -export type AgentErrorStage = 'after_progress' | 'after_text' | 'before_output'; diff --git a/apps/bot/src/lib/ai/attachments.ts b/apps/bot/src/lib/ai/attachments.ts index 9bba68ea..dc48fa9a 100644 --- a/apps/bot/src/lib/ai/attachments.ts +++ b/apps/bot/src/lib/ai/attachments.ts @@ -1,8 +1,8 @@ import nodePath from 'node:path/posix'; import type { SandboxContext } from '@repo/ai'; import type { Message } from 'chat'; -import type { SeededAttachment } from '@/lib/ai/types/attachments'; import { sanitizeFilename } from '@/lib/utils/sanitize'; +import type { SeededAttachment } from '@/types/attachments'; export async function seedAttachments({ message, diff --git a/apps/bot/src/lib/ai/attempts.ts b/apps/bot/src/lib/ai/attempts.ts index f9a663ed..4a7db7de 100644 --- a/apps/bot/src/lib/ai/attempts.ts +++ b/apps/bot/src/lib/ai/attempts.ts @@ -1,5 +1,5 @@ import type { PiAttempt } from '@repo/ai'; -import type { AttemptFailure } from '@/lib/ai/types/attempts'; +import type { AttemptFailure } from '@/types/attempts'; export function nextAttempt({ attempts, diff --git a/apps/bot/src/lib/ai/stream/tasks/chat.ts b/apps/bot/src/lib/ai/stream/tasks/chat.ts index 36c8bd84..af24a733 100644 --- a/apps/bot/src/lib/ai/stream/tasks/chat.ts +++ b/apps/bot/src/lib/ai/stream/tasks/chat.ts @@ -1,3 +1,4 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { arrayLength, booleanField, @@ -5,7 +6,6 @@ import { plural, textField, } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const message: ToolTaskRendererEntry = { request: ({ input }) => ({ diff --git a/apps/bot/src/lib/ai/stream/tasks/default.ts b/apps/bot/src/lib/ai/stream/tasks/default.ts index a74c538d..b686314a 100644 --- a/apps/bot/src/lib/ai/stream/tasks/default.ts +++ b/apps/bot/src/lib/ai/stream/tasks/default.ts @@ -1,5 +1,5 @@ +import type { DefaultToolTaskRenderer } from '@/types/task-renderers'; import { errorOutput, textField } from './helpers'; -import type { DefaultToolTaskRenderer } from './types/renderers'; export const defaultTool: DefaultToolTaskRenderer = { request: ({ input, toolName }) => { diff --git a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts index 518ef15a..6a92c737 100644 --- a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts +++ b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const generateImage: ToolTaskRendererEntry = { title: 'Generating image', diff --git a/apps/bot/src/lib/ai/stream/tasks/get-file.ts b/apps/bot/src/lib/ai/stream/tasks/get-file.ts index 07671d17..19265b6f 100644 --- a/apps/bot/src/lib/ai/stream/tasks/get-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/get-file.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const getFile: ToolTaskRendererEntry = { title: 'Downloading file', diff --git a/apps/bot/src/lib/ai/stream/tasks/index.ts b/apps/bot/src/lib/ai/stream/tasks/index.ts index fadc0579..e4567f33 100644 --- a/apps/bot/src/lib/ai/stream/tasks/index.ts +++ b/apps/bot/src/lib/ai/stream/tasks/index.ts @@ -1,4 +1,5 @@ import { clamp } from '@/lib/utils/text'; +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { fetchMessages, getChannelInfo, @@ -18,7 +19,6 @@ import { scheduleReminder } from './schedule-reminder'; import { searchSlack } from './search-slack'; import { searchWeb } from './search-web'; import { summarizeThread } from './summarize-thread'; -import type { ToolTaskRendererEntry } from './types/renderers'; import { uploadFile } from './upload-file'; type RenderPhase = 'request' | 'response' | 'error'; diff --git a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts index d335d83b..df89895c 100644 --- a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts @@ -1,4 +1,4 @@ -import type { ToolTaskRendererEntry } from './types/renderers'; +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; export const leaveThread: ToolTaskRendererEntry = { title: 'Leaving thread', diff --git a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts index dc7c31fd..d64d9415 100644 --- a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts +++ b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const mermaid: ToolTaskRendererEntry = { title: 'Creating diagram', diff --git a/apps/bot/src/lib/ai/stream/tasks/pi.ts b/apps/bot/src/lib/ai/stream/tasks/pi.ts index da85d2a3..f50128eb 100644 --- a/apps/bot/src/lib/ai/stream/tasks/pi.ts +++ b/apps/bot/src/lib/ai/stream/tasks/pi.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const command: ToolTaskRendererEntry = { title: 'Running command', diff --git a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts index 04753b16..70133b4d 100644 --- a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts +++ b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { numberField, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const scheduleReminder: ToolTaskRendererEntry = { title: 'Scheduling reminder', diff --git a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts index 2e267872..a2030e25 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const searchSlack: ToolTaskRendererEntry = { title: 'Searching Slack', diff --git a/apps/bot/src/lib/ai/stream/tasks/search-web.ts b/apps/bot/src/lib/ai/stream/tasks/search-web.ts index ff96db5e..7b807155 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-web.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-web.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { field, numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const searchWeb: ToolTaskRendererEntry = { title: 'Searching the web', diff --git a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts index 33d9f754..1ca0e062 100644 --- a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const summarizeThread: ToolTaskRendererEntry = { title: 'Summarizing thread', diff --git a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts index 84755147..30a5f3a0 100644 --- a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts @@ -1,5 +1,5 @@ +import type { ToolTaskRendererEntry } from '@/types/task-renderers'; import { booleanField, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types/renderers'; export const uploadFile: ToolTaskRendererEntry = { title: 'Uploading file', diff --git a/apps/bot/src/lib/ai/tools/generate-image.ts b/apps/bot/src/lib/ai/tools/generate-image.ts index 504bb7fd..667ca931 100644 --- a/apps/bot/src/lib/ai/tools/generate-image.ts +++ b/apps/bot/src/lib/ai/tools/generate-image.ts @@ -1,7 +1,7 @@ import { provider } from '@repo/ai'; import { generateImage, tool } from 'ai'; import { z } from 'zod'; -import type { GeneratedImage } from '@/lib/ai/types/tools/generate-image'; +import type { GeneratedImage } from '@/types/tools/generate-image'; export function generateImageTool({ upload, diff --git a/apps/bot/src/lib/errors.ts b/apps/bot/src/lib/errors.ts index 47b51fd8..ac01067b 100644 --- a/apps/bot/src/lib/errors.ts +++ b/apps/bot/src/lib/errors.ts @@ -1,5 +1,5 @@ -import type { AgentErrorStage } from '@/lib/agent/types/errors'; import { errorMessage } from '@/lib/utils/error'; +import type { AgentErrorStage } from '@/types/agent'; const CREDIT_ERROR_PATTERN = /\b(credit|credits|quota|daily limit|requires more credits)\b/i; diff --git a/apps/bot/src/lib/slack/names.ts b/apps/bot/src/lib/slack/names.ts index e0b4e710..020e578b 100644 --- a/apps/bot/src/lib/slack/names.ts +++ b/apps/bot/src/lib/slack/names.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { bot, slack } from '@/lib/chat'; -import type { UserProfile } from '@/lib/slack/types/names'; +import type { UserProfile } from '@/types/names'; const profileFieldsSchema = z.record( z.string(), diff --git a/apps/bot/src/lib/agent/types/steering.ts b/apps/bot/src/types/agent.ts similarity index 76% rename from apps/bot/src/lib/agent/types/steering.ts rename to apps/bot/src/types/agent.ts index 89965da4..019d742f 100644 --- a/apps/bot/src/lib/agent/types/steering.ts +++ b/apps/bot/src/types/agent.ts @@ -1,5 +1,7 @@ import type { Message, Thread } from 'chat'; +export type AgentErrorStage = 'after_progress' | 'after_text' | 'before_output'; + export interface TurnInput { message: Message; thread: Thread; diff --git a/apps/bot/src/lib/ai/types/attachments.ts b/apps/bot/src/types/attachments.ts similarity index 100% rename from apps/bot/src/lib/ai/types/attachments.ts rename to apps/bot/src/types/attachments.ts diff --git a/apps/bot/src/lib/ai/types/attempts.ts b/apps/bot/src/types/attempts.ts similarity index 100% rename from apps/bot/src/lib/ai/types/attempts.ts rename to apps/bot/src/types/attempts.ts diff --git a/apps/bot/src/lib/slack/types/names.ts b/apps/bot/src/types/names.ts similarity index 100% rename from apps/bot/src/lib/slack/types/names.ts rename to apps/bot/src/types/names.ts diff --git a/apps/bot/src/features/customizations/types/slack.ts b/apps/bot/src/types/slack-views.ts similarity index 100% rename from apps/bot/src/features/customizations/types/slack.ts rename to apps/bot/src/types/slack-views.ts diff --git a/apps/bot/src/lib/ai/stream/tasks/types/renderers.ts b/apps/bot/src/types/task-renderers.ts similarity index 100% rename from apps/bot/src/lib/ai/stream/tasks/types/renderers.ts rename to apps/bot/src/types/task-renderers.ts diff --git a/apps/bot/src/lib/ai/types/tools/generate-image.ts b/apps/bot/src/types/tools/generate-image.ts similarity index 100% rename from apps/bot/src/lib/ai/types/tools/generate-image.ts rename to apps/bot/src/types/tools/generate-image.ts diff --git a/docs/index.md b/docs/index.md index 11d02ecf..ddc0e3c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,6 @@ flowchart LR - [Sandbox And Sessions](./runtime/sandbox): E2B lifecycle, session files, recovery, and skills. - [Streaming](./runtime/streaming): Assistant text, task rows, stop controls, and Slack limits. - [Turn Controls](./runtime/controls): Interruption, stop, shutdown, and session parking. -- [Runtime Error Catalog](./runtime/error-catalog): Observed failures, root causes, and fix paths. - [Tools](./reference/tools): The model-facing tool surface and safety boundaries. - [Prompts](./reference/prompts): How the system prompt is assembled. - [Data Model](./reference/data-model): What Postgres stores and why. diff --git a/docs/runtime/error-catalog.md b/docs/runtime/error-catalog.md deleted file mode 100644 index 41d54b6c..00000000 --- a/docs/runtime/error-catalog.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Runtime Error Catalog -description: Observed Gorkie runtime errors, likely causes, and fixes. ---- - -# Runtime Error Catalog - -This page tracks recent production and dev runtime failures seen in bot logs or Slack UI. Keep entries evidence based. If the same symptom can have multiple causes, list each separately. - -## Final Stream Metadata Failed - -**Symptom:** The bot visibly replies in Slack, then logs: - -```text -[agent] failed to read final stream metadata after text output -finishReason: metadata_unavailable -``` - -**Observed count:** 14 entries in `apps/bot/logs` on 2026-06-24. - -| Count | Cause | Evidence | -| --- | --- | --- | -| 8 | Provider gateway timeout | Hack Club returned a Cloudflare 504 HTML error page after text output had streamed. | -| 4 | Turn interruption | The final metadata read rejected with `Request was aborted`. | -| 1 | Spending limit | Provider returned `429 Daily spending limit of $3 reached`. | -| 1 | Credits or token budget | OpenRouter returned `402 This request requires more credits, or fewer max_tokens`. | - -**Why it happens:** `result.stream` can finish enough to render Slack output, but later awaits for `result.text` or `result.finishReason` can still reject while the provider finalizes metadata. After visible output, posting a generic final error is noisy, so the bot logs the failure and records `metadata_unavailable`. - -**Implemented behavior:** - -1. The bot only swallows the final metadata error after text or progress has streamed. -2. The completion log uses `metadata_unavailable` so it does not look like a model finish reason. -3. Provider 504, 429, and 402 remain separate provider or account failures, not Slack rendering failures. -4. Aborts should be checked against newer messages or stop actions before treating them as bugs. - -## Image Generation Failed - -**Symptom:** `generateImage` completes with an error, or Slack says image generation failed. - -**Observed causes:** - -| Cause | Evidence | Fix path | -| --- | --- | --- | -| Gateway timeout | `AI_RetryError: Failed after 3 attempts. Last error: AI_APICallError: Gateway Timeout`. | Retry later, improve user-facing error copy, and do not present zero uploads as success. | -| Credits or token budget | `AI_APICallError: This request requires more credits, or fewer max_tokens`. | Fix the provider account limit or route image generation to a provider key with enough credits. | - -**Notes:** The 402 is not a local image upload bug. It happens before an image exists. - -## Raw Slack Channel Id Passed To Chat Tools - -**Symptom:** Tool returns: - -```text -Adapter "C0793T42XV4" not found for channel ID "C0793T42XV4" -``` - -**Cause:** Chat SDK channel ids include the platform prefix, for example `slack:C123456`. A raw Slack channel id like `C123456` is not a Chat SDK id. - -**Fix path:** - -1. Keep ID format guidance in the global Slack prompt. -2. Keep individual tool fields short, for example `Channel id.` -3. Keep validation errors direct and short. - -## Model Not Found - -**Symptom:** Attempt fallback logs: - -```text -Model minimax/minimax-m3 was not found. -``` - -**Cause:** The selected provider route did not expose that model id at that moment. - -**Fix path:** - -1. Check the provider model registry before changing code. -2. Keep fallback order stable unless the provider is consistently broken. -3. If a provider alias changes, update `packages/ai/src/providers/pi.ts` and document the provider-side change. - -## Provider Route Missing API Key - -**Symptom:** Attempt failure says: - -```text -No API key found for nvidia. -``` - -**Cause:** The provider routed the selected model through Nvidia, but the runtime did not have a matching Nvidia key. - -**Fix path:** - -1. Treat this as provider route configuration unless local code explicitly selected Nvidia. -2. Either provide the expected key or use a model route backed by configured credentials. -3. Do not confuse this with Slack or sandbox failures. - -## Too Much Tool Activity - -**Symptom:** Slack UI becomes noisy or fragile when a turn emits many tool and reasoning events. A log or UI summary may show a large activity count. - -**Cause:** Reasoning rows were not counted against the same visible activity limit as tool rows, so the UI could exceed the intended 45 visible task cap. - -**Fix path:** - -1. Count reasoning and tool tasks through the same activity budget. -2. Show one overflow row for hidden activity. -3. Keep the overflow label generic, because it can include reasoning and tools. - -## Custom Instructions Follow The Wrong User - -**Symptom:** In a shared thread, Gorkie follows the first speaker's saved instructions even after another user pings it. - -**Cause:** Per-user customization was embedded in the persisted system prompt for the thread. A later turn by another user could resume the same session with stale user-specific instructions in history. - -**Fix path:** - -1. Keep global customization behavior rules in the static system prompt. -2. Put only the current speaker's saved customization in a live turn prelude. -3. Tell the model to treat older turn instruction blocks as historical context only. - -## Long Running Command Input Missing In UI - -**Symptom:** A task row says `Running command`, but the command details are missing or too small to identify the command. - -**Likely cause:** Long command inputs, dense comments, or task renderer detail truncation can leave the UI with little useful command text. - -**Fix path:** - -1. Render command details from the first meaningful non-comment shell lines. -2. Clamp details only after extracting the useful lines. -3. If the UI has an independent input length cutoff, make the cutoff explicit and render a stable summary instead of an empty detail. diff --git a/packages/sandbox/src/provider.ts b/packages/sandbox/src/provider.ts index 48f235be..c9eb44b2 100644 --- a/packages/sandbox/src/provider.ts +++ b/packages/sandbox/src/provider.ts @@ -136,7 +136,7 @@ export class E2BSandboxProvider implements HarnessV1SandboxProvider { this.logger.info( { sessionId, sandboxId: nextSandbox.sandboxId, template: this.template }, - '[sandbox] created e2b sandbox' + '[sandbox] created sandbox' ); return session; From 6dbd6980db0cdbc985f193b9248a14ebb25e7259 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 25 Jun 2026 04:42:33 +0000 Subject: [PATCH 4/9] feat: add deleteByThread function to sandbox queries --- AGENTS.md | 6 +- REWRITE_TODO.md | 181 ------------------ TODO.md | 285 ++++++++++++++++++++++++++++ packages/db/src/queries/sandbox.ts | 6 + REWRITE_PLAN.md => plans/rewrite.md | 5 +- 5 files changed, 297 insertions(+), 186 deletions(-) delete mode 100644 REWRITE_TODO.md create mode 100644 TODO.md rename REWRITE_PLAN.md => plans/rewrite.md (99%) diff --git a/AGENTS.md b/AGENTS.md index d07e133e..f25c1c55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,8 @@ Each conversation gets its own persistent E2B sandbox. The sandbox is remote Lin - Chat SDK: `https://github.com/vercel/chat` - Pi internals when needed: `https://github.com/earendil-works/pi` - Docs and architecture: start in `docs/index.md`. -- Cleanup tracker: `REWRITE_TODO.md`. -- Long-form build plan and coding examples: `REWRITE_PLAN.md`. +- Cleanup tracker: `TODO.md`. +- Long-form build plan and coding examples: `plans/rewrite.md`. ## Where Things Belong @@ -42,7 +42,7 @@ Each conversation gets its own persistent E2B sandbox. The sandbox is remote Lin ## Coding Rules -Full detail and examples live in `REWRITE_PLAN.md`. +Full detail and examples live in `plans/rewrite.md`. - Inline over extract: no one-shot helpers. - Avoid constants unless absolutely needed: inline one-use literals and values. diff --git a/REWRITE_TODO.md b/REWRITE_TODO.md deleted file mode 100644 index 8ce7f861..00000000 --- a/REWRITE_TODO.md +++ /dev/null @@ -1,181 +0,0 @@ -# Gorkie v2 TODO - -Working notes for the rewrite. `REWRITE_PLAN.md` is the architectural plan; this file tracks concrete remaining work, refactors, verification, and upstream bets. Keep this priority-sorted and update checkboxes as work lands. - -## P0 - Cleanup Before Slack History - -- [ ] Decide whether `apps/bot/src/lib/ai/stream/index.ts` should keep its three stream-state collections or move to a small state object. Do not refactor unless it clearly reduces clutter. -- [ ] Live verify markdown-heavy long Slack responses split into follow-up messages without `msg_too_long`, broken tables, broken code fences, stranded list items, or dangling intro lines. -- [ ] Add-assistant sidebar features, like context on the assistant sidebar for channel id, etc. `b3da0360c12bb8d1c28fd9849c18fbb747845698`. - -## P1 - Bounded Slack Context And History - -- [ ] Add a bounded Slack context prelude before each Pi prompt so Gorkie starts with the local Slack situation instead of needing to discover basic context from inside the sandbox. -- [ ] Add per-user Slack search using the Acting User's user token, not a shared bot/admin token, so Slack filters public channels, private channels, DMs, group DMs, users, and files to exactly what that user can access. -- [ ] Store Slack user-token authorization separately from bot credentials. Token lookup must be keyed by Acting User, and tool calls must fail closed if the requesting user has not authorized the needed user scopes. -- [ ] Add user-token search tools for Slack content/files/users with explicit result provenance: every prompt/tool result should say the results came from the Acting User's Slack-visible scope, not from global bot memory. -- [ ] Keep user-token search privacy scoped. Do not persist raw private-channel/DM search results into shared agent memory, global caches, or another user's thread context; only summarize into the current turn when the Slack surface is safe for the participants. -- [ ] Add guardrails for shared-channel use: if a user searches their private DMs/private channels from a public or multi-user thread, require a private draft/confirmation step before posting any sensitive result back into the shared thread. -- [ ] Thread mention behavior: when mentioned in a Slack thread, fetch the latest thread messages before prompting Pi. Target the Claude-like practical bound of the last 50 thread messages, sorted chronologically. -- [ ] Channel mention behavior: when mentioned in a channel root message, fetch recent top-level channel messages before prompting Pi. Target the Claude-like practical bound of the last 20 channel messages, sorted chronologically, excluding irrelevant bot/self noise. -- [ ] Use Chat SDK APIs first: adapter `fetchMessages` for thread replies and adapter `fetchChannelMessages` for channel context. Use Chat SDK state helpers only when pagination semantics are explicitly desired. -- [ ] Convert fetched Slack messages with `toAiMessages` from `chat/ai` when feeding model-message shaped context is useful. Prefer `includeNames: true` for multi-user thread context so Pi can distinguish speakers. -- [ ] Keep the model-facing history surface small: `listThreads` for public channel thread discovery and `readConversationHistory` for public channel/thread reads. Add another tool only if the model cannot complete a real workflow cleanly. -- [ ] Tighten Slack reader-tool privacy gates during broad history work. Gorkie can read DMs it has token access to, which is useful for current DM conversations but dangerous if tools let one user fetch or search another user's private DM context. Scope DM reads to the current DM/thread, block or require explicit approval for cross-DM reads, and make model-facing tool descriptions say private conversations are not general workspace memory. -- [ ] Make prompt text explicit: tell Pi which context was preloaded, the bounds used, and that anything outside those bounds requires calling Slack/Chat tools rather than pretending it saw the whole workspace. -- [ ] Bound by message count first, then add a token/character budget if real Slack threads produce oversized prompts. Trim oldest messages first, preserving the triggering message and direct parent/root. -- [ ] Include attachments only through Chat SDK supported paths. `toAiMessages` can include images and text-like files when `fetchData()` exists; log skipped unsupported attachments without failing the turn. -- [ ] Respect permissions and private-channel access. If Slack history fetch fails, continue with a short context note saying history was unavailable and let Pi use tools if needed. -- [ ] Add tests or harnessed smoke coverage for: thread mention with 50-message cap, channel mention with 20-message cap, DM follow-up, failed history fetch, and attachment-skipping behavior. -- [ ] Live verify in Slack that Gorkie can answer a thread-context question from the preload, and can still call `readConversationHistory` for older or broader context. - -## P2 - Reliability Verification - -- [ ] Verify sandbox deletion recovery: delete or destroy a stored sandbox, send a follow-up in the same Slack thread, confirm v2 creates a fresh sandbox, re-seeds the mirrored Pi session file, and preserves conversation memory. -- [ ] Verify attachment seeding after a fresh sandbox resume. -- [ ] Verify Slack App Home: open, edit instructions, load preset, save preset, clear instructions. -- [ ] Verify Slack root mention vs reply-only mention vs subscribed thread behavior. -- [ ] Verify Langfuse receives AI SDK spans using `@ai-sdk/otel` + `LangfuseSpanProcessor`. [it doesn't!!] - -## P3 - Tool Scope Decisions -- TODO: Markdown rendering is broken in sendMessage, sendMessageToChannel - -and buggy in normal responses -- [ ] Investigate a Harness/Pi-native subagent flow for `summarizeThread`. AI SDK has generic tool-called subagent docs, but current Gorkie wiring does not expose a clean way to run a delegated Harness/Pi agent with the same per-turn attempt, sandbox/session shape, and cancellation behavior. Keep the direct AI SDK summary path until that contract is explicit. -- [ ] Evaluate Slack carousel blocks for multi-item tool outputs, especially generated images and file lists. Decide whether carousel output should replace the current repeated file posts, and revisit native Chat SDK streaming once carousel/task rendering is stable. -- [ ] Add a draft-first DM tool flow: Gorkie can prepare a DM or group-DM message for the Acting User, show recipient/body/context, and require explicit approval before any send. -- [ ] Support user-token DM actions only behind per-user authorization: start DMs/group DMs and send messages on behalf of the Acting User when scopes allow it, but never silently fall back to a shared user token. -- [ ] Add edit/cancel/send states for drafted DMs and group DMs. The user should be able to revise generated copy before sending, cancel it without side effects, or send after a clear confirmation. -- [ ] Keep bot-DM and user-DM semantics separate in tool names, prompts, task rows, and logs. `sendDirectMessage` as the bot is not the same as drafting or sending as a user. -- [ ] Add task renderers for DM draft lifecycle: drafting, awaiting approval, edited, sent, canceled, and failed. Avoid dumping raw message bodies into logs/task summaries when the destination is private. -- [ ] Improve scheduled reminders, to say here's your reminder for xyz you asked in this thread, xyz -- [ ] Rewrite and cleanup line-reply, since it has a BUNCH of code, which can just be achieved from asking the ai to do \n\n for every response, like tables, etc. and not to exceed 3k -- [ ] Refactor error message rendering - -## P4 - BYOK Proper - -- [x] Add `BYOK_ENCRYPTION_KEY` to bot env validation and deployment docs; require enough entropy for AES-256-GCM key derivation. -- [ ] Add tests for the bot-owned versioned secret encryption helper: round-trip, tamper failure, wrong-key failure, malformed ciphertext, and no plaintext in thrown errors. -- [x] Add `user_model_credentials` Drizzle schema and queries keyed by `(user_id, provider)`; store encrypted key, base URL metadata, provider slug, selected model id, key preview, validation status, validation message, last-used timestamp, and audit timestamps. -- [x] Keep raw BYOK secrets out of `packages/db` query logs and return types unless a bot-owned decrypting service explicitly asks for the encrypted payload. -- [x] Define a single exported provider discriminant union and provider adapter map in `packages/ai` for Pi attempts: OpenRouter-compatible, opencode-go, and any direct provider added later. -- [x] Replace static `chatAttempts` call sites with per-Acting-User attempt resolution: BYOK attempts first, service attempts only when no BYOK exists or explicit service fallback is enabled. -- [x] Update `executeTurn`, `executeCompact`, `nextAttempt`, and attempt logging so provider/model selection is per turn and logs never include raw env or key material. -- [x] Refactor `summarizeThread` to receive a per-turn language model when the BYOK provider can produce one. -- [ ] Make `generateImage` policy explicit in code/UI: service image provider only unless an image-capable BYOK provider is configured. -- [x] Add App Home Model Keys UI: add/rotate/delete credential, provider input, model id input, optional base URL, status display, and masked key preview only. -- [x] Ensure Slack modal `private_metadata`, view state re-renders, prompt hints, Pi session files, E2B env, task renderers, and logs never contain raw API keys. -- [ ] Validate credentials on save or first use, store safe validation status, and surface invalid-key/auth/quota errors to only the Acting User where Slack allows. -- [x] Decide service fallback UX: default off for BYOK failures, with an explicit opt-in if users may spend the shared service key after their key fails. -- [ ] Add tests for provider-to-`customEnv` mapping, BYOK-first fallback order, service fallback disabled, invalid-key handling, App Home modal parsing, and ownership checks. -- [ ] Live smoke: save a key, run a Slack turn, run compaction, rotate the key, delete the key, trigger an invalid-key turn, and verify two users in one thread use separate credentials without leaking provider/key details. - -## Upstream AI SDK / Harness Expectations - -- [ ] Native MCP support and skill support. -- [ ] Native steering support exposing queued user messages / `submitUserMessage` cleanly. -- [ ] Refactored Pi provider selection that is not ENV-prefix and model-id-order dependent. -- [ ] `ai-retry` support at the Harness/Pi boundary so custom retry logic can be deleted. -- [ ] Native Langfuse / OTel support deep enough for Harness/Pi model/tool/session internals. -- [ ] Official AI SDK E2B provider support with the resume/session-file hooks Gorkie needs. -- [ ] Add Pi-level retry parity from the old implementation so transient provider failures can retry within Pi before Gorkie's outer attempt fallback runs. - -## Borked Stuff -- [ ] Normalize user IDs [ping word] to user id & display name. e.g @Uxxxxxxx [@gorkie DEV] -- [ ] Add a prefix by who the message was sent by, e.g twa: make me a website, izie: idk -- [ ] Even though gorkie fetches the profile, it couldn't get a user's pronouns?? [chat sdk bug] -- [ ] When there are more than 50 task items gorkie fails -- [ ] Gorkie doesn't switch model properly -- [ ] "gorkies responses are shaped by instructions", when using custom instructions -- [ ] Commands needs proper timeout, and they should timeout before the sandbox expires: -E.G: Running commandpython3 -m http.server 8080 & -SERVER_PID=$! -sleep 1 - -echo "Server PID: $SERVER_PID" -ps aux | grep "http.server" | grep -v grep - -When this bug occurs it's impossible to stop gorkie -- [ ] -[2026-06-22 04:19:41.735 +0000] ERROR: [agent] turn failed - attempt: { - "model": "qwen3.7-max", - "provider": "opencode-go" - } - threadId: "slack:C0B7QEK0MQB:1782100463.342159" - err: { - "type": "Error", - "message": "524 {\"type\":\"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/\",\"title\":\"Error 524: A timeout occurred\",\"status\":524,\"detail\":\"The origin web server did not return a complete response within the 120-second Proxy Read Timeout window. The connection was established, but the origin took too long to respond.\",\"instance\":\"a0f8717e1ca6634a\",\"error_code\":524,\"error_name\":\"origin_response_timeout\",\"error_category\":\"origin\",\"ray_id\":\"a0f8717e1ca6634a\",\"timestamp\":\"2026-06-22T04:19:31Z\",\"zone\":\"dashscope-us.aliyuncs.com\",\"cloudflare_error\":true,\"retryable\":true,\"retry_after\":120,\"owner_action_required\":true,\"what_you_should_do\":\"**Wait and retry.** Back off for at least 120 seconds. If the error persists, the website operator should check for long-running processes or an overloaded origin.\",\"footer\":\"This error was generated by Cloudflare on behalf of the website owner.\"}", - "stack": - Error: 524 {"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/","title":"Error 524: A timeout occurred","status":524,"detail":"The origin web server did not return a complete response within the 120-second Proxy Read Timeout window. The connection was established, but the origin took too long to respond.","instance":"a0f8717e1ca6634a","error_code":524,"error_name":"origin_response_timeout","error_category":"origin","ray_id":"a0f8717e1ca6634a","timestamp":"2026-06-22T04:19:31Z","zone":"dashscope-us.aliyuncs.com","cloudflare_error":true,"retryable":true,"retry_after":120,"owner_action_required":true,"what_you_should_do":"**Wait and retry.** Back off for at least 120 seconds. If the error persists, the website operator should check for long-running processes or an overloaded origin.","footer":"This error was generated by Cloudflare on behalf of the website owner."} - at (/workspaces/worktrees/gorkie-slack/thermo-cleanup/gorkie-slack/node_modules/.bun/@ai-sdk+harness-pi@1.0.0-beta.11+04cb25890130b012/node_modules/@ai-sdk/harness-pi/dist/index.js:1660:53) - at processTicksAndRejections (native:7:39) - } - -524: Timeout -- [ ] can you add a thing to ask gorkie to be able to leave a thread even if its pinged in it -- [ ] Enforcing if people are in #gorkie channel is borken in dev - - - -● You've zeroed in on the real limitation. Let me be precise: - - "Can we force a timeout?" — yes, but it doesn't fix this hang - - You can force the model to set a timeout (required timeout param via a bash override). But that won't rescue the http.server & no-redirect case, and here's why: - - That hang isn't a running command waiting — the script already exited (exit 0). What's stuck is E2B's commands.run waiting for a stdout pipe that an orphaned background process still holds open. A timeout (or a stop) fires an - abort — but abort/timeout only help if they kill the process that holds the fd. E2B's abort evidently doesn't reap that orphaned child (that's exactly why your Stop "does nothing" here). A timeout would behave the same way — it - bounds a blocking command, but it can't close a pipe held by a process it isn't killing. - - So: forcing a timeout fixes the "blocking command runs too long" case, not the "orphaned bg process leaked the fd" case. Different bug. - - How you actually handle the orphan-fd case - - Only two things truly work: - - (a) Prevent it — prompt rule (cheap, no code surgery): - Tell the model: background processes must redirect output and detach: - python -m http.server 8080 > /tmp/s.log 2>&1 & # redirect frees the fd - With the redirect, the orphan never holds commands.run's pipe → it returns in ~1 ms → no hang ever. This is the reliable fix for the common case, and it's why the prompt rule matters more than the timeout. - - (b) Reap it — custom bash override (bulletproof): - This is what opencode does and why theirs can always be stopped: they spawn detached (own process group) and on abort/timeout call kill({ forceKillAfter: "3s" }) on the whole group — SIGTERM then SIGKILL. That reaps the orphan - even when the model forgets to redirect. The cost is reimplementing bash as a host tool. - - So the honest summary - - - A timeout (forced or default) bounds blocking commands — good, do it (8-min ceiling + model-set timeouts). - - It does not fix the orphaned-bg-fd hang. For that you either prevent it (prompt: redirect+detach) or reap it (custom bash with process-group kill). - - Stop "not working" is specifically the orphan case — there's no live foreground process for the abort to kill, and E2B isn't reaping the child. - - Pragmatic path for Monday: 8-min ceiling + the redirect/background prompt rule (covers ~all real cases). The detached-process-group bash override is the eventual "can never get stuck" upgrade. Want me to do the pragmatic two - now? - -✻ Sautéed for 1m 1s - -❯ in pi - -● Short answer: you can't fix the orphan in Pi — Pi doesn't run the process. - - Look at the chain again: Pi's remoteOps.exec only does the signal wiring (the AbortController, the setTimeout, chaining your stop signal). Then it hands off: - runShell(cmd) → options.sandbox.run({ command, abortSignal }) // ← YOUR E2BSandboxSession - So Pi never spawns, never kills, never touches a process group. The actual commands.run / kill happens in your packages/sandbox/src/session.ts against E2B. Pi just passes an abort signal down and waits. - - That means: - - Process-group reaping (the opencode forceKillAfter group-kill) has to live in your sandbox layer, not in Pi. Either in E2BSandboxSession.run (wrap the command in its own group and kill the group on abort/timeout) or in a - custom bash tool you own. Pi can't do it for you. - - Pi also has no timeout/default knob. PiHarnessSettings is only { auth, model, thinkingLevel }. harness-pi's exec applies a timer only when the model passes timeout — there's no Pi-level default to set. So "force a default - timeout in Pi" isn't a thing either. - - So everything you'd want — a default ceiling, forced timeouts, orphan reaping — lands in your code, not Pi: - - Ceiling/default: config.ts executionTimeoutMs (the timeoutMs you pass to E2B). ✅ easy. - - Forced timeout param: override bash with a required-timeout host tool. (medium) - - Orphan reaping / always-killable: custom bash (or session.run) that spawns detached + group-kills on abort. (medium, the real "never stuck" fix) - - Pi gives you the signal plumbing; the process control is yours. So for Monday the realistic lever is still the 8-min ceiling in config.ts + the redirect/background prompt rule, since those are the only ones that don't require - taking over Pi's bash. Want those two? - -✻ Brewed for 48s -- [ ] For some reason gorkie can read threads in private channels fix it -- [ ] Test out leaving thread: verify the `leaveThread` tool actually works end-to-end — ask gorkie to leave, confirm it stops auto-responding to that thread, and confirm it can still be @-mentioned back in. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..0609939e --- /dev/null +++ b/TODO.md @@ -0,0 +1,285 @@ +# Gorkie TODO + +Concrete remaining work for the current rewrite, plus open TODOs imported from +the v1 reference codebase. The active v2 tracker comes first. The rewrite plan +lives in `plans/rewrite.md`. + +## Slack Context And History + +### Context Prelude + +- [ ] Add a bounded Slack context prelude before each Pi prompt so Gorkie starts + with the local Slack situation instead of discovering basic context through + tools. +- [ ] Thread mention preload: when mentioned in a Slack thread, fetch the latest + thread messages before prompting Pi. Target the last 50 thread messages, + sorted chronologically. +- [ ] Channel root mention preload: when mentioned in a channel root message, + fetch recent top-level channel messages before prompting Pi. Target the last + 20 channel messages, sorted chronologically, excluding irrelevant bot/self + noise. +- [ ] Make prompt text explicit about which context was preloaded, the bounds + used, and that anything outside those bounds requires Slack/Chat tools. +- [ ] Bound by message count first, then add a token/character budget if real + Slack threads produce oversized prompts. Trim oldest messages first while + preserving the triggering message and direct parent/root. +- [ ] Include attachments only through Chat SDK supported paths. `toAiMessages` + can include images and text-like files when `fetchData()` exists; log skipped + unsupported attachments without failing the turn. + +### Chat SDK Reads + +- [ ] Use Chat SDK APIs first: adapter `fetchMessages` for thread replies and + adapter `fetchChannelMessages` for channel context. Use Chat SDK state helpers + only when pagination semantics are explicitly needed. +- [ ] Convert fetched Slack messages with `toAiMessages` from `chat/ai` when + model-message shaped context is useful. Prefer `includeNames: true` for + multi-user thread context so Pi can distinguish speakers. +- [ ] Keep the model-facing history surface small: `listThreads` for public + channel thread discovery and `readConversationHistory` for public + channel/thread reads. Add another reader only if a real workflow cannot be + completed cleanly. +- [ ] Fix private-channel/thread access behavior. Gorkie should not let one user + use bot/tool access as global private-channel memory. + +### User-Scoped Search + +- [ ] Add per-user Slack search using the Acting User's user token, not a shared + bot/admin token, so Slack filters public channels, private channels, DMs, + group DMs, users, and files to exactly what that user can access. +- [ ] Store Slack user-token authorization separately from bot credentials. + Token lookup must be keyed by Acting User, and tool calls must fail closed + when the requesting user has not authorized the needed user scopes. +- [ ] Add user-token search tools for Slack content, files, and users with + explicit result provenance. Tool results should say they came from the Acting + User's Slack-visible scope, not from global bot memory. +- [ ] Keep user-token search privacy scoped. Do not persist raw private-channel + or DM search results into shared agent memory, global caches, or another + user's thread context. +- [ ] Add shared-channel guardrails. If a user searches private DMs or private + channels from a public or multi-user thread, require a private + draft/confirmation step before posting sensitive results back to the shared + thread. + +## Slack Actions And UX + +### Thread Behavior + +- [ ] Verify Slack root mention, reply-only mention, and subscribed-thread + behavior. +- [ ] Fix dev channel enforcement for the `#gorkie` channel. + +### Posting And Drafting + +- [ ] Support user-token DM actions only behind per-user authorization. Starting + DMs/group DMs and sending messages on behalf of the Acting User must never + silently fall back to a shared user token. +- [ ] Add DM draft lifecycle task renderers: drafting, awaiting approval, + edited, sent, canceled, and failed. Do not dump private message bodies into + logs or task summaries. +- [ ] Evaluate Slack carousel blocks for multi-item tool outputs, especially + generated images and file lists. Decide whether carousel output should replace + repeated file posts after task rendering is stable. +- [ ] Add assistant-sidebar context features, such as visible channel id/context + metadata. Reference commit: `b3da0360c12bb8d1c28fd9849c18fbb747845698`. + +## Streaming And Task Rendering + +- [ ] Decide whether `apps/bot/src/lib/ai/stream/index.ts` should keep its three + stream-state collections or move to a small state object. Refactor only if it + clearly reduces clutter. +- [ ] Live verify markdown-heavy long Slack responses split into follow-up + messages without `msg_too_long`, broken tables, broken code fences, stranded + list items, or dangling intro lines. +- [ ] Verify task activity stays under Slack limits when reasoning blocks and + tool rows are both present. Slack becomes unstable around large task counts. +- [ ] Refactor error message rendering so user-facing failures are lower-case, + stage-aware, and do not confuse provider failures with Slack rendering bugs. + +## Sandbox And Commands + +### Sandbox Recovery + +- [ ] Verify sandbox deletion recovery: delete or destroy a stored sandbox, send + a follow-up in the same Slack thread, confirm v2 creates a fresh sandbox, + re-seeds the mirrored Pi session file, and preserves conversation memory. + +### Command Execution Hangs + +- [ ] Add a prompt rule for background commands: redirect output and detach, for + example `python -m http.server 8080 > /tmp/server.log 2>&1 &`. +- [ ] Investigate a host-owned bash override or sandbox `run` wrapper that + starts commands in a process group and kills the group on abort/timeout. This + is the real fix for orphaned background processes that keep stdout/stderr + pipes open after the shell exits. + +### Provider Timeout Evidence + +- [ ] Handle retryable provider 524s cleanly. Observed failure: + `qwen3.7-max` via `opencode-go` returned a Cloudflare 524 + `origin_response_timeout` from `dashscope-us.aliyuncs.com` with + `retryable: true` and `retry_after: 120`. + +## Models, BYOK, And Providers + +### BYOK Implementation + +- [ ] Add `BYOK_ENCRYPTION_KEY` to bot env validation and deployment docs. + Require enough entropy for AES-256-GCM key derivation. +- [ ] Add a bot-owned versioned secret encryption helper. +- [ ] Add tests for the encryption helper: round-trip, tamper failure, + wrong-key failure, malformed ciphertext, and no plaintext in thrown errors. +- [ ] Add `user_model_credentials` Drizzle schema and queries keyed by + `(user_id, provider)`. Store encrypted key, base URL metadata, provider slug, + selected model id, key preview, validation status, validation message, + last-used timestamp, and audit timestamps. +- [ ] Keep raw BYOK secrets out of `packages/db` query logs and return types + unless a bot-owned decrypting service explicitly asks for the encrypted + payload. +- [ ] Define a single exported provider discriminant union and provider adapter + map in `packages/ai` for Pi attempts: OpenRouter-compatible, opencode-go, and + any direct provider added later. +- [ ] Replace static `chatAttempts` call sites with per-Acting-User attempt + resolution: BYOK attempts first, service attempts only when no BYOK exists or + explicit service fallback is enabled. +- [ ] Update turn execution, compaction, attempt selection, and attempt logging + so provider/model selection is per turn and logs never include raw env or key + material. +- [ ] Make `generateImage` policy explicit in code/UI: service image provider + only unless an image-capable BYOK provider is configured. +- [ ] Add App Home Model Keys UI: add/rotate/delete credential, provider input, + model id input, optional base URL, status display, and masked key preview + only. +- [ ] Ensure Slack modal `private_metadata`, view state re-renders, prompt + hints, Pi session files, E2B env, task renderers, and logs never contain raw + API keys. +- [ ] Validate credentials on save or first use, store safe validation status, + and surface invalid-key/auth/quota errors only to the Acting User where Slack + allows. +- [ ] Decide service fallback UX: default off for BYOK failures, with explicit + opt-in if users may spend the shared service key after their key fails. +- [ ] Add tests for provider-to-`customEnv` mapping, BYOK-first fallback order, + service fallback disabled, invalid-key handling, App Home modal parsing, and + ownership checks. +- [ ] Live smoke: save a key, run a Slack turn, run compaction, rotate the key, + delete the key, trigger an invalid-key turn, and verify two users in one + thread use separate credentials without leaking provider/key details. + +### Provider Selection + +- [ ] Investigate why Gorkie sometimes does not switch models properly. +- [ ] Verify Langfuse receives AI SDK spans using `@ai-sdk/otel` plus + `LangfuseSpanProcessor`. Current note: it does not. + +## Verification Matrix + +- [ ] Add tests or harnessed smoke coverage for thread mention with 50-message + cap, channel mention with 20-message cap, DM follow-up, failed history fetch, + and attachment-skipping behavior. +- [ ] Live verify in Slack that Gorkie can answer a thread-context question from + the preload and can still call `readConversationHistory` for older or broader + context. + +## Upstream AI SDK And Harness Bets + +- [ ] Native MCP support and skill support. +- [ ] Native steering support exposing queued user messages or + `submitUserMessage` cleanly. +- [ ] Refactored Pi provider selection that is not ENV-prefix and + model-id-order dependent. +- [ ] `ai-retry` support at the Harness/Pi boundary so custom retry logic can be + deleted. +- [ ] Native Langfuse/OTel support deep enough for Harness/Pi model, tool, and + session internals. +- [ ] Official AI SDK E2B provider support with the resume/session-file hooks + Gorkie needs. +- [ ] Pi-level retry parity with the old implementation so transient provider + failures can retry inside Pi before Gorkie's outer attempt fallback runs. + +## V1 Reference TODOs + +Imported from `/workspaces/worktrees/gorkie-slack/reference/TODO.md`. These are +not automatically v2 requirements, but they capture bugs and product gaps found +in the old implementation. + +### Provider And Model Reliability + +- [ ] Add a configurable output cap for chat and sandbox model calls so provider + fallback requests do not reserve unaffordable default output budgets. The v1 + trace showed HackClub returning Cloudflare 504 HTML, then OpenRouter rejecting + fallback with a 402 because it reserved up to `65_536` output tokens. +- [ ] Add multi-provider retry for the Pi sandbox agent so coding-agent failures + can fall back to alternative providers instead of ending the task. +- [ ] Evaluate whether the primary chat model should move from Gemini Flash to + a smaller GPT-5 family model. Document cost, latency, context, and quality + tradeoffs before changing defaults. +- [ ] Remove the local `ai-retry` patch when upstream supports AI SDK 7 without + local declaration changes. Until then, keep AI SDK packages on the same v7 + canary line, keep `ai-retry` pinned, and smoke-test provider fallback after + provider or AI SDK bumps. + +### MCP And OAuth + +- [ ] Show manually denied tools in the thinking/task panel after approval + resume. Denied approvals need synthetic pre-tasks in the new response stream. +- [ ] Improve MCP tool task display. Show the normalized MCP tool name in the + title, such as `Using Fathom: list_meetings`, plus concise clamped input and + output previews. +- [ ] Lock down connection-defining MCP server fields after creation. URL, + transport, and auth type should require deleting and re-adding the server. +- [ ] Add scheduled MCP OAuth token refresh before expiry and record refresh + failures without breaking unrelated MCP servers. +- [ ] Add MCP tool discovery before full OAuth so users can preview available + tools in the Slack setup flow. +- [ ] When setting an entire MCP tool group, apply the change to the whole group, + not only the currently visible pagination page. Search results can stay scoped + to the visible results. + +### Slack Stream And Task UX + +- [ ] If a terminal tool fires without visible reasoning, show the terminal task + directly, such as `Replied`, `Skipping`, or `Left channel`, instead of a + generic thinking task. +- [ ] Investigate intermittent missing thinking/reasoning in Slack task streams. + Verify reasoning lifecycle events appear before tool calls and remain visible + after terminal tasks. +- [ ] Prevent Slack task cards from overflowing block limits. Start a + continuation task card when the current card approaches Slack limits. +- [ ] Make Slack search errors non-fatal so a search failure does not mark an + otherwise successful task as failed. +- [ ] Fix pinned-item detection for Slack search results. Confirm whether + user-specific pinning requires `pins.list`, `conversations.info`, or another + Slack API path. +- [ ] After sandbox upload tools run, inject the uploaded file list into the + model-visible response context so the agent knows what was uploaded. + +### Sandbox And Browser Tooling + +- [ ] Investigate why `agent-browser --snapshot /path/to/file.png` does not save + files where the sandbox agent expects them. Determine whether it writes + relative to the command CWD, daemon CWD, or a temp directory. +- [ ] Add `agentmail` to the E2B sandbox template or document it as a first-run + install for the `agent-browser` skill. + +### Cleanup And Package Shape + +- [ ] Clean up message pipeline utilities from the old Slack stack: + `message-context.ts`, `triggers.ts`, `conversations.ts`, and `context.ts`. + Remove duplicated `users.info` patterns and tighten stale types if any of + that code is reintroduced. +- [ ] Continue reducing schema and type clutter. Move reusable types into clear + type owners, keep Slack actions/views feature-owned, and avoid one-shot + helpers. +- [ ] Consider whether more package extraction is worth it, especially + observability and sandbox logic, if those areas become shared by bot and + server code. +- [ ] Build out `packages/kv` before any feature uses it. Add typed key builders + and query helpers, and decide whether serverless paths need Upstash instead + of a persistent Redis connection. + +### Workflow + +- [ ] Add an opt-in checkpoint flow for larger agent tasks. Commits must stay + scoped to the current task and avoid unrelated dirty worktree changes. +- [ ] If a planning thread exceeds roughly 50 messages, start a new planning + block to avoid context degradation. diff --git a/packages/db/src/queries/sandbox.ts b/packages/db/src/queries/sandbox.ts index 5de7dc58..547609d6 100644 --- a/packages/db/src/queries/sandbox.ts +++ b/packages/db/src/queries/sandbox.ts @@ -29,6 +29,12 @@ export async function getByThread( return rows[0] ?? null; } +export async function deleteByThread(threadId: string): Promise { + await db + .delete(sandboxSessions) + .where(eq(sandboxSessions.threadId, threadId)); +} + export async function upsert(session: NewSandboxSession): Promise { await db .insert(sandboxSessions) diff --git a/REWRITE_PLAN.md b/plans/rewrite.md similarity index 99% rename from REWRITE_PLAN.md rename to plans/rewrite.md index acd5f7b0..ea66f578 100644 --- a/REWRITE_PLAN.md +++ b/plans/rewrite.md @@ -123,8 +123,9 @@ typecheck/build yet — that's expected. All removed code (incl. old schemas) li | `packages/logging` | Keep — pino. | **AI deps:** the old `ai-retry` dependency/patch is removed, but the behavior is still needed. -Rebuild an ai-powered retry/fallback layer for Harness/pi model calls instead of trying to wrap -AI SDK `generateText` directly. Keep app-specific host-tool deps in `apps/bot`, not `packages/ai`. +Rebuild retry/fallback around Harness/Pi model attempts instead of assuming every model call is +a direct AI SDK `generateText` call. Keep app-specific host-tool deps in `apps/bot`, not +`packages/ai`. `apps/server` (MCP OAuth callback) returns only in the MCP phase. ## 4. Target architecture From 1a47e432c4ba30b1d8e11526a0cb4d4ec251d452 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 25 Jun 2026 04:46:58 +0000 Subject: [PATCH 5/9] chore: update TODOs and remove unused deleteByThread function --- TODO.md | 5 +++++ packages/db/src/queries/sandbox.ts | 6 ------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 0609939e..a751e034 100644 --- a/TODO.md +++ b/TODO.md @@ -6,6 +6,11 @@ lives in `plans/rewrite.md`. ## Slack Context And History +### Summaries + +- [ ] Revisit `summarizeThread` subagent design after there is an ephemeral + Harness/Pi session path that does not create persistent sandbox rows. + ### Context Prelude - [ ] Add a bounded Slack context prelude before each Pi prompt so Gorkie starts diff --git a/packages/db/src/queries/sandbox.ts b/packages/db/src/queries/sandbox.ts index 547609d6..5de7dc58 100644 --- a/packages/db/src/queries/sandbox.ts +++ b/packages/db/src/queries/sandbox.ts @@ -29,12 +29,6 @@ export async function getByThread( return rows[0] ?? null; } -export async function deleteByThread(threadId: string): Promise { - await db - .delete(sandboxSessions) - .where(eq(sandboxSessions.threadId, threadId)); -} - export async function upsert(session: NewSandboxSession): Promise { await db .insert(sandboxSessions) From 76346044905230fcdb959afb3ccc5bdc73af9d33 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 25 Jun 2026 04:49:36 +0000 Subject: [PATCH 6/9] chore: remove constant --- .agents/skills/gorkie-cleanup/SKILL.md | 2 +- apps/bot/src/features/customizations/index.ts | 3 +-- apps/bot/src/features/customizations/schema.ts | 4 +--- apps/bot/src/features/customizations/views.ts | 5 ++--- apps/bot/src/lib/agent/compaction.ts | 5 +---- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.agents/skills/gorkie-cleanup/SKILL.md b/.agents/skills/gorkie-cleanup/SKILL.md index 4f6faeef..5f2d0358 100644 --- a/.agents/skills/gorkie-cleanup/SKILL.md +++ b/.agents/skills/gorkie-cleanup/SKILL.md @@ -1,4 +1,4 @@ ---- +all --- name: gorkie-cleanup description: > Use when cleaning or refactoring this Gorkie Slack codebase. Covers the local diff --git a/apps/bot/src/features/customizations/index.ts b/apps/bot/src/features/customizations/index.ts index ad03e099..3baff44f 100644 --- a/apps/bot/src/features/customizations/index.ts +++ b/apps/bot/src/features/customizations/index.ts @@ -15,7 +15,6 @@ import logger from '@/lib/logger'; import { toLogError } from '@/lib/utils/error'; import { openedViewSchema, - PROMPT_INPUT, parseModalState, promptFromViewValues, slackActionViewSchema, @@ -162,7 +161,7 @@ bot.onAction('home_clear_prompt', async (event) => { bot.onModalSubmit( ['home_save_prompt', 'home_save_preset_prompt'], async (event) => { - const prompt = event.values[PROMPT_INPUT]?.trim(); + const prompt = event.values.prompt?.trim(); if (prompt === undefined) { return { action: 'errors', diff --git a/apps/bot/src/features/customizations/schema.ts b/apps/bot/src/features/customizations/schema.ts index 372c43c7..fb21260b 100644 --- a/apps/bot/src/features/customizations/schema.ts +++ b/apps/bot/src/features/customizations/schema.ts @@ -1,7 +1,5 @@ import { z } from 'zod'; -export const PROMPT_INPUT = 'prompt'; - const modalStateSchema = z .object({ showPresets: z.boolean().default(false), @@ -56,6 +54,6 @@ export function promptFromViewValues({ return null; } - const input = parsed.data.customization_prompt?.[PROMPT_INPUT]; + const input = parsed.data.customization_prompt?.prompt; return typeof input?.value === 'string' ? input.value.trim() : null; } diff --git a/apps/bot/src/features/customizations/views.ts b/apps/bot/src/features/customizations/views.ts index fbf76671..5fcc640a 100644 --- a/apps/bot/src/features/customizations/views.ts +++ b/apps/bot/src/features/customizations/views.ts @@ -10,7 +10,6 @@ import type { SlackModalView, SlackTextInputElement, } from '@/types/slack-views'; -import { PROMPT_INPUT } from './schema'; const maxHomePromptLength = 600; const maxPromptLength = 3000; @@ -83,7 +82,7 @@ export function buildPromptModal({ showPresets?: boolean; }): SlackModalView { const input: SlackTextInputElement = { - action_id: PROMPT_INPUT, + action_id: 'prompt', max_length: maxPromptLength, multiline: true, placeholder: createSlackPlainText( @@ -163,7 +162,7 @@ export function buildPresetModal({ { block_id: 'customization_prompt', element: { - action_id: PROMPT_INPUT, + action_id: 'prompt', initial_value: prompt, max_length: maxPromptLength, multiline: true, diff --git a/apps/bot/src/lib/agent/compaction.ts b/apps/bot/src/lib/agent/compaction.ts index c8581825..a1bc669c 100644 --- a/apps/bot/src/lib/agent/compaction.ts +++ b/apps/bot/src/lib/agent/compaction.ts @@ -16,9 +16,6 @@ import { bot } from '@/lib/chat'; import { agentErrorMessage } from '@/lib/errors'; import logger from '@/lib/logger'; -// Compaction is queued like a turn so it never races an in-flight response on -// the same thread; Pi compacts its session in place, and we persist the smaller -// transcript so the next turn resumes from it. export function compactTurn(input: { instructions?: string; message: Message; @@ -76,7 +73,7 @@ async function executeCompact({ await sandbox.pauseSession({ threadId }); logger.info({ threadId }, '[agent] compaction complete'); await thread - .post({ markdown: '🧹 Compacted this thread’s context.' }) + .post({ markdown: "compacted this thread's context." }) .catch(() => undefined); } catch (error) { logger.error({ err: error, threadId }, '[agent] compaction failed'); From 54f37a9315966dcfcd87681e92d1a9146c07de3e Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 25 Jun 2026 05:07:17 +0000 Subject: [PATCH 7/9] refactor: obr logic --- apps/bot/src/bot.ts | 8 +-- apps/bot/src/features/customizations/views.ts | 32 ++++------- apps/bot/src/lib/agent/index.ts | 4 +- apps/bot/src/lib/agent/mentions.ts | 18 +++--- apps/bot/src/lib/agent/steering.ts | 9 +-- apps/bot/src/lib/ai/stream/index.ts | 8 +-- apps/bot/src/lib/ai/stream/tasks/chat.ts | 55 ++++++++----------- apps/bot/src/lib/ai/stream/tasks/default.ts | 20 +++---- .../src/lib/ai/stream/tasks/generate-image.ts | 10 ++-- apps/bot/src/lib/ai/stream/tasks/get-file.ts | 10 ++-- apps/bot/src/lib/ai/stream/tasks/helpers.ts | 28 +++++----- apps/bot/src/lib/ai/stream/tasks/index.ts | 8 +-- .../src/lib/ai/stream/tasks/leave-thread.ts | 4 +- apps/bot/src/lib/ai/stream/tasks/mermaid.ts | 12 ++-- apps/bot/src/lib/ai/stream/tasks/pi.ts | 16 +++--- .../lib/ai/stream/tasks/schedule-reminder.ts | 16 +++--- .../src/lib/ai/stream/tasks/search-slack.ts | 14 ++--- .../bot/src/lib/ai/stream/tasks/search-web.ts | 14 ++--- .../lib/ai/stream/tasks/summarize-thread.ts | 13 ++--- .../src/lib/ai/stream/tasks/upload-file.ts | 16 +++--- apps/bot/src/lib/ai/tools/get-channel-info.ts | 17 ++---- apps/bot/src/lib/ai/tools/search-web.ts | 4 +- apps/bot/src/lib/ai/tools/utils.ts | 7 ++- apps/bot/src/lib/ai/toolset.ts | 2 +- apps/bot/src/lib/onboarding.ts | 4 +- apps/bot/src/types/task-renderers.ts | 20 +++---- .../src/types/{slack-views.ts => views.ts} | 2 +- 27 files changed, 167 insertions(+), 204 deletions(-) rename apps/bot/src/types/{slack-views.ts => views.ts} (97%) diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index c4f7766f..e4cbf465 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -3,11 +3,7 @@ import { compactTurn, runTurn, stopTurn } from '@/lib/agent'; import { isUserAllowed } from '@/lib/allowed-users'; import { bot, slack } from '@/lib/chat'; import logger from '@/lib/logger'; -import { - acceptOptIn, - OPT_IN_ACCEPT_ACTION, - offerOptIn, -} from '@/lib/onboarding'; +import { acceptOptIn, offerOptIn } from '@/lib/onboarding'; import { toLogError } from '@/lib/utils/error'; import '@/features/assistant'; import '@/features/customizations'; @@ -59,7 +55,7 @@ bot.onSubscribedMessage(async (thread, message) => { await runCommandOrTurn(thread, message); }); -bot.onAction(OPT_IN_ACCEPT_ACTION, acceptOptIn); +bot.onAction('opt_in_accept', acceptOptIn); bot.onAction('stop_turn', async (event) => { const threadId = event.value ?? event.threadId; diff --git a/apps/bot/src/features/customizations/views.ts b/apps/bot/src/features/customizations/views.ts index 5fcc640a..f49b1436 100644 --- a/apps/bot/src/features/customizations/views.ts +++ b/apps/bot/src/features/customizations/views.ts @@ -4,12 +4,7 @@ import { escapeSlackText, } from '@chat-adapter/slack/format'; import { personas } from '@repo/ai'; -import type { - SlackBlock, - SlackHomeView, - SlackModalView, - SlackTextInputElement, -} from '@/types/slack-views'; +import type { SlackBlock, SlackHomeView, SlackModalView } from '@/types/views'; const maxHomePromptLength = 600; const maxPromptLength = 3000; @@ -81,20 +76,6 @@ export function buildPromptModal({ prompt: string | null; showPresets?: boolean; }): SlackModalView { - const input: SlackTextInputElement = { - action_id: 'prompt', - max_length: maxPromptLength, - multiline: true, - placeholder: createSlackPlainText( - 'e.g. Keep responses concise. Prefer TypeScript. Call me Alex.' - ), - type: 'plain_text_input', - }; - - if (prompt) { - input.initial_value = prompt; - } - const presetBlocks: SlackBlock[] = showPresets ? personas.map((persona) => ({ accessory: { @@ -127,7 +108,16 @@ export function buildPromptModal({ { type: 'divider' }, { block_id: 'customization_prompt', - element: input, + element: { + action_id: 'prompt', + ...(prompt ? { initial_value: prompt } : {}), + max_length: maxPromptLength, + multiline: true, + placeholder: createSlackPlainText( + 'e.g. Keep responses concise. Prefer TypeScript. Call me Alex.' + ), + type: 'plain_text_input', + }, hint: createSlackPlainText( 'Gorkie follows these instructions across every conversation.' ), diff --git a/apps/bot/src/lib/agent/index.ts b/apps/bot/src/lib/agent/index.ts index dc3eb485..171ed759 100644 --- a/apps/bot/src/lib/agent/index.ts +++ b/apps/bot/src/lib/agent/index.ts @@ -16,7 +16,7 @@ import { sandbox } from '@/lib/agent/sandbox'; import { abortReasonOf, interruptTurn, - pendingResumeInput, + queuedFollowUpInput, } from '@/lib/agent/steering'; import { clearTurn, getTurn, setTurn } from '@/lib/agent/turns'; import { startThinking } from '@/lib/agent/utils'; @@ -141,7 +141,7 @@ async function executeTurn( // single follow-up so steering does not drop intermediate corrections. const resume = abortReasonOf(controller.signal) === 'interrupt' - ? pendingResumeInput(activeTurn) + ? queuedFollowUpInput(activeTurn) : undefined; if (resume) { runTurn(resume).catch((error: unknown) => { diff --git a/apps/bot/src/lib/agent/mentions.ts b/apps/bot/src/lib/agent/mentions.ts index f28345ed..2b88f77f 100644 --- a/apps/bot/src/lib/agent/mentions.ts +++ b/apps/bot/src/lib/agent/mentions.ts @@ -1,19 +1,19 @@ import { bot } from '@/lib/chat'; -const slackUserMentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; +const mentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; export async function annotateMentions(text: string): Promise { - const names = new Map(); + const mentionNames = new Map(); const missingIds = new Set(); - for (const mention of text.matchAll(slackUserMentionPattern)) { + for (const mention of text.matchAll(mentionPattern)) { const userId = mention[1]; const label = mention[2]; - if (!userId || names.has(userId)) { + if (!userId || mentionNames.has(userId)) { continue; } if (label) { - names.set(userId, label); + mentionNames.set(userId, label); continue; } missingIds.add(userId); @@ -22,15 +22,15 @@ export async function annotateMentions(text: string): Promise { await Promise.all( [...missingIds].map(async (userId) => { const user = await bot.getUser(userId).catch(() => undefined); - names.set(userId, user?.userName ?? userId); + mentionNames.set(userId, user?.userName ?? userId); }) ); - if (names.size === 0) { + if (mentionNames.size === 0) { return text; } - return text.replace(slackUserMentionPattern, (token, userId: string) => { - const name = names.get(userId); + return text.replace(mentionPattern, (token, userId: string) => { + const name = mentionNames.get(userId); return name ? `@${name} (${userId})` : token; }); } diff --git a/apps/bot/src/lib/agent/steering.ts b/apps/bot/src/lib/agent/steering.ts index d63302d3..d079f66d 100644 --- a/apps/bot/src/lib/agent/steering.ts +++ b/apps/bot/src/lib/agent/steering.ts @@ -1,8 +1,6 @@ import { Message, parseMarkdown } from 'chat'; import type { AbortReason, ActiveTurn, TurnInput } from '@/types/agent'; -// Carried as the AbortSignal reason so the turn loop knows why it was aborted: -// an `interrupt` restarts with the queued follow-up; `stop`/`shutdown` do not. export class TurnAbort extends Error { readonly reason: AbortReason; constructor(reason: AbortReason) { @@ -16,9 +14,8 @@ export function abortReasonOf(signal: AbortSignal): AbortReason | undefined { if (!signal.aborted) { return; } - return signal.reason instanceof TurnAbort - ? signal.reason.reason - : 'interrupt'; + const { reason } = signal; + return reason instanceof TurnAbort ? reason.reason : 'interrupt'; } export function interruptTurn({ @@ -32,7 +29,7 @@ export function interruptTurn({ activeTurn.controller.abort(new TurnAbort('interrupt')); } -export function pendingResumeInput( +export function queuedFollowUpInput( activeTurn: ActiveTurn ): TurnInput | undefined { const latest = activeTurn.pendingMessages.at(-1); diff --git a/apps/bot/src/lib/ai/stream/index.ts b/apps/bot/src/lib/ai/stream/index.ts index cc531224..e8654846 100644 --- a/apps/bot/src/lib/ai/stream/index.ts +++ b/apps/bot/src/lib/ai/stream/index.ts @@ -2,7 +2,7 @@ import type { TextStreamPart, ToolSet } from 'ai'; import type { StreamChunk } from 'chat'; import logger from '@/lib/logger'; import { clamp } from '@/lib/utils/text'; -import { renderToolTask } from './tasks'; +import { renderTask } from './tasks'; const MAX_VISIBLE_TASKS = 45; const REASONING_OUTPUT_MAX_LENGTH = 2800; @@ -75,7 +75,7 @@ export async function* renderStream({ }, '[tool] called' ); - const rendered = renderToolTask({ + const rendered = renderTask({ input: part.input, phase: 'request', toolName: part.toolName, @@ -105,7 +105,7 @@ export async function* renderStream({ }, '[tool] completed' ); - const rendered = renderToolTask({ + const rendered = renderTask({ input, output: part.output, phase: 'response', @@ -135,7 +135,7 @@ export async function* renderStream({ }, '[tool] failed' ); - const rendered = renderToolTask({ + const rendered = renderTask({ input, output: part.error, phase: 'error', diff --git a/apps/bot/src/lib/ai/stream/tasks/chat.ts b/apps/bot/src/lib/ai/stream/tasks/chat.ts index af24a733..c1193ae9 100644 --- a/apps/bot/src/lib/ai/stream/tasks/chat.ts +++ b/apps/bot/src/lib/ai/stream/tasks/chat.ts @@ -1,31 +1,24 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { - arrayLength, - booleanField, - numberField, - plural, - textField, -} from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { arraySize, bool, number, plural, text } from './helpers'; -export const message: ToolTaskRendererEntry = { +export const message: TaskRendererEntry = { request: ({ input }) => ({ - details: textField(input, 'id'), + details: text(input, 'id'), }), response: ({ output }) => ({ - output: `Sent message${textField(output, 'threadId') ? ` in ${textField(output, 'threadId')}` : ''}.`, + output: `Sent message${text(output, 'threadId') ? ` in ${text(output, 'threadId')}` : ''}.`, title: 'Sent message', }), title: 'Sending message', }; -export const fetchMessages: ToolTaskRendererEntry = { +export const fetchMessages: TaskRendererEntry = { request: ({ input }) => ({ - details: textField(input, 'threadId') ?? textField(input, 'channelId'), + details: text(input, 'threadId') ?? text(input, 'channelId'), }), response: ({ input, output }) => { - const count = arrayLength(output, 'messages') ?? 0; - const target = - textField(input, 'threadId') ?? textField(input, 'channelId'); + const count = arraySize(output, 'messages') ?? 0; + const target = text(input, 'threadId') ?? text(input, 'channelId'); return { output: count === 0 && target @@ -37,9 +30,9 @@ export const fetchMessages: ToolTaskRendererEntry = { title: 'Reading messages', }; -export const listThreads: ToolTaskRendererEntry = { +export const listThreads: TaskRendererEntry = { response: ({ output }) => { - const count = arrayLength(output, 'threads') ?? 0; + const count = arraySize(output, 'threads') ?? 0; return { output: `Found ${plural(count, 'thread')}.`, title: 'Listed threads', @@ -48,13 +41,13 @@ export const listThreads: ToolTaskRendererEntry = { title: 'Listing threads', }; -export const getUser: ToolTaskRendererEntry = { +export const getUser: TaskRendererEntry = { response: ({ input, output }) => { const name = - textField(output, 'userName') ?? - textField(output, 'fullName') ?? - textField(input, 'userId'); - const pronouns = textField(output, 'pronouns'); + text(output, 'userName') ?? + text(output, 'fullName') ?? + text(input, 'userId'); + const pronouns = text(output, 'pronouns'); return { output: name ? `Found ${name}${pronouns ? ` (${pronouns})` : ''}.` @@ -65,11 +58,10 @@ export const getUser: ToolTaskRendererEntry = { title: 'Looking up user', }; -export const getChannelInfo: ToolTaskRendererEntry = { +export const getChannelInfo: TaskRendererEntry = { response: ({ output }) => { - const name = - textField(output, 'name') ?? textField(output, 'id') ?? 'channel'; - const members = numberField(output, 'memberCount'); + const name = text(output, 'name') ?? text(output, 'id') ?? 'channel'; + const members = number(output, 'memberCount'); return { output: `${name}${members === undefined ? '' : ` · ${plural(members, 'member')}`}.`, title: 'Read channel', @@ -78,14 +70,13 @@ export const getChannelInfo: ToolTaskRendererEntry = { title: 'Reading channel', }; -export const reaction: ToolTaskRendererEntry = { +export const reaction: TaskRendererEntry = { request: ({ input }) => ({ - details: textField(input, 'emoji') ?? textField(input, 'messageId'), + details: text(input, 'emoji') ?? text(input, 'messageId'), }), response: ({ input, output }) => { - const emoji = - textField(output, 'emoji') ?? textField(input, 'emoji') ?? 'reaction'; - const added = booleanField(output, 'added'); + const emoji = text(output, 'emoji') ?? text(input, 'emoji') ?? 'reaction'; + const added = bool(output, 'added'); const action = added === false ? 'Could not update' : 'Added'; return { output: `${action} :${emoji}:.`, diff --git a/apps/bot/src/lib/ai/stream/tasks/default.ts b/apps/bot/src/lib/ai/stream/tasks/default.ts index b686314a..c9b9b8e9 100644 --- a/apps/bot/src/lib/ai/stream/tasks/default.ts +++ b/apps/bot/src/lib/ai/stream/tasks/default.ts @@ -1,14 +1,14 @@ -import type { DefaultToolTaskRenderer } from '@/types/task-renderers'; -import { errorOutput, textField } from './helpers'; +import type { DefaultTaskRenderer } from '@/types/task-renderers'; +import { errorOutput, text } from './helpers'; -export const defaultTool: DefaultToolTaskRenderer = { +export const defaultTool: DefaultTaskRenderer = { request: ({ input, toolName }) => { const detail = - textField(input, 'command') ?? - textField(input, 'file_path') ?? - textField(input, 'pattern') ?? - textField(input, 'path') ?? - textField(input, 'query'); + text(input, 'command') ?? + text(input, 'file_path') ?? + text(input, 'pattern') ?? + text(input, 'path') ?? + text(input, 'query'); return { details: detail ? `${toolName}: ${detail}` : undefined, }; @@ -16,8 +16,8 @@ export const defaultTool: DefaultToolTaskRenderer = { response: ({ output }) => ({ output: (typeof output === 'string' && output.trim() ? output : undefined) ?? - textField(output, 'text') ?? - textField(output, 'error') ?? + text(output, 'text') ?? + text(output, 'error') ?? 'Completed.', }), error: ({ output }) => ({ diff --git a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts index 6a92c737..dd124e59 100644 --- a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts +++ b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts @@ -1,13 +1,13 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { numberField, plural, textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text } from './helpers'; -export const generateImage: ToolTaskRendererEntry = { +export const generateImage: TaskRendererEntry = { title: 'Generating image', request: ({ input }) => ({ - details: textField(input, 'prompt'), + details: text(input, 'prompt'), }), response: ({ output }) => { - const uploaded = numberField(output, 'uploaded') ?? 0; + const uploaded = number(output, 'uploaded') ?? 0; return { output: `Uploaded ${plural(uploaded, 'image')}.`, title: uploaded > 0 ? 'Generated image' : 'Image generation finished', diff --git a/apps/bot/src/lib/ai/stream/tasks/get-file.ts b/apps/bot/src/lib/ai/stream/tasks/get-file.ts index 19265b6f..3a32df6c 100644 --- a/apps/bot/src/lib/ai/stream/tasks/get-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/get-file.ts @@ -1,11 +1,11 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { text } from './helpers'; -export const getFile: ToolTaskRendererEntry = { +export const getFile: TaskRendererEntry = { title: 'Downloading file', - request: ({ input }) => ({ details: textField(input, 'file') }), + request: ({ input }) => ({ details: text(input, 'file') }), response: ({ output }) => ({ - output: `Downloaded ${textField(output, 'filename') ?? 'file'}.`, + output: `Downloaded ${text(output, 'filename') ?? 'file'}.`, title: 'Downloaded file', }), }; diff --git a/apps/bot/src/lib/ai/stream/tasks/helpers.ts b/apps/bot/src/lib/ai/stream/tasks/helpers.ts index 14bb14e9..962a6a85 100644 --- a/apps/bot/src/lib/ai/stream/tasks/helpers.ts +++ b/apps/bot/src/lib/ai/stream/tasks/helpers.ts @@ -1,27 +1,27 @@ -export function field(input: unknown, key: string): unknown { +export function value(input: unknown, key: string): unknown { return input && typeof input === 'object' && key in input ? Reflect.get(input, key) : undefined; } -export function textField(input: unknown, key: string): string | undefined { - const value = field(input, key); - return typeof value === 'string' && value ? value : undefined; +export function text(input: unknown, key: string): string | undefined { + const raw = value(input, key); + return typeof raw === 'string' && raw ? raw : undefined; } -export function numberField(input: unknown, key: string): number | undefined { - const value = field(input, key); - return typeof value === 'number' ? value : undefined; +export function number(input: unknown, key: string): number | undefined { + const raw = value(input, key); + return typeof raw === 'number' ? raw : undefined; } -export function booleanField(input: unknown, key: string): boolean | undefined { - const value = field(input, key); - return typeof value === 'boolean' ? value : undefined; +export function bool(input: unknown, key: string): boolean | undefined { + const raw = value(input, key); + return typeof raw === 'boolean' ? raw : undefined; } -export function arrayLength(input: unknown, key: string): number | undefined { - const value = field(input, key); - return Array.isArray(value) ? value.length : undefined; +export function arraySize(input: unknown, key: string): number | undefined { + const raw = value(input, key); + return Array.isArray(raw) ? raw.length : undefined; } export function plural( @@ -39,7 +39,7 @@ export function errorOutput(output: unknown): string | undefined { if (typeof output === 'string') { return `**Error**: ${output.replace(/^Error:\s*/, '')}`; } - const message = textField(output, 'message') ?? textField(output, 'error'); + const message = text(output, 'message') ?? text(output, 'error'); if (!message) { return '**Error**: tool failed'; } diff --git a/apps/bot/src/lib/ai/stream/tasks/index.ts b/apps/bot/src/lib/ai/stream/tasks/index.ts index e4567f33..e680fa8e 100644 --- a/apps/bot/src/lib/ai/stream/tasks/index.ts +++ b/apps/bot/src/lib/ai/stream/tasks/index.ts @@ -1,5 +1,5 @@ import { clamp } from '@/lib/utils/text'; -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; import { fetchMessages, getChannelInfo, @@ -23,7 +23,7 @@ import { uploadFile } from './upload-file'; type RenderPhase = 'request' | 'response' | 'error'; -const toolRenderers: Record = { +const renderers: Record = { bash: command, compaction: { title: 'Compacting context' }, edit: { ...file, title: 'Editing file' }, @@ -50,7 +50,7 @@ const toolRenderers: Record = { write: { ...file, title: 'Writing file' }, }; -export function renderToolTask({ +export function renderTask({ input, output, phase, @@ -61,7 +61,7 @@ export function renderToolTask({ phase: RenderPhase; toolName: string; }) { - const entry = toolRenderers[toolName]; + const entry = renderers[toolName]; const renderer = phase === 'error' ? defaultTool.error diff --git a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts index df89895c..9e913a69 100644 --- a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts @@ -1,6 +1,6 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; -export const leaveThread: ToolTaskRendererEntry = { +export const leaveThread: TaskRendererEntry = { title: 'Leaving thread', response: () => ({ output: 'Left the thread.', diff --git a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts index d64d9415..9aa35561 100644 --- a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts +++ b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts @@ -1,14 +1,14 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { text } from './helpers'; -export const mermaid: ToolTaskRendererEntry = { +export const mermaid: TaskRendererEntry = { title: 'Creating diagram', request: ({ input }) => { - const detail = textField(input, 'title') ?? textField(input, 'code'); + const detail = text(input, 'title') ?? text(input, 'code'); return { details: detail }; }, response: ({ output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, @@ -16,7 +16,7 @@ export const mermaid: ToolTaskRendererEntry = { }; } return { - output: `Uploaded ${textField(output, 'title') ?? 'diagram'}.`, + output: `Uploaded ${text(output, 'title') ?? 'diagram'}.`, title: 'Created diagram', }; }, diff --git a/apps/bot/src/lib/ai/stream/tasks/pi.ts b/apps/bot/src/lib/ai/stream/tasks/pi.ts index f50128eb..2ca6e8e6 100644 --- a/apps/bot/src/lib/ai/stream/tasks/pi.ts +++ b/apps/bot/src/lib/ai/stream/tasks/pi.ts @@ -1,10 +1,10 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { text } from './helpers'; -export const command: ToolTaskRendererEntry = { +export const command: TaskRendererEntry = { title: 'Running command', request: ({ input }) => { - const commandText = textField(input, 'command'); + const commandText = text(input, 'command'); if (!commandText) { return {}; } @@ -18,10 +18,10 @@ export const command: ToolTaskRendererEntry = { }, }; -export const file: ToolTaskRendererEntry = { +export const file: TaskRendererEntry = { title: 'Reading file', request: ({ input }) => { - const detail = textField(input, 'path') ?? textField(input, 'file_path'); + const detail = text(input, 'path') ?? text(input, 'file_path'); return { details: detail }; }, response: ({ toolName }) => { @@ -38,10 +38,10 @@ export const file: ToolTaskRendererEntry = { }, }; -export const search: ToolTaskRendererEntry = { +export const search: TaskRendererEntry = { title: 'Searching files', request: ({ input }) => { - const detail = textField(input, 'pattern') ?? textField(input, 'path'); + const detail = text(input, 'pattern') ?? text(input, 'path'); return { details: detail }; }, }; diff --git a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts index 70133b4d..853c4a40 100644 --- a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts +++ b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts @@ -1,17 +1,17 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { numberField, textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, text } from './helpers'; -export const scheduleReminder: ToolTaskRendererEntry = { +export const scheduleReminder: TaskRendererEntry = { title: 'Scheduling reminder', request: ({ input }) => { - const seconds = numberField(input, 'seconds'); - const text = textField(input, 'text'); + const seconds = number(input, 'seconds'); + const reminder = text(input, 'text'); return { - details: `${seconds ?? '?'}s${text ? ` · ${text}` : ''}`, + details: `${seconds ?? '?'}s${reminder ? ` · ${reminder}` : ''}`, }; }, response: ({ output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, @@ -19,7 +19,7 @@ export const scheduleReminder: ToolTaskRendererEntry = { }; } return { - output: `Scheduled for ${textField(output, 'scheduledFor') ?? textField(output, 'userId') ?? 'later'}.`, + output: `Scheduled for ${text(output, 'scheduledFor') ?? text(output, 'userId') ?? 'later'}.`, title: 'Scheduled reminder', }; }, diff --git a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts index a2030e25..ad58343e 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts @@ -1,21 +1,21 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { numberField, plural, textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text } from './helpers'; -export const searchSlack: ToolTaskRendererEntry = { +export const searchSlack: TaskRendererEntry = { title: 'Searching Slack', request: ({ input }) => ({ - details: textField(input, 'query'), + details: text(input, 'query'), }), response: ({ input, output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, title: 'Slack search failed', }; } - const count = numberField(output, 'resultCount') ?? 0; - const query = textField(input, 'query'); + const count = number(output, 'resultCount') ?? 0; + const query = text(input, 'query'); return { output: `Found ${plural(count, 'Slack result')}${query ? ` for "${query}"` : ''}.`, title: 'Searched Slack', diff --git a/apps/bot/src/lib/ai/stream/tasks/search-web.ts b/apps/bot/src/lib/ai/stream/tasks/search-web.ts index 7b807155..1aec6c6d 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-web.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-web.ts @@ -1,20 +1,20 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { field, numberField, plural, textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text, value } from './helpers'; -export const searchWeb: ToolTaskRendererEntry = { +export const searchWeb: TaskRendererEntry = { title: 'Searching the web', request: ({ input }) => ({ - details: textField(input, 'query'), + details: text(input, 'query'), }), response: ({ input, output }) => { - const count = numberField(output, 'resultCount') ?? 0; - const links = field(output, 'links'); + const count = number(output, 'resultCount') ?? 0; + const links = value(output, 'links'); const topLinks = Array.isArray(links) ? links .filter((link): link is string => typeof link === 'string') .slice(0, 3) : []; - const query = textField(input, 'query'); + const query = text(input, 'query'); return { output: `Found ${plural(count, 'web result')}${query ? ` for "${query}"` : ''}.${topLinks.length > 0 ? ` ${topLinks.join(', ')}` : ''}`, title: 'Searched the web', diff --git a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts index 1ca0e062..a2024f94 100644 --- a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts @@ -1,22 +1,21 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { numberField, plural, textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text } from './helpers'; -export const summarizeThread: ToolTaskRendererEntry = { +export const summarizeThread: TaskRendererEntry = { title: 'Summarizing thread', request: ({ input }) => { - const detail = - textField(input, 'threadId') ?? textField(input, 'instructions'); + const detail = text(input, 'threadId') ?? text(input, 'instructions'); return { details: detail }; }, response: ({ output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, title: 'Summary failed', }; } - const count = numberField(output, 'messageCount'); + const count = number(output, 'messageCount'); return { output: count === undefined ? undefined : `Read ${plural(count, 'message')}.`, diff --git a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts index 30a5f3a0..7872bdf8 100644 --- a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts @@ -1,18 +1,18 @@ -import type { ToolTaskRendererEntry } from '@/types/task-renderers'; -import { booleanField, textField } from './helpers'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { bool, text } from './helpers'; -export const uploadFile: ToolTaskRendererEntry = { +export const uploadFile: TaskRendererEntry = { title: 'Uploading file', request: ({ input }) => ({ - details: textField(input, 'path'), + details: text(input, 'path'), }), response: ({ input, output }) => { const filename = - textField(output, 'filename') ?? - textField(input, 'filename') ?? - textField(input, 'path') ?? + text(output, 'filename') ?? + text(input, 'filename') ?? + text(input, 'path') ?? 'file'; - const uploaded = booleanField(output, 'uploaded'); + const uploaded = bool(output, 'uploaded'); return { output: uploaded === false diff --git a/apps/bot/src/lib/ai/tools/get-channel-info.ts b/apps/bot/src/lib/ai/tools/get-channel-info.ts index 3a62e6c6..8ccf369a 100644 --- a/apps/bot/src/lib/ai/tools/get-channel-info.ts +++ b/apps/bot/src/lib/ai/tools/get-channel-info.ts @@ -1,14 +1,11 @@ import { tool } from 'ai'; -import type { Chat } from 'chat'; import { z } from 'zod'; -import { slack } from '@/lib/chat'; import { toChatSlackChannelId } from '@/lib/slack/ids'; +import { assertReadableChannel } from './utils'; export function getChannelInfoTool({ - bot, currentThreadId, }: { - bot: Chat; currentThreadId: string; }) { return tool({ @@ -19,15 +16,9 @@ export function getChannelInfoTool({ }), execute: async ({ channelId }) => { const chatChannelId = toChatSlackChannelId(channelId); - const info = await bot.channel(chatChannelId).fetchMetadata(); - if ( - slack.channelIdFromThreadId(currentThreadId) !== chatChannelId && - (info.isDM || info.channelVisibility !== 'workspace') - ) { - throw new Error( - 'Reading DMs, private channels, or external conversations is not allowed.' - ); - } + const info = await assertReadableChannel(chatChannelId, { + currentThreadId, + }); return { id: info.id, name: info.name, diff --git a/apps/bot/src/lib/ai/tools/search-web.ts b/apps/bot/src/lib/ai/tools/search-web.ts index 6ae7cba4..16997afd 100644 --- a/apps/bot/src/lib/ai/tools/search-web.ts +++ b/apps/bot/src/lib/ai/tools/search-web.ts @@ -15,10 +15,10 @@ export function searchWebTool({ apiKey }: { apiKey: string }) { .describe("A specific, clear web search query for what you're after."), }), execute: async ({ query }) => { - const { results } = await exa.searchAndContents(query, { + const { results } = await exa.search(query, { type: 'auto', numResults: 8, - text: { maxCharacters: 1200 }, + contents: { text: { maxCharacters: 1200 } }, }); const links = results.slice(0, 5).map((result) => result.url); return { diff --git a/apps/bot/src/lib/ai/tools/utils.ts b/apps/bot/src/lib/ai/tools/utils.ts index f24ce647..f52b6e39 100644 --- a/apps/bot/src/lib/ai/tools/utils.ts +++ b/apps/bot/src/lib/ai/tools/utils.ts @@ -4,19 +4,20 @@ import { toRawSlackChannelId } from '@/lib/slack/ids'; export async function assertReadableChannel( chatChannelId: string, options?: { currentThreadId?: string } -): Promise { +) { const currentChannelId = options?.currentThreadId ? slack.channelIdFromThreadId(options.currentThreadId) : undefined; + const metadata = await bot.channel(chatChannelId).fetchMetadata(); if (currentChannelId && chatChannelId === currentChannelId) { - return; + return metadata; } - const metadata = await bot.channel(chatChannelId).fetchMetadata(); if (metadata.isDM || metadata.channelVisibility !== 'workspace') { throw new Error( 'Reading DMs, private channels, or external conversations is not allowed.' ); } + return metadata; } export async function joinChannel(channelId: string): Promise { diff --git a/apps/bot/src/lib/ai/toolset.ts b/apps/bot/src/lib/ai/toolset.ts index c054198e..9499b221 100644 --- a/apps/bot/src/lib/ai/toolset.ts +++ b/apps/bot/src/lib/ai/toolset.ts @@ -40,7 +40,7 @@ export function buildTools({ readConversationHistory: readConversationHistoryTool({ currentThreadId: thread.id, }), - getChannelInfo: getChannelInfoTool({ bot, currentThreadId: thread.id }), + getChannelInfo: getChannelInfoTool({ currentThreadId: thread.id }), mermaid: mermaidTool({ thread }), scheduleReminder: scheduleReminderTool({ message }), searchSlack: searchSlackTool({ message }), diff --git a/apps/bot/src/lib/onboarding.ts b/apps/bot/src/lib/onboarding.ts index 18e54afc..3437c6f7 100644 --- a/apps/bot/src/lib/onboarding.ts +++ b/apps/bot/src/lib/onboarding.ts @@ -19,8 +19,6 @@ import { toLogError } from '@/lib/utils/error'; // silence. Clicking "I accept" is the recorded consent: it grants access and // invites them into the terms channel. -export const OPT_IN_ACCEPT_ACTION = 'opt_in_accept'; - const slackErrorSchema = z.looseObject({ data: z .looseObject({ @@ -47,7 +45,7 @@ export async function offerOptIn(thread: Thread, user: Author): Promise { ), Actions([ Button({ - id: OPT_IN_ACCEPT_ACTION, + id: 'opt_in_accept', label: 'I accept — opt me in', style: 'primary', value: thread.id, diff --git a/apps/bot/src/types/task-renderers.ts b/apps/bot/src/types/task-renderers.ts index e00aba38..e0b3d5f1 100644 --- a/apps/bot/src/types/task-renderers.ts +++ b/apps/bot/src/types/task-renderers.ts @@ -1,25 +1,25 @@ -interface ToolTaskRenderInput { +interface TaskRenderInput { input: unknown; output?: unknown; toolName: string; } -interface ToolTaskRenderResult { +interface RenderedTask { details?: string; output?: string; title?: string; } -type ToolTaskRenderer = (input: ToolTaskRenderInput) => ToolTaskRenderResult; +type TaskRenderer = (input: TaskRenderInput) => RenderedTask; -export interface DefaultToolTaskRenderer { - error: ToolTaskRenderer; - request: ToolTaskRenderer; - response: ToolTaskRenderer; +export interface DefaultTaskRenderer { + error: TaskRenderer; + request: TaskRenderer; + response: TaskRenderer; } -export interface ToolTaskRendererEntry { - request?: ToolTaskRenderer; - response?: ToolTaskRenderer; +export interface TaskRendererEntry { + request?: TaskRenderer; + response?: TaskRenderer; title: string; } diff --git a/apps/bot/src/types/slack-views.ts b/apps/bot/src/types/views.ts similarity index 97% rename from apps/bot/src/types/slack-views.ts rename to apps/bot/src/types/views.ts index 3af79e1d..98707949 100644 --- a/apps/bot/src/types/slack-views.ts +++ b/apps/bot/src/types/views.ts @@ -16,7 +16,7 @@ interface SlackButtonElement { value?: string; } -export interface SlackTextInputElement { +interface SlackTextInputElement { action_id: string; initial_value?: string; max_length?: number; From 02868d45d2d7951d0380e5ede9f42b3826a675f0 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 25 Jun 2026 05:26:37 +0000 Subject: [PATCH 8/9] refactor: clean up bot logic and improve command handling --- apps/bot/src/bot.ts | 38 ++-------- apps/bot/src/lib/agent/index.ts | 4 +- apps/bot/src/lib/agent/prompt.ts | 20 +---- apps/bot/src/lib/agent/steering.ts | 4 +- apps/bot/src/lib/ai/tools/summarize-thread.ts | 8 +- apps/bot/src/lib/allowed-users.ts | 4 + apps/bot/src/lib/commands.ts | 75 +++++++++++++++++++ apps/bot/src/lib/onboarding.ts | 17 ++--- apps/bot/src/lib/utils/message.ts | 24 ++++++ 9 files changed, 126 insertions(+), 68 deletions(-) create mode 100644 apps/bot/src/lib/commands.ts create mode 100644 apps/bot/src/lib/utils/message.ts diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index e4cbf465..70c1ccb1 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,10 +1,12 @@ import type { Message, Thread } from 'chat'; -import { compactTurn, runTurn, stopTurn } from '@/lib/agent'; +import { runTurn, stopTurn } from '@/lib/agent'; import { isUserAllowed } from '@/lib/allowed-users'; import { bot, slack } from '@/lib/chat'; +import { handleCommand } from '@/lib/commands'; import logger from '@/lib/logger'; import { acceptOptIn, offerOptIn } from '@/lib/onboarding'; import { toLogError } from '@/lib/utils/error'; +import { rawText, withoutLeadingMentions } from '@/lib/utils/message'; import '@/features/assistant'; import '@/features/customizations'; @@ -63,7 +65,7 @@ bot.onAction('stop_turn', async (event) => { if (!stopped) { await event.thread - ?.postEphemeral(event.user, 'No active response to stop.', { + ?.postEphemeral(event.user, 'no active response to stop.', { fallbackToDM: false, }) .catch((error: unknown) => { @@ -79,39 +81,11 @@ bot.onAction('stop_turn', async (event) => { } }); -// Leading `<@U...>` mentions Slack puts before the actual message body. -const LEADING_MENTIONS = /^\s*(?:<@[A-Z0-9][A-Z0-9._-]*(?:\|[^>]+)?>\s*)+/; -const COMPACT_COMMAND = /^!compact\b(.*)$/is; - -function rawText(message: Message): string { - const raw = message.raw; - return raw && - typeof raw === 'object' && - 'text' in raw && - typeof raw.text === 'string' - ? raw.text - : message.text; -} - -// A `!compact` command addressed to Gorkie returns its (possibly empty) custom -// summary instructions; anything else returns null. -function compactInstructions(message: Message): string | null { - const body = rawText(message).replace(LEADING_MENTIONS, '').trim(); - const match = body.match(COMPACT_COMMAND); - return match ? (match[1] ?? '').trim() : null; -} - async function runCommandOrTurn( thread: Thread, message: Message ): Promise { - const instructions = compactInstructions(message); - if (instructions !== null) { - await compactTurn({ - instructions: instructions || undefined, - message, - thread, - }); + if (await handleCommand({ message, thread })) { return; } await runTurn({ message, thread }); @@ -127,7 +101,7 @@ function shouldIgnore(message: Message): boolean { } for (const line of rawText(message).split('\n')) { - if (line.replace(LEADING_MENTIONS, '').trimStart().startsWith('##')) { + if (withoutLeadingMentions(line).trimStart().startsWith('##')) { return true; } } diff --git a/apps/bot/src/lib/agent/index.ts b/apps/bot/src/lib/agent/index.ts index 171ed759..1a946adf 100644 --- a/apps/bot/src/lib/agent/index.ts +++ b/apps/bot/src/lib/agent/index.ts @@ -16,7 +16,7 @@ import { sandbox } from '@/lib/agent/sandbox'; import { abortReasonOf, interruptTurn, - queuedFollowUpInput, + queuedInput, } from '@/lib/agent/steering'; import { clearTurn, getTurn, setTurn } from '@/lib/agent/turns'; import { startThinking } from '@/lib/agent/utils'; @@ -141,7 +141,7 @@ async function executeTurn( // single follow-up so steering does not drop intermediate corrections. const resume = abortReasonOf(controller.signal) === 'interrupt' - ? queuedFollowUpInput(activeTurn) + ? queuedInput(activeTurn) : undefined; if (resume) { runTurn(resume).catch((error: unknown) => { diff --git a/apps/bot/src/lib/agent/prompt.ts b/apps/bot/src/lib/agent/prompt.ts index e2be1cad..d29d5b08 100644 --- a/apps/bot/src/lib/agent/prompt.ts +++ b/apps/bot/src/lib/agent/prompt.ts @@ -1,6 +1,7 @@ import { slackMrkdwnToMarkdown } from '@chat-adapter/slack/format'; import type { Message } from 'chat'; import { annotateMentions } from '@/lib/agent/mentions'; +import { rawSlackText } from '@/lib/utils/message'; export async function buildPrompt( message: Message, @@ -11,9 +12,9 @@ export async function buildPrompt( } = {} ): Promise { const prefix = `@${message.author.userName} (${message.author.userId})`; - const rawText = getRawSlackText(message); - const text = rawText - ? slackMrkdwnToMarkdown(await annotateMentions(rawText)) + const slackText = rawSlackText(message); + const text = slackText + ? slackMrkdwnToMarkdown(await annotateMentions(slackText)) : message.text; const messageText = `${prefix}: ${text}`; @@ -27,16 +28,3 @@ export async function buildPrompt( ].join('\n') : messageText; } - -function getRawSlackText(message: Message): string | undefined { - const raw = message.raw; - if ( - !raw || - typeof raw !== 'object' || - !('text' in raw) || - typeof raw.text !== 'string' - ) { - return; - } - return raw.text; -} diff --git a/apps/bot/src/lib/agent/steering.ts b/apps/bot/src/lib/agent/steering.ts index d079f66d..1e7ec23a 100644 --- a/apps/bot/src/lib/agent/steering.ts +++ b/apps/bot/src/lib/agent/steering.ts @@ -29,9 +29,7 @@ export function interruptTurn({ activeTurn.controller.abort(new TurnAbort('interrupt')); } -export function queuedFollowUpInput( - activeTurn: ActiveTurn -): TurnInput | undefined { +export function queuedInput(activeTurn: ActiveTurn): TurnInput | undefined { const latest = activeTurn.pendingMessages.at(-1); if (!latest) { return; diff --git a/apps/bot/src/lib/ai/tools/summarize-thread.ts b/apps/bot/src/lib/ai/tools/summarize-thread.ts index 7b230c3a..3906e6c2 100644 --- a/apps/bot/src/lib/ai/tools/summarize-thread.ts +++ b/apps/bot/src/lib/ai/tools/summarize-thread.ts @@ -23,11 +23,9 @@ export function summarizeThreadTool({ }), execute: async (input) => { const targetThreadId = input.threadId ?? threadId; - if (targetThreadId.startsWith('slack:')) { - const channelId = slack.channelIdFromThreadId(targetThreadId); - await assertReadableChannel(channelId, { currentThreadId: threadId }); - await joinChannel(channelId); - } + const channelId = slack.channelIdFromThreadId(targetThreadId); + await assertReadableChannel(channelId, { currentThreadId: threadId }); + await joinChannel(channelId); const result = await bot .thread(targetThreadId) .adapter.fetchMessages(targetThreadId, { diff --git a/apps/bot/src/lib/allowed-users.ts b/apps/bot/src/lib/allowed-users.ts index 4439b7eb..94247e0b 100644 --- a/apps/bot/src/lib/allowed-users.ts +++ b/apps/bot/src/lib/allowed-users.ts @@ -40,8 +40,12 @@ export async function addAllowedUser(userId: string): Promise { const allowedUsers = new Set( (await state.get(allowlistKey(channel))) ?? [] ); + const wasAllowed = allowedUsers.has(userId); allowedUsers.add(userId); await state.set(allowlistKey(channel), [...allowedUsers]); + if (!wasAllowed) { + logger.info({ channel, userId }, '[allowlist] user opted in'); + } } catch (error) { logger.warn( { ...toLogError(error), channel, userId }, diff --git a/apps/bot/src/lib/commands.ts b/apps/bot/src/lib/commands.ts new file mode 100644 index 00000000..6eb9d058 --- /dev/null +++ b/apps/bot/src/lib/commands.ts @@ -0,0 +1,75 @@ +import type { Message, Thread } from 'chat'; +import { compactTurn, stopTurn } from '@/lib/agent'; +import logger from '@/lib/logger'; +import { toLogError } from '@/lib/utils/error'; +import { rawText, withoutLeadingMentions } from '@/lib/utils/message'; + +type BotCommand = + | { + instructions?: string; + type: 'compact'; + } + | { + type: 'stop'; + }; + +export async function handleCommand({ + message, + thread, +}: { + message: Message; + thread: Thread; +}): Promise { + const command = cmd(message); + if (!command) { + return false; + } + if (command.type === 'compact') { + await compactTurn({ + instructions: command.instructions, + message, + thread, + }); + return true; + } + + const stopped = stopTurn({ threadId: thread.id }); + if (!stopped) { + await thread + .postEphemeral(message.author, 'no active response to stop.', { + fallbackToDM: false, + }) + .catch((error: unknown) => { + logger.warn( + { + ...toLogError(error), + threadId: thread.id, + userId: message.author.userId, + }, + 'Failed to post stop feedback' + ); + }); + } + return true; +} + +function cmd(message: Message): BotCommand | null { + const body = withoutLeadingMentions(rawText(message)).trim(); + + const match = body.match(/^!(\w+)\b(.*)$/is); + if (!match?.[1]) { + return null; + } + + switch (match[1].toLowerCase()) { + case 'compact': + return { + instructions: (match?.[2] ?? '').trim() || undefined, + type: 'compact', + }; + case 'stop': + return { type: 'stop' }; + default: + return null; + } +} diff --git a/apps/bot/src/lib/onboarding.ts b/apps/bot/src/lib/onboarding.ts index 3437c6f7..cd2d4811 100644 --- a/apps/bot/src/lib/onboarding.ts +++ b/apps/bot/src/lib/onboarding.ts @@ -35,18 +35,18 @@ export async function offerOptIn(thread: Thread, user: Author): Promise { await thread.postEphemeral( user, Card({ - title: '👋 First time meeting Gorkie', + title: ':wave: first time meeting gorkie', children: [ CardText( - `Hi! I'm Gorkie. Before I can help, you need to accept the terms posted in <#${env.OPT_IN_CHANNEL}>.` + `hi! i'm gorkie. before i can help, you need to accept the terms posted in <#${env.OPT_IN_CHANNEL}>.` ), CardText( - "Tap below to opt in — I'll add you to the terms channel and we can get started." + "tap below to opt in, i'll add you to the terms channel and we can get started." ), Actions([ Button({ id: 'opt_in_accept', - label: 'I accept — opt me in', + label: 'i accept, opt me in', style: 'primary', value: thread.id, }), @@ -70,7 +70,7 @@ export async function acceptOptIn(event: ActionEvent): Promise { await event.thread ?.postEphemeral( event.user, - "✅ You're all set — welcome to Gorkie! Ask me anything.", + "you're all set, welcome to gorkie. ask me anything.", { fallbackToDM: true } ) .catch((error: unknown) => { @@ -90,7 +90,8 @@ async function inviteToOptInChannel(userId: string): Promise { await slack.webClient.conversations.invite({ channel, users: userId }); } catch (error) { // Already a member is success; external users can't be invited (we log it). - if (slackErrorCode(error) === 'already_in_channel') { + const slackError = slackErrorSchema.safeParse(error).data?.data?.error; + if (slackError === 'already_in_channel') { return; } logger.warn( @@ -99,7 +100,3 @@ async function inviteToOptInChannel(userId: string): Promise { ); } } - -function slackErrorCode(error: unknown): string | undefined { - return slackErrorSchema.safeParse(error).data?.data?.error; -} diff --git a/apps/bot/src/lib/utils/message.ts b/apps/bot/src/lib/utils/message.ts new file mode 100644 index 00000000..c72a1079 --- /dev/null +++ b/apps/bot/src/lib/utils/message.ts @@ -0,0 +1,24 @@ +import type { Message } from 'chat'; + +const leadingMentions = /^\s*(?:<@[A-Z0-9][A-Z0-9._-]*(?:\|[^>]+)?>\s*)+/; + +export function rawSlackText(message: Message): string | undefined { + const raw = message.raw; + if ( + !raw || + typeof raw !== 'object' || + !('text' in raw) || + typeof raw.text !== 'string' + ) { + return; + } + return raw.text; +} + +export function rawText(message: Message): string { + return rawSlackText(message) ?? message.text; +} + +export function withoutLeadingMentions(text: string): string { + return text.replace(leadingMentions, ''); +} From c3f7ffcac92c248cb14a69a1a1e01763fe13cd8e Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 25 Jun 2026 09:36:47 +0000 Subject: [PATCH 9/9] refactor: rename gorkie-cleanup to gorkie-refactor and update description --- .agents/skills/{gorkie-cleanup => refactor}/SKILL.md | 6 +++--- .claude/skills/refactor | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) rename .agents/skills/{gorkie-cleanup => refactor}/SKILL.md (98%) create mode 120000 .claude/skills/refactor diff --git a/.agents/skills/gorkie-cleanup/SKILL.md b/.agents/skills/refactor/SKILL.md similarity index 98% rename from .agents/skills/gorkie-cleanup/SKILL.md rename to .agents/skills/refactor/SKILL.md index 5f2d0358..ce6740c5 100644 --- a/.agents/skills/gorkie-cleanup/SKILL.md +++ b/.agents/skills/refactor/SKILL.md @@ -1,5 +1,5 @@ -all --- -name: gorkie-cleanup +--- +name: refactor description: > Use when cleaning or refactoring this Gorkie Slack codebase. Covers the local cleanup style: simplifying names, collapsing thin helpers, splitting real @@ -7,7 +7,7 @@ description: > running the repo validation suite. --- -# Gorkie Cleanup +# Gorkie Refactor Clean by reducing jumps, not by adding architecture. diff --git a/.claude/skills/refactor b/.claude/skills/refactor new file mode 120000 index 00000000..bf174cc1 --- /dev/null +++ b/.claude/skills/refactor @@ -0,0 +1 @@ +../../.agents/skills/gorkie-cleanup \ No newline at end of file