diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index df62996..617f313 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -4,9 +4,9 @@ import { globSync } from 'tinyglobby'; import { promisify } from 'util'; import type { - BlockMetrics, - TokenMetrics, - TranscriptLine + BlockMetrics, + TokenMetrics, + TranscriptLine } from '../types'; // Ensure fs.promises compatibility for older Node versions @@ -15,185 +15,185 @@ const readFileSync = fs.readFileSync; const statSync = fs.statSync; export async function getSessionDuration(transcriptPath: string): Promise { - try { - if (!fs.existsSync(transcriptPath)) { - return null; - } + try { + if (!fs.existsSync(transcriptPath)) { + return null; + } - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n').filter((line: string) => line.trim()); + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n').filter((line: string) => line.trim()); - if (lines.length === 0) { - return null; - } + if (lines.length === 0) { + return null; + } - let firstTimestamp: Date | null = null; - let lastTimestamp: Date | null = null; + let firstTimestamp: Date | null = null; + let lastTimestamp: Date | null = null; - // Find first valid timestamp - for (const line of lines) { - try { - const data = JSON.parse(line) as { timestamp?: string }; - if (data.timestamp) { - firstTimestamp = new Date(data.timestamp); - break; - } - } catch { - // Skip invalid lines - } - } + // Find first valid timestamp + for (const line of lines) { + try { + const data = JSON.parse(line) as { timestamp?: string }; + if (data.timestamp) { + firstTimestamp = new Date(data.timestamp); + break; + } + } catch { + // Skip invalid lines + } + } - // Find last valid timestamp (iterate backwards) - for (let i = lines.length - 1; i >= 0; i--) { - try { - const data = JSON.parse(lines[i] ?? '') as { timestamp?: string }; - if (data.timestamp) { - lastTimestamp = new Date(data.timestamp); - break; - } - } catch { - // Skip invalid lines - } - } + // Find last valid timestamp (iterate backwards) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const data = JSON.parse(lines[i] ?? '') as { timestamp?: string }; + if (data.timestamp) { + lastTimestamp = new Date(data.timestamp); + break; + } + } catch { + // Skip invalid lines + } + } - if (!firstTimestamp || !lastTimestamp) { - return null; - } + if (!firstTimestamp || !lastTimestamp) { + return null; + } - // Calculate duration in milliseconds - const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); + // Calculate duration in milliseconds + const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); - // Convert to minutes - const totalMinutes = Math.floor(durationMs / (1000 * 60)); + // Convert to minutes + const totalMinutes = Math.floor(durationMs / (1000 * 60)); - if (totalMinutes < 1) { - return '<1m'; - } + if (totalMinutes < 1) { + return '<1m'; + } - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; - if (hours === 0) { - return `${minutes}m`; - } else if (minutes === 0) { - return `${hours}hr`; - } else { - return `${hours}hr ${minutes}m`; - } - } catch { - return null; - } + if (hours === 0) { + return `${minutes}m`; + } else if (minutes === 0) { + return `${hours}hr`; + } else { + return `${hours}hr ${minutes}m`; + } + } catch { + return null; + } } export async function getTokenMetrics(transcriptPath: string): Promise { - try { - // Use Node.js-compatible file reading - if (!fs.existsSync(transcriptPath)) { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } + try { + // Use Node.js-compatible file reading + if (!fs.existsSync(transcriptPath)) { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + } - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n'); + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n'); - let inputTokens = 0; - let outputTokens = 0; - let cachedTokens = 0; - let contextLength = 0; + let inputTokens = 0; + let outputTokens = 0; + let cachedTokens = 0; + let contextLength = 0; - // Parse each line and sum up token usage for totals - let mostRecentMainChainEntry: TranscriptLine | null = null; - let mostRecentTimestamp: Date | null = null; + // Parse each line and sum up token usage for totals + let mostRecentMainChainEntry: TranscriptLine | null = null; + let mostRecentTimestamp: Date | null = null; - for (const line of lines) { - try { - const data = JSON.parse(line) as TranscriptLine; - if (data.message?.usage) { - inputTokens += data.message.usage.input_tokens || 0; - outputTokens += data.message.usage.output_tokens || 0; - cachedTokens += data.message.usage.cache_read_input_tokens ?? 0; - cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0; + for (const line of lines) { + try { + const data = JSON.parse(line) as TranscriptLine; + if (data.message?.usage) { + inputTokens += data.message.usage.input_tokens || 0; + outputTokens += data.message.usage.output_tokens || 0; + cachedTokens += data.message.usage.cache_read_input_tokens ?? 0; + cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0; - // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) - if (data.isSidechain !== true && data.timestamp) { - const entryTime = new Date(data.timestamp); - if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { - mostRecentTimestamp = entryTime; - mostRecentMainChainEntry = data; - } - } - } - } catch { - // Skip invalid JSON lines - } - } + // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) + if (data.isSidechain !== true && data.timestamp) { + const entryTime = new Date(data.timestamp); + if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { + mostRecentTimestamp = entryTime; + mostRecentMainChainEntry = data; + } + } + } + } catch { + // Skip invalid JSON lines + } + } - // Calculate context length from the most recent main chain message - if (mostRecentMainChainEntry?.message?.usage) { - const usage = mostRecentMainChainEntry.message.usage; - contextLength = (usage.input_tokens || 0) - + (usage.cache_read_input_tokens ?? 0) - + (usage.cache_creation_input_tokens ?? 0); - } + // Calculate context length from the most recent main chain message + if (mostRecentMainChainEntry?.message?.usage) { + const usage = mostRecentMainChainEntry.message.usage; + contextLength = (usage.input_tokens || 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0); + } - const totalTokens = inputTokens + outputTokens + cachedTokens; + const totalTokens = inputTokens + outputTokens + cachedTokens; - return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; - } catch { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } + return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; + } catch { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + } } /** * Gets block metrics for the current 5-hour block from JSONL files */ export function getBlockMetrics(transcriptPath: string | undefined): BlockMetrics | null { - if (!transcriptPath || typeof transcriptPath !== 'string') { - return null; - } + if (!transcriptPath || typeof transcriptPath !== 'string') { + return null; + } - let claudePath: string | null = null; + let claudePath: string | null = null; - // On Windows, always use the user's home directory .claude folder - if (process.platform === 'win32') { - const homeDir = process.env.USERPROFILE ?? process.env.HOME; - if (homeDir) { - claudePath = path.join(homeDir, '.claude'); - // Verify the .claude folder exists - if (!fs.existsSync(claudePath)) { - return null; - } - } - } else { - // On other platforms, walk up the directory tree to find .claude folder - let currentPath = path.dirname(transcriptPath); - const visitedPaths = new Set(); + // On Windows, always use the user's home directory .claude folder + if (process.platform === 'win32') { + const homeDir = process.env.USERPROFILE ?? process.env.HOME; + if (homeDir) { + claudePath = path.join(homeDir, '.claude'); + // Verify the .claude folder exists + if (!fs.existsSync(claudePath)) { + return null; + } + } + } else { + // On other platforms, walk up the directory tree to find .claude folder + let currentPath = path.dirname(transcriptPath); + const visitedPaths = new Set(); - while (currentPath && !visitedPaths.has(currentPath)) { - visitedPaths.add(currentPath); + while (currentPath && !visitedPaths.has(currentPath)) { + visitedPaths.add(currentPath); - const baseName = path.basename(currentPath); - if (baseName === '.claude') { - claudePath = currentPath; - break; - } + const baseName = path.basename(currentPath); + if (baseName === '.claude') { + claudePath = currentPath; + break; + } - const parentPath = path.dirname(currentPath); - // Stop if we've reached the root (parent is same as current) - if (parentPath === currentPath) { - break; - } - currentPath = parentPath; - } - } + const parentPath = path.dirname(currentPath); + // Stop if we've reached the root (parent is same as current) + if (parentPath === currentPath) { + break; + } + currentPath = parentPath; + } + } - if (!claudePath) - return null; + if (!claudePath) + return null; - try { - return findMostRecentBlockStartTime(claudePath); - } catch { - return null; - } + try { + return findMostRecentBlockStartTime(claudePath); + } catch { + return null; + } } /** @@ -201,204 +201,204 @@ export function getBlockMetrics(transcriptPath: string | undefined): BlockMetric * Uses file modification times as hints to avoid unnecessary reads */ function findMostRecentBlockStartTime( - rootDir: string, - sessionDurationHours = 5 + rootDir: string, + sessionDurationHours = 5 ): BlockMetrics | null { - const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; - const now = new Date(); + const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; + const now = new Date(); - // Step 1: Find all JSONL files with their modification times - // Use forward slashes for glob patterns on all platforms (tinyglobby requirement) - const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); - const files = globSync([pattern], { - absolute: true, // Ensure we get absolute paths - cwd: rootDir // Set working directory to rootDir - }); + // Step 1: Find all JSONL files with their modification times + // Use forward slashes for glob patterns on all platforms (tinyglobby requirement) + const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); + const files = globSync([pattern], { + absolute: true, // Ensure we get absolute paths + cwd: rootDir // Set working directory to rootDir + }); - if (files.length === 0) - return null; + if (files.length === 0) + return null; - // Step 2: Get file stats and sort by modification time (most recent first) - const filesWithStats = files.map((file) => { - const stats = statSync(file); - return { file, mtime: stats.mtime }; - }); + // Step 2: Get file stats and sort by modification time (most recent first) + const filesWithStats = files.map((file) => { + const stats = statSync(file); + return { file, mtime: stats.mtime }; + }); - filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - // Step 3: Progressive lookback - start small and expand if needed - // Start with 2x session duration (10 hours), expand to 48 hours if needed - const lookbackChunks = [ - 10, // 2x session duration - catches most cases - 20, // 4x session duration - catches longer sessions - 48 // Maximum lookback for marathon sessions - ]; + // Step 3: Progressive lookback - start small and expand if needed + // Start with 2x session duration (10 hours), expand to 48 hours if needed + const lookbackChunks = [ + 10, // 2x session duration - catches most cases + 20, // 4x session duration - catches longer sessions + 48 // Maximum lookback for marathon sessions + ]; - let timestamps: Date[] = []; - let mostRecentTimestamp: Date | null = null; - let continuousWorkStart: Date | null = null; - let foundSessionGap = false; + let timestamps: Date[] = []; + let mostRecentTimestamp: Date | null = null; + let continuousWorkStart: Date | null = null; + let foundSessionGap = false; - for (const lookbackHours of lookbackChunks) { - const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); - timestamps = []; + for (const lookbackHours of lookbackChunks) { + const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); + timestamps = []; - // Collect timestamps for this lookback period - for (const { file, mtime } of filesWithStats) { - if (mtime.getTime() < cutoffTime.getTime()) { - break; - } - const fileTimestamps = getAllTimestampsFromFile(file); - timestamps.push(...fileTimestamps); - } + // Collect timestamps for this lookback period + for (const { file, mtime } of filesWithStats) { + if (mtime.getTime() < cutoffTime.getTime()) { + break; + } + const fileTimestamps = getAllTimestampsFromFile(file); + timestamps.push(...fileTimestamps); + } - if (timestamps.length === 0) { - continue; // Try next chunk - } + if (timestamps.length === 0) { + continue; // Try next chunk + } - // Sort timestamps (most recent first) - timestamps.sort((a, b) => b.getTime() - a.getTime()); + // Sort timestamps (most recent first) + timestamps.sort((a, b) => b.getTime() - a.getTime()); - // Get most recent timestamp (only set once) - if (!mostRecentTimestamp && timestamps[0]) { - mostRecentTimestamp = timestamps[0]; + // Get most recent timestamp (only set once) + if (!mostRecentTimestamp && timestamps[0]) { + mostRecentTimestamp = timestamps[0]; - // Check if the most recent activity is within the current session period - const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime(); - if (timeSinceLastActivity > sessionDurationMs) { - // No activity within the current session period - return null; - } - } + // Check if the most recent activity is within the current session period + const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime(); + if (timeSinceLastActivity > sessionDurationMs) { + // No activity within the current session period + return null; + } + } - // Look for a session gap in this chunk - continuousWorkStart = mostRecentTimestamp; - for (let i = 1; i < timestamps.length; i++) { - const currentTimestamp = timestamps[i]; - const previousTimestamp = timestamps[i - 1]; + // Look for a session gap in this chunk + continuousWorkStart = mostRecentTimestamp; + for (let i = 1; i < timestamps.length; i++) { + const currentTimestamp = timestamps[i]; + const previousTimestamp = timestamps[i - 1]; - if (!currentTimestamp || !previousTimestamp) - continue; + if (!currentTimestamp || !previousTimestamp) + continue; - const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); + const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); - if (gap >= sessionDurationMs) { - // Found a true session boundary - foundSessionGap = true; - break; - } + if (gap >= sessionDurationMs) { + // Found a true session boundary + foundSessionGap = true; + break; + } - continuousWorkStart = currentTimestamp; - } + continuousWorkStart = currentTimestamp; + } - // If we found a gap, we're done - if (foundSessionGap) { - break; - } + // If we found a gap, we're done + if (foundSessionGap) { + break; + } - // If this was our last chunk, use what we have - if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) { - break; - } - } + // If this was our last chunk, use what we have + if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) { + break; + } + } - if (!mostRecentTimestamp || !continuousWorkStart) { - return null; - } + if (!mostRecentTimestamp || !continuousWorkStart) { + return null; + } - // Build actual blocks from timestamps going forward - const blocks: { start: Date; end: Date }[] = []; - const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime()); + // Build actual blocks from timestamps going forward + const blocks: { start: Date; end: Date }[] = []; + const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime()); - let currentBlockStart: Date | null = null; - let currentBlockEnd: Date | null = null; + let currentBlockStart: Date | null = null; + let currentBlockEnd: Date | null = null; - for (const timestamp of sortedTimestamps) { - if (timestamp.getTime() < continuousWorkStart.getTime()) - continue; + for (const timestamp of sortedTimestamps) { + if (timestamp.getTime() < continuousWorkStart.getTime()) + continue; - if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) { - // Start new block - currentBlockStart = floorToHour(timestamp); - currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs); - blocks.push({ start: currentBlockStart, end: currentBlockEnd }); - } - } + if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) { + // Start new block + currentBlockStart = floorToHour(timestamp); + currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs); + blocks.push({ start: currentBlockStart, end: currentBlockEnd }); + } + } - // Find current block - for (const block of blocks) { - if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) { - // Verify we have activity in this block - const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime() - && t.getTime() <= block.end.getTime() - ); + // Find current block + for (const block of blocks) { + if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) { + // Verify we have activity in this block + const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime() + && t.getTime() <= block.end.getTime() + ); - if (hasActivity) { - return { - startTime: block.start, - lastActivity: mostRecentTimestamp - }; - } - } - } + if (hasActivity) { + return { + startTime: block.start, + lastActivity: mostRecentTimestamp + }; + } + } + } - return null; + return null; } /** * Gets all timestamps from a JSONL file */ function getAllTimestampsFromFile(filePath: string): Date[] { - const timestamps: Date[] = []; - try { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.trim().split('\n').filter(line => line.length > 0); + const timestamps: Date[] = []; + try { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.trim().split('\n').filter(line => line.length > 0); - for (const line of lines) { - try { - const json = JSON.parse(line) as { - timestamp?: string; - isSidechain?: boolean; - message?: { usage?: { input_tokens?: number; output_tokens?: number } }; - }; + for (const line of lines) { + try { + const json = JSON.parse(line) as { + timestamp?: string; + isSidechain?: boolean; + message?: { usage?: { input_tokens?: number; output_tokens?: number } }; + }; - // Only treat entries with real token usage as block activity - const usage = json.message?.usage; - if (!usage) - continue; + // Only treat entries with real token usage as block activity + const usage = json.message?.usage; + if (!usage) + continue; - const hasInputTokens = typeof usage.input_tokens === 'number'; - const hasOutputTokens = typeof usage.output_tokens === 'number'; - if (!hasInputTokens || !hasOutputTokens) - continue; + const hasInputTokens = typeof usage.input_tokens === 'number'; + const hasOutputTokens = typeof usage.output_tokens === 'number'; + if (!hasInputTokens || !hasOutputTokens) + continue; - if (json.isSidechain === true) - continue; + if (json.isSidechain === true) + continue; - const timestamp = json.timestamp; - if (typeof timestamp !== 'string') - continue; + const timestamp = json.timestamp; + if (typeof timestamp !== 'string') + continue; - const date = new Date(timestamp); - if (!Number.isNaN(date.getTime())) - timestamps.push(date); - } catch { - // Skip invalid JSON lines - continue; - } - } + const date = new Date(timestamp); + if (!Number.isNaN(date.getTime())) + timestamps.push(date); + } catch { + // Skip invalid JSON lines + continue; + } + } - return timestamps; - } catch { - return []; - } + return timestamps; + } catch { + return []; + } } /** * Floors a timestamp to the beginning of the hour (matching existing logic) */ function floorToHour(timestamp: Date): Date { - const floored = new Date(timestamp); - floored.setUTCMinutes(0, 0, 0); - return floored; + const floored = new Date(timestamp); + floored.setUTCMinutes(0, 0, 0); + return floored; } \ No newline at end of file