// --------------------------------------------------------------------------- // Timeline Writer — pure formatter for timeline.log // // Receives a raw event (same shape as events.jsonl entries) and returns // a formatted one-liner or null (skip). No state — deduplication is // handled by the caller via lastTokenCount. // --------------------------------------------------------------------------- export interface TimelineConfig { maxCommandLength: number; maxMessageLength: number; maxPathLength: number; maxReasoningLength: number; maxPlanStepLength: number; maxLineLength: number; maxStderrLength: number; maxErrorLength: number; } export const DEFAULT_TIMELINE_CONFIG: TimelineConfig = { maxCommandLength: 120, maxMessageLength: 200, maxPathLength: 80, maxReasoningLength: 150, maxPlanStepLength: 80, maxLineLength: 500, maxStderrLength: 200, maxErrorLength: 200, }; export interface TimelineEvent { t: string; method: string; params?: unknown; code?: number | null; signal?: string | null; data?: string; synthetic?: boolean; } export interface TimelineResult { line: string | null; newTokenCount?: number; } // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- export function formatTimelineLine( event: TimelineEvent, config: TimelineConfig = DEFAULT_TIMELINE_CONFIG, lastTokenCount?: number, ): TimelineResult { const ts = localTime(event.t); const p = asObj(event.params); switch (event.method) { case 'thread/status/changed': { const status = asStr(asObj(p?.status)?.type); if (status === 'active') return line(ts, 'STARTED', '', config); // Skip 'idle' — it fires right before turn/completed and adds no value. // The DONE line already communicates task completion. if (status === 'waitingOnUserInput') return line(ts, 'ASK', 'waiting for orchestrator', config); return SKIP; } case 'turn/started': { const turnId = asStr(asObj(p?.turn)?.id) ?? ''; return line(ts, 'TURN', truncate(turnId, 40), config); } case 'turn/completed': { const turn = asObj(p?.turn); const status = asStr(turn?.status) ?? 'unknown'; const errMsg = asStr(asObj(turn?.error)?.message); const detail = errMsg ? `${status}: ${truncate(errMsg, 100)}` : status; return line(ts, 'DONE', detail, config); } case 'turn/plan/updated': { const steps = Array.isArray(p?.plan) ? (p.plan as Array<{ step?: string; status?: string }>) : []; return line(ts, 'PLAN', formatPlan(steps, config), config); } case 'item/started': { // Only reasoning start — gives "agent is alive" signal during silent gaps const startedItem = asObj(p?.item); if (asStr(startedItem?.type) === 'reasoning') { return line(ts, 'THINK', '(reasoning…)', config); } return SKIP; } case 'item/completed': return formatItemCompleted(ts, asObj(p?.item), config); case 'thread/tokenUsage/updated': { const usage = asObj(asObj(p?.tokenUsage)?.total); const window = typeof (asObj(p?.tokenUsage) as Record | undefined)?.modelContextWindow === 'number' ? (asObj(p?.tokenUsage) as Record).modelContextWindow as number : undefined; if (!usage || !window) return SKIP; const total = typeof usage.totalTokens === 'number' ? usage.totalTokens : 0; if (total === (lastTokenCount ?? -1)) return SKIP; // deduplicate const pct = (Math.round(total / window * 1000) / 10).toFixed(1); return { line: capLine(`${ts} ${pad('TOKENS')} ${total} / ${window} (${pct}%)`, config), newTokenCount: total }; } case 'item/commandExecution/requestApproval': { const cmd = cleanCommand(asStr(p?.command) ?? ''); return line(ts, 'APPROVE', `cmd: ${truncate(cmd, 100)}`, config); } case 'item/fileChange/requestApproval': { const changes = Array.isArray(p?.changes) ? (p.changes as Array<{ path?: string }>) : []; const paths = changes.map(c => truncatePath(asStr(c?.path) ?? '', 40)).join(', '); return line(ts, 'APPROVE', `files: ${paths} (${changes.length} files)`, config); } case '_process_exit': return line(ts, 'EXIT', `code=${event.code ?? '?'} signal=${String(event.signal ?? 'null')}`, config); case '_stderr': { const data = stripAnsi(event.data ?? '').replace(/\n/g, ' ').trim(); if (!data) return SKIP; return line(ts, 'STDERR', truncate(data, config.maxStderrLength), config); } case 'error': { const errObj = asObj(p?.error); const message = asStr(errObj?.message) ?? asStr(p?.message) ?? 'unknown'; const info = asStr(errObj?.codexErrorInfo) ?? asStr(p?.codexErrorInfo) ?? ''; const detail = info ? `${info} — ${truncate(message, config.maxErrorLength)}` : truncate(message, config.maxErrorLength); return line(ts, 'ERROR', detail, config); } default: return SKIP; } } // --------------------------------------------------------------------------- // Item completed sub-formatter // --------------------------------------------------------------------------- function formatItemCompleted( ts: string, item: Record | undefined, config: TimelineConfig, ): TimelineResult { if (!item) return SKIP; const itemType = asStr(item.type); switch (itemType) { case 'commandExecution': { const cmd = cleanCommand(asStr(item.command) ?? ''); const durMs = typeof item.durationMs === 'number' ? item.durationMs : undefined; const dur = durMs === undefined ? '?' : durMs <= 10 ? '<1ms' : `${(durMs / 1000).toFixed(1)}s`; const exit = item.exitCode ?? '?'; return line(ts, 'CMD', `${truncate(cmd, config.maxCommandLength)} → exit=${exit} (${dur})`, config); } case 'fileChange': { const changes = Array.isArray(item.changes) ? (item.changes as Array>) : []; if (changes.length === 0) return SKIP; const lines = changes.map(c => { const path = truncatePath(asStr(c.path) ?? 'unknown', config.maxPathLength); const kind = asStr(c.kind) ?? 'modified'; return capLine(`${ts} ${pad('FILE')} ${path} (${kind})`, config); }); return { line: lines.join('\n') }; } case 'agentMessage': { const text = (asStr(item.text) ?? '').replace(/\n/g, ' ').trim(); if (!text) return SKIP; const max = config.maxMessageLength; const display = text.length > max ? `${text.slice(0, max)} [+${text.length - max} chars]` : text; return line(ts, 'MSG', display, config); } case 'reasoning': { const summaries = Array.isArray(item.summary) ? item.summary as unknown[] : []; if (summaries.length === 0) return SKIP; const first = typeof summaries[0] === 'string' ? summaries[0] : asStr((summaries[0] as Record | undefined)?.text) ?? ''; // Extract only the bold heading (e.g. "**Planning execution steps**") // before the first double-newline or the first sentence that starts // with "I need", "I should", etc. (internal reasoning noise). const boldMatch = first.match(/\*\*(.+?)\*\*/); const heading = boldMatch ? boldMatch[1]! : first.split(/\n\n/)[0]!; const cleaned = heading.replace(/\*\*/g, '').replace(/\n/g, ' ').trim(); if (!cleaned) return SKIP; return line(ts, 'THINK', truncate(cleaned, config.maxReasoningLength), config); } case 'mcpToolCall': { const server = asStr(item.server) ?? '?'; const tool = asStr(item.tool) ?? '?'; const status = asStr(item.status) ?? '?'; return line(ts, 'MCP', `${server}/${tool} → ${status}`, config); } default: return SKIP; } } // --------------------------------------------------------------------------- // Plan formatter // --------------------------------------------------------------------------- function formatPlan(steps: Array<{ step?: string; status?: string }>, config: TimelineConfig): string { if (steps.length === 0) return '(empty plan)'; const icon = (s?: string): string => { switch (s) { case 'completed': return '✓'; case 'inProgress': return '→'; default: return ' '; } }; const formatted = steps.map(s => `[${icon(s.status)}] ${truncate(s.step ?? '', config.maxPlanStepLength)}`); const joined = formatted.join(' · '); if (joined.length <= 450) return joined; const first3 = formatted.slice(0, 3).join(' · '); return `${first3} … +${steps.length - 3} more`; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const SKIP: TimelineResult = { line: null }; function pad(tag: string): string { return tag.padEnd(7); } function line(ts: string, tag: string, detail: string, config: TimelineConfig): TimelineResult { return { line: capLine(`${ts} ${pad(tag)} ${detail}`.trimEnd(), config) }; } function capLine(l: string, config: TimelineConfig): string { if (l.length <= config.maxLineLength) return l; return l.slice(0, config.maxLineLength - 3) + '...'; } function localTime(iso: string): string { const d = new Date(iso); return [d.getHours(), d.getMinutes(), d.getSeconds()] .map(n => String(n).padStart(2, '0')) .join(':'); } export function truncate(s: string, max: number): string { if (s.length <= max) return s; return s.slice(0, max - 1) + '…'; } function truncatePath(p: string, max: number): string { if (!p || p.length <= max) return p ?? ''; return '…' + p.slice(p.length - max + 1); } export function cleanCommand(raw: string): string { const match = raw.match(/^\/bin\/(?:z?sh|bash)\s+-[a-z]*c\s+['"]?([\s\S]+?)['"]?$/); let cmd = match ? match[1]! : raw; // Strip all rtk prefixes — not just the leading one. Chained commands // like "rtk find ... && rtk wc -l ..." need every occurrence removed. cmd = cmd.replace(/\brtk /g, ''); return cmd; } function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*m/g, ''); } function asObj(v: unknown): Record | undefined { if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined; return v as Record; } function asStr(v: unknown): string | undefined { return typeof v === 'string' ? v : undefined; }