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
JavaScript
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 };