UNPKG

claude-highscore

Version:

Claude Code Transcript Time Analyzer - Analyze response times from claude.ai transcripts

1,350 lines (1,333 loc) 52.7 kB
#!/usr/bin/env node import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/cli.tsx import { Command } from "commander"; // src/config.ts import { promises as fs } from "fs"; import { homedir } from "os"; import { join } from "path"; var DEFAULT_CONFIG = { defaultOutputFormat: "table", defaultExportPath: "./cctime-export", theme: { primaryColor: "cyan", accentColor: "yellow", chartStyle: "line" }, analysis: { includeSystemMessages: false, groupBySession: true, timeFormat: "24h" }, export: { includeMetadata: true, prettyPrint: true } }; class ConfigManager { config; configPath; constructor(config, configPath) { this.config = config; this.configPath = configPath || join(homedir(), ".cctime", "config.json"); } get(key) { return this.config[key]; } set(key, value) { this.config[key] = value; } getConfig() { return { ...this.config }; } async save() { const dir = join(homedir(), ".cctime"); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), "utf-8"); } merge(partial) { this.config = deepMerge(this.config, partial); } } async function loadConfig(configPath) { const resolvedPath = configPath?.replace("~", homedir()) || join(homedir(), ".cctime", "config.json"); try { const configData = await fs.readFile(resolvedPath, "utf-8"); const userConfig = JSON.parse(configData); validateConfig(userConfig); return deepMerge(DEFAULT_CONFIG, userConfig); } catch (error) { if (error.code === "ENOENT") { return { ...DEFAULT_CONFIG }; } throw new Error(`Failed to load config: ${error.message}`); } } function validateConfig(config) { if (config.defaultOutputFormat && !["json", "csv", "table"].includes(config.defaultOutputFormat)) { throw new Error(`Invalid output format: ${config.defaultOutputFormat}`); } if (config.theme?.chartStyle && !["line", "bar"].includes(config.theme.chartStyle)) { throw new Error(`Invalid chart style: ${config.theme.chartStyle}`); } if (config.analysis?.timeFormat && !["12h", "24h"].includes(config.analysis.timeFormat)) { throw new Error(`Invalid time format: ${config.analysis.timeFormat}`); } } function deepMerge(target, source) { const result = { ...target }; for (const key in source) { if (source[key] !== undefined) { if (typeof source[key] === "object" && source[key] !== null && !Array.isArray(source[key])) { result[key] = deepMerge(result[key], source[key]); } else { result[key] = source[key]; } } } return result; } // package.json var version = "1.8.1"; // src/export/index.ts import * as fs2 from "fs/promises"; import * as path from "path"; function formatDate(dateStr) { return new Date(dateStr).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); } function formatDuration(ms) { if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 60000).toFixed(1)}m`; } function toSerializable(data) { const daily = Array.from(data.daily.entries()).map(([date, metrics]) => ({ date, totalResponseTimeMs: metrics.totalResponseTimeMs, averageResponseTimeMs: metrics.averageResponseTimeMs, responseCount: metrics.responseCount, sessionCount: metrics.sessions.size, sessions: Array.from(metrics.sessions), percentiles: metrics.percentiles })); const sessions = Array.from(data.sessions.entries()).map(([id, session]) => ({ sessionId: session.sessionId, projectPath: session.projectPath, totalResponses: session.totalResponses, totalResponseTimeMs: session.totalResponseTimeMs, averageResponseTimeMs: session.averageResponseTimeMs, firstMessage: session.firstMessage, lastMessage: session.lastMessage })); return { summary: data.summary, daily, sessions }; } async function exportJSON(data, outputPath, includeStats) { try { const exportData = toSerializable(data); if (!includeStats) { delete exportData.sessions; } const jsonContent = JSON.stringify(exportData, null, 2); await fs2.writeFile(outputPath, jsonContent, "utf-8"); return { success: true, filePath: outputPath }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error during JSON export" }; } } async function exportCSV(data, outputPath, includeStats) { try { const headers = [ "Date", "Total Response Time (ms)", "Average Response Time (ms)", "Response Count", "Session Count", "P50 (ms)", "P90 (ms)", "P99 (ms)" ]; const rows = [headers.join(",")]; const dailyData = Array.from(data.daily.entries()).sort(([a], [b]) => a.localeCompare(b)); for (const [date, metrics] of dailyData) { const row = [ date, metrics.totalResponseTimeMs.toString(), metrics.averageResponseTimeMs.toFixed(2), metrics.responseCount.toString(), metrics.sessions.size.toString(), metrics.percentiles.p50.toFixed(2), metrics.percentiles.p90.toFixed(2), metrics.percentiles.p99.toFixed(2) ]; rows.push(row.map((cell) => `"${cell}"`).join(",")); } if (includeStats) { rows.push(""); rows.push("Summary Statistics"); rows.push(`Total Response Time,${data.summary.totalResponseTimeMs}`); rows.push(`Total Responses,${data.summary.totalResponses}`); rows.push(`Average Response Time,${data.summary.averageResponseTimeMs.toFixed(2)}`); rows.push(`Unique Sessions,${data.summary.uniqueSessions}`); rows.push(`Date Range,"${data.summary.dateRange.from} to ${data.summary.dateRange.to}"`); } const csvContent = rows.join(` `); await fs2.writeFile(outputPath, csvContent, "utf-8"); return { success: true, filePath: outputPath }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error during CSV export" }; } } async function exportMarkdown(data, outputPath, includeStats) { try { const lines = ["# Claude Code Response Time Analysis"]; lines.push(""); lines.push("## Summary Statistics"); lines.push(""); lines.push(`- **Total Response Time**: ${formatDuration(data.summary.totalResponseTimeMs)}`); lines.push(`- **Total Responses**: ${data.summary.totalResponses}`); lines.push(`- **Average Response Time**: ${formatDuration(data.summary.averageResponseTimeMs)}`); lines.push(`- **Unique Sessions**: ${data.summary.uniqueSessions}`); lines.push(`- **Date Range**: ${formatDate(data.summary.dateRange.from)} to ${formatDate(data.summary.dateRange.to)}`); lines.push(""); lines.push("## Daily Metrics"); lines.push(""); lines.push("| Date | Total Time | Avg Time | Responses | Sessions | P50 | P90 | P99 |"); lines.push("|------|------------|----------|-----------|----------|-----|-----|-----|"); const dailyData = Array.from(data.daily.entries()).sort(([a], [b]) => a.localeCompare(b)); for (const [date, metrics] of dailyData) { lines.push(`| ${formatDate(date)} | ${formatDuration(metrics.totalResponseTimeMs)} | ${formatDuration(metrics.averageResponseTimeMs)} | ${metrics.responseCount} | ${metrics.sessions.size} | ${formatDuration(metrics.percentiles.p50)} | ${formatDuration(metrics.percentiles.p90)} | ${formatDuration(metrics.percentiles.p99)} |`); } if (includeStats && data.sessions.size > 0) { lines.push(""); lines.push("## Session Details"); lines.push(""); lines.push("| Session ID | Project | Responses | Total Time | Avg Time |"); lines.push("|------------|---------|-----------|------------|----------|"); const sessions = Array.from(data.sessions.values()).sort((a, b) => b.totalResponseTimeMs - a.totalResponseTimeMs).slice(0, 20); for (const session of sessions) { const sessionId = session.sessionId.substring(0, 8) + "..."; const projectName = session.projectPath.split("/").pop() || "Unknown"; lines.push(`| ${sessionId} | ${projectName} | ${session.totalResponses} | ${formatDuration(session.totalResponseTimeMs)} | ${formatDuration(session.averageResponseTimeMs)} |`); } if (data.sessions.size > 20) { lines.push(""); lines.push(`*Showing top 20 sessions out of ${data.sessions.size} total*`); } } const markdownContent = lines.join(` `); await fs2.writeFile(outputPath, markdownContent, "utf-8"); return { success: true, filePath: outputPath }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error during Markdown export" }; } } async function exportData(data, options) { if (!data || !data.daily || !data.sessions || !data.summary) { return { success: false, error: "Invalid or missing data to export" }; } if (options.toClipboard) { try { const serialized = toSerializable(data); let content; switch (options.format) { case "json": content = JSON.stringify(serialized, null, 2); break; case "csv": const headers = ["Date", "Total Time", "Avg Time", "Responses", "Sessions"]; const rows = [headers.join(",")]; Array.from(data.daily.entries()).forEach(([date, metrics]) => { rows.push([ date, metrics.totalResponseTimeMs, metrics.averageResponseTimeMs.toFixed(2), metrics.responseCount, metrics.sessions.size ].join(",")); }); content = rows.join(` `); break; case "markdown": content = `# Response Time Summary Total: ${formatDuration(data.summary.totalResponseTimeMs)} Average: ${formatDuration(data.summary.averageResponseTimeMs)} Responses: ${data.summary.totalResponses}`; break; default: return { success: false, error: "Unsupported format for clipboard" }; } return { success: true, error: "Clipboard export not implemented. Content prepared but not copied." }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Error preparing clipboard content" }; } } const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); const defaultFilename = `cctime-export-${timestamp}`; let outputPath = options.outputPath; if (!outputPath) { const extension = options.format === "markdown" ? "md" : options.format; outputPath = path.join(process.cwd(), `${defaultFilename}.${extension}`); } const dir = path.dirname(outputPath); try { await fs2.mkdir(dir, { recursive: true }); } catch (error) { return { success: false, error: `Failed to create directory: ${dir}` }; } const includeStats = options.includeStats ?? true; switch (options.format) { case "json": return exportJSON(data, outputPath, includeStats); case "csv": return exportCSV(data, outputPath, includeStats); case "markdown": return exportMarkdown(data, outputPath, includeStats); default: return { success: false, error: `Unsupported export format: ${options.format}` }; } } async function exportToFile(data, format, outputPath) { const result = await exportData(data, { format, outputPath }); if (!result.success) { throw new Error(result.error || "Export failed"); } return result.filePath || "Export completed"; } // src/finder.ts import { promises as fs3 } from "fs"; import path2 from "path"; import os from "os"; class SessionFinder { projectsDir; constructor(projectsDir) { this.projectsDir = projectsDir || path2.join(os.homedir(), ".claude", "projects"); if (global.DEBUG_MODE) { console.log(`\uD83D\uDD0D SessionFinder initialized with projects directory: ${this.projectsDir}`); } } pathToHyphenated(projectPath) { return projectPath.replace(/^\//, "").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").toLowerCase(); } isWithinDateRange(file, options) { if (!options.from && !options.to) return true; const fileDate = file.lastModified; if (options.from && fileDate < options.from) return false; if (options.to && fileDate > options.to) return false; return true; } parseSessionId(filename) { const match = filename.match(/^session_([a-zA-Z0-9]+)\.jsonl$/); return match ? match[1] : null; } async find(options = {}) { const sessionFiles = []; if (global.DEBUG_MODE) { console.log(`\uD83D\uDCC2 Scanning for transcript files in: ${this.projectsDir}`); } try { await fs3.access(this.projectsDir); if (global.DEBUG_MODE) { console.log("✅ Projects directory exists and is accessible"); } } catch (error) { if (global.DEBUG_MODE) { console.log("❌ Projects directory does not exist or is not accessible"); console.log(` Error: ${error.message}`); } return sessionFiles; } const projectDirs = await fs3.readdir(this.projectsDir); if (global.DEBUG_MODE) { console.log(`\uD83D\uDCC1 Found ${projectDirs.length} items in projects directory: [${projectDirs.join(", ")}]`); } for (const projectDir of projectDirs) { const projectDirPath = path2.join(this.projectsDir, projectDir); const stat = await fs3.stat(projectDirPath); if (!stat.isDirectory()) continue; const projectPath = "/" + projectDir.replace(/-/g, "/"); if (options.projectPath && !projectPath.includes(options.projectPath)) { continue; } const files = await fs3.readdir(projectDirPath); const jsonlFiles = files.filter((f) => f.endsWith(".jsonl")); if (global.DEBUG_MODE) { console.log(` \uD83D\uDCC1 Project: ${projectPath} (${projectDir})`); console.log(` All files found: [${files.join(", ")}]`); console.log(` Filtered to ${jsonlFiles.length} .jsonl files: [${jsonlFiles.join(", ")}]`); } for (const file of jsonlFiles) { const filePath = path2.join(projectDirPath, file); const fileStat = await fs3.stat(filePath); if (global.DEBUG_MODE) { console.log(` Processing file: ${file}`); console.log(` Size: ${fileStat.size} bytes`); console.log(` Modified: ${fileStat.mtime.toISOString()}`); } const sessionId = this.parseSessionId(file); if (!sessionId) { const fallbackId = file.replace(".jsonl", ""); if (global.DEBUG_MODE) { console.log(` No session ID match, using fallback: ${fallbackId}`); } const sessionFile2 = { sessionId: fallbackId, projectPath, filePath, lastModified: fileStat.mtime, size: fileStat.size }; if (this.isWithinDateRange(sessionFile2, options)) { sessionFiles.push(sessionFile2); if (global.DEBUG_MODE) { console.log(` ✅ Added to session files`); } } else { if (global.DEBUG_MODE) { console.log(` ❌ Filtered out by date range`); } } continue; } if (global.DEBUG_MODE) { console.log(` Parsed session ID: ${sessionId}`); } const sessionFile = { sessionId, projectPath, filePath, lastModified: fileStat.mtime, size: fileStat.size }; if (this.isWithinDateRange(sessionFile, options)) { sessionFiles.push(sessionFile); if (global.DEBUG_MODE) { console.log(` ✅ Added to session files`); } } else { if (global.DEBUG_MODE) { console.log(` ❌ Filtered out by date range`); } } } } if (global.DEBUG_MODE) { console.log(`\uD83C\uDFAF Total found: ${sessionFiles.length} session files`); if (sessionFiles.length > 0) { console.log(` Most recent: ${sessionFiles[0]?.sessionId} (${sessionFiles[0]?.lastModified.toISOString()})`); } } return sessionFiles.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); } async findById(sessionId) { const allSessions = await this.find(); return allSessions.find((s) => s.sessionId === sessionId) || null; } async getProjects() { const sessions = await this.find(); const projects = new Set(sessions.map((s) => s.projectPath)); return Array.from(projects).sort(); } } var finder_default = new SessionFinder; // src/aggregator.ts function formatDateToDay(dateString) { const date = new Date(dateString); return date.toISOString().split("T")[0]; } function calculatePercentile(sortedValues, percentile) { if (sortedValues.length === 0) return 0; const index = Math.ceil(percentile / 100 * sortedValues.length) - 1; return sortedValues[Math.max(0, index)]; } function groupByDay(responseTimes) { const groups = new Map; for (const responseTime of responseTimes) { const day = formatDateToDay(responseTime.userMessageTimestamp); if (!groups.has(day)) { groups.set(day, []); } groups.get(day).push(responseTime); } return groups; } function calculateDailyStats(date, responseTimes) { const responseTimesMs = responseTimes.map((rt) => rt.responseTimeMs); const sortedResponseTimes = [...responseTimesMs].sort((a, b) => a - b); const totalResponseTimeMs = responseTimesMs.reduce((sum, time) => sum + time, 0); const averageResponseTimeMs = totalResponseTimeMs / responseTimes.length; const sessions = new Set(responseTimes.map((rt) => rt.sessionId)); return { date, totalResponseTimeMs, averageResponseTimeMs, responseCount: responseTimes.length, sessions, percentiles: { p50: calculatePercentile(sortedResponseTimes, 50), p90: calculatePercentile(sortedResponseTimes, 90), p99: calculatePercentile(sortedResponseTimes, 99) } }; } function createSessionMetrics(responseTimes) { const sessionMap = new Map; for (const responseTime of responseTimes) { if (!sessionMap.has(responseTime.sessionId)) { sessionMap.set(responseTime.sessionId, []); } sessionMap.get(responseTime.sessionId).push(responseTime); } const sessionMetrics = new Map; for (const [sessionId, sessionResponses] of sessionMap) { const sortedResponses = [...sessionResponses].sort((a, b) => new Date(a.userMessageTimestamp).getTime() - new Date(b.userMessageTimestamp).getTime()); const totalResponseTimeMs = sessionResponses.reduce((sum, rt) => sum + rt.responseTimeMs, 0); sessionMetrics.set(sessionId, { sessionId, projectPath: sessionResponses[0].projectPath, totalResponses: sessionResponses.length, totalResponseTimeMs, averageResponseTimeMs: totalResponseTimeMs / sessionResponses.length, firstMessage: sortedResponses[0].userMessageTimestamp, lastMessage: sortedResponses[sortedResponses.length - 1].assistantMessageTimestamp }); } return sessionMetrics; } function generateSummaryStatistics(responseTimes, sessionMetrics) { if (responseTimes.length === 0) { return { totalResponseTimeMs: 0, totalResponses: 0, averageResponseTimeMs: 0, uniqueSessions: 0, dateRange: { from: "", to: "" } }; } const totalResponseTimeMs = responseTimes.reduce((sum, rt) => sum + rt.responseTimeMs, 0); const timestamps = responseTimes.map((rt) => new Date(rt.userMessageTimestamp).getTime()); const minTimestamp = Math.min(...timestamps); const maxTimestamp = Math.max(...timestamps); return { totalResponseTimeMs, totalResponses: responseTimes.length, averageResponseTimeMs: totalResponseTimeMs / responseTimes.length, uniqueSessions: sessionMetrics.size, dateRange: { from: new Date(minTimestamp).toISOString(), to: new Date(maxTimestamp).toISOString() } }; } function aggregateResponseTimes(responseTimes) { const groupedByDay = groupByDay(responseTimes); const dailyStats = new Map; for (const [date, dayResponses] of groupedByDay) { dailyStats.set(date, calculateDailyStats(date, dayResponses)); } const sessionMetrics = createSessionMetrics(responseTimes); const summary = generateSummaryStatistics(responseTimes, sessionMetrics); return { daily: dailyStats, sessions: sessionMetrics, summary }; } // src/parser/index.ts import { readFile } from "fs/promises"; function parseTranscriptLine(line) { if (!line.trim()) { return null; } try { const parsed = JSON.parse(line); if (!parsed.type || !parsed.timestamp) { return null; } const validTypes = ["user", "assistant", "system", "tool_result"]; if (!validTypes.includes(parsed.type)) { return null; } const entry = { type: parsed.type, timestamp: parsed.timestamp }; if (parsed.message !== undefined) { entry.message = parsed.message; } if (parsed.usage !== undefined) { entry.usage = { input_tokens: parsed.usage.input_tokens, output_tokens: parsed.usage.output_tokens, cache_read_input_tokens: parsed.usage.cache_read_input_tokens, cache_write_input_tokens: parsed.usage.cache_write_input_tokens }; } if (parsed.tool !== undefined) { entry.tool = parsed.tool; } if (parsed.result !== undefined) { entry.result = parsed.result; } return entry; } catch (error) { console.error(`Failed to parse line: ${error instanceof Error ? error.message : "Unknown error"}`); return null; } } async function parseTranscriptFile(filePath) { try { const fileContent = await readFile(filePath, "utf-8"); const lines = fileContent.split(` `); const entries = []; for (const line of lines) { const entry = parseTranscriptLine(line); if (entry) { entries.push(entry); } } return entries; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to read transcript file: ${error.message}`); } throw new Error("Failed to read transcript file: Unknown error"); } } // src/calculator.ts function calculateResponseTimes(entries, sessionId, projectPath) { const responseTimes = []; let lastUserMessage = null; for (const entry of entries) { if (entry.type === "user" && entry.message) { lastUserMessage = entry; } else if (entry.type === "assistant" && entry.message && lastUserMessage) { const userTime = new Date(lastUserMessage.timestamp).getTime(); const assistantTime = new Date(entry.timestamp).getTime(); const responseTimeMs = assistantTime - userTime; if (responseTimeMs > 0) { responseTimes.push({ userMessageTimestamp: lastUserMessage.timestamp, assistantMessageTimestamp: entry.timestamp, responseTimeMs, sessionId, projectPath }); } lastUserMessage = null; } else if (entry.type === "user" && !entry.message) { lastUserMessage = null; } } return responseTimes; } function filterOutliers(responseTimes, maxResponseTimeMs = 5 * 60 * 1000) { return responseTimes.filter((rt) => rt.responseTimeMs <= maxResponseTimeMs); } // src/parser/utils.ts import { basename, dirname as dirname2 } from "path"; function extractSessionId(filePath) { const dir = dirname2(filePath); const sessionId = basename(dir); return sessionId; } function extractProjectPath(filePath) { return dirname2(dirname2(filePath)); } async function processTranscript(filePath, options) { const sessionId = options?.sessionId || extractSessionId(filePath); const projectPath = options?.projectPath || extractProjectPath(filePath); const entries = await parseTranscriptFile(filePath); let responseTimes = calculateResponseTimes(entries, sessionId, projectPath); if (options?.filterOutliers !== false) { responseTimes = filterOutliers(responseTimes, options?.maxResponseTimeMs); } return { entries, responseTimes, sessionId, projectPath }; } // src/assistant-sequence-analyzer.ts import { readFile as readFile2 } from "fs/promises"; function extractMessageText(message) { if (typeof message === "string") { return message; } if (message && typeof message === "object") { if (message.content && typeof message.content === "string") { return message.content; } if (message.content && Array.isArray(message.content)) { const textContent = message.content.filter((c) => c.type === "text" && !c.tool_use_id).map((c) => c.text || "").join(" ").trim(); if (textContent) { return textContent; } const toolResult = message.content.find((c) => c.type === "tool_result"); if (toolResult) { return "[Tool result response]"; } } if (message.text) { return message.text; } } return "[No text content]"; } async function analyzeAssistantSequences(filePath, sessionId, projectPath) { const fileContent = await readFile2(filePath, "utf-8"); const lines = fileContent.split(` `).filter((line) => line.trim()); const rawEntries = []; for (const line of lines) { try { const rawEntry = JSON.parse(line); rawEntries.push(rawEntry); } catch (error) { } } if (global.DEBUG_MODE) { console.log(` DEBUG: Analyzing ${filePath}`); console.log(`DEBUG: Found ${rawEntries.length} total entries`); console.log(`DEBUG: User entries: ${rawEntries.filter((e) => e.type === "user").length}`); console.log(`DEBUG: Assistant entries: ${rawEntries.filter((e) => e.type === "assistant").length}`); } const sequences = []; let i = 0; while (i < rawEntries.length) { const entry = rawEntries[i]; if (entry.type === "user" && entry.message) { const userEntry = entry; if (userEntry.isCompactSummary === true) { i++; continue; } const messageObj = userEntry.message; if (typeof messageObj === "string") { if (messageObj.includes("<command-") || messageObj.includes("<system-reminder>") || messageObj.includes("<user-prompt-submit-hook>")) { i++; continue; } } if (messageObj && messageObj.role === "user" && messageObj.content) { if (Array.isArray(messageObj.content)) { const hasToolResult = messageObj.content.some((c) => c.tool_use_id || c.type === "tool_result"); if (hasToolResult) { i++; continue; } } } if (typeof messageObj === "string") { } else if (!messageObj.role && !messageObj.content) { i++; continue; } const userMessage = extractMessageText(userEntry.message); const userTimestamp = userEntry.timestamp; if (global.DEBUG_MODE) { console.log(` DEBUG: Found potential user message at index ${i}:`); console.log(` Message: "${userMessage}"`); console.log(` Timestamp: ${userTimestamp}`); } if (!userMessage || userMessage === "[No text content]") { if (global.DEBUG_MODE) { console.log(` Skipping: Empty message`); } i++; continue; } let firstAssistantIndex = -1; let lastAssistantIndex = -1; let messageCount = 0; let toolUseCount = 0; let previousAssistantTime = null; const MAX_GAP_MS = 10 * 60 * 1000; const MAX_SEQUENCE_DURATION_MS = 12 * 60 * 60 * 1000; const sequenceStartTime = new Date(userTimestamp); for (let j = i + 1;j < rawEntries.length; j++) { const entry2 = rawEntries[j]; if (entry2.type === "user") { if (entry2.isCompactSummary === true) { if (global.DEBUG_MODE) { console.log(` Compact summary found, ending sequence`); } break; } const msgObj = entry2.message; if (msgObj && typeof msgObj === "string") { if (msgObj.includes("<command-") || msgObj.includes("<system-reminder>") || msgObj.includes("<user-prompt-submit-hook>")) { continue; } break; } if (msgObj && msgObj.role === "user" && msgObj.content) { if (typeof msgObj.content === "string") { break; } if (Array.isArray(msgObj.content)) { const hasToolResult = msgObj.content.some((c) => c.tool_use_id || c.type === "tool_result"); if (!hasToolResult) { break; } } } } if (entry2.type === "assistant") { const currentTime = new Date(entry2.timestamp); const totalDuration = currentTime.getTime() - sequenceStartTime.getTime(); if (totalDuration > MAX_SEQUENCE_DURATION_MS) { if (global.DEBUG_MODE) { console.log(` Total duration of ${(totalDuration / 1000 / 60 / 60).toFixed(1)} hours exceeds 12 hours, ending sequence`); } break; } if (previousAssistantTime) { const gapMs = currentTime.getTime() - previousAssistantTime.getTime(); if (gapMs > MAX_GAP_MS) { if (global.DEBUG_MODE) { console.log(` Gap of ${gapMs}ms (${(gapMs / 1000 / 60).toFixed(1)} minutes) exceeds 10 minutes, ending sequence`); } break; } } if (firstAssistantIndex === -1) { firstAssistantIndex = j; } lastAssistantIndex = j; previousAssistantTime = currentTime; if (entry2.message) { messageCount++; const msg = entry2.message; if (msg && msg.content && Array.isArray(msg.content)) { const toolUses = msg.content.filter((c) => c.type === "tool_use").length; toolUseCount += toolUses; } } } if (entry2.type === "user" && entry2.message) { const msgObj = entry2.message; if (msgObj && msgObj.role === "user" && msgObj.content && Array.isArray(msgObj.content)) { const hasToolResult = msgObj.content.some((c) => c.tool_use_id || c.type === "tool_result"); if (hasToolResult) { previousAssistantTime = new Date(entry2.timestamp); } } } } if (firstAssistantIndex !== -1 && lastAssistantIndex !== -1) { const extendsToEnd = lastAssistantIndex === rawEntries.length - 1 || lastAssistantIndex === rawEntries.length - 2 && rawEntries[rawEntries.length - 1].type === "user" && rawEntries[rawEntries.length - 1].message?.content?.some?.((c) => c.tool_use_id || c.type === "tool_result"); const userTime = new Date(userTimestamp); const firstTimestamp = new Date(rawEntries[firstAssistantIndex].timestamp); const lastTimestamp = new Date(rawEntries[lastAssistantIndex].timestamp); const responseTimeMs = firstTimestamp.getTime() - userTime.getTime(); const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); if (extendsToEnd && durationMs > 10 * 60 * 1000) { if (global.DEBUG_MODE) { console.log(` Skipping sequence that extends to end of file with duration ${(durationMs / 1000 / 60).toFixed(1)} minutes`); } i = lastAssistantIndex + 1; continue; } if (global.DEBUG_MODE) { console.log(` Found sequence:`); console.log(` First assistant at index: ${firstAssistantIndex}`); console.log(` Last assistant at index: ${lastAssistantIndex}`); console.log(` Duration: ${durationMs}ms`); } sequences.push({ userMessage: userMessage.substring(0, 100) + (userMessage.length > 100 ? "..." : ""), userTimestamp, firstAssistantTimestamp: rawEntries[firstAssistantIndex].timestamp, lastAssistantTimestamp: rawEntries[lastAssistantIndex].timestamp, responseTimeMs, durationMs, messageCount, toolUseCount }); } else { if (global.DEBUG_MODE) { console.log(` No assistant messages found after this user message`); } } i = lastAssistantIndex !== -1 ? lastAssistantIndex + 1 : i + 1; } else { i++; } } const longestSequence = sequences.length > 0 ? sequences.reduce((longest, current) => current.durationMs > longest.durationMs ? current : longest) : null; const timeDistribution = { "0-10s": 0, "10-30s": 0, "30-60s": 0, "1-5m": 0, "5m+": 0 }; sequences.forEach((seq) => { const seconds = seq.durationMs / 1000; if (seconds <= 10) { timeDistribution["0-10s"]++; } else if (seconds <= 30) { timeDistribution["10-30s"]++; } else if (seconds <= 60) { timeDistribution["30-60s"]++; } else if (seconds <= 300) { timeDistribution["1-5m"]++; } else { timeDistribution["5m+"]++; } }); return { sessionId, projectPath, sequences, longestSequence, timeDistribution }; } function formatDuration2(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes}m`; } else if (minutes > 0) { const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } else { return `${seconds}s`; } } // src/display-sequences.ts import chalk from "chalk"; function displaySequenceAnalysis(analyses) { console.clear(); console.log(chalk.cyan.bold(` \uD83E\uDD16 Claude Assistant Response Sequence Analysis `)); const sortedAnalyses = [...analyses].sort((a, b) => { const aDuration = a.longestSequence?.durationMs || 0; const bDuration = b.longestSequence?.durationMs || 0; return bDuration - aDuration; }); const allSequences = analyses.flatMap((a) => a.sequences); const totalSequences = allSequences.length; const longestOverall = allSequences.reduce((longest, current) => !longest || current.durationMs > longest.durationMs ? current : longest, null); console.log(chalk.yellow.bold("Overall Statistics:")); console.log(` Total Response Sequences: ${chalk.white.bold(totalSequences)}`); console.log(` Sessions Analyzed: ${chalk.white.bold(analyses.length)}`); if (longestOverall) { console.log(` Longest Sequence Overall: ${chalk.white.bold(formatDuration2(longestOverall.durationMs))}`); } console.log(""); console.log(chalk.yellow.bold("Time Distribution (All Sessions):")); const totalDistribution = { "0-10s": 0, "10-30s": 0, "30-60s": 0, "1-5m": 0, "5m+": 0 }; analyses.forEach((analysis) => { Object.entries(analysis.timeDistribution).forEach(([range, count]) => { totalDistribution[range] += count; }); }); console.log(chalk.gray("─".repeat(40))); Object.entries(totalDistribution).forEach(([range, count]) => { const percentage = totalSequences > 0 ? (count / totalSequences * 100).toFixed(1) : "0"; const bar = "█".repeat(Math.floor(Number(percentage) / 2)); console.log(` ${chalk.cyan(range.padEnd(8))} ${chalk.white(count.toString().padStart(4))} ${chalk.gray(`(${percentage}%)`)} ${chalk.blue(bar)}`); }); console.log(chalk.gray("─".repeat(40))); console.log(` ` + chalk.yellow.bold("Session Analysis (Sorted by Longest Sequence):")); console.log(chalk.gray("═".repeat(80))); sortedAnalyses.forEach((analysis, index) => { const sessionName = analysis.sessionId.substring(0, 8) + "..."; const projectName = analysis.projectPath.split("/").pop() || "Unknown"; console.log(` ${chalk.cyan.bold(`${index + 1}. Session:`)} ${chalk.white(sessionName)} ${chalk.gray(`(${projectName})`)}`); console.log(` ${chalk.dim("Total Sequences:")} ${analysis.sequences.length}`); if (analysis.longestSequence) { const seq = analysis.longestSequence; console.log(` ${chalk.yellow("Longest Sequence:")}`); console.log(` ${chalk.dim("Response Time:")} ${chalk.white.bold(formatDuration2(seq.responseTimeMs))} ${chalk.dim("(user → first assistant)")}`); console.log(` ${chalk.dim("Processing Time:")} ${chalk.white.bold(formatDuration2(seq.durationMs))} ${chalk.dim("(first → last assistant)")}`); const displayMessage = seq.userMessage || "[No message text]"; const truncatedMessage = displayMessage.length > 80 ? displayMessage.substring(0, 77) + "..." : displayMessage; console.log(` ${chalk.dim("User Query:")} "${chalk.italic(truncatedMessage)}"`); console.log(` ${chalk.dim("Messages:")} ${seq.messageCount} ${chalk.dim("| Tool Uses:")} ${seq.toolUseCount}`); console.log(` ${chalk.dim("Started:")} ${new Date(seq.firstAssistantTimestamp).toLocaleTimeString()}`); console.log(` ${chalk.dim("Ended:")} ${new Date(seq.lastAssistantTimestamp).toLocaleTimeString()}`); } console.log(` ${chalk.dim("Time Distribution:")}`); Object.entries(analysis.timeDistribution).forEach(([range, count]) => { if (count > 0) { console.log(` ${range.padEnd(8)} ${chalk.white(count.toString().padStart(3))} sequences`); } }); if (analysis.sequences.length > 1) { console.log(` ${chalk.dim("Top 3 Longest Sequences:")}`); const topSequences = [...analysis.sequences].sort((a, b) => b.durationMs - a.durationMs).slice(0, 3); topSequences.forEach((seq, i) => { const msg = seq.userMessage || "[No message]"; const truncMsg = msg.length > 50 ? msg.substring(0, 47) + "..." : msg; console.log(` ${i + 1}. ${chalk.white(formatDuration2(seq.durationMs))} - "${truncMsg}"`); }); } console.log(chalk.gray("─".repeat(80))); }); console.log(` ` + chalk.dim(`Press Ctrl+C to exit `)); } // src/find-longest-sequence.ts import chalk2 from "chalk"; import { promises as fs4 } from "fs"; import path3 from "path"; import figlet from "figlet"; // src/streak-calculator.ts function calculateStreaks(sessions) { if (sessions.length === 0) { return { currentStreak: 0, longestStreak: 0, totalDaysUsed: 0 }; } const daysUsed = new Set; const sessionDates = []; for (const session of sessions) { const dateKey = session.lastModified.toISOString().split("T")[0]; daysUsed.add(dateKey); sessionDates.push(session.lastModified); } const sortedDays = Array.from(daysUsed).sort(); const sortedDates = sortedDays.map((d) => new Date(d)); let currentStreak = 0; let longestStreak = 0; let longestStreakStart; let longestStreakEnd; let tempStreakStart; let tempStreak = 0; const today = new Date; today.setHours(0, 0, 0, 0); const todayKey = today.toISOString().split("T")[0]; const hasToday = daysUsed.has(todayKey); for (let i = 0;i < sortedDates.length; i++) { const currentDate = sortedDates[i]; if (i === 0) { tempStreak = 1; tempStreakStart = currentDate; } else { const prevDate = sortedDates[i - 1]; const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)); if (daysDiff === 1) { tempStreak++; } else { if (tempStreak > longestStreak) { longestStreak = tempStreak; longestStreakStart = tempStreakStart; longestStreakEnd = prevDate; } tempStreak = 1; tempStreakStart = currentDate; } } } if (tempStreak > longestStreak) { longestStreak = tempStreak; longestStreakStart = tempStreakStart; longestStreakEnd = sortedDates[sortedDates.length - 1]; } if (hasToday || sortedDates.length > 0 && isYesterday(sortedDates[sortedDates.length - 1])) { currentStreak = 1; let checkDate = new Date(sortedDates[sortedDates.length - 1]); for (let i = sortedDates.length - 2;i >= 0; i--) { const daysDiff = Math.floor((checkDate.getTime() - sortedDates[i].getTime()) / (1000 * 60 * 60 * 24)); if (daysDiff === 1) { currentStreak++; checkDate = sortedDates[i]; } else { break; } } } return { currentStreak, longestStreak, longestStreakStart, longestStreakEnd, totalDaysUsed: daysUsed.size }; } function isYesterday(date) { const yesterday = new Date; yesterday.setDate(yesterday.getDate() - 1); yesterday.setHours(0, 0, 0, 0); const checkDate = new Date(date); checkDate.setHours(0, 0, 0, 0); return checkDate.getTime() === yesterday.getTime(); } function formatStreakMessage(streakInfo) { if (streakInfo.longestStreak === 0) { return "No usage streak found"; } const messages = []; const longestStreakText = streakInfo.longestStreak === 1 ? "1 day" : `${streakInfo.longestStreak} days`; if (streakInfo.longestStreakStart && streakInfo.longestStreakEnd) { const start = streakInfo.longestStreakStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }); const end = streakInfo.longestStreakEnd.toLocaleDateString("en-US", { month: "short", day: "numeric" }); if (start === end) { messages.push(`\uD83D\uDD25 Longest streak: ${longestStreakText} (${start})`); } else { messages.push(`\uD83D\uDD25 Longest streak: ${longestStreakText} (${start} - ${end})`); } } else { messages.push(`\uD83D\uDD25 Longest streak: ${longestStreakText}`); } if (streakInfo.currentStreak > 0 && streakInfo.currentStreak !== streakInfo.longestStreak) { const currentStreakText = streakInfo.currentStreak === 1 ? "1 day" : `${streakInfo.currentStreak} days`; messages.push(`\uD83D\uDCC8 Current streak: ${currentStreakText}`); } return messages.join(` `); } // src/find-longest-sequence.ts async function findLongestSequence(options = {}) { process.stdout.write("Loading..."); let files = []; let totalSequences = 0; if (options.file) { const stat = await fs4.stat(options.file); if (!stat.isFile() || !options.file.endsWith(".jsonl")) { process.stdout.write("\r\x1B[K"); console.error("Invalid file: must be a .jsonl file"); return; } const sessionId = path3.basename(options.file, ".jsonl"); files = [{ sessionId, projectPath: path3.dirname(options.file), filePath: options.file, lastModified: stat.mtime, size: stat.size }]; } else if (options.dir) { const dirStat = await fs4.stat(options.dir); if (!dirStat.isDirectory()) { process.stdout.write("\r\x1B[K"); console.error("Invalid directory"); return; } const dirFiles = await fs4.readdir(options.dir); const jsonlFiles = dirFiles.filter((f) => f.endsWith(".jsonl")); for (const file of jsonlFiles) { const filePath = path3.join(options.dir, file); const fileStat = await fs4.stat(filePath); const sessionId = file.replace(".jsonl", ""); files.push({ sessionId, projectPath: options.dir, filePath, lastModified: fileStat.mtime, size: fileStat.size }); } if (files.length === 0) { process.stdout.write("\r\x1B[K"); console.error("No .jsonl files found in directory"); return; } } else { const finder = new SessionFinder; files = await finder.find({}); if (files.length === 0) { process.stdout.write("\r\x1B[K"); console.error("No transcript files found"); return; } } let longestSequence = null; let longestSessionId = ""; let longestProjectPath = ""; for (const file of files) { try { const analysis = await analyzeAssistantSequences(file.filePath, file.sessionId, file.projectPath); totalSequences += analysis.sequences.length; for (const sequence of analysis.sequences) { if (!longestSequence || sequence.durationMs > longestSequence.durationMs) { longestSequence = sequence; longestSessionId = file.sessionId; longestProjectPath = file.projectPath; } } } catch (error) { } } if (!longestSequence) { process.stdout.write("\r\x1B[K"); console.log("No sequences found in any session"); return; } console.clear(); console.log(chalk2.cyan.bold(`\uD83C\uDFC6 Claude agentic highscore: ${chalk2.yellow.bold(formatDuration2(longestSequence.durationMs))} (best from ${totalSequences} total sessions)`)); const streakInfo = calculateStreaks(files); console.log(chalk2.magenta.bold(formatStreakMessage(streakInfo))); console.log(chalk2.gray(` Try it yourself with: npx claude-highscore@latest`)); console.log(chalk2.gray(` Brought to you by`)); const everyText = figlet.textSync("EVERY", { font: "Big", horizontalLayout: "fitted", verticalLayout: "default" }); console.log(` ` + everyText); console.log(chalk2.gray("@every")); } // src/cli.tsx import * as fs5 from "fs/promises"; import path4 from "path"; var program = new Command; program.name("cctime").description("Claude Code Transcript Time Analyzer - Analyze response times from claude.ai transcripts").version(version).option("-f, --file <path>", "Path to claude transcript file").option("-d, --dir <path>", "Directory containing multiple transcript files").option("-o, --output <format>", "Output format (json, csv, table)", "table").option("-e, --export <path>", "Export results to file without interactive mode").option("--export-format <format>", "Export format for non-interactive mode (json, csv, markdown)", "json").option("--config <path>", "Path to config file", "~/.cctime/config.json").option("-w, --watch", "Watch for changes in session files").option("--analyze-sequences", "Analyze assistant response sequences").option("--longest", "Find the single longest assistant processing time").option("--debug", "Enable debug logging for troubleshooting").parse(process.argv); var options = program.opts(); async function main() { try { global.DEBUG_MODE = options.debug || false; if (options.debug) { console.log("\uD83D\uDC1B Debug mode enabled"); console.log("\uD83C\uDFE0 Home directory:", __require("os").homedir()); console.log("\uD83D\uDCC1 Expected Claude projects directory:", __require("path").join(__require("os").homedir(), ".claude", "projects")); } const config = await loadConfig(options.config); const configManager = new ConfigManager(config); if (options.export) { console.log("Loading transcript data..."); try { let files = []; if (options.file) { const stat2 = await fs5.stat(options.file); if (!stat2.isFile() || !options.file.endsWith(".jsonl")) { console.error("Invalid file: must be a .jsonl file"); process.exit(1); } const sessionId = path4.basename(options.file, ".jsonl"); files = [{ sessionId, projectPath: path4.dirname(options.file), filePath: options.file, lastModified: stat2.mtime, size: stat2.size }]; } else if (options.dir) { const dirStat = await fs5.stat(options.dir); if (!dirStat.isDirectory()) { console.error("Invalid directory"); process.exit(1); } const dirFiles = await fs5.readdir(options.dir); const jsonlFiles = dirFiles.filter((f) => f.endsWith(".jsonl")); for (const file of jsonlFiles) { const filePath = path4.join(options.dir, file); const fileStat = await fs5.stat(filePath); const sessionId = file.replace(".jsonl", ""); files.push({ sessionId, projectPath: options.dir, filePath, lastModified: fileStat.mtime, size: fileStat.size }); } } else { const finder = new SessionFinder; files = await finder.find({}); } if (files.length === 0) { console.error("No transcript files found"); process.exit(1); } console.log(`Found ${files.length} transcript files`); const allResponseTimes = []; for (const file of files) { try { const { responseTimes } = await processTranscript(file.filePath, { sessionId: file.sessionId, projectPath: file.projectPath }); allResponseTimes.push(...responseTimes); } catch (error) { console.error(`Error processing ${file.filePath}:`, error.message); } } if (allResponseTimes.length === 0) { console.error("No response times found"); process.exit(1); } const processedData = aggregateResponseTimes(allResponseTimes); const format = options.exportFormat; const resultPath = await exportToFile(processedData, format, options.export); console.log(`Successfully exported to: ${resultPath}`); process.exit(0); } catch (error) { console.error("Export failed:", error.message); process.exit(1); } } else if (options.longest) { try { await findLongestSequence({ file: options.file, dir: options.dir }); } catch (error) { console.error("Error:", error.message); process.exit(1); } } else if (