700 lines · 20.0 KB · TypeScript
RawDownload
/** * 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 } 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 inputconst 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 <command-name>/model</command-name> 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',  'renderer',  'main',  'preload',  'public',  'assets',  'config',  'tests',  'test',  'specs',  'spec',  'e2e',  'docs',  'scripts',  'screens',  'features',  'pages',  'views',  'models',  'controllers',  'routes',  'middleware',  'api',  'common',  'shared',  '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)  if (hasPathSeparator(path) && path.length > 2) {    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. *

Lines 1–500 of 700.