feat: Created pure-function event formatters (10 functions) mapping RPC…

- "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
This commit is contained in:
Lex Christopherson 2026-03-27 15:01:19 -06:00
parent b5adaf2d9f
commit 4c8bbca46f
7 changed files with 1173 additions and 2 deletions

View file

@ -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.
</project_structure>
<flags>
| Flag | Description |
|------|-------------|
| `--output-format <fmt>` | `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 <id>` | 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 <path>` | Pre-supply answers and secrets from JSON file |
| `--events <types>` | Filter JSONL to specific event types (comma-separated, implies `--json`) |
| `--verbose` | Show tool calls in progress output |
| `--context <path>` | Spec file path for `new-milestone` (use `-` for stdin) |
| `--context-text <text>` | Inline spec text for `new-milestone` |
| `--auto` | Chain into auto-mode after `new-milestone` |
</flags>
<answer_injection>
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.
</answer_injection>
<event_streaming>
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`.
</event_streaming>
<all_commands>
| 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 <phase>` | Force specific phase (research, plan, execute, complete, reassess, uat, replan) |
| `stop` / `pause` | Control auto-mode |
| `steer <desc>` | Hard-steer plan mid-execution |
| `skip` / `undo` | Unit control |
| `queue` | Queue/reorder milestones |
| `history` | View execution history |
| `doctor` | Health check + auto-fix |
| `knowledge <rule>` | Add persistent project knowledge |
See `references/commands.md` for the complete reference.
</all_commands>

View file

@ -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'] } : {}),
};
}

View file

@ -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);
});
});

View file

@ -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<ButtonBuilder>[] = [];
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<ButtonBuilder>();
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<ButtonBuilder>().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<T>(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;
}

View file

@ -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<import('discord.js').ButtonBuilder>[];
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

View file

@ -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);
});
});

View file

@ -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<string> = new Set([
'extension_ui_request', // blockers
'execution_complete',
'error',
'session_error',
]);
/** Event types shown at default level and above. */
const DEFAULT_SHOWN: ReadonlySet<string> = 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<string> = new Set([
'cost_update',
'state_update',
'status',
'set_status',
'set_widget',
'set_title',
]);
// ---------------------------------------------------------------------------
// VerbosityManager
// ---------------------------------------------------------------------------
export class VerbosityManager {
private levels: Map<string, VerbosityLevel> = 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);
}
}