import { readdir, stat } from 'fs/promises' import { basename, join } from 'path' import { readSessionLines } from './fs-utils.js' import { calculateCost, getShortModelName } from './models.js' import { discoverAllSessions, getProvider } from './providers/index.js' import { flushCodexCache } from './codex-cache.js' import { flushAntigravityCache } from './providers/antigravity.js' import type { ParsedProviderCall } from './providers/types.js' import type { AssistantMessageContent, ClassifiedTurn, ContentBlock, DateRange, JournalEntry, ParsedApiCall, ParsedTurn, ProjectSummary, SessionSummary, TokenUsage, ToolUseBlock, } from './types.js' import { classifyTurn, BASH_TOOLS } from './classifier.js' import { extractBashCommands } from './bash-utils.js' function unsanitizePath(dirName: string): string { return dirName.replace(/-/g, '/') } function normalizeProjectPathKey(projectPath: string): string { const normalized = projectPath.trim().replace(/\\/g, '/') return (normalized.replace(/\/+$/, '') || normalized).toLowerCase() } function parseJsonlLine(line: string): JournalEntry | null { try { return JSON.parse(line) as JournalEntry } catch { return null } } function extractToolNames(content: ContentBlock[]): string[] { return content .filter((b): b is ToolUseBlock => b.type === 'tool_use') .map(b => b.name) } function extractMcpTools(tools: string[]): string[] { return tools.filter(t => t.startsWith('mcp__')) } function extractSkillNames(content: ContentBlock[]): string[] { return content .filter((b): b is ToolUseBlock => b.type === 'tool_use' && b.name === 'Skill') .map(b => { const input = (b.input ?? {}) as Record const raw = input['skill'] ?? input['name'] return typeof raw === 'string' ? raw.trim() : '' }) .filter(name => name.length > 0) } function extractCoreTools(tools: string[]): string[] { return tools.filter(t => !t.startsWith('mcp__')) } function extractBashCommandsFromContent(content: ContentBlock[]): string[] { return content .filter((b): b is ToolUseBlock => b.type === 'tool_use' && BASH_TOOLS.has((b as ToolUseBlock).name)) .flatMap(b => { const command = (b.input as Record)?.command return typeof command === 'string' ? extractBashCommands(command) : [] }) } function getUserMessageText(entry: JournalEntry): string { if (!entry.message || entry.message.role !== 'user') return '' const content = entry.message.content if (typeof content === 'string') return content if (Array.isArray(content)) { return content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map(b => b.text) .join(' ') } return '' } function getMessageId(entry: JournalEntry): string | null { if (entry.type !== 'assistant') return null const msg = entry.message as AssistantMessageContent | undefined return msg?.id ?? null } function parseApiCall(entry: JournalEntry): ParsedApiCall | null { if (entry.type !== 'assistant') return null const msg = entry.message as AssistantMessageContent | undefined if (!msg?.usage || !msg?.model) return null const usage = msg.usage const tokens: TokenUsage = { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0, cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0, cacheReadInputTokens: usage.cache_read_input_tokens ?? 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: usage.server_tool_use?.web_search_requests ?? 0, } const tools = extractToolNames(msg.content ?? []) const skills = extractSkillNames(msg.content ?? []) const costUSD = calculateCost( msg.model, tokens.inputTokens, tokens.outputTokens, tokens.cacheCreationInputTokens, tokens.cacheReadInputTokens, tokens.webSearchRequests, usage.speed ?? 'standard', ) const bashCmds = extractBashCommandsFromContent(msg.content ?? []) return { provider: 'claude', model: msg.model, usage: tokens, costUSD, tools, mcpTools: extractMcpTools(tools), skills, hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), speed: usage.speed ?? 'standard', timestamp: entry.timestamp ?? '', bashCommands: bashCmds, deduplicationKey: msg.id ?? `claude:${entry.timestamp}`, } } function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { const firstIdxById = new Map() const lastIdxById = new Map() for (let i = 0; i < entries.length; i++) { const id = getMessageId(entries[i]!) if (!id) continue if (!firstIdxById.has(id)) firstIdxById.set(id, i) lastIdxById.set(id, i) } if (lastIdxById.size === 0) return entries const result: JournalEntry[] = [] for (let i = 0; i < entries.length; i++) { const id = getMessageId(entries[i]!) if (id && lastIdxById.get(id) !== i) continue if (id && firstIdxById.get(id) !== i) { const firstTs = entries[firstIdxById.get(id)!]!.timestamp result.push({ ...entries[i]!, timestamp: firstTs ?? entries[i]!.timestamp }) continue } result.push(entries[i]!) } return result } function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set): ParsedTurn[] { const turns: ParsedTurn[] = [] let currentUserMessage = '' let currentCalls: ParsedApiCall[] = [] let currentTimestamp = '' let currentSessionId = '' for (const entry of entries) { if (entry.type === 'user') { const text = getUserMessageText(entry) if (text.trim()) { if (currentCalls.length > 0) { turns.push({ userMessage: currentUserMessage, assistantCalls: currentCalls, timestamp: currentTimestamp, sessionId: currentSessionId, }) } currentUserMessage = text currentCalls = [] currentTimestamp = entry.timestamp ?? '' currentSessionId = entry.sessionId ?? '' } } else if (entry.type === 'assistant') { const msgId = getMessageId(entry) if (msgId && seenMsgIds.has(msgId)) continue if (msgId) seenMsgIds.add(msgId) const call = parseApiCall(entry) if (call) currentCalls.push(call) } } if (currentCalls.length > 0) { turns.push({ userMessage: currentUserMessage, assistantCalls: currentCalls, timestamp: currentTimestamp, sessionId: currentSessionId, }) } return turns } /** * Extract MCP tool inventory observed across a session's JSONL entries. * * Claude Code emits `attachment.type === "deferred_tools_delta"` entries whose * `addedNames` array lists every tool currently available at that turn (built-in * tools plus all `mcp____` names exposed by configured MCP * servers). Tool inventory can change mid-session if the user reloads MCP * config, so we union every occurrence rather than trusting only the first. * * Built-in tools are filtered out: only `mcp__*` identifiers survive. */ // Fully-qualified MCP tool name shape: `mcp____`. Both server // and tool segments must be non-empty. Names like `mcp__server` (no tool // segment) or `mcp__server__` (trailing empty tool) would silently pollute // the inventory and break downstream `split('__')` consumers, so they're // rejected here. function isMcpToolName(name: string): boolean { if (!name.startsWith('mcp__')) return false const rest = name.slice(5) // strip `mcp__` const sep = rest.indexOf('__') if (sep <= 0) return false // missing or empty server if (sep >= rest.length - 2) return false // missing or empty tool return true } export function extractMcpInventory(entries: JournalEntry[]): string[] { const inventory = new Set() for (const entry of entries) { const att = entry['attachment'] if (!att || typeof att !== 'object') continue const a = att as { type?: unknown; addedNames?: unknown } if (a.type !== 'deferred_tools_delta') continue if (!Array.isArray(a.addedNames)) continue for (const name of a.addedNames) { if (typeof name !== 'string') continue if (!isMcpToolName(name)) continue inventory.add(name) } } if (inventory.size === 0) return [] return Array.from(inventory).sort() } function extractCanonicalCwd(entries: JournalEntry[]): string | undefined { for (const entry of entries) { if (typeof entry.cwd !== 'string') continue const cwd = entry.cwd.trim() if (cwd) return cwd } return undefined } function buildSessionSummary( sessionId: string, project: string, turns: ClassifiedTurn[], mcpInventory?: string[], ): SessionSummary { const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null) const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null) const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null) const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null) const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null) const skillBreakdown: SessionSummary['skillBreakdown'] = Object.create(null) let totalCost = 0 let totalInput = 0 let totalOutput = 0 let totalCacheRead = 0 let totalCacheWrite = 0 let apiCalls = 0 let firstTs = '' let lastTs = '' for (const turn of turns) { const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) if (!categoryBreakdown[turn.category]) { categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 } } categoryBreakdown[turn.category].turns++ categoryBreakdown[turn.category].costUSD += turnCost if (turn.hasEdits) { categoryBreakdown[turn.category].editTurns++ categoryBreakdown[turn.category].retries += turn.retries if (turn.retries === 0) categoryBreakdown[turn.category].oneShotTurns++ } if (turn.subCategory) { const skillKey = turn.subCategory if (!skillBreakdown[skillKey]) { skillBreakdown[skillKey] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } } skillBreakdown[skillKey].turns++ skillBreakdown[skillKey].costUSD += turnCost if (turn.hasEdits) { skillBreakdown[skillKey].editTurns++ if (turn.retries === 0) skillBreakdown[skillKey].oneShotTurns++ } } for (const call of turn.assistantCalls) { totalCost += call.costUSD totalInput += call.usage.inputTokens totalOutput += call.usage.outputTokens totalCacheRead += call.usage.cacheReadInputTokens totalCacheWrite += call.usage.cacheCreationInputTokens apiCalls++ const modelKey = getShortModelName(call.model) if (!modelBreakdown[modelKey]) { modelBreakdown[modelKey] = { calls: 0, costUSD: 0, tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, } } modelBreakdown[modelKey].calls++ modelBreakdown[modelKey].costUSD += call.costUSD modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens for (const tool of extractCoreTools(call.tools)) { toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 } toolBreakdown[tool].calls++ } for (const mcp of call.mcpTools) { const server = mcp.split('__')[1] ?? mcp mcpBreakdown[server] = mcpBreakdown[server] ?? { calls: 0 } mcpBreakdown[server].calls++ } for (const cmd of call.bashCommands) { bashBreakdown[cmd] = bashBreakdown[cmd] ?? { calls: 0 } bashBreakdown[cmd].calls++ } if (!firstTs || call.timestamp < firstTs) firstTs = call.timestamp if (!lastTs || call.timestamp > lastTs) lastTs = call.timestamp } } return { sessionId, project, firstTimestamp: firstTs || turns[0]?.timestamp || '', lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '', totalCostUSD: totalCost, totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalCacheReadTokens: totalCacheRead, totalCacheWriteTokens: totalCacheWrite, apiCalls, turns, modelBreakdown, toolBreakdown, mcpBreakdown, bashBreakdown, categoryBreakdown, skillBreakdown, ...(mcpInventory && mcpInventory.length > 0 ? { mcpInventory } : {}), } } async function parseSessionFile( filePath: string, project: string, seenMsgIds: Set, dateRange?: DateRange, ): Promise<{ session: SessionSummary; canonicalCwd?: string } | null> { // Skip files whose mtime is older than the range start. A session file // can only contain entries up to its last-modified time; if that predates // the requested range, nothing in this file can match. if (dateRange) { try { const s = await stat(filePath) if (s.mtimeMs < dateRange.start.getTime()) return null } catch { /* fall through to normal read; missing stat shouldn't break parsing */ } } const entries: JournalEntry[] = [] let hasLines = false for await (const line of readSessionLines(filePath)) { hasLines = true const entry = parseJsonlLine(line) if (entry) entries.push(entry) } if (!hasLines) return null if (entries.length === 0) return null const sessionId = basename(filePath, '.jsonl') const dedupedEntries = dedupeStreamingMessageIds(entries) let turns = groupIntoTurns(dedupedEntries, seenMsgIds) if (dateRange) { // Bucket a turn by the timestamp of its first assistant call (when the cost was // actually incurred). Filtering entries directly produced orphan assistant calls // when a user message sat in one day and the response landed in another -- those // got pushed as turns with empty timestamps, which some code paths counted and // others dropped, producing inconsistent Today totals. turns = turns.filter(turn => { if (turn.assistantCalls.length === 0) return false const firstCallTs = turn.assistantCalls[0]!.timestamp if (!firstCallTs) return false const ts = new Date(firstCallTs) return ts >= dateRange.start && ts <= dateRange.end }) if (turns.length === 0) return null } const classified = turns.map(classifyTurn) // Inventory is extracted from the full entry stream, not just the // turns we kept after date filtering: tool availability is set up // once at the start of a session (with possible mid-session reloads), // and we want to reflect what was loaded even if the user only ran // turns inside a narrow date window. const mcpInventory = extractMcpInventory(entries) const canonicalCwd = extractCanonicalCwd(entries) return { session: buildSessionSummary(sessionId, project, classified, mcpInventory), ...(canonicalCwd ? { canonicalCwd } : {}), } } async function collectJsonlFiles(dirPath: string): Promise { const files = await readdir(dirPath).catch(() => []) const jsonlFiles = files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f)) for (const entry of files) { if (entry.endsWith('.jsonl')) continue const subagentsPath = join(dirPath, entry, 'subagents') const subFiles = await readdir(subagentsPath).catch(() => []) for (const sf of subFiles) { if (sf.endsWith('.jsonl')) jsonlFiles.push(join(subagentsPath, sf)) } } return jsonlFiles } async function scanProjectDirs(dirs: Array<{ path: string; name: string }>, seenMsgIds: Set, dateRange?: DateRange): Promise { const projectMap = new Map() for (const { path: dirPath, name: dirName } of dirs) { const jsonlFiles = await collectJsonlFiles(dirPath) for (const filePath of jsonlFiles) { const parsed = await parseSessionFile(filePath, dirName, seenMsgIds, dateRange) if (parsed && parsed.session.apiCalls > 0) { const projectPath = parsed.canonicalCwd ?? unsanitizePath(dirName) const projectKey = parsed.canonicalCwd ? normalizeProjectPathKey(parsed.canonicalCwd) : `slug:${dirName}` const existing = projectMap.get(projectKey) if (existing) { existing.sessions.push(parsed.session) } else { projectMap.set(projectKey, { project: dirName, projectPath, sessions: [parsed.session] }) } } } } // If a slug has both cwd-keyed and slug-keyed entries (mixed sessions where // some carry a canonical cwd and some don't), fold the slug-keyed sessions // into the cwd-keyed entry so the canonical projectPath is preserved // regardless of file iteration order. const cwdKeyByDirName = new Map() for (const [key, entry] of projectMap) { if (!key.startsWith('slug:') && !cwdKeyByDirName.has(entry.project)) { cwdKeyByDirName.set(entry.project, key) } } for (const [key, entry] of [...projectMap]) { if (!key.startsWith('slug:')) continue const cwdKey = cwdKeyByDirName.get(entry.project) if (!cwdKey) continue const target = projectMap.get(cwdKey)! target.sessions.push(...entry.sessions) projectMap.delete(key) } const projects: ProjectSummary[] = [] for (const { project, projectPath, sessions } of projectMap.values()) { projects.push({ project, projectPath, sessions, totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), }) } return projects } function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { const tools = call.tools const usage: TokenUsage = { inputTokens: call.inputTokens, outputTokens: call.outputTokens, cacheCreationInputTokens: call.cacheCreationInputTokens, cacheReadInputTokens: call.cacheReadInputTokens, cachedInputTokens: call.cachedInputTokens, reasoningTokens: call.reasoningTokens, webSearchRequests: call.webSearchRequests, } const apiCall: ParsedApiCall = { provider: call.provider, model: call.model, usage, costUSD: call.costUSD, tools, mcpTools: extractMcpTools(tools), skills: [], hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), speed: call.speed, timestamp: call.timestamp, bashCommands: call.bashCommands, deduplicationKey: call.deduplicationKey, } return { userMessage: call.userMessage, assistantCalls: [apiCall], timestamp: call.timestamp, sessionId: call.sessionId, } } async function parseProviderSources( providerName: string, sources: Array<{ path: string; project: string }>, seenKeys: Set, dateRange?: DateRange, ): Promise { const provider = await getProvider(providerName) if (!provider) return [] const sessionMap = new Map() try { for (const source of sources) { if (dateRange) { try { const s = await stat(source.path) if (s.mtimeMs < dateRange.start.getTime()) continue } catch { /* fall through; treat unknown stat as "may contain data" */ } } const parser = provider.createSessionParser( { path: source.path, project: source.project, provider: providerName }, seenKeys, ) for await (const call of parser.parse()) { if (dateRange) { if (!call.timestamp) continue const ts = new Date(call.timestamp) if (ts < dateRange.start || ts > dateRange.end) continue } const turn = providerCallToTurn(call) const classified = classifyTurn(turn) const key = `${providerName}:${call.sessionId}:${source.project}` const existing = sessionMap.get(key) if (existing) { existing.turns.push(classified) } else { sessionMap.set(key, { project: source.project, turns: [classified] }) } } } } finally { if (providerName === 'codex') await flushCodexCache() if (providerName === 'antigravity') { const liveIds = new Set(sources.map(s => basename(s.path, '.pb'))) await flushAntigravityCache(liveIds) } } const projectMap = new Map() for (const [key, { project, turns }] of sessionMap) { const sessionId = key.split(':')[1] ?? key const session = buildSessionSummary(sessionId, project, turns) if (session.apiCalls > 0) { const existing = projectMap.get(project) ?? [] existing.push(session) projectMap.set(project, existing) } } const projects: ProjectSummary[] = [] for (const [dirName, sessions] of projectMap) { projects.push({ project: dirName, projectPath: unsanitizePath(dirName), sessions, totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), }) } return projects } const CACHE_TTL_MS = 60_000 const MAX_CACHE_ENTRIES = 10 const sessionCache = new Map() function cacheKey(dateRange?: DateRange, providerFilter?: string): string { const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none' // Include the Claude config-dir env so a config change in a long-lived // process (menubar / GNOME extension / test workers) does not return // stale data keyed under a previous configuration. const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '') return `${s}:${providerFilter ?? 'all'}:${claudeEnv}` } export function clearSessionCache(): void { sessionCache.clear() } function cachePut(key: string, data: ProjectSummary[]) { const now = Date.now() for (const [k, v] of sessionCache) { if (now - v.ts > CACHE_TTL_MS) sessionCache.delete(k) } if (sessionCache.size >= MAX_CACHE_ENTRIES) { const oldest = [...sessionCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0] if (oldest) sessionCache.delete(oldest[0]) } sessionCache.set(key, { data, ts: now }) } export function filterProjectsByName( projects: ProjectSummary[], include?: string[], exclude?: string[], ): ProjectSummary[] { let result = projects if (include && include.length > 0) { const patterns = include.map(s => s.toLowerCase()) result = result.filter(p => { const name = p.project.toLowerCase() const path = p.projectPath.toLowerCase() return patterns.some(pat => name.includes(pat) || path.includes(pat)) }) } if (exclude && exclude.length > 0) { const patterns = exclude.map(s => s.toLowerCase()) result = result.filter(p => { const name = p.project.toLowerCase() const path = p.projectPath.toLowerCase() return !patterns.some(pat => name.includes(pat) || path.includes(pat)) }) } return result } export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { const key = cacheKey(dateRange, providerFilter) const cached = sessionCache.get(key) if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data const seenMsgIds = new Set() const seenKeys = new Set() const allSources = await discoverAllSessions(providerFilter) const claudeSources = allSources.filter(s => s.provider === 'claude') const nonClaudeSources = allSources.filter(s => s.provider !== 'claude') const claudeDirs = claudeSources.map(s => ({ path: s.path, name: s.project })) const claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, dateRange) const providerGroups = new Map>() for (const source of nonClaudeSources) { const existing = providerGroups.get(source.provider) ?? [] existing.push({ path: source.path, project: source.project }) providerGroups.set(source.provider, existing) } const otherProjects: ProjectSummary[] = [] for (const [providerName, sources] of providerGroups) { const projects = await parseProviderSources(providerName, sources, seenKeys, dateRange) otherProjects.push(...projects) } const mergedMap = new Map() for (const p of [...claudeProjects, ...otherProjects]) { const existing = mergedMap.get(p.project) if (existing) { existing.sessions.push(...p.sessions) existing.totalCostUSD += p.totalCostUSD existing.totalApiCalls += p.totalApiCalls } else { mergedMap.set(p.project, { ...p }) } } const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD) cachePut(key, result) return result }