import { feature } from 'bun:bundle' import { open } from 'fs/promises' import { basename, dirname, join, sep } from 'path' import type { ModelUsage } from 'src/entrypoints/agentSdkTypes.js' import type { Entry, TranscriptMessage } from '../types/logs.js' import { logForDebugging } from './debug.js' import { errorMessage, isENOENT } from './errors.js' import { getFsImplementation } from './fsOperations.js' import { readJSONLFile } from './json.js' import { SYNTHETIC_MODEL } from './messages.js' import { getProjectsDir, isTranscriptMessage } from './sessionStorage.js' import { SHELL_TOOL_NAMES } from './shell/shellToolUtils.js' import { jsonParse } from './slowOperations.js' import { getTodayDateString, getYesterdayDateString, isDateBefore, loadStatsCache, mergeCacheWithNewStats, type PersistedStatsCache, saveStatsCache, toDateString, withStatsCacheLock, } from './statsCache.js' export type DailyActivity = { date: string // YYYY-MM-DD format messageCount: number sessionCount: number toolCallCount: number } export type DailyModelTokens = { date: string // YYYY-MM-DD format tokensByModel: { [modelName: string]: number } // total tokens (input + output) per model } export type StreakInfo = { currentStreak: number longestStreak: number currentStreakStart: string | null longestStreakStart: string | null longestStreakEnd: string | null } export type SessionStats = { sessionId: string duration: number // in milliseconds messageCount: number timestamp: string } export type ClaudeCodeStats = { // Activity overview totalSessions: number totalMessages: number totalDays: number activeDays: number // Streaks streaks: StreakInfo // Daily activity for heatmap dailyActivity: DailyActivity[] // Daily token usage per model for charts dailyModelTokens: DailyModelTokens[] // Session info longestSession: SessionStats | null // Model usage aggregated modelUsage: { [modelName: string]: ModelUsage } // Time stats firstSessionDate: string | null lastSessionDate: string | null peakActivityDay: string | null peakActivityHour: number | null // Speculation time saved totalSpeculationTimeSavedMs: number // Shot stats (ant-only, gated by SHOT_STATS feature flag) shotDistribution?: { [shotCount: number]: number } oneShotRate?: number } /** * Result of processing session files - intermediate stats that can be merged. */ type ProcessedStats = { dailyActivity: DailyActivity[] dailyModelTokens: DailyModelTokens[] modelUsage: { [modelName: string]: ModelUsage } sessionStats: SessionStats[] hourCounts: { [hour: number]: number } totalMessages: number totalSpeculationTimeSavedMs: number shotDistribution?: { [shotCount: number]: number } } /** * Options for processing session files. */ type ProcessOptions = { // Only include data from dates >= this date (YYYY-MM-DD format) fromDate?: string // Only include data from dates <= this date (YYYY-MM-DD format) toDate?: string } /** * Process session files and extract stats. * Can filter by date range. */ async function processSessionFiles( sessionFiles: string[], options: ProcessOptions = {}, ): Promise { const { fromDate, toDate } = options const fs = getFsImplementation() const dailyActivityMap = new Map() const dailyModelTokensMap = new Map() const sessions: SessionStats[] = [] const hourCounts = new Map() let totalMessages = 0 let totalSpeculationTimeSavedMs = 0 const modelUsageAgg: { [modelName: string]: ModelUsage } = {} const shotDistributionMap = feature('SHOT_STATS') ? new Map() : undefined // Track parent sessions that already recorded a shot count (dedup across subagents) const sessionsWithShotCount = new Set() // Process session files in parallel batches for better performance const BATCH_SIZE = 20 for (let i = 0; i < sessionFiles.length; i += BATCH_SIZE) { const batch = sessionFiles.slice(i, i + BATCH_SIZE) const results = await Promise.all( batch.map(async sessionFile => { try { // If we have a fromDate filter, skip files that haven't been modified since then if (fromDate) { let fileSize = 0 try { const fileStat = await fs.stat(sessionFile) const fileModifiedDate = toDateString(fileStat.mtime) if (isDateBefore(fileModifiedDate, fromDate)) { return { sessionFile, entries: null, error: null, skipped: true, } } fileSize = fileStat.size } catch { // If we can't stat the file, try to read it anyway } // For large files, peek at the session start date before reading everything. // Sessions that pass the mtime filter but started before fromDate are skipped // (e.g. a month-old session resumed today gets a new mtime write but old start date). if (fileSize > 65536) { const startDate = await readSessionStartDate(sessionFile) if (startDate && isDateBefore(startDate, fromDate)) { return { sessionFile, entries: null, error: null, skipped: true, } } } } const entries = await readJSONLFile(sessionFile) return { sessionFile, entries, error: null, skipped: false } } catch (error) { return { sessionFile, entries: null, error, skipped: false } } }), ) for (const { sessionFile, entries, error, skipped } of results) { if (skipped) continue if (error || !entries) { logForDebugging( `Failed to read session file ${sessionFile}: ${errorMessage(error)}`, ) continue } const sessionId = basename(sessionFile, '.jsonl') const messages: TranscriptMessage[] = [] for (const entry of entries) { if (isTranscriptMessage(entry)) { messages.push(entry) } else if (entry.type === 'speculation-accept') { totalSpeculationTimeSavedMs += entry.timeSavedMs } } if (messages.length === 0) continue // Subagent transcripts mark all messages as sidechain. We still want // their token usage counted, but not as separate sessions. const isSubagentFile = sessionFile.includes(`${sep}subagents${sep}`) // Extract shot count from PR attribution in gh pr create calls (ant-only) // This must run before the sidechain filter since subagent transcripts // mark all messages as sidechain if (feature('SHOT_STATS') && shotDistributionMap) { const parentSessionId = isSubagentFile ? basename(dirname(dirname(sessionFile))) : sessionId if (!sessionsWithShotCount.has(parentSessionId)) { const shotCount = extractShotCountFromMessages(messages) if (shotCount !== null) { sessionsWithShotCount.add(parentSessionId) shotDistributionMap.set( shotCount, (shotDistributionMap.get(shotCount) || 0) + 1, ) } } } // Filter out sidechain messages for session metadata (duration, counts). // For subagent files, use all messages since they're all sidechain. const mainMessages = isSubagentFile ? messages : messages.filter(m => !m.isSidechain) if (mainMessages.length === 0) continue const firstMessage = mainMessages[0]! const lastMessage = mainMessages.at(-1)! const firstTimestamp = new Date(firstMessage.timestamp) const lastTimestamp = new Date(lastMessage.timestamp) // Skip sessions with malformed timestamps — some transcripts on disk // have entries missing the timestamp field (e.g. partial/remote writes). // new Date(undefined) produces an Invalid Date, and toDateString() would // throw RangeError: Invalid Date on .toISOString(). if (isNaN(firstTimestamp.getTime()) || isNaN(lastTimestamp.getTime())) { logForDebugging( `Skipping session with invalid timestamp: ${sessionFile}`, ) continue } const dateKey = toDateString(firstTimestamp) // Apply date filters if (fromDate && isDateBefore(dateKey, fromDate)) continue if (toDate && isDateBefore(toDate, dateKey)) continue // Track daily activity (use first message date as session date) const existing = dailyActivityMap.get(dateKey) || { date: dateKey, messageCount: 0, sessionCount: 0, toolCallCount: 0, } // Subagent files contribute tokens and tool calls, but aren't sessions. if (!isSubagentFile) { const duration = lastTimestamp.getTime() - firstTimestamp.getTime() sessions.push({ sessionId, duration, messageCount: mainMessages.length, timestamp: firstMessage.timestamp, }) totalMessages += mainMessages.length existing.sessionCount++ existing.messageCount += mainMessages.length const hour = firstTimestamp.getHours() hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1) } if (!isSubagentFile || dailyActivityMap.has(dateKey)) { dailyActivityMap.set(dateKey, existing) } // Process messages for tool usage and model stats for (const message of mainMessages) { if (message.type === 'assistant') { const content = message.message?.content if (Array.isArray(content)) { for (const block of content) { if (block.type === 'tool_use') { const activity = dailyActivityMap.get(dateKey) if (activity) { activity.toolCallCount++ } } } } // Track model usage if available (skip synthetic messages) if (message.message?.usage) { const usage = message.message.usage const model = message.message.model || 'unknown' // Skip synthetic messages - they are internal and shouldn't appear in stats if (model === SYNTHETIC_MODEL) { continue } if (!modelUsageAgg[model]) { modelUsageAgg[model] = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0, contextWindow: 0, maxOutputTokens: 0, } } modelUsageAgg[model]!.inputTokens += usage.input_tokens || 0 modelUsageAgg[model]!.outputTokens += usage.output_tokens || 0 modelUsageAgg[model]!.cacheReadInputTokens += usage.cache_read_input_tokens || 0 modelUsageAgg[model]!.cacheCreationInputTokens += usage.cache_creation_input_tokens || 0 // Track daily tokens per model const totalTokens = (usage.input_tokens || 0) + (usage.output_tokens || 0) if (totalTokens > 0) { const dayTokens = dailyModelTokensMap.get(dateKey) || {} dayTokens[model] = (dayTokens[model] || 0) + totalTokens dailyModelTokensMap.set(dateKey, dayTokens) } } } } } } return { dailyActivity: Array.from(dailyActivityMap.values()).sort((a, b) => a.date.localeCompare(b.date), ), dailyModelTokens: Array.from(dailyModelTokensMap.entries()) .map(([date, tokensByModel]) => ({ date, tokensByModel })) .sort((a, b) => a.date.localeCompare(b.date)), modelUsage: modelUsageAgg, sessionStats: sessions, hourCounts: Object.fromEntries(hourCounts), totalMessages, totalSpeculationTimeSavedMs, ...(feature('SHOT_STATS') && shotDistributionMap ? { shotDistribution: Object.fromEntries(shotDistributionMap) } : {}), } } /** * Get all session files from all project directories. * Includes both main session files and subagent transcript files. */ async function getAllSessionFiles(): Promise { const projectsDir = getProjectsDir() const fs = getFsImplementation() // Get all project directories let allEntries try { allEntries = await fs.readdir(projectsDir) } catch (e) { if (isENOENT(e)) return [] throw e } const projectDirs = allEntries .filter(dirent => dirent.isDirectory()) .map(dirent => join(projectsDir, dirent.name)) // Collect all session files from all projects in parallel const projectResults = await Promise.all( projectDirs.map(async projectDir => { try { const entries = await fs.readdir(projectDir) // Collect main session files (*.jsonl directly in project dir) const mainFiles = entries .filter(dirent => dirent.isFile() && dirent.name.endsWith('.jsonl')) .map(dirent => join(projectDir, dirent.name)) // Collect subagent files from session subdirectories in parallel // Structure: {projectDir}/{sessionId}/subagents/agent-{agentId}.jsonl const sessionDirs = entries.filter(dirent => dirent.isDirectory()) const subagentResults = await Promise.all( sessionDirs.map(async sessionDir => { const subagentsDir = join(projectDir, sessionDir.name, 'subagents') try { const subagentEntries = await fs.readdir(subagentsDir) return subagentEntries .filter( dirent => dirent.isFile() && dirent.name.endsWith('.jsonl') && dirent.name.startsWith('agent-'), ) .map(dirent => join(subagentsDir, dirent.name)) } catch { // subagents directory doesn't exist for this session, skip return [] } }), ) return [...mainFiles, ...subagentResults.flat()] } catch (error) { logForDebugging( `Failed to read project directory ${projectDir}: ${errorMessage(error)}`, ) return [] } }), ) return projectResults.flat() } /** * Convert a PersistedStatsCache to ClaudeCodeStats by computing derived fields. */ function cacheToStats( cache: PersistedStatsCache, todayStats: ProcessedStats | null, ): ClaudeCodeStats { // Merge cache with today's stats const dailyActivityMap = new Map() for (const day of cache.dailyActivity) { dailyActivityMap.set(day.date, { ...day }) } if (todayStats) { for (const day of todayStats.dailyActivity) { const existing = dailyActivityMap.get(day.date) if (existing) { existing.messageCount += day.messageCount existing.sessionCount += day.sessionCount existing.toolCallCount += day.toolCallCount } else { dailyActivityMap.set(day.date, { ...day }) } } } const dailyModelTokensMap = new Map() for (const day of cache.dailyModelTokens) { dailyModelTokensMap.set(day.date, { ...day.tokensByModel }) } if (todayStats) { for (const day of todayStats.dailyModelTokens) { const existing = dailyModelTokensMap.get(day.date) if (existing) { for (const [model, tokens] of Object.entries(day.tokensByModel)) { existing[model] = (existing[model] || 0) + tokens } } else { dailyModelTokensMap.set(day.date, { ...day.tokensByModel }) } } } // Merge model usage const modelUsage = { ...cache.modelUsage } if (todayStats) { for (const [model, usage] of Object.entries(todayStats.modelUsage)) { if (modelUsage[model]) { modelUsage[model] = { inputTokens: modelUsage[model]!.inputTokens + usage.inputTokens, outputTokens: modelUsage[model]!.outputTokens + usage.outputTokens, cacheReadInputTokens: modelUsage[model]!.cacheReadInputTokens + usage.cacheReadInputTokens, cacheCreationInputTokens: modelUsage[model]!.cacheCreationInputTokens + usage.cacheCreationInputTokens, webSearchRequests: modelUsage[model]!.webSearchRequests + usage.webSearchRequests, costUSD: modelUsage[model]!.costUSD + usage.costUSD, contextWindow: Math.max( modelUsage[model]!.contextWindow, usage.contextWindow, ), maxOutputTokens: Math.max( modelUsage[model]!.maxOutputTokens, usage.maxOutputTokens, ), } } else { modelUsage[model] = { ...usage } } } } // Merge hour counts const hourCountsMap = new Map() for (const [hour, count] of Object.entries(cache.hourCounts)) { hourCountsMap.set(parseInt(hour, 10), count) } if (todayStats) { for (const [hour, count] of Object.entries(todayStats.hourCounts)) { const hourNum = parseInt(hour, 10) hourCountsMap.set(hourNum, (hourCountsMap.get(hourNum) || 0) + count) } } // Calculate derived stats const dailyActivityArray = Array.from(dailyActivityMap.values()).sort( (a, b) => a.date.localeCompare(b.date), ) const streaks = calculateStreaks(dailyActivityArray) const dailyModelTokens = Array.from(dailyModelTokensMap.entries()) .map(([date, tokensByModel]) => ({ date, tokensByModel })) .sort((a, b) => a.date.localeCompare(b.date)) // Compute session aggregates: combine cache aggregates with today's stats const totalSessions = cache.totalSessions + (todayStats?.sessionStats.length || 0) const totalMessages = cache.totalMessages + (todayStats?.totalMessages || 0) // Find longest session (compare cache's longest with today's sessions) let longestSession = cache.longestSession if (todayStats) { for (const session of todayStats.sessionStats) { if (!longestSession || session.duration > longestSession.duration) { longestSession = session } } } // Find first/last session dates let firstSessionDate = cache.firstSessionDate let lastSessionDate: string | null = null if (todayStats) { for (const session of todayStats.sessionStats) { if (!firstSessionDate || session.timestamp < firstSessionDate) { firstSessionDate = session.timestamp } if (!lastSessionDate || session.timestamp > lastSessionDate) { lastSessionDate = session.timestamp } } } // If no today sessions, derive lastSessionDate from dailyActivity if (!lastSessionDate && dailyActivityArray.length > 0) { lastSessionDate = dailyActivityArray.at(-1)!.date } const peakActivityDay = dailyActivityArray.length > 0 ? dailyActivityArray.reduce((max, d) => d.messageCount > max.messageCount ? d : max, ).date : null const peakActivityHour = hourCountsMap.size > 0 ? Array.from(hourCountsMap.entries()).reduce((max, [hour, count]) => count > max[1] ? [hour, count] : max, )[0] : null const totalDays = firstSessionDate && lastSessionDate ? Math.ceil( (new Date(lastSessionDate).getTime() - new Date(firstSessionDate).getTime()) / (1000 * 60 * 60 * 24), ) + 1 : 0 const totalSpeculationTimeSavedMs = cache.totalSpeculationTimeSavedMs + (todayStats?.totalSpeculationTimeSavedMs || 0) const result: ClaudeCodeStats = { totalSessions, totalMessages, totalDays, activeDays: dailyActivityMap.size, streaks, dailyActivity: dailyActivityArray, dailyModelTokens, longestSession, modelUsage, firstSessionDate, lastSessionDate, peakActivityDay, peakActivityHour, totalSpeculationTimeSavedMs, } if (feature('SHOT_STATS')) { const shotDistribution: { [shotCount: number]: number } = { ...(cache.shotDistribution || {}), } if (todayStats?.shotDistribution) { for (const [count, sessions] of Object.entries( todayStats.shotDistribution, )) { const key = parseInt(count, 10) shotDistribution[key] = (shotDistribution[key] || 0) + sessions } } result.shotDistribution = shotDistribution const totalWithShots = Object.values(shotDistribution).reduce( (sum, n) => sum + n, 0, ) result.oneShotRate = totalWithShots > 0 ? Math.round(((shotDistribution[1] || 0) / totalWithShots) * 100) : 0 } return result } /** * Aggregates stats from all Claude Code sessions across all projects. * Uses a disk cache to avoid reprocessing historical data. */ export async function aggregateClaudeCodeStats(): Promise { const allSessionFiles = await getAllSessionFiles() if (allSessionFiles.length === 0) { return getEmptyStats() } // Use lock to prevent race conditions with background cache updates const updatedCache = await withStatsCacheLock(async () => { // Load the cache const cache = await loadStatsCache() const yesterday = getYesterdayDateString() // Determine what needs to be processed // - If no cache: process everything up to yesterday, then today separately // - If cache exists: process from day after lastComputedDate to yesterday, then today let result = cache if (!cache.lastComputedDate) { // No cache - process all historical data (everything before today) logForDebugging('Stats cache empty, processing all historical data') const historicalStats = await processSessionFiles(allSessionFiles, { toDate: yesterday, }) if ( historicalStats.sessionStats.length > 0 || historicalStats.dailyActivity.length > 0 ) { result = mergeCacheWithNewStats(cache, historicalStats, yesterday) await saveStatsCache(result) } } else if (isDateBefore(cache.lastComputedDate, yesterday)) { // Cache is stale - process new days // Process from day after lastComputedDate to yesterday const nextDay = getNextDay(cache.lastComputedDate) logForDebugging( `Stats cache stale (${cache.lastComputedDate}), processing ${nextDay} to ${yesterday}`, ) const newStats = await processSessionFiles(allSessionFiles, { fromDate: nextDay, toDate: yesterday, }) if ( newStats.sessionStats.length > 0 || newStats.dailyActivity.length > 0 ) { result = mergeCacheWithNewStats(cache, newStats, yesterday) await saveStatsCache(result) } else { // No new data, but update lastComputedDate result = { ...cache, lastComputedDate: yesterday } await saveStatsCache(result) } } return result }) // Always process today's data live (it's incomplete) // This doesn't need to be in the lock since it doesn't modify the cache const today = getTodayDateString() const todayStats = await processSessionFiles(allSessionFiles, { fromDate: today, toDate: today, }) // Combine cache with today's stats return cacheToStats(updatedCache, todayStats) } export type StatsDateRange = '7d' | '30d' | 'all' /** * Aggregates stats for a specific date range. * For 'all', uses the cached aggregation. For other ranges, processes files directly. */ export async function aggregateClaudeCodeStatsForRange( range: StatsDateRange, ): Promise { if (range === 'all') { return aggregateClaudeCodeStats() } const allSessionFiles = await getAllSessionFiles() if (allSessionFiles.length === 0) { return getEmptyStats() } // Calculate fromDate based on range const today = new Date() const daysBack = range === '7d' ? 7 : 30 const fromDate = new Date(today) fromDate.setDate(today.getDate() - daysBack + 1) // +1 to include today const fromDateStr = toDateString(fromDate) // Process session files for the date range const stats = await processSessionFiles(allSessionFiles, { fromDate: fromDateStr, }) return processedStatsToClaudeCodeStats(stats) } /** * Convert ProcessedStats to ClaudeCodeStats. * Used for filtered date ranges that bypass the cache. */ function processedStatsToClaudeCodeStats( stats: ProcessedStats, ): ClaudeCodeStats { const dailyActivitySorted = stats.dailyActivity .slice() .sort((a, b) => a.date.localeCompare(b.date)) const dailyModelTokensSorted = stats.dailyModelTokens .slice() .sort((a, b) => a.date.localeCompare(b.date)) // Calculate streaks from daily activity const streaks = calculateStreaks(dailyActivitySorted) // Find longest session let longestSession: SessionStats | null = null for (const session of stats.sessionStats) { if (!longestSession || session.duration > longestSession.duration) { longestSession = session } } // Find first/last session dates let firstSessionDate: string | null = null let lastSessionDate: string | null = null for (const session of stats.sessionStats) { if (!firstSessionDate || session.timestamp < firstSessionDate) { firstSessionDate = session.timestamp } if (!lastSessionDate || session.timestamp > lastSessionDate) { lastSessionDate = session.timestamp } } // Peak activity day const peakActivityDay = dailyActivitySorted.length > 0 ? dailyActivitySorted.reduce((max, d) => d.messageCount > max.messageCount ? d : max, ).date : null // Peak activity hour const hourEntries = Object.entries(stats.hourCounts) const peakActivityHour = hourEntries.length > 0 ? parseInt( hourEntries.reduce((max, [hour, count]) => count > parseInt(max[1].toString()) ? [hour, count] : max, )[0], 10, ) : null // Total days in range const totalDays = firstSessionDate && lastSessionDate ? Math.ceil( (new Date(lastSessionDate).getTime() - new Date(firstSessionDate).getTime()) / (1000 * 60 * 60 * 24), ) + 1 : 0 const result: ClaudeCodeStats = { totalSessions: stats.sessionStats.length, totalMessages: stats.totalMessages, totalDays, activeDays: stats.dailyActivity.length, streaks, dailyActivity: dailyActivitySorted, dailyModelTokens: dailyModelTokensSorted, longestSession, modelUsage: stats.modelUsage, firstSessionDate, lastSessionDate, peakActivityDay, peakActivityHour, totalSpeculationTimeSavedMs: stats.totalSpeculationTimeSavedMs, } if (feature('SHOT_STATS') && stats.shotDistribution) { result.shotDistribution = stats.shotDistribution const totalWithShots = Object.values(stats.shotDistribution).reduce( (sum, n) => sum + n, 0, ) result.oneShotRate = totalWithShots > 0 ? Math.round(((stats.shotDistribution[1] || 0) / totalWithShots) * 100) : 0 } return result } /** * Get the next day after a given date string (YYYY-MM-DD format). */ function getNextDay(dateStr: string): string { const date = new Date(dateStr) date.setDate(date.getDate() + 1) return toDateString(date) } function calculateStreaks(dailyActivity: DailyActivity[]): StreakInfo { if (dailyActivity.length === 0) { return { currentStreak: 0, longestStreak: 0, currentStreakStart: null, longestStreakStart: null, longestStreakEnd: null, } } const today = new Date() today.setHours(0, 0, 0, 0) // Calculate current streak (working backwards from today) let currentStreak = 0 let currentStreakStart: string | null = null const checkDate = new Date(today) // Build a set of active dates for quick lookup const activeDates = new Set(dailyActivity.map(d => d.date)) while (true) { const dateStr = toDateString(checkDate) if (!activeDates.has(dateStr)) { break } currentStreak++ currentStreakStart = dateStr checkDate.setDate(checkDate.getDate() - 1) } // Calculate longest streak let longestStreak = 0 let longestStreakStart: string | null = null let longestStreakEnd: string | null = null if (dailyActivity.length > 0) { const sortedDates = Array.from(activeDates).sort() let tempStreak = 1 let tempStart = sortedDates[0]! for (let i = 1; i < sortedDates.length; i++) { const prevDate = new Date(sortedDates[i - 1]!) const currDate = new Date(sortedDates[i]!) const dayDiff = Math.round( (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24), ) if (dayDiff === 1) { tempStreak++ } else { if (tempStreak > longestStreak) { longestStreak = tempStreak longestStreakStart = tempStart longestStreakEnd = sortedDates[i - 1]! } tempStreak = 1 tempStart = sortedDates[i]! } } // Check final streak if (tempStreak > longestStreak) { longestStreak = tempStreak longestStreakStart = tempStart longestStreakEnd = sortedDates.at(-1)! } } return { currentStreak, longestStreak, currentStreakStart, longestStreakStart, longestStreakEnd, } } const SHOT_COUNT_REGEX = /(\d+)-shotted by/ /** * Extract the shot count from PR attribution text in a `gh pr create` Bash call. * The attribution format is: "N-shotted by model-name" * Returns the shot count, or null if not found. */ function extractShotCountFromMessages( messages: TranscriptMessage[], ): number | null { for (const m of messages) { if (m.type !== 'assistant') continue const content = m.message?.content if (!Array.isArray(content)) continue for (const block of content) { if ( block.type !== 'tool_use' || !SHELL_TOOL_NAMES.includes(block.name) || typeof block.input !== 'object' || block.input === null || !('command' in block.input) || typeof block.input.command !== 'string' ) { continue } const match = SHOT_COUNT_REGEX.exec(block.input.command) if (match) { return parseInt(match[1]!, 10) } } } return null } // Transcript message types — must match isTranscriptMessage() in sessionStorage.ts. // The canonical dateKey (see processSessionFiles) reads mainMessages[0].timestamp, // where mainMessages = entries.filter(isTranscriptMessage).filter(!isSidechain). // This peek must extract the same value to be a safe skip optimization. const TRANSCRIPT_MESSAGE_TYPES = new Set([ 'user', 'assistant', 'attachment', 'system', 'progress', ]) /** * Peeks at the head of a session file to get the session start date. * Uses a small 4 KB read to avoid loading the full file. * * Session files typically begin with non-transcript entries (`mode`, * `file-history-snapshot`, `attribution-snapshot`) before the first transcript * message, so we scan lines until we hit one. Each complete line is JSON-parsed * — naive string search is unsafe here because `file-history-snapshot` entries * embed a nested `snapshot.timestamp` carrying the *previous* session's date * (written by copyFileHistoryForResume), which would cause resumed sessions to * be miscategorised as old and silently dropped from stats. * * Returns a YYYY-MM-DD string, or null if no transcript message fits in the * head (caller falls through to the full read — safe default). */ export async function readSessionStartDate( filePath: string, ): Promise { try { const fd = await open(filePath, 'r') try { const buf = Buffer.allocUnsafe(4096) const { bytesRead } = await fd.read(buf, 0, buf.length, 0) if (bytesRead === 0) return null const head = buf.toString('utf8', 0, bytesRead) // Only trust complete lines — the 4KB boundary may bisect a JSON entry. const lastNewline = head.lastIndexOf('\n') if (lastNewline < 0) return null for (const line of head.slice(0, lastNewline).split('\n')) { if (!line) continue let entry: { type?: unknown timestamp?: unknown isSidechain?: unknown } try { entry = jsonParse(line) } catch { continue } if (typeof entry.type !== 'string') continue if (!TRANSCRIPT_MESSAGE_TYPES.has(entry.type)) continue if (entry.isSidechain === true) continue if (typeof entry.timestamp !== 'string') return null const date = new Date(entry.timestamp) if (Number.isNaN(date.getTime())) return null return toDateString(date) } return null } finally { await fd.close() } } catch { return null } } function getEmptyStats(): ClaudeCodeStats { return { totalSessions: 0, totalMessages: 0, totalDays: 0, activeDays: 0, streaks: { currentStreak: 0, longestStreak: 0, currentStreakStart: null, longestStreakStart: null, longestStreakEnd: null, }, dailyActivity: [], dailyModelTokens: [], longestSession: null, modelUsage: {}, firstSessionDate: null, lastSessionDate: null, peakActivityDay: null, peakActivityHour: null, totalSpeculationTimeSavedMs: 0, } }