/** * Transforms EnhancedChunk[] into SessionConversation structure. * * This module converts chunk-based data into a flat list of ChatItems * (UserGroups, SystemGroups, AIGroups) for a chat-style display. * Each item is independent - no pairing between user and AI chunks. */ import { isAssistantMessage, isEnhancedAIChunk, isEnhancedCompactChunk, isEnhancedSystemChunk, isEnhancedUserChunk, } from '@renderer/types/data'; import { getFirstSegment, hasPathSeparator, isRelativePath, splitPathSegments, } from '@renderer/utils/pathUtils'; import { isCommandContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import type { EnhancedAIChunk, EnhancedChunk, EnhancedCompactChunk, EnhancedSystemChunk, EnhancedUserChunk, ParsedMessage, Process, SemanticStep, } from '@renderer/types/data'; import type { AIGroup, AIGroupStatus, AIGroupSummary, AIGroupTokens, ChatItem, CommandInfo, CompactGroup, FileReference, ImageData, SessionConversation, SystemGroup, UserGroup, UserGroupContent, } from '@renderer/types/groups'; const logger = createLogger('Util:groupTransformer'); // ============================================================================= // Constants // ============================================================================= /** * Regex pattern for detecting slash commands. * Matches: /command-name [optional args] * Uses non-greedy matching and limited repetition to prevent ReDoS. */ // eslint-disable-next-line security/detect-unsafe-regex -- Pattern is safe: limited to 1000 chars and used on bounded user input const COMMAND_PATTERN = /\/([a-z][a-z-]{0,50})(?:\s+(\S[^\n]{0,1000}))?$/gim; /** * Maximum characters to extract for thinking preview. */ const THINKING_PREVIEW_LENGTH = 100; // ============================================================================= // Main Transformation Function // ============================================================================= /** * Transforms EnhancedChunk[] into SessionConversation. * * Produces a flat list of independent ChatItems (user, system, AI). * Each chunk type becomes its own item - no pairing or grouping. * * @param chunks - Array of enhanced chunks with semantic steps * @param _subagents - Array of all subagents in the session (unused, processes come from chunks) * @param isOngoing - Whether the session is still in progress (marks last AI group) * @returns SessionConversation structure for chat-style rendering */ export function transformChunksToConversation( chunks: EnhancedChunk[], _subagents: Process[], isOngoing: boolean = false ): SessionConversation { if (!chunks || chunks.length === 0) { return { sessionId: '', items: [], totalUserGroups: 0, totalSystemGroups: 0, totalAIGroups: 0, totalCompactGroups: 0, }; } const items: ChatItem[] = []; let userCount = 0; let systemCount = 0; let aiCount = 0; let compactCount = 0; for (const chunk of chunks) { if (isEnhancedUserChunk(chunk)) { items.push({ type: 'user', group: createUserGroupFromChunk(chunk, userCount++), }); } else if (isEnhancedSystemChunk(chunk)) { items.push({ type: 'system', group: createSystemGroup(chunk), }); systemCount++; } else if (isEnhancedAIChunk(chunk)) { items.push({ type: 'ai', group: createAIGroupFromChunk(chunk, aiCount), }); aiCount++; } else if (isEnhancedCompactChunk(chunk)) { items.push({ type: 'compact', group: createCompactGroup(chunk), }); compactCount++; } else { const unhandledChunkType = 'chunkType' in chunk ? (chunk as EnhancedChunk).chunkType : 'unknown'; logger.warn('Unhandled chunk type:', unhandledChunkType); } } // Post-pass: enrich CompactGroups with token deltas let phaseCounter = 1; for (let i = 0; i < items.length; i++) { if (items[i].type === 'compact') { phaseCounter++; const compactItem = items[i] as { type: 'compact'; group: CompactGroup }; compactItem.group.startingPhaseNumber = phaseCounter; // Find last AI group before and first AI group after const preAi = findLastAiBefore(items, i); const postAi = findFirstAiAfter(items, i); if (preAi && postAi) { const pre = getLastAssistantTotalTokens(preAi); // Use FIRST assistant message after compaction — it reflects the actual // compacted context size before the AI generates more content. const post = getFirstAssistantTotalTokens(postAi); if (pre !== undefined && post !== undefined) { compactItem.group.tokenDelta = { preCompactionTokens: pre, postCompactionTokens: post, delta: post - pre, }; } } } } // If session is ongoing, mark the last AI group (but don't override interrupted status) if (isOngoing && aiCount > 0) { // Find the last AI item and mark it as ongoing for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; if (item.type === 'ai') { const currentStatus = item.group.status; // Don't override 'interrupted' status - interruption takes precedence over ongoing if (currentStatus !== 'interrupted') { (item.group as AIGroup & { isOngoing?: boolean }).isOngoing = true; (item.group as AIGroup & { status?: AIGroupStatus }).status = 'in_progress'; } break; } } } return { sessionId: chunks[0]?.id ?? 'unknown', items, totalUserGroups: userCount, totalSystemGroups: systemCount, totalAIGroups: aiCount, totalCompactGroups: compactCount, }; } // ============================================================================= // UserGroup Creation // ============================================================================= /** * Creates a UserGroup from an EnhancedUserChunk. * * @param chunk - The user chunk to transform * @param index - Index within the session (for ordering) * @returns UserGroup with parsed content */ function createUserGroupFromChunk(chunk: EnhancedUserChunk, index: number): UserGroup { return createUserGroup(chunk.userMessage, index); } /** * Creates a UserGroup from a ParsedMessage. * * @param message - The user's input message * @param index - Index within the session (for ordering) * @returns UserGroup with parsed content */ function createUserGroup(message: ParsedMessage, index: number): UserGroup { const content = extractUserGroupContent(message); return { id: `user-${message.uuid}`, message, timestamp: message.timestamp, content, index, }; } /** * Extracts and parses content from a user message. * * @param message - The user message to parse * @returns Parsed UserGroupContent */ function extractUserGroupContent(message: ParsedMessage): UserGroupContent { let rawText = ''; const images: ImageData[] = []; const fileReferences: FileReference[] = []; // Extract text from content // Note: Image handling not yet implemented - images are not part of ContentBlock type if (typeof message.content === 'string') { rawText = message.content; } else if (Array.isArray(message.content)) { for (const block of message.content) { if (block.type === 'text' && block.text) { rawText += block.text; } } } // Sanitize content for display (handles XML tags from command messages) // This converts /model to "/model" const sanitizedText = sanitizeDisplayContent(rawText); // Check if this is a command message (for special handling) const isCommand = isCommandContent(rawText); // Extract commands from the sanitized text (for inline /commands in regular messages) // For command messages, the command is already extracted as sanitizedText const commands = isCommand ? [] : extractCommands(sanitizedText); // Extract file references (@file.ts) from sanitized text fileReferences.push(...extractFileReferences(sanitizedText)); // For command messages, use the sanitized command as display text // For regular messages, remove inline commands from display let displayText = sanitizedText; if (!isCommand) { for (const cmd of commands) { displayText = displayText.replace(cmd.raw, '').trim(); } } return { text: displayText || undefined, rawText: sanitizedText, // Use sanitized version as rawText for display commands, images, fileReferences, }; } /** * Extracts commands from text using regex. * * @param text - Text to parse for commands * @returns Array of CommandInfo objects */ function extractCommands(text: string): CommandInfo[] { if (!text) return []; const commands: CommandInfo[] = []; let match: RegExpExecArray | null; // Reset regex state COMMAND_PATTERN.lastIndex = 0; while ((match = COMMAND_PATTERN.exec(text)) !== null) { const [fullMatch, commandName, args] = match; commands.push({ name: commandName, args: args?.trim(), raw: fullMatch, startIndex: match.index, endIndex: match.index + fullMatch.length, }); } return commands; } /** * Known directory prefixes that identify file references. */ const KNOWN_DIRS = new Set([ 'src', 'apps', 'app', 'lib', 'types', 'packages', 'components', 'utils', 'services', 'hooks', 'store', 'public', 'assets', 'config', 'tests', 'test', 'specs', 'spec', 'e2e', 'docs', 'scripts', 'screens', 'features', 'pages', 'views', 'models', 'controllers', 'routes', 'middleware', 'api', 'common', 'core', 'modules', 'client', 'server', 'web', 'mobile', 'native', 'electron', 'node_modules', ]); /** * Simple pattern for detecting @ mentions that could be file paths. * The filtering logic in extractFileReferences determines validity. */ const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g; /** * Checks if a path looks like a valid file reference. * Must start with known dir, contain /, or start with ./ or ../ */ function isValidFileRef(path: string): boolean { // Check for relative path indicators if (isRelativePath(path)) { return true; } // Check if starts with known directory const first = getFirstSegment(path); if (KNOWN_DIRS.has(first)) { return true; } // Check if contains a path separator (indicates directory structure) // Require either 3+ segments or a file extension to avoid matching // npm scoped packages like @scope/name (extracted as "scope/name") if (hasPathSeparator(path) && path.length > 2) { const segments = splitPathSegments(path); if (segments.length > 2 || /\.[a-zA-Z0-9]+$/.test(path)) { return true; } } return false; } /** * Extracts file references (@file.ts) from text. * * @param text - Text to parse for file references * @returns Array of FileReference objects */ export function extractFileReferences(text: string): FileReference[] { if (!text) return []; const references: FileReference[] = []; // Reset regex state before use FILE_REF_PATTERN.lastIndex = 0; let match: RegExpExecArray | null; while ((match = FILE_REF_PATTERN.exec(text)) !== null) { const [fullMatch, path] = match; // Only include if it looks like a valid file reference if (isValidFileRef(path)) { references.push({ path, raw: fullMatch, }); } } return references; } // ============================================================================= // SystemGroup Creation // ============================================================================= /** * Creates a SystemGroup from an EnhancedSystemChunk. * * @param chunk - The system chunk to transform * @returns SystemGroup with command output */ function createSystemGroup(chunk: EnhancedSystemChunk): SystemGroup { return { id: chunk.id, // Use stable chunk ID instead of array index message: chunk.message, timestamp: chunk.startTime, commandOutput: chunk.commandOutput, }; } // ============================================================================= // CompactGroup Creation // ============================================================================= /** * Creates a CompactGroup from an EnhancedCompactChunk. * * @param chunk - The compact chunk to transform * @returns CompactGroup marking where conversation was compacted, with message content */ function createCompactGroup(chunk: EnhancedCompactChunk): CompactGroup { return { id: chunk.id, // Use stable chunk ID instead of array index timestamp: chunk.startTime, message: chunk.message, // Pass through the compact summary message }; } // ============================================================================= // AIGroup Creation // ============================================================================= /** * Creates an AIGroup from an EnhancedAIChunk. * * @param chunk - The AI chunk to transform * @param turnIndex - 0-based index of this AI group within the session * @returns AIGroup with semantic steps and metrics */ function createAIGroupFromChunk(chunk: EnhancedAIChunk, turnIndex: number): AIGroup { const steps = chunk.semanticSteps; // Calculate timing from all steps const startTime = steps.length > 0 ? steps[0].startTime : chunk.startTime; const endTime = steps.length > 0 ? (steps[steps.length - 1].endTime ?? steps[steps.length - 1].startTime) : chunk.endTime; const durationMs = endTime.getTime() - startTime.getTime(); // Find any source assistant message for token calculation const sourceMessage = chunk.responses.find((msg) => isAssistantMessage(msg)) ?? null; // Calculate tokens from all steps const tokens = calculateTokensFromSteps(steps, sourceMessage); // Generate summary from all steps const summary = computeAIGroupSummary(steps); // Determine status from all steps const status = determineAIGroupStatus(steps); return { id: chunk.id, // Use stable chunk ID instead of array index turnIndex, startTime, endTime, durationMs, steps, tokens, summary, status, processes: chunk.processes, chunkId: chunk.id, metrics: chunk.metrics, responses: chunk.responses, }; } /** * Calculates token metrics from semantic steps and source message. * * @param steps - Semantic steps in this AI Group * @param sourceMessage - Source assistant message (if available) * @returns Token metrics */ function calculateTokensFromSteps( steps: SemanticStep[], sourceMessage: ParsedMessage | null | undefined ): AIGroupTokens { let input = 0; let output = 0; let cached = 0; let thinking = 0; // Sum from steps for (const step of steps) { if (step.tokens) { input += step.tokens.input ?? 0; output += step.tokens.output ?? 0; cached += step.tokens.cached ?? 0; } if (step.tokenBreakdown) { input += step.tokenBreakdown.input ?? 0; output += step.tokenBreakdown.output ?? 0; cached += step.tokenBreakdown.cacheRead ?? 0; } if (step.type === 'thinking' && step.tokens?.output) { thinking += step.tokens.output; } } // Override with source message usage if available (more accurate) if (sourceMessage?.usage) { input = sourceMessage.usage.input_tokens ?? 0; output = sourceMessage.usage.output_tokens ?? 0; cached = sourceMessage.usage.cache_read_input_tokens ?? 0; } return { input, output, cached, thinking, }; } // ============================================================================= // AIGroup Summary & Status Computation // ============================================================================= /** * Computes summary statistics for an AIGroup's collapsed view. * * @param steps - Semantic steps in the AI Group * @returns Summary statistics */ function computeAIGroupSummary(steps: SemanticStep[]): AIGroupSummary { let thinkingPreview: string | undefined; let toolCallCount = 0; let outputMessageCount = 0; let subagentCount = 0; let totalDurationMs = 0; let totalTokens = 0; let outputTokens = 0; let cachedTokens = 0; for (const step of steps) { // Extract thinking preview from first thinking step if (!thinkingPreview && step.type === 'thinking' && step.content.thinkingText) { const fullText = step.content.thinkingText; thinkingPreview = fullText.length > THINKING_PREVIEW_LENGTH ? fullText.slice(0, THINKING_PREVIEW_LENGTH) + '...' : fullText; } // Count step types if (step.type === 'tool_call') toolCallCount++; if (step.type === 'output') outputMessageCount++; if (step.type === 'subagent') subagentCount++; // Sum duration totalDurationMs += step.durationMs ?? 0; // Sum tokens if (step.tokens) { totalTokens += (step.tokens.input ?? 0) + (step.tokens.output ?? 0); outputTokens += step.tokens.output ?? 0; cachedTokens += step.tokens.cached ?? 0; } if (step.tokenBreakdown) { totalTokens += step.tokenBreakdown.input + step.tokenBreakdown.output; outputTokens += step.tokenBreakdown.output; cachedTokens += step.tokenBreakdown.cacheRead; } } return { thinkingPreview, toolCallCount, outputMessageCount, subagentCount, totalDurationMs, totalTokens, outputTokens, cachedTokens, }; } /** * Determines the status of an AIGroup based on its steps. * * @param steps - Semantic steps in the AI Group * @returns AIGroupStatus */ function determineAIGroupStatus(steps: SemanticStep[]): AIGroupStatus { if (steps.length === 0) return 'error'; // Check for interruption const hasInterruption = steps.some((step) => step.type === 'interruption'); if (hasInterruption) return 'interrupted'; // Check for errors const hasError = steps.some((step) => step.type === 'tool_result' && step.content.isError); if (hasError) return 'error'; // Check if any step is incomplete (no endTime) const hasIncomplete = steps.some((step) => !step.endTime); if (hasIncomplete) return 'in_progress'; // Otherwise, complete return 'complete'; } // ============================================================================= // CompactGroup Enrichment Helpers // ============================================================================= /** * Find the last AI group before a given index in the items array. */ function findLastAiBefore(items: ChatItem[], index: number): AIGroup | null { for (let i = index - 1; i >= 0; i--) { if (items[i].type === 'ai') return items[i].group as AIGroup; } return null; } /** * Find the first AI group after a given index in the items array. */ function findFirstAiAfter(items: ChatItem[], index: number): AIGroup | null { for (let i = index + 1; i < items.length; i++) { if (items[i].type === 'ai') return items[i].group as AIGroup; } return null; } /** * Get total tokens from the last assistant message in an AI group. * Sums input_tokens, output_tokens, cache_read_input_tokens, and cache_creation_input_tokens. */ function getLastAssistantTotalTokens(aiGroup: AIGroup): number | undefined { const responses = aiGroup.responses || []; for (let i = responses.length - 1; i >= 0; i--) { const msg = responses[i]; if (msg.type === 'assistant' && msg.usage) { return ( (msg.usage.input_tokens ?? 0) + (msg.usage.output_tokens ?? 0) + (msg.usage.cache_read_input_tokens ?? 0) + (msg.usage.cache_creation_input_tokens ?? 0) ); } } return undefined; } /** * Get total tokens from the FIRST assistant message in an AI group. * Used for post-compaction token measurement: the first response after compaction * reflects the actual compacted context size before the AI generates more content. */ function getFirstAssistantTotalTokens(aiGroup: AIGroup): number | undefined { const responses = aiGroup.responses || []; for (const msg of responses) { if (msg.type === 'assistant' && msg.usage) { return ( (msg.usage.input_tokens ?? 0) + (msg.usage.output_tokens ?? 0) + (msg.usage.cache_read_input_tokens ?? 0) + (msg.usage.cache_creation_input_tokens ?? 0) ); } } return undefined; } // ============================================================================= // Helper Functions // =============================================================================