import { createReadStream, statSync } from 'fs' import { createInterface } from 'readline' import { basename, dirname } from 'path' import type { ParseResult, SessionEvent, SessionEventType, SessionMeta, SessionOutcome } from '../../types/session.js' function slugFromProjectPath(jsonlPath: string): string { return basename(dirname(jsonlPath)) } function detectSkillInvoked(text: string | null): string | null { if (!text) return null const m = text.match(/Launching skill:\s*([a-zA-Z0-9:_\-\/]+)/) return m ? m[1] : null } function detectHook(text: string | null): string | null { if (!text) return null const m = text.match(/(?:Hook|hook)\s+["']?([a-zA-Z0-9_\-\.]+\.sh)["']?/) return m ? m[1] : null } function extractText(rawMsg: unknown): string | null { if (!rawMsg || typeof rawMsg !== 'object') return null const msg = rawMsg as { content?: unknown } if (typeof msg.content === 'string') return msg.content if (!Array.isArray(msg.content)) return null const parts: string[] = [] for (const part of msg.content) { if (part && typeof part === 'object' && 'type' in part) { const p = part as { type: string; text?: string } if (p.type === 'text' && typeof p.text === 'string') parts.push(p.text) } } return parts.length ? parts.join('\n') : null } function classifyEventType(raw: Record): SessionEventType { const t = raw.type if (typeof t !== 'string') return 'unknown' if (t === 'user' || t === 'assistant' || t === 'system' || t === 'attachment' || t === 'permission-mode' || t === 'file-history-snapshot') { return t as SessionEventType } return 'unknown' } function deriveOutcome(events: SessionEvent[]): SessionOutcome { if (events.length === 0) return 'unknown' const last = events[events.length - 1] const raw = last.raw as { message?: { stop_reason?: string } } if (last.type === 'assistant' && raw.message?.stop_reason && raw.message.stop_reason !== 'end_turn' && raw.message.stop_reason !== 'tool_use') { if (raw.message.stop_reason === 'error') return 'error' } if (last.type === 'assistant') return 'completed' return 'interrupted' } export async function parseSessionFile(jsonlPath: string): Promise { const stream = createReadStream(jsonlPath, { encoding: 'utf-8' }) const rl = createInterface({ input: stream, crlfDelay: Infinity }) const events: SessionEvent[] = [] let sessionId: string | null = null let permissionMode: string | null = null let firstPrompt: string | null = null let userMessages = 0 let assistantMsgs = 0 let toolCalls = 0 const toolsUsed = new Set() const skillsInvoked = new Set() let firstTs: string | null = null let lastTs: string | null = null let idx = 0 for await (const line of rl) { if (!line.trim()) continue let raw: Record try { raw = JSON.parse(line) } catch { continue } const type = classifyEventType(raw) const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : null if (timestamp) { if (!firstTs) firstTs = timestamp lastTs = timestamp } if (type === 'permission-mode' && typeof raw.permissionMode === 'string') { permissionMode = raw.permissionMode } if (typeof raw.sessionId === 'string') sessionId = raw.sessionId const text = extractText((raw as { message?: unknown }).message) let toolName: string | null = null let toolInput: Record | null = null let toolResult: unknown = null if (type === 'assistant' && raw.message && typeof raw.message === 'object') { const content = (raw.message as { content?: unknown[] }).content if (Array.isArray(content)) { for (const part of content) { if (part && typeof part === 'object' && 'type' in part) { const p = part as { type: string; name?: string; input?: Record } if (p.type === 'tool_use' && typeof p.name === 'string') { toolCalls++ toolsUsed.add(p.name) toolName = p.name toolInput = p.input ?? null } } } } assistantMsgs++ } if (type === 'user') { userMessages++ if (!firstPrompt && text && !text.startsWith('<') && text.length > 0) { firstPrompt = text.slice(0, 500) } if (raw.message && typeof raw.message === 'object') { const content = (raw.message as { content?: unknown[] }).content if (Array.isArray(content)) { for (const part of content) { if (part && typeof part === 'object' && 'type' in part) { const p = part as { type: string; content?: unknown } if (p.type === 'tool_result') toolResult = p.content } } } } } const skill = detectSkillInvoked(text) if (skill) skillsInvoked.add(skill) const hook = detectHook(text) events.push({ index: idx++, type, timestamp, raw, text, tool_name: toolName, tool_input: toolInput, tool_result: toolResult, skill_invoked: skill, hook_name: hook, }) } if (!sessionId) { sessionId = basename(jsonlPath, '.jsonl') } const durationSec = firstTs && lastTs ? Math.max(0, Math.round((Date.parse(lastTs) - Date.parse(firstTs)) / 1000)) : null const outcome = deriveOutcome(events) const stats = statSync(jsonlPath) const meta: SessionMeta = { session_id: sessionId, project_path: dirname(jsonlPath), project_slug: slugFromProjectPath(jsonlPath), jsonl_path: jsonlPath, started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(), ended_at: lastTs, duration_sec: durationSec, event_count: events.length, user_messages: userMessages, assistant_msgs: assistantMsgs, tool_calls: toolCalls, first_prompt: firstPrompt, tools_used: [...toolsUsed], skills_invoked: [...skillsInvoked], outcome, permission_mode: permissionMode, file_size: stats.size, indexed_at: new Date().toISOString(), } return { meta, events } }