singularity-forge/packages/daemon/src/event-formatter.ts
2026-04-15 13:38:15 +02:00

414 lines
13 KiB
TypeScript

/**
* 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 '@singularity-forge/rpc-client';
import type { RpcExtensionUIRequest } from '@singularity-forge/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 = '';
// Try content array first (most common for agent messages)
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');
}
// Try message field — could be string, object with content array, or object with text
if (!text && event.message != null) {
if (typeof event.message === 'string') {
text = event.message;
} else if (typeof event.message === 'object') {
const msg = event.message as Record<string, unknown>;
if (Array.isArray(msg.content)) {
const blocks = msg.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 if (typeof msg.text === 'string') {
text = msg.text;
} else if (typeof msg.content === 'string') {
text = msg.content;
}
}
}
// Fallback to text or content as plain strings
if (!text) {
text = typeof event.text === 'string' ? event.text : '';
}
if (!text && typeof event.content === 'string') {
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;
}