agent-ecosystem/src/main/services/parsing/SessionParser.ts
iliya e005671123 feat: add Sentry error tracking and update docs
- Integrate @sentry/electron and @sentry/react for crash reporting
- Add Sentry Vite plugin for source maps
- Add error tracking to main process, renderer, and IPC layer
- Exclude source maps from packaged builds
- Update README with new screenshots
- Add Sentry opt-out toggle in settings
- Update release workflow with Sentry config
2026-03-22 17:03:15 +02:00

381 lines
11 KiB
TypeScript

/**
* 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 { startMainSpan } from '../../sentry';
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> {
return startMainSpan('session.parse', 'parse', async () => {
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;
}
}