/** * Session export utilities for claude-devtools. * * Provides formatters to export session data as plain text, Markdown, or JSON, * and a download trigger for browser-based file saving. */ import type { Chunk, SessionDetail } from '@renderer/types/data'; import type { ContentBlock } from '@shared/types'; // ============================================================================= // Types // ============================================================================= export type ExportFormat = 'markdown' | 'json' | 'plaintext'; interface ExtractOptions { includeThinking?: boolean; } // ============================================================================= // Helpers (not exported) // ============================================================================= function formatNumber(n: number): string { return n.toLocaleString('en-US'); } function formatCost(cost?: number): string { if (cost == null) return 'N/A'; return `$${cost.toFixed(2)}`; } function formatTimestamp(date: Date): string { return date .toISOString() .replace('T', ' ') .replace(/\.\d{3}Z$/, ' UTC'); } function formatDurationForExport(ms: number): string { if (ms < 1000) return `${ms}ms`; const secs = Math.floor(ms / 1000); const mins = Math.floor(secs / 60); const remainSecs = secs % 60; if (mins === 0) return `${secs}s`; return `${mins}m ${remainSecs}s`; } function truncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return text.slice(0, maxLen) + '...'; } // ============================================================================= // extractTextFromContent // ============================================================================= /** * Extract readable text from message content (string or ContentBlock[]). * * @param content - String content or array of ContentBlocks * @param options - Options controlling extraction behavior * @returns Extracted text with newlines between blocks */ export function extractTextFromContent( content: string | ContentBlock[], options?: ExtractOptions ): string { if (typeof content === 'string') { return content; } if (!Array.isArray(content) || content.length === 0) { return ''; } const parts: string[] = []; for (const block of content) { switch (block.type) { case 'text': parts.push(block.text); break; case 'thinking': if (options?.includeThinking) { parts.push(block.thinking); } break; case 'tool_use': parts.push(`Tool: ${block.name}\nInput: ${JSON.stringify(block.input, null, 2)}`); break; case 'tool_result': { const resultContent = block.content; if (typeof resultContent === 'string') { parts.push(resultContent); } else if (Array.isArray(resultContent)) { // Recursively extract from nested content blocks const nested = extractTextFromContent(resultContent); if (nested) parts.push(nested); } break; } case 'image': parts.push('[Image]'); break; } } return parts.join('\n'); } // ============================================================================= // Plain Text Chunk Formatters // ============================================================================= function formatToolExecutionPlainText(exec: { toolCall: { name: string; input: Record }; result?: { content: string | unknown[]; isError: boolean }; }): string[] { const lines: string[] = []; lines.push(` TOOL: ${exec.toolCall.name}`); lines.push(` Input: ${JSON.stringify(exec.toolCall.input)}`); if (exec.result) { const prefix = exec.result.isError ? ' [ERROR] Result: ' : ' Result: '; const resultText = typeof exec.result.content === 'string' ? exec.result.content : JSON.stringify(exec.result.content); lines.push(`${prefix}${truncate(resultText, 500)}`); } else { lines.push(' [No result]'); } return lines; } function formatChunkPlainText(chunk: Chunk): string[] { const lines: string[] = []; switch (chunk.chunkType) { case 'user': { lines.push(`USER: ${extractTextFromContent(chunk.userMessage.content)}`); break; } case 'ai': { // Render thinking blocks first, then text for (const response of chunk.responses) { if (Array.isArray(response.content)) { // Check for thinking blocks for (const block of response.content) { if (block.type === 'thinking') { lines.push(`THINKING: ${block.thinking}`); } } // Then text const text = extractTextFromContent(response.content); if (text) { lines.push(`ASSISTANT: ${text}`); } } else if (typeof response.content === 'string') { lines.push(`ASSISTANT: ${response.content}`); } } // Tool executions for (const exec of chunk.toolExecutions) { lines.push(...formatToolExecutionPlainText(exec)); } break; } case 'system': { lines.push(`SYSTEM: ${chunk.commandOutput}`); break; } case 'compact': { lines.push('[Context compacted]'); break; } } return lines; } // ============================================================================= // Markdown Chunk Formatters // ============================================================================= function formatToolExecutionMarkdown(exec: { toolCall: { name: string; input: Record }; result?: { content: string | unknown[]; isError: boolean }; }): string[] { const lines: string[] = []; lines.push(`**Tool:** \`${exec.toolCall.name}\``); lines.push(''); lines.push('```json'); lines.push(JSON.stringify(exec.toolCall.input, null, 2)); lines.push('```'); lines.push(''); if (exec.result) { if (exec.result.isError) { lines.push('**Error:**'); } else { lines.push('**Result:**'); } lines.push(''); const resultText = typeof exec.result.content === 'string' ? exec.result.content : JSON.stringify(exec.result.content, null, 2); lines.push('```'); lines.push(truncate(resultText, 2000)); lines.push('```'); } return lines; } function formatChunkMarkdown(chunk: Chunk, turnNum: number): string[] { const lines: string[] = []; switch (chunk.chunkType) { case 'user': { lines.push(`### User (Turn ${turnNum})`); lines.push(''); lines.push(extractTextFromContent(chunk.userMessage.content)); lines.push(''); break; } case 'ai': { lines.push(`### Assistant (Turn ${turnNum})`); lines.push(''); for (const response of chunk.responses) { if (Array.isArray(response.content)) { // Thinking blocks as blockquotes for (const block of response.content) { if (block.type === 'thinking') { lines.push('> *Thinking:*'); for (const thinkLine of block.thinking.split('\n')) { lines.push(`> ${thinkLine}`); } lines.push(''); } } // Text content const text = extractTextFromContent(response.content); if (text) { lines.push(text); lines.push(''); } } else if (typeof response.content === 'string') { lines.push(response.content); lines.push(''); } } // Tool executions for (const exec of chunk.toolExecutions) { lines.push(...formatToolExecutionMarkdown(exec)); lines.push(''); } break; } case 'system': { lines.push(`### System (Turn ${turnNum})`); lines.push(''); lines.push(chunk.commandOutput); lines.push(''); break; } case 'compact': { lines.push('---'); lines.push(''); lines.push('*Context compacted*'); lines.push(''); break; } } return lines; } // ============================================================================= // Export Functions // ============================================================================= /** * Export session as plain text transcript. * * Produces a flat text format with clear labels (USER:, ASSISTANT:, TOOL:, etc.) * and separator lines between sections. */ export function exportAsPlainText(detail: SessionDetail): string { const { session, metrics, chunks } = detail; const lines: string[] = []; // Header lines.push('═'.repeat(60)); lines.push('SESSION EXPORT'); lines.push('═'.repeat(60)); lines.push(`Session: ${session.id}`); lines.push(`Project: ${session.projectPath}`); if (session.gitBranch) { lines.push(`Branch: ${session.gitBranch}`); } lines.push(`Date: ${formatTimestamp(new Date(session.createdAt))}`); lines.push(''); // Metrics lines.push('─'.repeat(40)); lines.push('METRICS'); lines.push('─'.repeat(40)); lines.push(`Duration: ${formatDurationForExport(metrics.durationMs)}`); lines.push(`Total Tokens: ${formatNumber(metrics.totalTokens)}`); lines.push(`Input Tokens: ${formatNumber(metrics.inputTokens)}`); lines.push(`Output Tokens: ${formatNumber(metrics.outputTokens)}`); lines.push(`Cache Read: ${formatNumber(metrics.cacheReadTokens)}`); lines.push(`Cache Created: ${formatNumber(metrics.cacheCreationTokens)}`); lines.push(`Messages: ${formatNumber(metrics.messageCount)}`); lines.push(`Cost: ${formatCost(metrics.costUsd)}`); lines.push(''); // Conversation lines.push('═'.repeat(60)); lines.push('CONVERSATION'); lines.push('═'.repeat(60)); lines.push(''); for (const chunk of chunks) { lines.push('─'.repeat(40)); lines.push(...formatChunkPlainText(chunk)); lines.push(''); } return lines.join('\n'); } /** * Export session as structured Markdown. * * Produces Markdown with headings, tables, code blocks, and blockquotes * suitable for viewing in any Markdown renderer. */ export function exportAsMarkdown(detail: SessionDetail): string { const { session, metrics, chunks } = detail; const lines: string[] = []; // Title lines.push('# Session Export'); lines.push(''); // Property table lines.push('| Property | Value |'); lines.push('|----------|-------|'); lines.push(`| Session | \`${session.id}\` |`); lines.push(`| Project | \`${session.projectPath}\` |`); if (session.gitBranch) { lines.push(`| Branch | \`${session.gitBranch}\` |`); } lines.push(`| Date | ${formatTimestamp(new Date(session.createdAt))} |`); lines.push(''); // Metrics table lines.push('## Metrics'); lines.push(''); lines.push('| Metric | Value |'); lines.push('|--------|-------|'); lines.push(`| Duration | ${formatDurationForExport(metrics.durationMs)} |`); lines.push(`| Total Tokens | ${formatNumber(metrics.totalTokens)} |`); lines.push(`| Input Tokens | ${formatNumber(metrics.inputTokens)} |`); lines.push(`| Output Tokens | ${formatNumber(metrics.outputTokens)} |`); lines.push(`| Cache Read | ${formatNumber(metrics.cacheReadTokens)} |`); lines.push(`| Cache Created | ${formatNumber(metrics.cacheCreationTokens)} |`); lines.push(`| Messages | ${formatNumber(metrics.messageCount)} |`); lines.push(`| Cost | ${formatCost(metrics.costUsd)} |`); lines.push(''); // Conversation lines.push('## Conversation'); lines.push(''); let turnNum = 0; for (const chunk of chunks) { turnNum++; lines.push(...formatChunkMarkdown(chunk, turnNum)); } return lines.join('\n'); } /** * Export session as pretty-printed JSON. */ export function exportAsJson(detail: SessionDetail): string { return JSON.stringify(detail, null, 2); } /** * Trigger a browser file download for the given session in the specified format. * * Creates a Blob, generates an object URL, and simulates an anchor click * to initiate the download. */ export function triggerDownload(detail: SessionDetail, format: ExportFormat): void { const formatters: Record< ExportFormat, { fn: (d: SessionDetail) => string; ext: string; mime: string } > = { markdown: { fn: exportAsMarkdown, ext: 'md', mime: 'text/markdown;charset=utf-8' }, json: { fn: exportAsJson, ext: 'json', mime: 'application/json;charset=utf-8' }, plaintext: { fn: exportAsPlainText, ext: 'txt', mime: 'text/plain;charset=utf-8' }, }; const { fn, ext, mime } = formatters[format]; const content = fn(detail); const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `session-${detail.session.id}.${ext}`; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); }