From 4c8bbca46fc30b18645673149b38da451c09bc6c Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 15:01:19 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20Created=20pure-function=20event=20forma?= =?UTF-8?q?tters=20(10=20functions)=20mapping=20RPC=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/event-formatter.ts" - "packages/daemon/src/verbosity.ts" - "packages/daemon/src/event-formatter.test.ts" - "packages/daemon/src/verbosity.test.ts" - "packages/daemon/src/types.ts" - "packages/daemon/src/config.ts" GSD-Task: S04/T01 --- gsd-orchestrator/SKILL.md | 90 ++++- packages/daemon/src/config.ts | 1 + packages/daemon/src/event-formatter.test.ts | 402 ++++++++++++++++++++ packages/daemon/src/event-formatter.ts | 386 +++++++++++++++++++ packages/daemon/src/types.ts | 24 ++ packages/daemon/src/verbosity.test.ts | 171 +++++++++ packages/daemon/src/verbosity.ts | 101 +++++ 7 files changed, 1173 insertions(+), 2 deletions(-) create mode 100644 packages/daemon/src/event-formatter.test.ts create mode 100644 packages/daemon/src/event-formatter.ts create mode 100644 packages/daemon/src/verbosity.test.ts create mode 100644 packages/daemon/src/verbosity.ts diff --git a/gsd-orchestrator/SKILL.md b/gsd-orchestrator/SKILL.md index 6d828cde4..ad423afdf 100644 --- a/gsd-orchestrator/SKILL.md +++ b/gsd-orchestrator/SKILL.md @@ -115,15 +115,101 @@ GSD creates and manages all state in `.gsd/`: PROJECT.md # What this project is REQUIREMENTS.md # Capability contract DECISIONS.md # Architectural decisions (append-only) + KNOWLEDGE.md # Persistent project knowledge (patterns, rules, lessons) STATE.md # Current phase and next action milestones/ M001-xxxxx/ M001-xxxxx-CONTEXT.md # Scope, constraints, assumptions M001-xxxxx-ROADMAP.md # Slices with checkboxes + M001-xxxxx-SUMMARY.md # Completion summary slices/S01/ S01-PLAN.md # Tasks - tasks/T01-PLAN.md # Individual task spec + S01-SUMMARY.md # Slice summary + tasks/ + T01-PLAN.md # Individual task spec + T01-SUMMARY.md # Task completion summary ``` -You never need to edit these files. GSD manages them. But you can read them to understand progress. +State is derived from files on disk — checkboxes in ROADMAP.md and PLAN.md are the source of truth for completion. You never need to edit these files. GSD manages them. But you can read them to understand progress. + + +| Flag | Description | +|------|-------------| +| `--output-format ` | `text` (default), `json` (structured result at exit), `stream-json` (JSONL events) | +| `--json` | Alias for `--output-format stream-json` — JSONL event stream to stdout | +| `--bare` | Skip CLAUDE.md, AGENTS.md, user settings, user skills. Use for CI/ecosystem runs. | +| `--resume ` | Resume a prior headless session by its session ID | +| `--timeout N` | Overall timeout in ms (default: 300000, use 0 to disable) | +| `--model ID` | Override LLM model | +| `--supervised` | Forward interactive UI requests to orchestrator via stdout/stdin | +| `--response-timeout N` | Timeout (ms) for orchestrator response in supervised mode (default: 30000) | +| `--answers ` | Pre-supply answers and secrets from JSON file | +| `--events ` | Filter JSONL to specific event types (comma-separated, implies `--json`) | +| `--verbose` | Show tool calls in progress output | +| `--context ` | Spec file path for `new-milestone` (use `-` for stdin) | +| `--context-text ` | Inline spec text for `new-milestone` | +| `--auto` | Chain into auto-mode after `new-milestone` | + + + +Pre-supply answers and secrets for fully autonomous runs: + +```bash +gsd headless --answers answers.json --output-format json auto 2>/dev/null +``` + +```json +{ + "questions": { "question_id": "selected_option" }, + "secrets": { "API_KEY": "sk-..." }, + "defaults": { "strategy": "first_option" } +} +``` + +- **questions** — question ID to answer (string for single-select, string[] for multi-select) +- **secrets** — env var to value, injected into child process environment +- **defaults.strategy** — `"first_option"` (default) or `"cancel"` for unmatched questions + +See `references/answer-injection.md` for the full mechanism. + + + +For real-time monitoring, use JSONL event streaming: + +```bash +gsd headless --json auto 2>/dev/null | while read -r line; do + TYPE=$(echo "$line" | jq -r '.type') + case "$TYPE" in + tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;; + extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;; + agent_end) echo "Session ended" ;; + esac +done +``` + +Filter to specific events: `--events agent_end,execution_complete,extension_ui_request` + +Available types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, +`tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`, +`message_update`, `turn_start`, `turn_end`, `cost_update`, `execution_complete`. + + + +| Command | Purpose | +|---------|---------| +| `auto` | Run all queued units until milestone complete or blocked (default) | +| `next` | Run exactly one unit, then exit | +| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM, ~50ms) | +| `new-milestone` | Create milestone from spec file | +| `dispatch ` | Force specific phase (research, plan, execute, complete, reassess, uat, replan) | +| `stop` / `pause` | Control auto-mode | +| `steer ` | Hard-steer plan mid-execution | +| `skip` / `undo` | Unit control | +| `queue` | Queue/reorder milestones | +| `history` | View execution history | +| `doctor` | Health check + auto-fix | +| `knowledge ` | Add persistent project knowledge | + +See `references/commands.md` for the complete reference. + diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index 5e543f3fa..8b2c6063a 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -56,6 +56,7 @@ export function validateConfig(raw: unknown): DaemonConfig { token: typeof d['token'] === 'string' ? d['token'] : '', guild_id: typeof d['guild_id'] === 'string' ? d['guild_id'] : '', owner_id: typeof d['owner_id'] === 'string' ? d['owner_id'] : '', + ...(typeof d['dm_on_blocker'] === 'boolean' ? { dm_on_blocker: d['dm_on_blocker'] } : {}), }; } diff --git a/packages/daemon/src/event-formatter.test.ts b/packages/daemon/src/event-formatter.test.ts new file mode 100644 index 000000000..dead1e385 --- /dev/null +++ b/packages/daemon/src/event-formatter.test.ts @@ -0,0 +1,402 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder } from 'discord.js'; +import type { SdkAgentEvent } from '@gsd-build/rpc-client'; +import type { PendingBlocker, FormattedEvent } from './types.js'; +import type { RpcExtensionUIRequest } from '@gsd-build/rpc-client'; +import { + formatToolStart, + formatToolEnd, + formatMessage, + formatBlocker, + formatCompletion, + formatError, + formatCostUpdate, + formatSessionStarted, + formatTaskTransition, + formatGenericEvent, + formatEvent, +} from './event-formatter.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function embedColor(fe: FormattedEvent): number | null { + return fe.embed?.data.color ?? null; +} + +function embedTitle(fe: FormattedEvent): string | undefined { + return fe.embed?.data.title; +} + +function embedDescription(fe: FormattedEvent): string | undefined { + return fe.embed?.data.description; +} + +// --------------------------------------------------------------------------- +// formatToolStart +// --------------------------------------------------------------------------- + +describe('formatToolStart', () => { + it('produces grey embed with tool name', () => { + const result = formatToolStart({ type: 'tool_execution_start', name: 'read_file' }); + assert.ok(result.content.includes('read_file')); + assert.equal(embedColor(result), 0x95a5a6); // grey + assert.ok(embedTitle(result)?.includes('read_file')); + }); + + it('handles missing name gracefully', () => { + const result = formatToolStart({ type: 'tool_execution_start' }); + assert.ok(result.content.includes('unknown')); + }); + + it('includes input in description when present', () => { + const result = formatToolStart({ type: 'tool_execution_start', name: 'bash', input: 'ls -la' }); + assert.ok(embedDescription(result)?.includes('ls -la')); + }); +}); + +// --------------------------------------------------------------------------- +// formatToolEnd +// --------------------------------------------------------------------------- + +describe('formatToolEnd', () => { + it('shows success icon for normal completion', () => { + const result = formatToolEnd({ type: 'tool_execution_end', name: 'read_file', output: 'done' }); + assert.ok(result.content.includes('✅')); + assert.equal(embedColor(result), 0x95a5a6); // grey + }); + + it('shows error icon and red color for errored tool', () => { + const result = formatToolEnd({ type: 'tool_execution_end', name: 'bash', isError: true }); + assert.ok(result.content.includes('❌')); + assert.equal(embedColor(result), 0xe74c3c); // red + }); + + it('includes duration when present', () => { + const result = formatToolEnd({ type: 'tool_execution_end', name: 'bash', duration: 3500 }); + assert.ok(result.embed?.data.footer?.text?.includes('3.5s')); + }); +}); + +// --------------------------------------------------------------------------- +// formatMessage +// --------------------------------------------------------------------------- + +describe('formatMessage', () => { + it('extracts text from content blocks', () => { + const result = formatMessage({ + type: 'message', + content: [{ type: 'text', text: 'Hello world' }], + }); + assert.ok(embedDescription(result)?.includes('Hello world')); + assert.equal(embedColor(result), 0x3498db); // blue + }); + + it('falls back to message field when content is a string', () => { + const result = formatMessage({ type: 'message', message: 'plain text' }); + assert.ok(embedDescription(result)?.includes('plain text')); + }); + + it('handles empty content blocks', () => { + const result = formatMessage({ type: 'message', content: [] }); + assert.ok(result.content.includes('empty message')); + assert.equal(result.embed, undefined); + }); + + it('handles null content gracefully', () => { + const result = formatMessage({ type: 'message' }); + assert.ok(result.content.includes('empty message')); + }); +}); + +// --------------------------------------------------------------------------- +// formatBlocker — select +// --------------------------------------------------------------------------- + +describe('formatBlocker', () => { + it('produces ActionRow with numbered buttons for select', () => { + const blocker: PendingBlocker = { + id: 'req-1', + method: 'select', + message: 'Choose an option', + event: { + type: 'extension_ui_request', + id: 'req-1', + method: 'select', + title: 'Choose', + options: ['Option A', 'Option B', 'Option C'], + }, + }; + + const result = formatBlocker(blocker, '12345'); + assert.ok(result.content.includes('<@12345>')); + assert.equal(embedColor(result), 0xf1c40f); // yellow + assert.ok(result.components); + assert.ok(result.components!.length > 0); + + // Check buttons + const row = result.components![0]; + const buttons = row.components; + assert.equal(buttons.length, 3); + }); + + it('handles empty options array for select', () => { + const blocker: PendingBlocker = { + id: 'req-2', + method: 'select', + message: 'Pick one', + event: { + type: 'extension_ui_request', + id: 'req-2', + method: 'select', + title: 'Pick', + options: [], + }, + }; + + const result = formatBlocker(blocker, '12345'); + // No components when no options + assert.equal(result.components, undefined); + // Embed should show 'No options' + const fields = result.embed?.data.fields; + assert.ok(fields?.some((f) => f.value.includes('No options'))); + }); + + it('produces Yes/No buttons for confirm', () => { + const blocker: PendingBlocker = { + id: 'req-3', + method: 'confirm', + message: 'Are you sure?', + event: { + type: 'extension_ui_request', + id: 'req-3', + method: 'confirm', + title: 'Confirm', + message: 'This will delete everything', + }, + }; + + const result = formatBlocker(blocker, '99999'); + assert.ok(result.components); + assert.equal(result.components!.length, 1); + const buttons = result.components![0].components; + assert.equal(buttons.length, 2); + }); + + it('produces text instructions for input method', () => { + const blocker: PendingBlocker = { + id: 'req-4', + method: 'input', + message: 'Enter your name', + event: { + type: 'extension_ui_request', + id: 'req-4', + method: 'input', + title: 'Name', + placeholder: 'John Doe', + }, + }; + + const result = formatBlocker(blocker, '12345'); + // No interactive buttons for input — text instructions only + assert.equal(result.components, undefined); + const fields = result.embed?.data.fields; + assert.ok(fields?.some((f) => f.value.includes('Reply in this channel'))); + }); + + it('produces text instructions for editor method', () => { + const blocker: PendingBlocker = { + id: 'req-5', + method: 'editor', + message: 'Edit the config', + event: { + type: 'extension_ui_request', + id: 'req-5', + method: 'editor', + title: 'Config', + prefill: 'key: value', + }, + }; + + const result = formatBlocker(blocker, '12345'); + assert.equal(result.components, undefined); + const fields = result.embed?.data.fields; + assert.ok(fields?.some((f) => f.value.includes('Reply in this channel'))); + assert.ok(fields?.some((f) => f.value.includes('key: value'))); + }); +}); + +// --------------------------------------------------------------------------- +// formatCompletion +// --------------------------------------------------------------------------- + +describe('formatCompletion', () => { + it('shows green for completed', () => { + const result = formatCompletion({ type: 'execution_complete', status: 'completed' }); + assert.equal(embedColor(result), 0x2ecc71); // green + assert.ok(result.content.includes('🏁')); + }); + + it('shows red for error status', () => { + const result = formatCompletion({ + type: 'execution_complete', + status: 'error', + reason: 'Out of tokens', + }); + assert.equal(embedColor(result), 0xe74c3c); // red + assert.ok(embedDescription(result)?.includes('Out of tokens')); + }); + + it('includes stats when present', () => { + const result = formatCompletion({ + type: 'execution_complete', + status: 'completed', + stats: { cost: 0.42, tokens: { total: 10000 } }, + }); + const fields = result.embed?.data.fields; + assert.ok(fields?.some((f) => f.value.includes('$0.42'))); + assert.ok(fields?.some((f) => f.value.includes('10,000'))); + }); +}); + +// --------------------------------------------------------------------------- +// formatError +// --------------------------------------------------------------------------- + +describe('formatError', () => { + it('includes session ID and error message', () => { + const result = formatError('sess-abc', 'Connection refused'); + assert.equal(embedColor(result), 0xe74c3c); // red + assert.ok(embedDescription(result)?.includes('Connection refused')); + assert.ok(result.embed?.data.footer?.text?.includes('sess-abc')); + }); +}); + +// --------------------------------------------------------------------------- +// formatCostUpdate +// --------------------------------------------------------------------------- + +describe('formatCostUpdate', () => { + it('formats cumulative cost', () => { + const result = formatCostUpdate({ + type: 'cost_update', + cumulativeCost: 1.23, + tokens: { input: 5000, output: 2000 }, + }); + assert.ok(result.content.includes('$1.23')); + assert.equal(embedColor(result), 0x3498db); // blue + }); + + it('handles zero cost', () => { + const result = formatCostUpdate({ + type: 'cost_update', + cumulativeCost: 0, + tokens: { input: 0, output: 0 }, + }); + assert.ok(result.content.includes('$0.0000')); + }); +}); + +// --------------------------------------------------------------------------- +// formatSessionStarted +// --------------------------------------------------------------------------- + +describe('formatSessionStarted', () => { + it('includes project name', () => { + const result = formatSessionStarted('my-project'); + assert.ok(result.content.includes('my-project')); + assert.ok(embedDescription(result)?.includes('my-project')); + assert.equal(embedColor(result), 0x3498db); // blue + }); +}); + +// --------------------------------------------------------------------------- +// formatTaskTransition +// --------------------------------------------------------------------------- + +describe('formatTaskTransition', () => { + it('shows complete icon for completed tasks', () => { + const result = formatTaskTransition({ + type: 'task_transition', + taskId: 'T01', + sliceId: 'S01', + status: 'complete', + }); + assert.ok(result.content.includes('✅')); + assert.equal(embedColor(result), 0x2ecc71); // green + }); + + it('shows error icon for errored tasks', () => { + const result = formatTaskTransition({ + type: 'task_transition', + taskId: 'T02', + status: 'error', + }); + assert.ok(result.content.includes('❌')); + assert.equal(embedColor(result), 0xe74c3c); // red + }); +}); + +// --------------------------------------------------------------------------- +// formatGenericEvent +// --------------------------------------------------------------------------- + +describe('formatGenericEvent', () => { + it('renders unknown event type as grey embed', () => { + const result = formatGenericEvent({ type: 'some_custom_event', data: 'hello' }); + assert.equal(embedColor(result), 0x95a5a6); // grey + assert.ok(embedTitle(result)?.includes('some_custom_event')); + }); + + it('handles events with no extra fields', () => { + const result = formatGenericEvent({ type: 'bare_event' }); + assert.ok(result.content.includes('bare_event')); + }); +}); + +// --------------------------------------------------------------------------- +// formatEvent — dispatch +// --------------------------------------------------------------------------- + +describe('formatEvent', () => { + it('dispatches tool_execution_start', () => { + const result = formatEvent({ type: 'tool_execution_start', name: 'read' }); + assert.ok(result.content.includes('🔧')); + }); + + it('dispatches execution_complete', () => { + const result = formatEvent({ type: 'execution_complete', status: 'completed' }); + assert.ok(result.content.includes('🏁')); + }); + + it('falls back to generic for unknown types', () => { + const result = formatEvent({ type: 'totally_unknown' }); + assert.ok(result.content.includes('📡')); + }); + + it('dispatches cost_update', () => { + const result = formatEvent({ type: 'cost_update', cumulativeCost: 0.5 }); + assert.ok(result.content.includes('💰')); + }); + + it('dispatches message types', () => { + for (const type of ['message_start', 'message_end', 'message']) { + const result = formatEvent({ type, message: 'hi' }); + assert.ok(result.content.includes('💬'), `Failed for type: ${type}`); + } + }); + + // Negative: missing type field + it('handles event with missing type gracefully', () => { + const result = formatEvent({} as SdkAgentEvent); + assert.ok(result.content); // should not throw + }); + + // Negative: null fields + it('handles event with null fields gracefully', () => { + const result = formatEvent({ type: 'tool_execution_start', name: null } as unknown as SdkAgentEvent); + assert.ok(result.content); + }); +}); diff --git a/packages/daemon/src/event-formatter.ts b/packages/daemon/src/event-formatter.ts new file mode 100644 index 000000000..ccd2a7733 --- /dev/null +++ b/packages/daemon/src/event-formatter.ts @@ -0,0 +1,386 @@ +/** + * event-formatter.ts — Pure functions mapping RPC event types to Discord embeds. + * + * Each formatter returns a FormattedEvent (content string + optional EmbedBuilder + + * optional ActionRow components). Distinct embed colors per category: + * green = success / completion + * red = error + * yellow = blocker (needs attention) + * blue = info / session lifecycle + * grey = tool / generic + */ + +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import type { SdkAgentEvent } from '@gsd-build/rpc-client'; +import type { RpcExtensionUIRequest } from '@gsd-build/rpc-client'; +import type { FormattedEvent, PendingBlocker } from './types.js'; + +// --------------------------------------------------------------------------- +// Color palette +// --------------------------------------------------------------------------- + +const COLOR = { + success: 0x2ecc71, // green + error: 0xe74c3c, // red + blocker: 0xf1c40f, // yellow + info: 0x3498db, // blue + tool: 0x95a5a6, // grey +} as const; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Truncate a string to maxLen, appending ellipsis if truncated. */ +function truncate(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + return s.slice(0, maxLen - 1) + '…'; +} + +/** Safe string extraction from an unknown field. */ +function str(value: unknown, fallback = ''): string { + if (typeof value === 'string') return value; + if (value == null) return fallback; + return String(value); +} + +/** Safe number extraction. */ +function num(value: unknown, fallback = 0): number { + if (typeof value === 'number' && !Number.isNaN(value)) return value; + return fallback; +} + +/** Format a cost value to a readable string. */ +function formatCost(cost: number): string { + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +} + +// --------------------------------------------------------------------------- +// Formatters +// --------------------------------------------------------------------------- + +export function formatToolStart(event: SdkAgentEvent): FormattedEvent { + const toolName = str(event.name || event.toolName, 'unknown'); + const embed = new EmbedBuilder() + .setColor(COLOR.tool) + .setTitle(`🔧 ${truncate(toolName, 60)}`) + .setTimestamp(); + + const input = str(event.input || event.args); + if (input) { + embed.setDescription(`\`\`\`\n${truncate(input, 300)}\n\`\`\``); + } + + return { content: `🔧 Tool: ${toolName}`, embed }; +} + +export function formatToolEnd(event: SdkAgentEvent): FormattedEvent { + const toolName = str(event.name || event.toolName, 'unknown'); + const isError = event.isError === true || event.error != null; + const color = isError ? COLOR.error : COLOR.tool; + const icon = isError ? '❌' : '✅'; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`${icon} ${truncate(toolName, 60)}`) + .setTimestamp(); + + const output = str(event.output || event.result); + if (output) { + embed.setDescription(`\`\`\`\n${truncate(output, 300)}\n\`\`\``); + } + + const duration = num(event.duration || event.durationMs); + if (duration > 0) { + embed.setFooter({ text: `${(duration / 1000).toFixed(1)}s` }); + } + + return { content: `${icon} Tool done: ${toolName}`, embed }; +} + +export function formatMessage(event: SdkAgentEvent): FormattedEvent { + // Extract text from content blocks or message field + let text = ''; + if (Array.isArray(event.content)) { + const blocks = event.content as Array<{ type?: string; text?: string }>; + text = blocks + .filter((b) => b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text!) + .join('\n'); + } else { + text = str(event.message || event.text || event.content); + } + + if (!text) { + return { content: '💬 (empty message)' }; + } + + const embed = new EmbedBuilder() + .setColor(COLOR.info) + .setDescription(truncate(text, 2000)) + .setTimestamp(); + + const role = str(event.role); + if (role) { + embed.setAuthor({ name: role }); + } + + return { content: `💬 ${truncate(text, 200)}`, embed }; +} + +/** + * Format a blocker (extension_ui_request needing user response). + * Produces an embed with @mention and interactive buttons for select/confirm, + * or text instructions for input/editor. + */ +export function formatBlocker( + blocker: PendingBlocker, + ownerId: string, +): FormattedEvent { + const mention = `<@${ownerId}>`; + const embed = new EmbedBuilder() + .setColor(COLOR.blocker) + .setTitle('⚠️ Blocker — Response Needed') + .setDescription(truncate(blocker.message, 2000)) + .setTimestamp(); + + const components: ActionRowBuilder[] = []; + + switch (blocker.method) { + case 'select': { + const evt = blocker.event as { options?: string[] }; + const options = Array.isArray(evt.options) ? evt.options : []; + + if (options.length > 0) { + // Discord ActionRow max 5 buttons, so chunk + const chunks = chunkArray(options.slice(0, 25), 5); + for (const chunk of chunks) { + const row = new ActionRowBuilder(); + chunk.forEach((opt, i) => { + const globalIndex = options.indexOf(opt); + row.addComponents( + new ButtonBuilder() + .setCustomId(`blocker:${blocker.id}:select:${globalIndex}`) + .setLabel(truncate(`${globalIndex + 1}. ${opt}`, 80)) + .setStyle(ButtonStyle.Primary), + ); + }); + components.push(row); + } + } + + embed.addFields({ + name: 'Options', + value: options.map((o, i) => `**${i + 1}.** ${truncate(o, 100)}`).join('\n') || 'No options', + }); + break; + } + + case 'confirm': { + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`blocker:${blocker.id}:confirm:true`) + .setLabel('Yes') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`blocker:${blocker.id}:confirm:false`) + .setLabel('No') + .setStyle(ButtonStyle.Danger), + ); + components.push(row); + + const msg = str((blocker.event as { message?: string }).message); + if (msg) { + embed.addFields({ name: 'Details', value: truncate(msg, 1024) }); + } + break; + } + + case 'input': { + const placeholder = str((blocker.event as { placeholder?: string }).placeholder); + embed.addFields({ + name: 'How to respond', + value: `Reply in this channel with your answer.${placeholder ? `\n*Hint: ${placeholder}*` : ''}`, + }); + break; + } + + case 'editor': { + const prefill = str((blocker.event as { prefill?: string }).prefill); + embed.addFields({ + name: 'How to respond', + value: 'Reply in this channel with the full text.' + + (prefill ? `\n\nCurrent value:\n\`\`\`\n${truncate(prefill, 500)}\n\`\`\`` : ''), + }); + break; + } + + default: { + embed.addFields({ + name: 'How to respond', + value: `Reply in this channel (method: ${blocker.method}).`, + }); + break; + } + } + + return { + content: `${mention} ⚠️ **Blocker** — ${truncate(blocker.message, 150)}`, + embed, + components: components.length > 0 ? components : undefined, + }; +} + +export function formatCompletion(event: SdkAgentEvent): FormattedEvent { + const status = str(event.status, 'completed'); + const isError = status === 'error' || status === 'cancelled'; + const color = isError ? COLOR.error : COLOR.success; + const icon = isError ? '⚠️' : '🏁'; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`${icon} Execution ${status}`) + .setTimestamp(); + + const reason = str(event.reason); + if (reason) { + embed.setDescription(truncate(reason, 2000)); + } + + // Include final stats if present + const stats = event.stats as { cost?: number; tokens?: { total?: number } } | undefined; + if (stats) { + const fields: string[] = []; + if (stats.cost != null) fields.push(`Cost: ${formatCost(num(stats.cost))}`); + if (stats.tokens?.total != null) fields.push(`Tokens: ${num(stats.tokens.total).toLocaleString()}`); + if (fields.length) embed.addFields({ name: 'Summary', value: fields.join(' · ') }); + } + + return { content: `${icon} Execution ${status}`, embed }; +} + +export function formatError(sessionId: string, error: string): FormattedEvent { + const embed = new EmbedBuilder() + .setColor(COLOR.error) + .setTitle('❌ Session Error') + .setDescription(`\`\`\`\n${truncate(error, 2000)}\n\`\`\``) + .setFooter({ text: `Session: ${sessionId}` }) + .setTimestamp(); + + return { content: `❌ Error: ${truncate(error, 200)}`, embed }; +} + +export function formatCostUpdate(event: SdkAgentEvent): FormattedEvent { + const cost = num(event.cumulativeCost ?? event.totalCost); + const tokens = event.tokens as + | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number } + | undefined; + + const embed = new EmbedBuilder() + .setColor(COLOR.info) + .setTitle('💰 Cost Update') + .setTimestamp(); + + const fields: string[] = [`Total: ${formatCost(cost)}`]; + if (tokens) { + const input = num(tokens.input); + const output = num(tokens.output); + if (input || output) { + fields.push(`Tokens: ${input.toLocaleString()} in / ${output.toLocaleString()} out`); + } + } + embed.setDescription(fields.join('\n')); + + return { content: `💰 Cost: ${formatCost(cost)}`, embed }; +} + +export function formatSessionStarted(projectName: string): FormattedEvent { + const embed = new EmbedBuilder() + .setColor(COLOR.info) + .setTitle('🚀 Session Started') + .setDescription(`Project: **${truncate(projectName, 200)}**`) + .setTimestamp(); + + return { content: `🚀 Session started: ${projectName}`, embed }; +} + +export function formatTaskTransition(event: SdkAgentEvent): FormattedEvent { + const taskId = str(event.taskId || event.task); + const sliceId = str(event.sliceId || event.slice); + const status = str(event.status || event.state); + const icon = status === 'complete' ? '✅' : status === 'error' ? '❌' : '📋'; + + const embed = new EmbedBuilder() + .setColor(status === 'complete' ? COLOR.success : status === 'error' ? COLOR.error : COLOR.info) + .setTitle(`${icon} Task Transition`) + .setTimestamp(); + + const fields: string[] = []; + if (sliceId) fields.push(`Slice: ${sliceId}`); + if (taskId) fields.push(`Task: ${taskId}`); + if (status) fields.push(`Status: ${status}`); + embed.setDescription(fields.join('\n')); + + return { content: `${icon} ${taskId || 'Task'} → ${status || 'unknown'}`, embed }; +} + +export function formatGenericEvent(event: SdkAgentEvent): FormattedEvent { + const type = str(event.type, 'unknown'); + const embed = new EmbedBuilder() + .setColor(COLOR.tool) + .setTitle(`📡 ${truncate(type, 60)}`) + .setTimestamp(); + + // Include a JSON preview of the event, stripping the type field + const { type: _t, ...rest } = event; + const preview = JSON.stringify(rest); + if (preview.length > 2) { // more than '{}' + embed.setDescription(`\`\`\`json\n${truncate(preview, 1000)}\n\`\`\``); + } + + return { content: `📡 Event: ${type}`, embed }; +} + +// --------------------------------------------------------------------------- +// Dispatch — maps event type to the right formatter +// --------------------------------------------------------------------------- + +/** + * Format any SdkAgentEvent for Discord. Falls back to formatGenericEvent + * for unknown types. + */ +export function formatEvent(event: SdkAgentEvent, ownerId?: string): FormattedEvent { + const type = str(event.type); + + switch (type) { + case 'tool_execution_start': + return formatToolStart(event); + case 'tool_execution_end': + return formatToolEnd(event); + case 'message_start': + case 'message_end': + case 'message': + return formatMessage(event); + case 'execution_complete': + return formatCompletion(event); + case 'cost_update': + return formatCostUpdate(event); + case 'task_transition': + return formatTaskTransition(event); + default: + return formatGenericEvent(event); + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +} diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts index 16ed5ea16..4dc74291e 100644 --- a/packages/daemon/src/types.ts +++ b/packages/daemon/src/types.ts @@ -5,6 +5,14 @@ import type { RpcClient, SdkAgentEvent, RpcExtensionUIRequest } from '@gsd-build */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +/** + * Per-channel verbosity for Discord event streaming. + * - 'default': tool calls, messages, transitions, blockers, errors, completions + * - 'verbose': everything including cost_update and status events + * - 'quiet': only blockers, errors, completions + */ +export type VerbosityLevel = 'default' | 'verbose' | 'quiet'; + /** * A single structured log entry written as JSON-lines. */ @@ -24,6 +32,8 @@ export interface DaemonConfig { token: string; guild_id: string; owner_id: string; + /** When true, DM the owner on blocker events in addition to channel messages */ + dm_on_blocker?: boolean; }; projects: { scan_roots: string[]; @@ -157,6 +167,20 @@ export interface StartSessionOptions { cliPath?: string; } +// --------------------------------------------------------------------------- +// Formatted Event — output of event-formatter.ts +// --------------------------------------------------------------------------- + +/** + * Formatted Discord message payload for a GSD event. + * content is the plain-text fallback; embeds and components are optional. + */ +export interface FormattedEvent { + content: string; + embed?: import('discord.js').EmbedBuilder; + components?: import('discord.js').ActionRowBuilder[]; +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- diff --git a/packages/daemon/src/verbosity.test.ts b/packages/daemon/src/verbosity.test.ts new file mode 100644 index 000000000..42c61e9b6 --- /dev/null +++ b/packages/daemon/src/verbosity.test.ts @@ -0,0 +1,171 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { VerbosityManager, shouldShowAtLevel } from './verbosity.js'; + +// --------------------------------------------------------------------------- +// VerbosityManager +// --------------------------------------------------------------------------- + +describe('VerbosityManager', () => { + let vm: VerbosityManager; + + beforeEach(() => { + vm = new VerbosityManager(); + }); + + it('returns default level for unknown channel', () => { + assert.equal(vm.getLevel('chan-1'), 'default'); + }); + + it('set/get round-trips', () => { + vm.setLevel('chan-1', 'quiet'); + assert.equal(vm.getLevel('chan-1'), 'quiet'); + vm.setLevel('chan-1', 'verbose'); + assert.equal(vm.getLevel('chan-1'), 'verbose'); + }); + + it('different channels are independent', () => { + vm.setLevel('chan-a', 'quiet'); + vm.setLevel('chan-b', 'verbose'); + assert.equal(vm.getLevel('chan-a'), 'quiet'); + assert.equal(vm.getLevel('chan-b'), 'verbose'); + assert.equal(vm.getLevel('chan-c'), 'default'); + }); + + it('shouldShow delegates to the level-based filter', () => { + vm.setLevel('chan-q', 'quiet'); + assert.equal(vm.shouldShow('chan-q', 'tool_execution_start'), false); + assert.equal(vm.shouldShow('chan-q', 'extension_ui_request'), true); + }); +}); + +// --------------------------------------------------------------------------- +// shouldShowAtLevel — quiet +// --------------------------------------------------------------------------- + +describe('shouldShowAtLevel — quiet', () => { + const level = 'quiet' as const; + + it('shows blockers', () => { + assert.equal(shouldShowAtLevel(level, 'extension_ui_request'), true); + }); + + it('shows execution_complete', () => { + assert.equal(shouldShowAtLevel(level, 'execution_complete'), true); + }); + + it('shows error', () => { + assert.equal(shouldShowAtLevel(level, 'error'), true); + }); + + it('shows session_error', () => { + assert.equal(shouldShowAtLevel(level, 'session_error'), true); + }); + + it('hides tool calls', () => { + assert.equal(shouldShowAtLevel(level, 'tool_execution_start'), false); + assert.equal(shouldShowAtLevel(level, 'tool_execution_end'), false); + }); + + it('hides messages', () => { + assert.equal(shouldShowAtLevel(level, 'message_start'), false); + assert.equal(shouldShowAtLevel(level, 'message'), false); + }); + + it('hides cost_update', () => { + assert.equal(shouldShowAtLevel(level, 'cost_update'), false); + }); + + it('hides task_transition', () => { + assert.equal(shouldShowAtLevel(level, 'task_transition'), false); + }); + + it('hides unknown events', () => { + assert.equal(shouldShowAtLevel(level, 'totally_random'), false); + }); +}); + +// --------------------------------------------------------------------------- +// shouldShowAtLevel — default +// --------------------------------------------------------------------------- + +describe('shouldShowAtLevel — default', () => { + const level = 'default' as const; + + it('shows blockers', () => { + assert.equal(shouldShowAtLevel(level, 'extension_ui_request'), true); + }); + + it('shows execution_complete', () => { + assert.equal(shouldShowAtLevel(level, 'execution_complete'), true); + }); + + it('shows error', () => { + assert.equal(shouldShowAtLevel(level, 'error'), true); + }); + + it('shows tool calls', () => { + assert.equal(shouldShowAtLevel(level, 'tool_execution_start'), true); + assert.equal(shouldShowAtLevel(level, 'tool_execution_end'), true); + }); + + it('shows messages', () => { + assert.equal(shouldShowAtLevel(level, 'message_start'), true); + assert.equal(shouldShowAtLevel(level, 'message_end'), true); + assert.equal(shouldShowAtLevel(level, 'message'), true); + }); + + it('shows task_transition', () => { + assert.equal(shouldShowAtLevel(level, 'task_transition'), true); + }); + + it('shows session_started', () => { + assert.equal(shouldShowAtLevel(level, 'session_started'), true); + }); + + it('hides cost_update', () => { + assert.equal(shouldShowAtLevel(level, 'cost_update'), false); + }); + + it('hides status events', () => { + assert.equal(shouldShowAtLevel(level, 'state_update'), false); + assert.equal(shouldShowAtLevel(level, 'status'), false); + }); + + it('hides unknown events', () => { + assert.equal(shouldShowAtLevel(level, 'something_weird'), false); + }); +}); + +// --------------------------------------------------------------------------- +// shouldShowAtLevel — verbose +// --------------------------------------------------------------------------- + +describe('shouldShowAtLevel — verbose', () => { + const level = 'verbose' as const; + + it('shows everything that quiet/default show', () => { + const events = [ + 'extension_ui_request', 'execution_complete', 'error', 'session_error', + 'tool_execution_start', 'tool_execution_end', 'message_start', 'message_end', + 'message', 'task_transition', 'session_started', + ]; + for (const e of events) { + assert.equal(shouldShowAtLevel(level, e), true, `Expected verbose to show ${e}`); + } + }); + + it('shows cost_update', () => { + assert.equal(shouldShowAtLevel(level, 'cost_update'), true); + }); + + it('shows status events', () => { + assert.equal(shouldShowAtLevel(level, 'state_update'), true); + assert.equal(shouldShowAtLevel(level, 'status'), true); + assert.equal(shouldShowAtLevel(level, 'set_status'), true); + }); + + it('shows unknown/arbitrary events', () => { + assert.equal(shouldShowAtLevel(level, 'something_arbitrary'), true); + }); +}); diff --git a/packages/daemon/src/verbosity.ts b/packages/daemon/src/verbosity.ts new file mode 100644 index 000000000..e40b11c87 --- /dev/null +++ b/packages/daemon/src/verbosity.ts @@ -0,0 +1,101 @@ +/** + * verbosity.ts — Per-channel verbosity filter for Discord event streaming. + * + * Controls which RPC event types reach each Discord channel. + * Three levels: + * - 'quiet': blockers, errors, completions only + * - 'default': tool calls, messages, transitions, blockers, errors, completions + * - 'verbose': everything (adds cost_update, status, generic events) + */ + +import type { VerbosityLevel } from './types.js'; + +// --------------------------------------------------------------------------- +// Event classification +// --------------------------------------------------------------------------- + +/** Event types that are always shown (even in quiet mode). */ +const ALWAYS_SHOWN: ReadonlySet = new Set([ + 'extension_ui_request', // blockers + 'execution_complete', + 'error', + 'session_error', +]); + +/** Event types shown at default level and above. */ +const DEFAULT_SHOWN: ReadonlySet = new Set([ + 'tool_execution_start', + 'tool_execution_end', + 'message_start', + 'message_end', + 'message', + 'task_transition', + 'session_started', +]); + +/** Event types shown only at verbose level. */ +const VERBOSE_ONLY: ReadonlySet = new Set([ + 'cost_update', + 'state_update', + 'status', + 'set_status', + 'set_widget', + 'set_title', +]); + +// --------------------------------------------------------------------------- +// VerbosityManager +// --------------------------------------------------------------------------- + +export class VerbosityManager { + private levels: Map = new Map(); + + /** Get the verbosity level for a channel. Defaults to 'default'. */ + getLevel(channelId: string): VerbosityLevel { + return this.levels.get(channelId) ?? 'default'; + } + + /** Set the verbosity level for a channel. */ + setLevel(channelId: string, level: VerbosityLevel): void { + this.levels.set(channelId, level); + } + + /** + * Determine whether an event of the given type should be shown + * in the specified channel. + */ + shouldShow(channelId: string, eventType: string): boolean { + const level = this.getLevel(channelId); + return shouldShowAtLevel(level, eventType); + } +} + +// --------------------------------------------------------------------------- +// Pure filter — exported for direct use and testability +// --------------------------------------------------------------------------- + +/** + * Pure predicate: should an event of this type be shown at the given verbosity level? + */ +export function shouldShowAtLevel(level: VerbosityLevel, eventType: string): boolean { + // Always-shown events pass through regardless of level + if (ALWAYS_SHOWN.has(eventType)) return true; + + switch (level) { + case 'quiet': + // Quiet only shows ALWAYS_SHOWN events + return false; + + case 'default': + // Default shows ALWAYS_SHOWN + DEFAULT_SHOWN + return DEFAULT_SHOWN.has(eventType); + + case 'verbose': + // Verbose shows everything + return true; + + default: + // Unknown level → treat as default + return DEFAULT_SHOWN.has(eventType); + } +}