UNPKG

ccusage-live

Version:

Enhanced Claude Code usage analysis tool with live team monitoring and collaboration features

1,007 lines (1,006 loc) 38.2 kB
import { CLAUDE_CONFIG_DIR_ENV, CLAUDE_PROJECTS_DIR_NAME, DEFAULT_CLAUDE_CODE_PATH, DEFAULT_CLAUDE_CONFIG_PATH, DEFAULT_RECENT_DAYS, PricingFetcher, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR, __toESM, require_usingCtx } from "./pricing-fetcher-C4VsnO1-.js"; import { getTotalTokens } from "./_token-utils-WjkbrjKv.js"; import { activityDateSchema, createDailyDate, createMonthlyDate, createProjectPath, createSessionId, dailyDateSchema, isoTimestampSchema, messageIdSchema, modelNameSchema, monthlyDateSchema, projectPathSchema, requestIdSchema, sessionIdSchema, versionSchema } from "./_types-Dbrgtaqy.js"; import { logger } from "./logger-D3prNztu.js"; import { z } from "zod"; import a, { readFile } from "node:fs/promises"; import path from "node:path"; import process$1 from "node:process"; import { toArray } from "@antfu/utils"; import { Result } from "@praha/byethrow"; import { groupBy, uniq } from "es-toolkit"; import { sort } from "fast-sort"; import b from "node:fs"; import F from "node:os"; import { isDirectorySync } from "path-type"; import { glob } from "tinyglobby"; var d = Object.defineProperty; var n = (s, t) => d(s, "name", { value: t, configurable: !0 }); typeof Symbol.asyncDispose != "symbol" && Object.defineProperty(Symbol, "asyncDispose", { configurable: !1, enumerable: !1, writable: !1, value: Symbol.for("asyncDispose") }); var P = class { static { n(this, "FsFixture"); } path; constructor(t) { this.path = t; } getPath(...t) { return path.join(this.path, ...t); } exists(t = "") { return a.access(this.getPath(t)).then(() => !0, () => !1); } rm(t = "") { return a.rm(this.getPath(t), { recursive: !0, force: !0 }); } cp(t, r, i) { return r ? r.endsWith(path.sep) && (r += path.basename(t)) : r = path.basename(t), a.cp(t, this.getPath(r), i); } mkdir(t) { return a.mkdir(this.getPath(t), { recursive: !0 }); } writeFile(t, r) { return a.writeFile(this.getPath(t), r); } writeJson(t, r) { return this.writeFile(t, JSON.stringify(r, null, 2)); } readFile(t, r) { return a.readFile(this.getPath(t), r); } async [Symbol.asyncDispose]() { await this.rm(); } }; const v = b.realpathSync(F.tmpdir()), D = `fs-fixture-${Date.now()}-${process.pid}`; let m = 0; const j = n(() => (m += 1, m), "getId"); var u = class { static { n(this, "Path"); } path; constructor(t) { this.path = t; } }; var f = class extends u { static { n(this, "Directory"); } }; var y = class extends u { static { n(this, "File"); } content; constructor(t, r) { super(t), this.content = r; } }; var l = class { static { n(this, "Symlink"); } target; type; path; constructor(t, r) { this.target = t, this.type = r; } }; const w = n((s, t, r) => { const i = []; for (const p in s) { if (!Object.hasOwn(s, p)) continue; const e = path.join(t, p); let o = s[p]; if (typeof o == "function") { const g = Object.assign(Object.create(r), { filePath: e }), h = o(g); if (h instanceof l) { h.path = e, i.push(h); continue; } else o = h; } typeof o == "string" ? i.push(new y(e, o)) : i.push(new f(e), ...w(o, e, r)); } return i; }, "flattenFileTree"), k = n(async (s, t) => { const r = t?.tempDir ? path.resolve(t.tempDir) : v, i = path.join(r, `${D}-${j()}/`); if (await a.mkdir(i, { recursive: !0 }), s) { if (typeof s == "string") await a.cp(s, i, { recursive: !0, filter: t?.templateFilter }); else if (typeof s == "object") { const p = { fixturePath: i, getPath: n((...e) => path.join(i, ...e), "getPath"), symlink: n((e, o) => new l(e, o), "symlink") }; await Promise.all(w(s, i, p).map(async (e) => { e instanceof f ? await a.mkdir(e.path, { recursive: !0 }) : e instanceof l ? (await a.mkdir(path.dirname(e.path), { recursive: !0 }), await a.symlink(e.target, e.path, e.type)) : e instanceof y && (await a.mkdir(path.dirname(e.path), { recursive: !0 }), await a.writeFile(e.path, e.content)); })); } } return new P(i); }, "createFixture"); /** * Default session duration in hours (Claude's billing block duration) */ const DEFAULT_SESSION_DURATION_HOURS = 5; /** * Floors a timestamp to the beginning of the hour in UTC * @param timestamp - The timestamp to floor * @returns New Date object floored to the UTC hour */ function floorToHour(timestamp) { const floored = new Date(timestamp); floored.setUTCMinutes(0, 0, 0); return floored; } /** * Identifies and creates session blocks from usage entries * Groups entries into time-based blocks (typically 5-hour periods) with gap detection * @param entries - Array of usage entries to process * @param sessionDurationHours - Duration of each session block in hours * @returns Array of session blocks with aggregated usage data */ function identifySessionBlocks(entries, sessionDurationHours = DEFAULT_SESSION_DURATION_HOURS) { if (entries.length === 0) return []; const sessionDurationMs = sessionDurationHours * 60 * 60 * 1e3; const blocks = []; const sortedEntries = [...entries].sort((a$1, b$1) => a$1.timestamp.getTime() - b$1.timestamp.getTime()); let currentBlockStart = null; let currentBlockEntries = []; const now = new Date(); for (const entry of sortedEntries) { const entryTime = entry.timestamp; if (currentBlockStart == null) { currentBlockStart = floorToHour(entryTime); currentBlockEntries = [entry]; } else { const timeSinceBlockStart = entryTime.getTime() - currentBlockStart.getTime(); const lastEntry = currentBlockEntries.at(-1); if (lastEntry == null) continue; const lastEntryTime = lastEntry.timestamp; const timeSinceLastEntry = entryTime.getTime() - lastEntryTime.getTime(); if (timeSinceBlockStart > sessionDurationMs || timeSinceLastEntry > sessionDurationMs) { const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs); blocks.push(block); if (timeSinceLastEntry > sessionDurationMs) { const gapBlock = createGapBlock(lastEntryTime, entryTime, sessionDurationMs); if (gapBlock != null) blocks.push(gapBlock); } currentBlockStart = floorToHour(entryTime); currentBlockEntries = [entry]; } else currentBlockEntries.push(entry); } } if (currentBlockStart != null && currentBlockEntries.length > 0) { const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs); blocks.push(block); } return blocks; } /** * Creates a session block from a start time and usage entries * @param startTime - When the block started * @param entries - Usage entries in this block * @param now - Current time for active block detection * @param sessionDurationMs - Session duration in milliseconds * @returns Session block with aggregated data */ function createBlock(startTime, entries, now, sessionDurationMs) { const endTime = new Date(startTime.getTime() + sessionDurationMs); const lastEntry = entries[entries.length - 1]; const actualEndTime = lastEntry != null ? lastEntry.timestamp : startTime; const isActive = now.getTime() - actualEndTime.getTime() < sessionDurationMs && now < endTime; const tokenCounts = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; let costUSD = 0; const models = []; let usageLimitResetTime; for (const entry of entries) { tokenCounts.inputTokens += entry.usage.inputTokens; tokenCounts.outputTokens += entry.usage.outputTokens; tokenCounts.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; tokenCounts.cacheReadInputTokens += entry.usage.cacheReadInputTokens; costUSD += entry.costUSD ?? 0; usageLimitResetTime = entry.usageLimitResetTime ?? usageLimitResetTime; models.push(entry.model); } return { id: startTime.toISOString(), startTime, endTime, actualEndTime, isActive, entries, tokenCounts, costUSD, models: uniq(models), usageLimitResetTime }; } /** * Creates a gap block representing periods with no activity * @param lastActivityTime - Time of last activity before gap * @param nextActivityTime - Time of next activity after gap * @param sessionDurationMs - Session duration in milliseconds * @returns Gap block or null if gap is too short */ function createGapBlock(lastActivityTime, nextActivityTime, sessionDurationMs) { const gapDuration = nextActivityTime.getTime() - lastActivityTime.getTime(); if (gapDuration <= sessionDurationMs) return null; const gapStart = new Date(lastActivityTime.getTime() + sessionDurationMs); const gapEnd = nextActivityTime; return { id: `gap-${gapStart.toISOString()}`, startTime: gapStart, endTime: gapEnd, isActive: false, isGap: true, entries: [], tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, costUSD: 0, models: [] }; } /** * Calculates the burn rate (tokens/minute and cost/hour) for a session block * @param block - Session block to analyze * @returns Burn rate calculations or null if block has no activity */ function calculateBurnRate(block) { if (block.entries.length === 0 || (block.isGap ?? false)) return null; const firstEntryData = block.entries[0]; const lastEntryData = block.entries[block.entries.length - 1]; if (firstEntryData == null || lastEntryData == null) return null; const firstEntry = firstEntryData.timestamp; const lastEntry = lastEntryData.timestamp; const durationMinutes = (lastEntry.getTime() - firstEntry.getTime()) / (1e3 * 60); if (durationMinutes <= 0) return null; const totalTokens = getTotalTokens(block.tokenCounts); const tokensPerMinute = totalTokens / durationMinutes; const nonCacheTokens = (block.tokenCounts.inputTokens ?? 0) + (block.tokenCounts.outputTokens ?? 0); const tokensPerMinuteForIndicator = nonCacheTokens / durationMinutes; const costPerHour = block.costUSD / durationMinutes * 60; return { tokensPerMinute, tokensPerMinuteForIndicator, costPerHour }; } /** * Projects total usage for an active session block based on current burn rate * @param block - Active session block to project * @returns Projected usage totals or null if block is inactive or has no burn rate */ function projectBlockUsage(block) { if (!block.isActive || (block.isGap ?? false)) return null; const burnRate = calculateBurnRate(block); if (burnRate == null) return null; const now = new Date(); const remainingTime = block.endTime.getTime() - now.getTime(); const remainingMinutes = Math.max(0, remainingTime / (1e3 * 60)); const currentTokens = getTotalTokens(block.tokenCounts); const projectedAdditionalTokens = burnRate.tokensPerMinute * remainingMinutes; const totalTokens = currentTokens + projectedAdditionalTokens; const projectedAdditionalCost = burnRate.costPerHour / 60 * remainingMinutes; const totalCost = block.costUSD + projectedAdditionalCost; return { totalTokens: Math.round(totalTokens), totalCost: Math.round(totalCost * 100) / 100, remainingMinutes: Math.round(remainingMinutes) }; } /** * Filters session blocks to include only recent ones and active blocks * @param blocks - Array of session blocks to filter * @param days - Number of recent days to include (default: 3) * @returns Filtered array of recent or active blocks */ function filterRecentBlocks(blocks, days = DEFAULT_RECENT_DAYS) { const now = new Date(); const cutoffTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1e3); return blocks.filter((block) => { return block.startTime >= cutoffTime || block.isActive; }); } var import_usingCtx = __toESM(require_usingCtx(), 1); /** * TypeScript exhaustiveness helper */ function unreachable(value) { throw new Error(`Unreachable code reached with value: ${String(value)}`); } let sharedPricingFetcher = null; function getSharedPricingFetcher(offline) { if (!sharedPricingFetcher) sharedPricingFetcher = new PricingFetcher(offline); return sharedPricingFetcher; } /** * 清理全局共享的PricingFetcher缓存(用于强制刷新价格数据) */ function clearGlobalPricingCache() { if (sharedPricingFetcher) sharedPricingFetcher.clearCache(); } /** * Get all Claude data directories to search for usage data * Supports multiple paths: environment variable (comma-separated), new default, and old default * @returns Array of valid Claude data directory paths */ function getClaudePaths() { const paths = []; const normalizedPaths = new Set(); const envPaths = (process$1.env[CLAUDE_CONFIG_DIR_ENV] ?? "").trim(); if (envPaths !== "") { const envPathList = envPaths.split(",").map((p) => p.trim()).filter((p) => p !== ""); for (const envPath of envPathList) { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME); if (isDirectorySync(projectsPath)) { if (!normalizedPaths.has(normalizedPath)) { normalizedPaths.add(normalizedPath); paths.push(normalizedPath); } } } } } const defaultPaths = [DEFAULT_CLAUDE_CONFIG_PATH, path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH)]; for (const defaultPath of defaultPaths) { const normalizedPath = path.resolve(defaultPath); if (isDirectorySync(normalizedPath)) { const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME); if (isDirectorySync(projectsPath)) { if (!normalizedPaths.has(normalizedPath)) { normalizedPaths.add(normalizedPath); paths.push(normalizedPath); } } } } if (paths.length === 0) throw new Error(`No valid Claude data directories found. Please ensure at least one of the following exists: - ${path.join(DEFAULT_CLAUDE_CONFIG_PATH, CLAUDE_PROJECTS_DIR_NAME)} - ${path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH, CLAUDE_PROJECTS_DIR_NAME)} - Or set ${CLAUDE_CONFIG_DIR_ENV} environment variable to valid directory path(s) containing a '${CLAUDE_PROJECTS_DIR_NAME}' subdirectory`.trim()); return paths; } /** * Extract project name from Claude JSONL file path * @param jsonlPath - Absolute path to JSONL file * @returns Project name extracted from path, or "unknown" if malformed */ function extractProjectFromPath(jsonlPath) { const normalizedPath = jsonlPath.replace(/[/\\]/g, path.sep); const segments = normalizedPath.split(path.sep); const projectsIndex = segments.findIndex((segment) => segment === CLAUDE_PROJECTS_DIR_NAME); if (projectsIndex === -1 || projectsIndex + 1 >= segments.length) return "unknown"; const projectName = segments[projectsIndex + 1]; return projectName != null && projectName.trim() !== "" ? projectName : "unknown"; } /** * Zod schema for validating Claude usage data from JSONL files */ const usageDataSchema = z.object({ timestamp: isoTimestampSchema, version: versionSchema.optional(), message: z.object({ usage: z.object({ input_tokens: z.number(), output_tokens: z.number(), cache_creation_input_tokens: z.number().optional(), cache_read_input_tokens: z.number().optional() }), model: modelNameSchema.optional(), id: messageIdSchema.optional(), content: z.array(z.object({ text: z.string().optional() })).optional() }), costUSD: z.number().optional(), requestId: requestIdSchema.optional(), isApiErrorMessage: z.boolean().optional() }); /** * Zod schema for model-specific usage breakdown data */ const modelBreakdownSchema = z.object({ modelName: modelNameSchema, inputTokens: z.number(), outputTokens: z.number(), cacheCreationTokens: z.number(), cacheReadTokens: z.number(), cost: z.number() }); /** * Zod schema for daily usage aggregation data */ const dailyUsageSchema = z.object({ date: dailyDateSchema, inputTokens: z.number(), outputTokens: z.number(), cacheCreationTokens: z.number(), cacheReadTokens: z.number(), totalCost: z.number(), modelsUsed: z.array(modelNameSchema), modelBreakdowns: z.array(modelBreakdownSchema), project: z.string().optional() }); /** * Zod schema for session-based usage aggregation data */ const sessionUsageSchema = z.object({ sessionId: sessionIdSchema, projectPath: projectPathSchema, inputTokens: z.number(), outputTokens: z.number(), cacheCreationTokens: z.number(), cacheReadTokens: z.number(), totalCost: z.number(), lastActivity: activityDateSchema, versions: z.array(versionSchema), modelsUsed: z.array(modelNameSchema), modelBreakdowns: z.array(modelBreakdownSchema) }); /** * Zod schema for monthly usage aggregation data */ const monthlyUsageSchema = z.object({ month: monthlyDateSchema, inputTokens: z.number(), outputTokens: z.number(), cacheCreationTokens: z.number(), cacheReadTokens: z.number(), totalCost: z.number(), modelsUsed: z.array(modelNameSchema), modelBreakdowns: z.array(modelBreakdownSchema), project: z.string().optional() }); /** * Aggregates token counts and costs by model name */ function aggregateByModel(entries, getModel, getUsage, getCost) { const modelAggregates = new Map(); const defaultStats = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0 }; for (const entry of entries) { const modelName = getModel(entry) ?? "unknown"; if (modelName === "<synthetic>") continue; const usage = getUsage(entry); const cost = getCost(entry); const existing = modelAggregates.get(modelName) ?? defaultStats; modelAggregates.set(modelName, { inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost }); } return modelAggregates; } /** * Aggregates model breakdowns from multiple sources */ function aggregateModelBreakdowns(breakdowns) { const modelAggregates = new Map(); const defaultStats = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0 }; for (const breakdown of breakdowns) { if (breakdown.modelName === "<synthetic>") continue; const existing = modelAggregates.get(breakdown.modelName) ?? defaultStats; modelAggregates.set(breakdown.modelName, { inputTokens: existing.inputTokens + breakdown.inputTokens, outputTokens: existing.outputTokens + breakdown.outputTokens, cacheCreationTokens: existing.cacheCreationTokens + breakdown.cacheCreationTokens, cacheReadTokens: existing.cacheReadTokens + breakdown.cacheReadTokens, cost: existing.cost + breakdown.cost }); } return modelAggregates; } /** * Converts model aggregates to sorted model breakdowns */ function createModelBreakdowns(modelAggregates) { return Array.from(modelAggregates.entries()).map(([modelName, stats]) => ({ modelName, ...stats })).sort((a$1, b$1) => b$1.cost - a$1.cost); } /** * Calculates total token counts and costs from entries */ function calculateTotals(entries, getUsage, getCost) { return entries.reduce((acc, entry) => { const usage = getUsage(entry); const cost = getCost(entry); return { inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), cacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost }; }, { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0, totalCost: 0 }); } /** * Filters items by date range */ function filterByDateRange(items, getDate, since, until) { if (since == null && until == null) return items; return items.filter((item) => { const dateStr = getDate(item).substring(0, 10).replace(/-/g, ""); if (since != null && dateStr < since) return false; if (until != null && dateStr > until) return false; return true; }); } /** * Filters items by project name */ function filterByProject(items, getProject, projectFilter) { if (projectFilter == null) return items; return items.filter((item) => { const projectName = getProject(item); return projectName === projectFilter; }); } /** * Checks if an entry is a duplicate based on hash */ function isDuplicateEntry(uniqueHash, processedHashes) { if (uniqueHash == null) return false; return processedHashes.has(uniqueHash); } /** * Marks an entry as processed */ function markAsProcessed(uniqueHash, processedHashes) { if (uniqueHash != null) processedHashes.add(uniqueHash); } /** * Extracts unique models from entries, excluding synthetic model */ function extractUniqueModels(entries, getModel) { return uniq(entries.map(getModel).filter((m$1) => m$1 != null && m$1 !== "<synthetic>")); } /** * Formats a date string to YYYY-MM-DD format * @param dateStr - Input date string * @returns Formatted date string in YYYY-MM-DD format */ function formatDate(dateStr) { const date = new Date(dateStr); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } /** * Formats a date string to compact format with year on first line and month-day on second * @param dateStr - Input date string * @returns Formatted date string with newline separator (YYYY\nMM-DD) */ function formatDateCompact(dateStr) { const date = new Date(dateStr); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}\n${month}-${day}`; } /** * Generic function to sort items by date based on sort order * @param items - Array of items to sort * @param getDate - Function to extract date/timestamp from item * @param order - Sort order (asc or desc) * @returns Sorted array */ function sortByDate(items, getDate, order = "desc") { const sorted = sort(items); switch (order) { case "desc": return sorted.desc((item) => new Date(getDate(item)).getTime()); case "asc": return sorted.asc((item) => new Date(getDate(item)).getTime()); default: unreachable(order); } } /** * Create a unique identifier for deduplication using message ID and request ID */ function createUniqueHash(data) { const messageId = data.message.id; const requestId = data.requestId; if (messageId == null || requestId == null) return null; return `${messageId}:${requestId}`; } /** * Extract the earliest timestamp from a JSONL file * Scans through the file until it finds a valid timestamp */ async function getEarliestTimestamp(filePath) { try { const content = await readFile(filePath, "utf-8"); const lines = content.trim().split("\n"); let earliestDate = null; for (const line of lines) { if (line.trim() === "") continue; try { const json = JSON.parse(line); if (json.timestamp != null && typeof json.timestamp === "string") { const date = new Date(json.timestamp); if (!Number.isNaN(date.getTime())) { if (earliestDate == null || date < earliestDate) earliestDate = date; } } } catch { continue; } } return earliestDate; } catch (error) { logger.debug(`Failed to get earliest timestamp for ${filePath}:`, error); return null; } } /** * Sort files by their earliest timestamp * Files without valid timestamps are placed at the end */ async function sortFilesByTimestamp(files) { const filesWithTimestamps = await Promise.all(files.map(async (file) => ({ file, timestamp: await getEarliestTimestamp(file) }))); return filesWithTimestamps.sort((a$1, b$1) => { if (a$1.timestamp == null && b$1.timestamp == null) return 0; if (a$1.timestamp == null) return 1; if (b$1.timestamp == null) return -1; return a$1.timestamp.getTime() - b$1.timestamp.getTime(); }).map((item) => item.file); } /** * Calculates cost for a single usage data entry based on the specified cost calculation mode * @param data - Usage data entry * @param mode - Cost calculation mode (auto, calculate, or display) * @param fetcher - Pricing fetcher instance for calculating costs from tokens * @returns Calculated cost in USD */ async function calculateCostForEntry(data, mode, fetcher) { if (mode === "display") return data.costUSD ?? 0; if (mode === "calculate") { if (data.message.model != null) return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); return 0; } if (mode === "auto") { if (data.costUSD != null) return data.costUSD; if (data.message.model != null) return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); return 0; } unreachable(mode); } /** * Get Claude Code usage limit expiration date * @param data - Usage data entry * @returns Usage limit expiration date */ function getUsageLimitResetTime(data) { let resetTime = null; if (data.isApiErrorMessage === true) { const timestampMatch = data.message?.content?.find((c) => c.text != null && c.text.includes("Claude AI usage limit reached"))?.text?.match(/\|(\d+)/) ?? null; if (timestampMatch?.[1] != null) { const resetTimestamp = Number.parseInt(timestampMatch[1]); resetTime = resetTimestamp > 0 ? new Date(resetTimestamp * 1e3) : null; } } return resetTime; } /** * Glob files from multiple Claude paths in parallel * @param claudePaths - Array of Claude base paths * @returns Array of file paths with their base directories */ async function globUsageFiles(claudePaths) { const filePromises = claudePaths.map(async (claudePath) => { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); const files = await glob([USAGE_DATA_GLOB_PATTERN], { cwd: claudeDir, absolute: true }).catch(() => []); return files.map((file) => ({ file, baseDir: claudeDir })); }); return (await Promise.all(filePromises)).flat(); } /** * Loads and aggregates Claude usage data by day * Processes all JSONL files in the Claude projects directory and groups usage by date * @param options - Optional configuration for loading and filtering data * @returns Array of daily usage summaries sorted by date */ async function loadDailyUsageData(options) { const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); const allFiles = await globUsageFiles(claudePaths); const fileList = allFiles.map((f$1) => f$1.file); if (fileList.length === 0) return []; const projectFilteredFiles = filterByProject(fileList, (filePath) => extractProjectFromPath(filePath), options?.project); const sortedFiles = await sortFilesByTimestamp(projectFilteredFiles); const mode = options?.mode ?? "auto"; const fetcher = mode === "display" ? null : getSharedPricingFetcher(options?.offline); const processedHashes = new Set(); const allEntries = []; for (const file of sortedFiles) { const content = await readFile(file, "utf-8"); const lines = content.trim().split("\n").filter((line) => line.length > 0); for (const line of lines) try { const parsed = JSON.parse(line); const result = usageDataSchema.safeParse(parsed); if (!result.success) continue; const data = result.data; const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) continue; markAsProcessed(uniqueHash, processedHashes); const date = formatDate(data.timestamp); const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : data.costUSD ?? 0; const project = extractProjectFromPath(file); allEntries.push({ data, date, cost, model: data.message.model, project }); } catch {} } const needsProjectGrouping = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping ? (entry) => `${entry.date}\x00${entry.project}` : (entry) => entry.date; const groupedData = groupBy(allEntries, groupingKey); const results = Object.entries(groupedData).map(([groupKey, entries]) => { if (entries == null) return void 0; const parts = groupKey.split("\0"); const date = parts[0] ?? groupKey; const project = parts.length > 1 ? parts[1] : void 0; const modelAggregates = aggregateByModel(entries, (entry) => entry.model, (entry) => entry.data.message.usage, (entry) => entry.cost); const modelBreakdowns = createModelBreakdowns(modelAggregates); const totals = calculateTotals(entries, (entry) => entry.data.message.usage, (entry) => entry.cost); const modelsUsed = extractUniqueModels(entries, (e) => e.model); return { date: createDailyDate(date), ...totals, modelsUsed, modelBreakdowns, ...project != null && { project } }; }).filter((item) => item != null); const dateFiltered = filterByDateRange(results, (item) => item.date, options?.since, options?.until); const finalFiltered = filterByProject(dateFiltered, (item) => item.project, options?.project); return sortByDate(finalFiltered, (item) => item.date, options?.order); } /** * Loads and aggregates Claude usage data by session * Groups usage data by project path and session ID based on file structure * @param options - Optional configuration for loading and filtering data * @returns Array of session usage summaries sorted by last activity */ async function loadSessionData(options) { const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); const filesWithBase = await globUsageFiles(claudePaths); if (filesWithBase.length === 0) return []; const projectFilteredWithBase = filterByProject(filesWithBase, (item) => extractProjectFromPath(item.file), options?.project); const fileToBaseMap = new Map(projectFilteredWithBase.map((f$1) => [f$1.file, f$1.baseDir])); const sortedFilesWithBase = await sortFilesByTimestamp(projectFilteredWithBase.map((f$1) => f$1.file)).then((sortedFiles) => sortedFiles.map((file) => ({ file, baseDir: fileToBaseMap.get(file) ?? "" }))); const mode = options?.mode ?? "auto"; const fetcher = mode === "display" ? null : getSharedPricingFetcher(options?.offline); const processedHashes = new Set(); const allEntries = []; for (const { file, baseDir } of sortedFilesWithBase) { const relativePath = path.relative(baseDir, file); const parts = relativePath.split(path.sep); const sessionId = parts[parts.length - 2] ?? "unknown"; const joinedPath = parts.slice(0, -2).join(path.sep); const projectPath = joinedPath.length > 0 ? joinedPath : "Unknown Project"; const content = await readFile(file, "utf-8"); const lines = content.trim().split("\n").filter((line) => line.length > 0); for (const line of lines) try { const parsed = JSON.parse(line); const result = usageDataSchema.safeParse(parsed); if (!result.success) continue; const data = result.data; const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) continue; markAsProcessed(uniqueHash, processedHashes); const sessionKey = `${projectPath}/${sessionId}`; const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : data.costUSD ?? 0; allEntries.push({ data, sessionKey, sessionId, projectPath, cost, timestamp: data.timestamp, model: data.message.model }); } catch {} } const groupedBySessions = groupBy(allEntries, (entry) => entry.sessionKey); const results = Object.entries(groupedBySessions).map(([_, entries]) => { if (entries == null) return void 0; const latestEntry = entries.reduce((latest, current) => current.timestamp > latest.timestamp ? current : latest); const versions = []; for (const entry of entries) if (entry.data.version != null) versions.push(entry.data.version); const modelAggregates = aggregateByModel(entries, (entry) => entry.model, (entry) => entry.data.message.usage, (entry) => entry.cost); const modelBreakdowns = createModelBreakdowns(modelAggregates); const totals = calculateTotals(entries, (entry) => entry.data.message.usage, (entry) => entry.cost); const modelsUsed = extractUniqueModels(entries, (e) => e.model); return { sessionId: createSessionId(latestEntry.sessionId), projectPath: createProjectPath(latestEntry.projectPath), ...totals, lastActivity: formatDate(latestEntry.timestamp), versions: uniq(versions).sort(), modelsUsed, modelBreakdowns }; }).filter((item) => item != null); const dateFiltered = filterByDateRange(results, (item) => item.lastActivity, options?.since, options?.until); const sessionFiltered = filterByProject(dateFiltered, (item) => item.projectPath, options?.project); return sortByDate(sessionFiltered, (item) => item.lastActivity, options?.order); } /** * Loads and aggregates Claude usage data by month * Uses daily usage data as the source and groups by month * @param options - Optional configuration for loading and filtering data * @returns Array of monthly usage summaries sorted by month */ async function loadMonthlyUsageData(options) { const dailyData = await loadDailyUsageData(options); const needsProjectGrouping = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping ? (data) => `${data.date.substring(0, 7)}\x00${data.project ?? "unknown"}` : (data) => data.date.substring(0, 7); const groupedByMonth = groupBy(dailyData, groupingKey); const monthlyArray = []; for (const [groupKey, dailyEntries] of Object.entries(groupedByMonth)) { if (dailyEntries == null) continue; const parts = groupKey.split("\0"); const month = parts[0] ?? groupKey; const project = parts.length > 1 ? parts[1] : void 0; const allBreakdowns = dailyEntries.flatMap((daily) => daily.modelBreakdowns); const modelAggregates = aggregateModelBreakdowns(allBreakdowns); const modelBreakdowns = createModelBreakdowns(modelAggregates); const models = []; for (const data of dailyEntries) for (const model of data.modelsUsed) if (model !== "<synthetic>") models.push(model); let totalInputTokens = 0; let totalOutputTokens = 0; let totalCacheCreationTokens = 0; let totalCacheReadTokens = 0; let totalCost = 0; for (const daily of dailyEntries) { totalInputTokens += daily.inputTokens; totalOutputTokens += daily.outputTokens; totalCacheCreationTokens += daily.cacheCreationTokens; totalCacheReadTokens += daily.cacheReadTokens; totalCost += daily.totalCost; } const monthlyUsage = { month: createMonthlyDate(month), inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cacheCreationTokens: totalCacheCreationTokens, cacheReadTokens: totalCacheReadTokens, totalCost, modelsUsed: uniq(models), modelBreakdowns, ...project != null && { project } }; monthlyArray.push(monthlyUsage); } return sortByDate(monthlyArray, (item) => `${item.month}-01`, options?.order); } /** * Loads usage data and organizes it into session blocks (typically 5-hour billing periods) * Processes all usage data and groups it into time-based blocks for billing analysis * @param options - Optional configuration including session duration and filtering * @returns Array of session blocks with usage and cost information */ async function loadSessionBlockData(options) { const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); const allFiles = []; for (const claudePath of claudePaths) { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); const files = await glob([USAGE_DATA_GLOB_PATTERN], { cwd: claudeDir, absolute: true }); allFiles.push(...files); } if (allFiles.length === 0) return []; const blocksFilteredFiles = filterByProject(allFiles, (filePath) => extractProjectFromPath(filePath), options?.project); const sortedFiles = await sortFilesByTimestamp(blocksFilteredFiles); const mode = options?.mode ?? "auto"; const fetcher = mode === "display" ? null : getSharedPricingFetcher(options?.offline); const processedHashes = new Set(); const allEntries = []; for (const file of sortedFiles) { const content = await readFile(file, "utf-8"); const lines = content.trim().split("\n").filter((line) => line.length > 0); for (const line of lines) try { const parsed = JSON.parse(line); const result = usageDataSchema.safeParse(parsed); if (!result.success) continue; const data = result.data; const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) continue; markAsProcessed(uniqueHash, processedHashes); const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : data.costUSD ?? 0; const usageLimitResetTime = getUsageLimitResetTime(data); allEntries.push({ timestamp: new Date(data.timestamp), usage: { inputTokens: data.message.usage.input_tokens, outputTokens: data.message.usage.output_tokens, cacheCreationInputTokens: data.message.usage.cache_creation_input_tokens ?? 0, cacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0 }, costUSD: cost, model: data.message.model ?? "unknown", version: data.version, usageLimitResetTime: usageLimitResetTime ?? void 0 }); } catch (error) { logger.debug(`Skipping invalid JSON line in 5-hour blocks: ${error instanceof Error ? error.message : String(error)}`); } } const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours); const dateFiltered = options?.since != null && options.since !== "" || options?.until != null && options.until !== "" ? blocks.filter((block) => { const blockDateStr = formatDate(block.startTime.toISOString()).replace(/-/g, ""); if (options.since != null && options.since !== "" && blockDateStr < options.since) return false; if (options.until != null && options.until !== "" && blockDateStr > options.until) return false; return true; }) : blocks; return sortByDate(dateFiltered, (block) => block.startTime, options?.order); } export { DEFAULT_SESSION_DURATION_HOURS, calculateBurnRate, calculateCostForEntry, clearGlobalPricingCache, createUniqueHash, dailyUsageSchema, extractProjectFromPath, filterRecentBlocks, formatDate, formatDateCompact, getClaudePaths, getEarliestTimestamp, getUsageLimitResetTime, globUsageFiles, identifySessionBlocks, loadDailyUsageData, loadMonthlyUsageData, loadSessionBlockData, loadSessionData, modelBreakdownSchema, monthlyUsageSchema, projectBlockUsage, sessionUsageSchema, sortFilesByTimestamp, usageDataSchema };