import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { isInScanRoots } from "./chat-types"; // Claude local JSONL message format interface ClaudeMessage { type: string; timestamp: string; userType?: string; cwd?: string; uuid?: string; sessionId?: string; message?: { role?: string; content?: string | Array<{ type: string; text?: string }>; }; } // Parsed Claude conversation event export interface ClaudeEvent { id: string; timestamp: Date; projectPath: string; sessionId: string; messageType: "user" | "assistant"; messagePreview: string; fullContent: string; // Full message content for search conversationFile: string; } /** * Get the Claude projects directory path */ export function getClaudeProjectsDir(): string { if (process.env.SCRIBE_CLAUDE_PROJECTS_DIR) { return process.env.SCRIBE_CLAUDE_PROJECTS_DIR; } return join(homedir(), ".claude", "projects"); } /** * Convert a project path to Claude's directory naming convention * e.g., /Users/satchmo/code/invoicer -> -Users-satchmo-code-invoicer */ export function pathToClaudeDir(projectPath: string): string { return projectPath.replace(/\//g, "-"); } /** * Convert Claude's directory name back to a path * e.g., -Users-satchmo-code-invoicer -> /Users/satchmo/code/invoicer */ export function claudeDirToPath(dirName: string): string { // Handle the leading dash which represents the root / if (dirName.startsWith("-")) { return `/${dirName.slice(1).replace(/-/g, "/")}`; } return dirName.replace(/-/g, "/"); } async function pathExists(p: string): Promise { try { await stat(p); return true; } catch { return false; } } /** * Resolve a Claude encoded directory name back to its real filesystem path. * * Claude encodes project paths by replacing every "/" with "-", which is lossy * for any directory whose name already contains a hyphen (e.g. the encoded * `-Users-me-code-yours-wallet` could decode to `.../yours/wallet` OR * `.../yours-wallet`). The naive `claudeDirToPath` always picks the "/" reading, * which leaves the path unable to join to a project root and ~79% of Claude * sessions unassigned (OPL-2241). * * Here we disambiguate against the actual filesystem: split the encoded name into * tokens and, at each boundary, try consuming successively more tokens as one * segment, descending into whichever interpretation exists on disk. This handles * hyphens at any depth, not just the basename. If no fully-existing path can be * reconstructed (e.g. the project was deleted), we fall back to the naive decode. */ export async function resolveClaudeDirPath(dirName: string): Promise { // Only absolute (leading-dash) encodings can be resolved against the fs root. if (!dirName.startsWith("-")) { return claudeDirToPath(dirName); } const tokens = dirName.slice(1).split("-"); const resolve = async ( base: string, start: number, ): Promise => { // All tokens consumed against existing directories — base is the answer. if (start >= tokens.length) { return base; } let segment = tokens[start]; for (let end = start; end < tokens.length; end++) { if (end > start) { segment += `-${tokens[end]}`; } const candidate = `${base}/${segment}`; if (await pathExists(candidate)) { const rest = await resolve(candidate, end + 1); if (rest !== null) { return rest; } } } return null; }; const resolved = await resolve("", 0); return resolved ?? claudeDirToPath(dirName); } /** * Find all Claude project directories */ export async function findClaudeProjectDirs(): Promise< Array<{ dir: string; projectPath: string }> > { const claudeDir = getClaudeProjectsDir(); const results: Array<{ dir: string; projectPath: string }> = []; try { const entries = await readdir(claudeDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { results.push({ dir: join(claudeDir, entry.name), // Resolve against the filesystem so hyphenated project directories // decode correctly and join to their project root (OPL-2241). projectPath: await resolveClaudeDirPath(entry.name), }); } } } catch { // Claude directory doesn't exist or isn't accessible } return results; } /** * Parse a Claude JSONL conversation file to extract events */ export async function parseClaudeConversation( filePath: string, projectPath: string, ): Promise { const events: ClaudeEvent[] = []; const fileName = basename(filePath); try { const content = await readFile(filePath, "utf-8"); const lines = content.split("\n").filter((line) => line.trim()); // Surface silently-skipped malformed lines instead of losing them with zero // diagnostic (a session file corrupted mid-write must not look like "no // activity"). Mirrors the codex parser's convention. let skippedLines = 0; let firstSkippedSample: string | undefined; for (const line of lines) { try { const record: ClaudeMessage = JSON.parse(line); // Skip snapshot updates and meta messages if ( record.type === "snapshot" || record.type === "summary" || (record as { isSnapshotUpdate?: boolean }).isSnapshotUpdate ) { continue; } // Only process user and assistant messages if (record.type !== "user" && record.type !== "assistant") { continue; } // Extract full message content. Modern Claude turns use an array of // content blocks; concatenate ALL `text` blocks (not just the first) // so multi-part assistant/user messages keep their full content. // tool_use / tool_result / thinking blocks carry no human-authored // text and are intentionally dropped. let fullContent = ""; const messageContent = record.message?.content; if (typeof messageContent === "string") { fullContent = messageContent; } else if (Array.isArray(messageContent)) { fullContent = messageContent .filter( (block) => block.type === "text" && typeof block.text === "string", ) .map((block) => block.text as string) .join("\n"); } // A turn that carries only tool_result/tool_use blocks (no text) has no // human-readable content. Skip it entirely so we never emit a blank // timeline event — the dominant source of empty Claude Code rows. if (!fullContent.trim()) { continue; } // Create truncated preview for UI display const preview = fullContent.length > 200 ? `${fullContent.slice(0, 200)}...` : fullContent; events.push({ id: record.uuid || `${fileName}-${events.length}`, timestamp: new Date(record.timestamp), projectPath: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd : projectPath, sessionId: record.sessionId || fileName.replace(".jsonl", ""), messageType: record.type as "user" | "assistant", messagePreview: preview, fullContent, // Store full content for search conversationFile: fileName, }); } catch { skippedLines++; if (firstSkippedSample === undefined) { firstSkippedSample = line.slice(0, 120); } } } if (skippedLines > 0) { console.warn( `[claude] skipped ${skippedLines} malformed line(s) in ${filePath}` + (firstSkippedSample ? ` (first: ${firstSkippedSample})` : ""), ); } } catch (err) { console.warn( `[claude] failed to read/parse conversation file ${filePath}: ${err}`, ); } return events; } /** * Get Claude conversations for a specific project path */ export async function getClaudeConversations( projectPath: string, since?: Date, ): Promise { const claudeDir = join(getClaudeProjectsDir(), pathToClaudeDir(projectPath)); const events: ClaudeEvent[] = []; try { const files = await readdir(claudeDir); const jsonlFiles = files.filter( (f) => f.endsWith(".jsonl") && !f.startsWith("agent-"), ); for (const file of jsonlFiles) { const filePath = join(claudeDir, file); // Check file modification time if filtering by date if (since) { const stats = await stat(filePath); if (stats.mtime < since) { continue; } } const fileEvents = await parseClaudeConversation(filePath, projectPath); events.push(...fileEvents); } } catch { // Directory doesn't exist or isn't accessible } // Sort by timestamp events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); // Filter by date if specified if (since) { return events.filter((e) => e.timestamp >= since); } return events; } /** * Get all Claude conversations from all projects within scan roots (legacy - returns all at once) */ export async function getAllClaudeConversations( scanRoots: string[], since?: Date, ): Promise { const allEvents: ClaudeEvent[] = []; for await (const event of getAllClaudeConversationsStreaming( scanRoots, since, )) { allEvents.push(event); } // Sort by timestamp allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return allEvents; } /** * Stream Claude conversations from all projects within scan roots * Yields events as they're parsed from JSONL files */ export async function* getAllClaudeConversationsStreaming( scanRoots: string[], since?: Date, ): AsyncGenerator { const projectDirs = await findClaudeProjectDirs(); for (const { dir, projectPath } of projectDirs) { try { const files = await readdir(dir); const jsonlFiles = files.filter( (f) => f.endsWith(".jsonl") && !f.startsWith("agent-"), ); for (const file of jsonlFiles) { const filePath = join(dir, file); // Check file modification time if filtering by date if (since) { const stats = await stat(filePath); if (stats.mtime < since) { continue; } } const fileEvents = await parseClaudeConversation(filePath, projectPath); // Yield events that pass the date filter for (const event of fileEvents) { if ( isInScanRoots(event.projectPath, scanRoots) && (!since || event.timestamp >= since) ) { yield event; } } } } catch { // Directory not accessible } } } /** * Session data yielded by getClaudeSessionsStreaming */ export interface ClaudeSession { projectPath: string; sessionId: string; conversationFile: string; events: ClaudeEvent[]; } /** * Stream Claude sessions (complete JSONL files) from all projects within scan roots. * Each yield contains all events from one conversation file, enabling batch processing. * This is more memory-efficient than collecting all events, as we process one file at a time. */ export async function* getClaudeSessionsStreaming( scanRoots: string[], since?: Date, ): AsyncGenerator { const projectDirs = await findClaudeProjectDirs(); for (const { dir, projectPath } of projectDirs) { try { const files = await readdir(dir); const jsonlFiles = files.filter( (f) => f.endsWith(".jsonl") && !f.startsWith("agent-"), ); for (const file of jsonlFiles) { const filePath = join(dir, file); // Check file modification time if filtering by date if (since) { const stats = await stat(filePath); if (stats.mtime < since) { continue; } } const fileEvents = await parseClaudeConversation(filePath, projectPath); // Filter events by date if specified const filteredEvents = fileEvents.filter( (event) => isInScanRoots(event.projectPath, scanRoots) && (!since || event.timestamp >= since), ); // Only yield if there are events to process if (filteredEvents.length > 0) { yield { projectPath: filteredEvents[0].projectPath, sessionId: filteredEvents[0].sessionId, conversationFile: file, events: filteredEvents, }; } } } catch { // Directory not accessible } } } /** * Estimate time spent on a Claude conversation session * Based on message count and time between messages */ export function estimateSessionMinutes(events: ClaudeEvent[]): number { if (events.length === 0) return 0; // Group by session const sessions = new Map(); for (const event of events) { const key = event.sessionId; if (!sessions.has(key)) { sessions.set(key, []); } sessions.get(key)?.push(event); } let totalMinutes = 0; for (const sessionEvents of sessions.values()) { if (sessionEvents.length < 2) { // Single message sessions count as 5 minutes totalMinutes += 5; continue; } // Sort by timestamp sessionEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); // Calculate duration from first to last message, capped at 2 hours per session const first = sessionEvents[0].timestamp; const last = sessionEvents[sessionEvents.length - 1].timestamp; const durationMs = last.getTime() - first.getTime(); const durationMinutes = Math.min(durationMs / 60000, 120); // Add some base time for the session totalMinutes += Math.max(durationMinutes, 5); } return Math.round(totalMinutes); }