/** * 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; }}
/**
* 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;
}
}