/** * JSONL Parser for Claude Code Usage Analytics * * High-performance streaming parser for ~/.claude/projects/ JSONL files. * Replaces better-ccusage dependency with optimized custom implementation. * * Key features: * - Streaming line-by-line parsing (memory efficient) * - Only parses "assistant" entries with usage data * - Parallel file processing with configurable concurrency * - Graceful error handling for malformed entries */ import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; import * as os from 'os'; // ============================================================================ // TYPE DEFINITIONS // ============================================================================ /** Raw usage data from JSONL entry */ export interface RawUsageEntry { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; model: string; sessionId: string; timestamp: string; projectPath: string; version?: string; } /** Internal structure matching JSONL assistant entries */ interface JsonlAssistantEntry { type: 'assistant'; sessionId: string; timestamp: string; version?: string; cwd?: string; message: { model: string; usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; }; }; } /** Parser options */ export interface ParserOptions { /** Max files to parse concurrently (default: 10) */ concurrency?: number; /** Skip files older than this date */ minDate?: Date; /** Custom projects directory (default: ~/.claude/projects) */ projectsDir?: string; } // ============================================================================ // CORE PARSING FUNCTIONS // ============================================================================ /** * Parse a single JSONL line into RawUsageEntry if valid * Returns null for non-assistant entries or entries without usage data */ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { // Strip UTF-8 BOM if present (can occur on first line of some files) const cleanLine = line.replace(/^\uFEFF/, '').trim(); if (!cleanLine) return null; try { const entry = JSON.parse(cleanLine); // Only process assistant entries with usage data if (entry.type !== 'assistant') return null; if (!entry.message?.usage) return null; if (!entry.message?.model) return null; const usage = entry.message.usage; const assistant = entry as JsonlAssistantEntry; return { inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0, cacheCreationTokens: usage.cache_creation_input_tokens || 0, cacheReadTokens: usage.cache_read_input_tokens || 0, model: assistant.message.model, sessionId: assistant.sessionId || '', timestamp: assistant.timestamp || new Date().toISOString(), projectPath, version: assistant.version, }; } catch { // Malformed JSON - skip silently return null; } } /** * Stream-parse a single JSONL file * Yields RawUsageEntry for each valid assistant entry */ export async function parseJsonlFile( filePath: string, projectPath: string ): Promise { const entries: RawUsageEntry[] = []; if (!fs.existsSync(filePath)) { return entries; } const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); for await (const line of rl) { const entry = parseUsageEntry(line, projectPath); if (entry) { entries.push(entry); } } return entries; } /** * Parse all JSONL files in a single project directory */ export async function parseProjectDirectory(projectDir: string): Promise { const entries: RawUsageEntry[] = []; if (!fs.existsSync(projectDir)) { return entries; } // Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project") const projectPath = path.basename(projectDir).replace(/-/g, '/'); try { const files = await fs.promises.readdir(projectDir); const jsonlFiles = files.filter((f) => f.endsWith('.jsonl')); // Parse files sequentially within a project to avoid too many open handles for (const file of jsonlFiles) { const filePath = path.join(projectDir, file); const fileEntries = await parseJsonlFile(filePath, projectPath); entries.push(...fileEntries); } } catch { // Directory access error - skip silently } return entries; } // ============================================================================ // DIRECTORY SCANNING // ============================================================================ /** * Get default Claude projects directory */ export function getDefaultProjectsDir(): string { const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); return path.join(configDir, 'projects'); } /** * Find all project directories under ~/.claude/projects/ */ export function findProjectDirectories(projectsDir?: string): string[] { const dir = projectsDir || getDefaultProjectsDir(); if (!fs.existsSync(dir)) { return []; } try { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) .map((entry) => path.join(dir, entry.name)); } catch { return []; } } /** * Scan all projects and parse all JSONL files * Main entry point for usage data extraction * * @param options - Parser configuration * @returns All parsed usage entries from all projects */ export async function scanProjectsDirectory(options: ParserOptions = {}): Promise { const { concurrency = 10, projectsDir } = options; const allEntries: RawUsageEntry[] = []; const projectDirs = findProjectDirectories(projectsDir); if (projectDirs.length === 0) { return allEntries; } // Process projects in batches for controlled concurrency for (let i = 0; i < projectDirs.length; i += concurrency) { const batch = projectDirs.slice(i, i + concurrency); const batchResults = await Promise.all(batch.map((dir) => parseProjectDirectory(dir))); for (const entries of batchResults) { allEntries.push(...entries); } } // Filter by date if specified if (options.minDate) { const minTime = options.minDate.getTime(); return allEntries.filter((entry) => { const entryTime = new Date(entry.timestamp).getTime(); return entryTime >= minTime; }); } return allEntries; } /** * Get count of JSONL files across all projects (for progress reporting) */ export function countJsonlFiles(projectsDir?: string): number { const projectDirs = findProjectDirectories(projectsDir); let count = 0; for (const dir of projectDirs) { try { const files = fs.readdirSync(dir); count += files.filter((f) => f.endsWith('.jsonl')).length; } catch { // Skip inaccessible directories } } return count; }