378 lines · 10.7 KB · TypeScript
RawDownload
/** * SessionParser service - Parses Claude Code session JSONL files. * * Responsibilities: * - Parse JSONL files into structured messages * - Extract all message metadata * - Identify tool calls and tool results * - Calculate session metrics */ import {  isParsedInternalUserMessage,  isParsedRealUserMessage,  type ParsedMessage,  type SessionMetrics,  type ToolCall,  type ToolResult,} from '@main/types';import {  calculateMetrics,  extractTextContent,  getTaskCalls,  parseJsonlFile,} from '@main/utils/jsonl';import * as path from 'path'; import { type ProjectScanner } from '../discovery/ProjectScanner'; /** * Result of parsing a session file. */export interface ParsedSession {  /** All parsed messages */  messages: ParsedMessage[];  /** Session metrics */  metrics: SessionMetrics;  /** All Task calls found in the session */  taskCalls: ToolCall[];  /** Messages grouped by type */  byType: {    user: ParsedMessage[]; // All user messages    realUser: ParsedMessage[]; // Only real user messages (not tool results)    internalUser: ParsedMessage[]; // Only tool result messages    assistant: ParsedMessage[];    system: ParsedMessage[];    other: ParsedMessage[];  };  /** Sidechain messages */  sidechainMessages: ParsedMessage[];  /** Main thread messages (non-sidechain) */  mainMessages: ParsedMessage[];} export class SessionParser {  private projectScanner: ProjectScanner;   constructor(projectScanner: ProjectScanner) {    this.projectScanner = projectScanner;  }   // ===========================================================================  // Core Parsing  // ===========================================================================   /**   * Parse a session JSONL file and return structured data.   */  async parseSession(projectId: string, sessionId: string): Promise<ParsedSession> {    const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId);    return this.parseSessionFile(sessionPath);  }   /**   * Parse a JSONL file at the given path.   */  async parseSessionFile(filePath: string): Promise<ParsedSession> {    const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());    return this.processMessages(messages);  }   /**   * Process parsed messages into structured data.   */  private processMessages(messages: ParsedMessage[]): ParsedSession {    // Group by type    const byType = {      user: messages.filter((m) => m.type === 'user'),      realUser: messages.filter(isParsedRealUserMessage),      internalUser: messages.filter(isParsedInternalUserMessage),      assistant: messages.filter((m) => m.type === 'assistant'),      system: messages.filter((m) => m.type === 'system'),      other: messages.filter(        (m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system'      ),    };     // Separate sidechain and main messages    const sidechainMessages = messages.filter((m) => m.isSidechain);    const mainMessages = messages.filter((m) => !m.isSidechain);     // Calculate metrics    const metrics = calculateMetrics(messages);     // Extract all Task calls    const taskCalls = getTaskCalls(messages);     return {      messages,      metrics,      taskCalls,      byType,      sidechainMessages,      mainMessages,    };  }   // ===========================================================================  // Message Queries  // ===========================================================================   /**   * Get user messages from a parsed session.   */  getUserMessages(session: ParsedSession): ParsedMessage[] {    return session.byType.user;  }   /**   * Get assistant messages from a parsed session.   */  getAssistantMessages(session: ParsedSession): ParsedMessage[] {    return session.byType.assistant;  }   /**   * Get messages in a time range.   */  getMessagesInRange(messages: ParsedMessage[], startTime: Date, endTime: Date): ParsedMessage[] {    return messages.filter((m) => m.timestamp >= startTime && m.timestamp <= endTime);  }   /**   * Get responses to a specific user message.   * Finds all assistant messages that follow the user message until the next user message.   */  getResponses(messages: ParsedMessage[], userMessageUuid: string): ParsedMessage[] {    const userMsgIndex = messages.findIndex((m) => m.uuid === userMessageUuid);    if (userMsgIndex === -1) return [];     const responses: ParsedMessage[] = [];     for (let i = userMsgIndex + 1; i < messages.length; i++) {      const msg = messages[i];       // Stop at next user message      if (msg.type === 'user') break;       // Include assistant responses      if (msg.type === 'assistant') {        responses.push(msg);      }    }     return responses;  }   // ===========================================================================  // Tool Call Analysis  // ===========================================================================   /**   * Get all Task (subagent) calls from messages.   */  getTaskCalls(messages: ParsedMessage[]): ToolCall[] {    return getTaskCalls(messages);  }   /**   * Get all tool calls of a specific type.   */  getToolCallsByName(messages: ParsedMessage[], toolName: string): ToolCall[] {    return messages.flatMap((m) => m.toolCalls.filter((tc) => tc.name === toolName));  }   /**   * Find the tool result for a specific tool call.   */  findToolResult(    messages: ParsedMessage[],    toolCallId: string  ): { message: ParsedMessage; result: ToolResult } | null {    for (const msg of messages) {      const result = msg.toolResults.find((tr) => tr.toolUseId === toolCallId);      if (result) {        return { message: msg, result };      }    }    return null;  }   // ===========================================================================  // Timing Analysis  // ===========================================================================   /**   * Get the time range of messages.   */  getTimeRange(messages: ParsedMessage[]): { start: Date; end: Date; durationMs: number } {    if (messages.length === 0) {      const now = new Date();      return { start: now, end: now, durationMs: 0 };    }     const timestamps = messages.map((m) => m.timestamp.getTime());    let min = timestamps[0];    let max = timestamps[0];    for (let i = 1; i < timestamps.length; i++) {      if (timestamps[i] < min) min = timestamps[i];      if (timestamps[i] > max) max = timestamps[i];    }    const start = new Date(min);    const end = new Date(max);     return {      start,      end,      durationMs: end.getTime() - start.getTime(),    };  }   /**   * Calculate metrics for a subset of messages.   */  calculateMetrics(messages: ParsedMessage[]): SessionMetrics {    return calculateMetrics(messages);  }   // ===========================================================================  // Text Extraction  // ===========================================================================   /**   * Extract text content from a message.   */  extractText(message: ParsedMessage): string {    return extractTextContent(message);  }   /**   * Get a preview of a message (first N characters).   */  getMessagePreview(message: ParsedMessage, maxLength: number = 100): string {    const text = extractTextContent(message);    if (text.length <= maxLength) return text;    return text.substring(0, maxLength) + '...';  }   // ===========================================================================  // Message Threading  // ===========================================================================   /**   * Build a parent-child message tree.   */  buildMessageTree(messages: ParsedMessage[]): Map<string, ParsedMessage[]> {    const tree = new Map<string, ParsedMessage[]>();     for (const msg of messages) {      const parentId = msg.parentUuid ?? 'root';      if (!tree.has(parentId)) {        tree.set(parentId, []);      }      tree.get(parentId)!.push(msg);    }     return tree;  }   /**   * Get child messages of a specific message.   */  getChildMessages(messages: ParsedMessage[], parentUuid: string): ParsedMessage[] {    return messages.filter((m) => m.parentUuid === parentUuid);  }   /**   * Get the conversation thread for a message (ancestors + descendants).   */  getThread(messages: ParsedMessage[], messageUuid: string): ParsedMessage[] {    const thread: ParsedMessage[] = [];    const messageMap = new Map(messages.map((m) => [m.uuid, m]));     // Get ancestors    let current = messageMap.get(messageUuid);    const ancestors: ParsedMessage[] = [];     while (current) {      ancestors.unshift(current);      current = current.parentUuid ? messageMap.get(current.parentUuid) : undefined;    }     thread.push(...ancestors);     // Get descendants    const descendants = this.getDescendants(messages, messageUuid);    // Add descendants that aren't already in thread    for (const desc of descendants) {      if (!thread.find((m) => m.uuid === desc.uuid)) {        thread.push(desc);      }    }     return thread;  }   /**   * Get all descendants of a message.   */  private getDescendants(messages: ParsedMessage[], parentUuid: string): ParsedMessage[] {    const result: ParsedMessage[] = [];    const children = messages.filter((m) => m.parentUuid === parentUuid);     for (const child of children) {      result.push(child);      result.push(...this.getDescendants(messages, child.uuid));    }     return result;  }   // ===========================================================================  // Subagent File Parsing  // ===========================================================================   /**   * Parse a subagent JSONL file.   */  async parseSubagentFile(filePath: string): Promise<{    messages: ParsedMessage[];    metrics: SessionMetrics;  }> {    const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());    const metrics = calculateMetrics(messages);     return { messages, metrics };  }   /**   * Parse all subagent files for a session.   */  async parseAllSubagents(    projectId: string,    sessionId: string  ): Promise<    Map<      string,      {        filePath: string;        messages: ParsedMessage[];        metrics: SessionMetrics;      }    >  > {    const subagentFiles = await this.projectScanner.listSubagentFiles(projectId, sessionId);    const results = new Map();     for (const filePath of subagentFiles) {      // Extract agent ID from filename (agent-{id}.jsonl)      const filename = path.basename(filePath);      const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, '');       const { messages, metrics } = await this.parseSubagentFile(filePath);      results.set(agentId, { filePath, messages, metrics });    }     return results;  }}