← kuato
/**
* Parse Claude Code session JSONL files
*
* Extracts structured data from raw session transcripts including:
* - Token usage (total and per-model)
* - User messages
* - Tools used
* - Files touched (from tool calls)
* - Timestamps
*/
import { readFileSync } from 'fs';
import type {
SessionMessage,
AssistantMessage,
ParsedSession,
ContentBlock,
} from './types.js';
/**
* Parse a single JSONL file into structured session data
*/
export function parseSessionFile(filePath: string): ParsedSession | null {
const content = readFileSync(filePath, 'utf-8');
return parseSessionContent(content, filePath);
}
/**
* Parse JSONL content string into structured session data
*/
export function parseSessionContent(
content: string,
sessionId?: string
): ParsedSession | null {
const lines = content.trim().split('\n').filter(Boolean);
if (lines.length === 0) {
return null;
}
const messages: SessionMessage[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line);
// Only include user/assistant messages (skip summary, system, etc.)
if (parsed.type === 'user' || parsed.type === 'assistant') {
messages.push(parsed as SessionMessage);
}
} catch {
// Skip malformed lines
continue;
}
}
if (messages.length === 0) {
return null;
}
// Extract session metadata from first and last conversation messages
const firstMessage = messages[0];
const lastMessage = messages[messages.length - 1];
// Initialize accumulators
const userMessages: string[] = [];
const toolsUsed = new Set<string>();
const filesFromToolCalls = new Set<string>();
const modelsUsed = new Set<string>();
let inputTokens = 0;
let outputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
const modelTokens: Record<
string,
{ input: number; output: number; cacheCreation: number; cacheRead: number }
> = {};
for (const msg of messages) {
if (msg.type === 'user') {
// Extract user message text
const userMsg = msg.message as { role: string; content: string };
if (typeof userMsg.content === 'string' && userMsg.content.trim()) {
userMessages.push(userMsg.content);
}
} else if (msg.type === 'assistant') {
const assistantMsg = msg.message as AssistantMessage;
// Track model
if (assistantMsg.model) {
modelsUsed.add(assistantMsg.model);
// Initialize model token tracking
if (!modelTokens[assistantMsg.model]) {
modelTokens[assistantMsg.model] = {
input: 0,
output: 0,
cacheCreation: 0,
cacheRead: 0,
};
}
}
// Accumulate token usage
if (assistantMsg.usage) {
const usage = assistantMsg.usage;
inputTokens += usage.input_tokens || 0;
outputTokens += usage.output_tokens || 0;
cacheCreationTokens += usage.cache_creation_input_tokens || 0;
cacheReadTokens += usage.cache_read_input_tokens || 0;
// Per-model tracking
if (assistantMsg.model && modelTokens[assistantMsg.model]) {
modelTokens[assistantMsg.model].input += usage.input_tokens || 0;
modelTokens[assistantMsg.model].output += usage.output_tokens || 0;
modelTokens[assistantMsg.model].cacheCreation +=
usage.cache_creation_input_tokens || 0;
modelTokens[assistantMsg.model].cacheRead +=
usage.cache_read_input_tokens || 0;
}
}
// Extract tools and files from content blocks
if (Array.isArray(assistantMsg.content)) {
for (const block of assistantMsg.content) {
extractFromContentBlock(block, toolsUsed, filesFromToolCalls);
}
}
}
}
// Derive session ID from file path or first message
const id =
sessionId?.match(/([a-f0-9-]{36})\.jsonl$/)?.[1] ||
firstMessage.sessionId ||
'unknown';
return {
id,
startedAt: new Date(firstMessage.timestamp),
endedAt: new Date(lastMessage.timestamp),
gitBranch: firstMessage.gitBranch || 'unknown',
cwd: firstMessage.cwd || '',
version: firstMessage.version || '',
messageCount: messages.length,
inputTokens,
outputTokens,
cacheCreationTokens,
cacheReadTokens,
userMessages,
toolsUsed: Array.from(toolsUsed),
filesFromToolCalls: Array.from(filesFromToolCalls),
modelsUsed: Array.from(modelsUsed),
modelTokens,
};
}
/**
* Extract tool names and file paths from a content block
*/
function extractFromContentBlock(
block: ContentBlock,
toolsUsed: Set<string>,
filesFromToolCalls: Set<string>
): void {
if (block.type === 'tool_use' && block.name) {
toolsUsed.add(block.name);
// Extract file paths from tool inputs
if (block.input) {
extractFilePaths(block.input, filesFromToolCalls);
}
}
}
/**
* Recursively extract file paths from tool input
*/
function extractFilePaths(
input: Record<string, unknown>,
files: Set<string>
): void {
for (const [key, value] of Object.entries(input)) {
// Common file path parameter names
if (
['file_path', 'path', 'file', 'filename', 'filePath'].includes(key) &&
typeof value === 'string'
) {
// Only add if it looks like a path
if (value.includes('/') || value.includes('\\')) {
files.add(value);
}
}
// Recurse into nested objects
if (value && typeof value === 'object' && !Array.isArray(value)) {
extractFilePaths(value as Record<string, unknown>, files);
}
}
}
/**
* Get a simple text summary suitable for search indexing
*/
export function getSearchableText(session: ParsedSession): string {
const parts: string[] = [];
// User messages are highest signal
parts.push(...session.userMessages);
// Tools and files provide context
parts.push(...session.toolsUsed);
parts.push(...session.filesFromToolCalls);
return parts.join(' ');
}