mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
283 lines
8.1 KiB
TypeScript
283 lines
8.1 KiB
TypeScript
/**
|
|
* JSONL Parser for Claude Code Usage Analytics
|
|
*
|
|
* High-performance streaming parser for ~/.claude/projects/ JSONL files.
|
|
* Replaces better-ccusage dependency with optimized custom implementation.
|
|
*
|
|
* Key features:
|
|
* - Streaming line-by-line parsing (memory efficient)
|
|
* - Only parses "assistant" entries with usage data
|
|
* - Parallel file processing with configurable concurrency
|
|
* - Graceful error handling for malformed entries
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as readline from 'readline';
|
|
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
|
|
|
// ============================================================================
|
|
// TYPE DEFINITIONS
|
|
// ============================================================================
|
|
|
|
/** Raw usage data from JSONL entry */
|
|
export interface RawUsageEntry {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cacheCreationTokens: number;
|
|
cacheReadTokens: number;
|
|
model: string;
|
|
sessionId: string;
|
|
timestamp: string;
|
|
projectPath: string;
|
|
version?: string;
|
|
target?: string;
|
|
}
|
|
|
|
/** Internal structure matching JSONL assistant entries */
|
|
interface JsonlAssistantEntry {
|
|
type: 'assistant';
|
|
sessionId: string;
|
|
timestamp: string;
|
|
version?: string;
|
|
cwd?: string;
|
|
target?: string;
|
|
message: {
|
|
model: string;
|
|
usage: {
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
cache_creation_input_tokens?: number;
|
|
cache_read_input_tokens?: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
/** Parser options */
|
|
export interface ParserOptions {
|
|
/** Max files to parse concurrently (default: 10) */
|
|
concurrency?: number;
|
|
/** Skip files older than this date */
|
|
minDate?: Date;
|
|
/** Custom projects directory (default: ~/.claude/projects) */
|
|
projectsDir?: string;
|
|
}
|
|
|
|
const DEFAULT_SCAN_CONCURRENCY = 10;
|
|
const MAX_SCAN_CONCURRENCY = 64;
|
|
|
|
// ============================================================================
|
|
// CORE PARSING FUNCTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse a single JSONL line into RawUsageEntry if valid
|
|
* Returns null for non-assistant entries or entries without usage data
|
|
*/
|
|
function toNonNegativeNumber(value: unknown): number {
|
|
const numeric = typeof value === 'number' ? value : Number(value);
|
|
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
return 0;
|
|
}
|
|
return numeric;
|
|
}
|
|
|
|
export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null {
|
|
// Strip UTF-8 BOM if present (can occur on first line of some files)
|
|
const cleanLine = line.replace(/^\uFEFF/, '').trim();
|
|
if (!cleanLine) return null;
|
|
|
|
try {
|
|
const entry = JSON.parse(cleanLine);
|
|
|
|
// Only process assistant entries with usage data
|
|
if (entry.type !== 'assistant') return null;
|
|
if (!entry.message?.usage) return null;
|
|
if (!entry.message?.model) return null;
|
|
|
|
const usage = entry.message.usage;
|
|
const assistant = entry as JsonlAssistantEntry;
|
|
|
|
return {
|
|
inputTokens: toNonNegativeNumber(usage.input_tokens),
|
|
outputTokens: toNonNegativeNumber(usage.output_tokens),
|
|
cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens),
|
|
cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens),
|
|
model: assistant.message.model,
|
|
sessionId: assistant.sessionId || '',
|
|
timestamp: assistant.timestamp || new Date().toISOString(),
|
|
projectPath,
|
|
version: assistant.version,
|
|
target:
|
|
typeof (entry as { target?: unknown }).target === 'string'
|
|
? ((entry as { target?: string }).target as string)
|
|
: undefined,
|
|
};
|
|
} catch {
|
|
// Malformed JSON - skip silently
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stream-parse a single JSONL file
|
|
* Yields RawUsageEntry for each valid assistant entry
|
|
*/
|
|
export async function parseJsonlFile(
|
|
filePath: string,
|
|
projectPath: string
|
|
): Promise<RawUsageEntry[]> {
|
|
const entries: RawUsageEntry[] = [];
|
|
|
|
let fileStream: fs.ReadStream | null = null;
|
|
let rl: readline.Interface | null = null;
|
|
try {
|
|
fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
const entry = parseUsageEntry(line, projectPath);
|
|
if (entry) {
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
} catch {
|
|
// File read/stream error - return whatever was parsed so far
|
|
} finally {
|
|
rl?.close();
|
|
fileStream?.destroy();
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function decodeProjectPath(projectDir: string): string {
|
|
const raw = path.basename(projectDir).replace(/-/g, '/');
|
|
const safeSegments = raw
|
|
.split('/')
|
|
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
|
|
|
return `/${safeSegments.join('/')}`;
|
|
}
|
|
|
|
/**
|
|
* Parse all JSONL files in a single project directory
|
|
*/
|
|
export async function parseProjectDirectory(projectDir: string): Promise<RawUsageEntry[]> {
|
|
const entries: RawUsageEntry[] = [];
|
|
|
|
// Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project")
|
|
const projectPath = decodeProjectPath(projectDir);
|
|
|
|
try {
|
|
const files = await fs.promises.readdir(projectDir);
|
|
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
|
|
|
|
// Parse files sequentially within a project to avoid too many open handles
|
|
for (const file of jsonlFiles) {
|
|
const filePath = path.join(projectDir, file);
|
|
const fileEntries = await parseJsonlFile(filePath, projectPath);
|
|
entries.push(...fileEntries);
|
|
}
|
|
} catch {
|
|
// Directory access error - skip silently
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
// ============================================================================
|
|
// DIRECTORY SCANNING
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get default Claude projects directory
|
|
*/
|
|
export function getDefaultProjectsDir(): string {
|
|
return path.join(getClaudeConfigDir(), 'projects');
|
|
}
|
|
|
|
/**
|
|
* Find all project directories under ~/.claude/projects/
|
|
*/
|
|
export function findProjectDirectories(projectsDir?: string): string[] {
|
|
const dir = projectsDir || getDefaultProjectsDir();
|
|
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join(dir, entry.name));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan all projects and parse all JSONL files
|
|
* Main entry point for usage data extraction
|
|
*
|
|
* @param options - Parser configuration
|
|
* @returns All parsed usage entries from all projects
|
|
*/
|
|
export async function scanProjectsDirectory(options: ParserOptions = {}): Promise<RawUsageEntry[]> {
|
|
const requestedConcurrency = options.concurrency;
|
|
const concurrency =
|
|
typeof requestedConcurrency === 'number' &&
|
|
Number.isInteger(requestedConcurrency) &&
|
|
requestedConcurrency > 0
|
|
? Math.min(requestedConcurrency, MAX_SCAN_CONCURRENCY)
|
|
: DEFAULT_SCAN_CONCURRENCY;
|
|
const { projectsDir } = options;
|
|
const allEntries: RawUsageEntry[] = [];
|
|
|
|
const projectDirs = findProjectDirectories(projectsDir);
|
|
|
|
if (projectDirs.length === 0) {
|
|
return allEntries;
|
|
}
|
|
|
|
// Process projects in batches for controlled concurrency
|
|
for (let i = 0; i < projectDirs.length; i += concurrency) {
|
|
const batch = projectDirs.slice(i, i + concurrency);
|
|
const batchResults = await Promise.all(batch.map((dir) => parseProjectDirectory(dir)));
|
|
|
|
for (const entries of batchResults) {
|
|
allEntries.push(...entries);
|
|
}
|
|
}
|
|
|
|
// Filter by date if specified
|
|
if (options.minDate) {
|
|
const minTime = options.minDate.getTime();
|
|
return allEntries.filter((entry) => {
|
|
const entryTime = Date.parse(entry.timestamp);
|
|
return Number.isFinite(entryTime) && entryTime >= minTime;
|
|
});
|
|
}
|
|
|
|
return allEntries;
|
|
}
|
|
|
|
/**
|
|
* Get count of JSONL files across all projects (for progress reporting)
|
|
*/
|
|
export function countJsonlFiles(projectsDir?: string): number {
|
|
const projectDirs = findProjectDirectories(projectsDir);
|
|
let count = 0;
|
|
|
|
for (const dir of projectDirs) {
|
|
try {
|
|
const files = fs.readdirSync(dir);
|
|
count += files.filter((f) => f.endsWith('.jsonl')).length;
|
|
} catch {
|
|
// Skip inaccessible directories
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|