Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
34 kB · 1061 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062import { 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<ProcessedStats> { const { fromDate, toDate } = options const fs = getFsImplementation()
const dailyActivityMap = new Map<string, DailyActivity>() const dailyModelTokensMap = new Map<string, { [modelName: string]: number }>() const sessions: SessionStats[] = [] const hourCounts = new Map<number, number>() let totalMessages = 0 let totalSpeculationTimeSavedMs = 0 const modelUsageAgg: { [modelName: string]: ModelUsage } = {} const shotDistributionMap = feature('SHOT_STATS') ? new Map<number, number>() : undefined // Track parent sessions that already recorded a shot count (dedup across subagents) const sessionsWithShotCount = new Set<string>()
// 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<Entry>(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<string[]> { 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<string, DailyActivity>() 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<string, { [model: string]: number }>() 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<number, number>() 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<ClaudeCodeStats> { 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<ClaudeCodeStats> { 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<string | null> { 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, }}