cc-haha/src/utils/stats.ts
程序员阿江(Relakkes) ace7f7b657 feat: add desktop token usage activity view
Expose local Claude Code CLI transcript usage in Settings so users can inspect recent token consumption and daily activity without leaving the desktop app.

The page uses server-side transcript aggregation for session, message, tool, model-token, and subagent token data. Daily token buckets use assistant message timestamps, and daily session counts use active parent sessions for the same date bucket so resumed sessions and cross-midnight work do not produce token-only days. Cache accounting is bumped to v5 to force recomputation under the corrected daily semantics.

Constraint: Usage data must come from local Claude Code CLI transcripts rather than mock/demo data.
Constraint: Desktop navigation keeps Token usage directly above Diagnostics.
Rejected: Bucket all token usage by session start date | hides resumed-session and cross-midnight consumption from the actual day it was spent.
Confidence: high
Scope-risk: moderate
Directive: Keep daily token and daily session counts on the same date-bucketing semantics.
Tested: bun run check:desktop
Tested: bun run check:server
Tested: Browser verification for Token usage in English and Chinese locale date labels
Not-tested: Full bun run verify quality gate
2026-05-10 11:37:49 +08:00

1111 lines
34 KiB
TypeScript

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 + cache read + cache creation) 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
}
type UsageLike = {
input_tokens?: number
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
function getTotalUsageTokens(usage: UsageLike): number {
return (
(usage.input_tokens || 0) +
(usage.output_tokens || 0) +
(usage.cache_read_input_tokens || 0) +
(usage.cache_creation_input_tokens || 0)
)
}
function isDateInRange(
date: string,
fromDate?: string,
toDate?: string,
): boolean {
if (fromDate && isDateBefore(date, fromDate)) return false
if (toDate && isDateBefore(toDate, date)) return false
return true
}
function getMessageDateKey(
message: Pick<TranscriptMessage, 'timestamp'>,
): string | null {
const messageTimestamp = new Date(message.timestamp)
if (isNaN(messageTimestamp.getTime())) return null
return toDateString(messageTimestamp)
}
/**
* 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 dailySessionIdsMap = new Map<string, Set<string>>()
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>()
const getDailyActivity = (date: string): DailyActivity => {
let activity = dailyActivityMap.get(date)
if (!activity) {
activity = {
date,
messageCount: 0,
sessionCount: 0,
toolCallCount: 0,
}
dailyActivityMap.set(date, activity)
}
return activity
}
const markSessionActiveOnDate = (date: string, parentSessionId: string) => {
let sessionIds = dailySessionIdsMap.get(date)
if (!sessionIds) {
sessionIds = new Set()
dailySessionIdsMap.set(date, sessionIds)
}
sessionIds.add(parentSessionId)
getDailyActivity(date)
}
// 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) {
try {
const fileStat = await fs.stat(sessionFile)
const fileModifiedDate = toDateString(fileStat.mtime)
if (isDateBefore(fileModifiedDate, fromDate)) {
return {
sessionFile,
entries: null,
error: null,
skipped: true,
}
}
} catch {
// If we can't stat the file, try to read it anyway
}
}
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}`)
const parentSessionId = isSubagentFile
? basename(dirname(dirname(sessionFile)))
: sessionId
// 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) {
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)
const includeSessionInRange = isDateInRange(dateKey, fromDate, toDate)
// Session-level aggregates still represent newly started top-level
// sessions. Daily activity is tracked below by each message date so token
// totals and visible per-day session counts share one date bucket.
if (!isSubagentFile && includeSessionInRange) {
const duration = lastTimestamp.getTime() - firstTimestamp.getTime()
sessions.push({
sessionId,
duration,
messageCount: mainMessages.length,
timestamp: firstMessage.timestamp,
})
totalMessages += mainMessages.length
const hour = firstTimestamp.getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
// Process messages for tool usage and model stats
for (const message of mainMessages) {
const messageDateKey = getMessageDateKey(message)
const includeMessageInRange =
messageDateKey !== null &&
isDateInRange(messageDateKey, fromDate, toDate)
if (includeMessageInRange && messageDateKey !== null) {
markSessionActiveOnDate(messageDateKey, parentSessionId)
if (!isSubagentFile) {
getDailyActivity(messageDateKey).messageCount++
}
}
if (message.type === 'assistant') {
const content = message.message?.content
if (
includeMessageInRange &&
messageDateKey !== null &&
Array.isArray(content)
) {
for (const block of content) {
if (block.type === 'tool_use') {
getDailyActivity(messageDateKey).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 (!includeMessageInRange || messageDateKey === null) {
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 = getTotalUsageTokens(usage)
if (totalTokens > 0) {
const tokenDateKey = messageDateKey
const dayTokens = dailyModelTokensMap.get(tokenDateKey) || {}
dayTokens[model] = (dayTokens[model] || 0) + totalTokens
dailyModelTokensMap.set(tokenDateKey, dayTokens)
}
}
}
}
}
}
for (const [date, sessionIds] of dailySessionIdsMap) {
getDailyActivity(date).sessionCount = sessionIds.size
}
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)
const toDateStr = toDateString(today)
// Process session files for the date range
const stats = await processSessionFiles(allSessionFiles, {
fromDate: fromDateStr,
toDate: toDateStr,
})
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,
}
}