Tabs -> Spaces

This commit is contained in:
Matthew Breedlove
2025-10-03 15:37:41 -04:00
parent 2053a5577d
commit bb73dd63c0
+300 -300
View File
@@ -4,9 +4,9 @@ import { globSync } from 'tinyglobby';
import { promisify } from 'util';
import type {
BlockMetrics,
TokenMetrics,
TranscriptLine
BlockMetrics,
TokenMetrics,
TranscriptLine
} from '../types';
// Ensure fs.promises compatibility for older Node versions
@@ -15,185 +15,185 @@ const readFileSync = fs.readFileSync;
const statSync = fs.statSync;
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
try {
if (!fs.existsSync(transcriptPath)) {
return null;
}
try {
if (!fs.existsSync(transcriptPath)) {
return null;
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n').filter((line: string) => line.trim());
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n').filter((line: string) => line.trim());
if (lines.length === 0) {
return null;
}
if (lines.length === 0) {
return null;
}
let firstTimestamp: Date | null = null;
let lastTimestamp: Date | null = null;
let firstTimestamp: Date | null = null;
let lastTimestamp: Date | null = null;
// Find first valid timestamp
for (const line of lines) {
try {
const data = JSON.parse(line) as { timestamp?: string };
if (data.timestamp) {
firstTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
// Find first valid timestamp
for (const line of lines) {
try {
const data = JSON.parse(line) as { timestamp?: string };
if (data.timestamp) {
firstTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
// Find last valid timestamp (iterate backwards)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const data = JSON.parse(lines[i] ?? '') as { timestamp?: string };
if (data.timestamp) {
lastTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
// Find last valid timestamp (iterate backwards)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const data = JSON.parse(lines[i] ?? '') as { timestamp?: string };
if (data.timestamp) {
lastTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
if (!firstTimestamp || !lastTimestamp) {
return null;
}
if (!firstTimestamp || !lastTimestamp) {
return null;
}
// Calculate duration in milliseconds
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
// Calculate duration in milliseconds
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
// Convert to minutes
const totalMinutes = Math.floor(durationMs / (1000 * 60));
// Convert to minutes
const totalMinutes = Math.floor(durationMs / (1000 * 60));
if (totalMinutes < 1) {
return '<1m';
}
if (totalMinutes < 1) {
return '<1m';
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}hr`;
} else {
return `${hours}hr ${minutes}m`;
}
} catch {
return null;
}
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}hr`;
} else {
return `${hours}hr ${minutes}m`;
}
} catch {
return null;
}
}
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n');
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let contextLength = 0;
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let contextLength = 0;
// Parse each line and sum up token usage for totals
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
// Parse each line and sum up token usage for totals
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
for (const line of lines) {
try {
const data = JSON.parse(line) as TranscriptLine;
if (data.message?.usage) {
inputTokens += data.message.usage.input_tokens || 0;
outputTokens += data.message.usage.output_tokens || 0;
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
for (const line of lines) {
try {
const data = JSON.parse(line) as TranscriptLine;
if (data.message?.usage) {
inputTokens += data.message.usage.input_tokens || 0;
outputTokens += data.message.usage.output_tokens || 0;
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
if (data.isSidechain !== true && data.timestamp) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}
} catch {
// Skip invalid JSON lines
}
}
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
if (data.isSidechain !== true && data.timestamp) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}
} catch {
// Skip invalid JSON lines
}
}
// Calculate context length from the most recent main chain message
if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
contextLength = (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens ?? 0)
+ (usage.cache_creation_input_tokens ?? 0);
}
// Calculate context length from the most recent main chain message
if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
contextLength = (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens ?? 0)
+ (usage.cache_creation_input_tokens ?? 0);
}
const totalTokens = inputTokens + outputTokens + cachedTokens;
const totalTokens = inputTokens + outputTokens + cachedTokens;
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
}
/**
* Gets block metrics for the current 5-hour block from JSONL files
*/
export function getBlockMetrics(transcriptPath: string | undefined): BlockMetrics | null {
if (!transcriptPath || typeof transcriptPath !== 'string') {
return null;
}
if (!transcriptPath || typeof transcriptPath !== 'string') {
return null;
}
let claudePath: string | null = null;
let claudePath: string | null = null;
// On Windows, always use the user's home directory .claude folder
if (process.platform === 'win32') {
const homeDir = process.env.USERPROFILE ?? process.env.HOME;
if (homeDir) {
claudePath = path.join(homeDir, '.claude');
// Verify the .claude folder exists
if (!fs.existsSync(claudePath)) {
return null;
}
}
} else {
// On other platforms, walk up the directory tree to find .claude folder
let currentPath = path.dirname(transcriptPath);
const visitedPaths = new Set<string>();
// On Windows, always use the user's home directory .claude folder
if (process.platform === 'win32') {
const homeDir = process.env.USERPROFILE ?? process.env.HOME;
if (homeDir) {
claudePath = path.join(homeDir, '.claude');
// Verify the .claude folder exists
if (!fs.existsSync(claudePath)) {
return null;
}
}
} else {
// On other platforms, walk up the directory tree to find .claude folder
let currentPath = path.dirname(transcriptPath);
const visitedPaths = new Set<string>();
while (currentPath && !visitedPaths.has(currentPath)) {
visitedPaths.add(currentPath);
while (currentPath && !visitedPaths.has(currentPath)) {
visitedPaths.add(currentPath);
const baseName = path.basename(currentPath);
if (baseName === '.claude') {
claudePath = currentPath;
break;
}
const baseName = path.basename(currentPath);
if (baseName === '.claude') {
claudePath = currentPath;
break;
}
const parentPath = path.dirname(currentPath);
// Stop if we've reached the root (parent is same as current)
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
}
const parentPath = path.dirname(currentPath);
// Stop if we've reached the root (parent is same as current)
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
}
if (!claudePath)
return null;
if (!claudePath)
return null;
try {
return findMostRecentBlockStartTime(claudePath);
} catch {
return null;
}
try {
return findMostRecentBlockStartTime(claudePath);
} catch {
return null;
}
}
/**
@@ -201,204 +201,204 @@ export function getBlockMetrics(transcriptPath: string | undefined): BlockMetric
* Uses file modification times as hints to avoid unnecessary reads
*/
function findMostRecentBlockStartTime(
rootDir: string,
sessionDurationHours = 5
rootDir: string,
sessionDurationHours = 5
): BlockMetrics | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = new Date();
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = new Date();
// Step 1: Find all JSONL files with their modification times
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
const files = globSync([pattern], {
absolute: true, // Ensure we get absolute paths
cwd: rootDir // Set working directory to rootDir
});
// Step 1: Find all JSONL files with their modification times
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
const files = globSync([pattern], {
absolute: true, // Ensure we get absolute paths
cwd: rootDir // Set working directory to rootDir
});
if (files.length === 0)
return null;
if (files.length === 0)
return null;
// Step 2: Get file stats and sort by modification time (most recent first)
const filesWithStats = files.map((file) => {
const stats = statSync(file);
return { file, mtime: stats.mtime };
});
// Step 2: Get file stats and sort by modification time (most recent first)
const filesWithStats = files.map((file) => {
const stats = statSync(file);
return { file, mtime: stats.mtime };
});
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
// Step 3: Progressive lookback - start small and expand if needed
// Start with 2x session duration (10 hours), expand to 48 hours if needed
const lookbackChunks = [
10, // 2x session duration - catches most cases
20, // 4x session duration - catches longer sessions
48 // Maximum lookback for marathon sessions
];
// Step 3: Progressive lookback - start small and expand if needed
// Start with 2x session duration (10 hours), expand to 48 hours if needed
const lookbackChunks = [
10, // 2x session duration - catches most cases
20, // 4x session duration - catches longer sessions
48 // Maximum lookback for marathon sessions
];
let timestamps: Date[] = [];
let mostRecentTimestamp: Date | null = null;
let continuousWorkStart: Date | null = null;
let foundSessionGap = false;
let timestamps: Date[] = [];
let mostRecentTimestamp: Date | null = null;
let continuousWorkStart: Date | null = null;
let foundSessionGap = false;
for (const lookbackHours of lookbackChunks) {
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
timestamps = [];
for (const lookbackHours of lookbackChunks) {
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
timestamps = [];
// Collect timestamps for this lookback period
for (const { file, mtime } of filesWithStats) {
if (mtime.getTime() < cutoffTime.getTime()) {
break;
}
const fileTimestamps = getAllTimestampsFromFile(file);
timestamps.push(...fileTimestamps);
}
// Collect timestamps for this lookback period
for (const { file, mtime } of filesWithStats) {
if (mtime.getTime() < cutoffTime.getTime()) {
break;
}
const fileTimestamps = getAllTimestampsFromFile(file);
timestamps.push(...fileTimestamps);
}
if (timestamps.length === 0) {
continue; // Try next chunk
}
if (timestamps.length === 0) {
continue; // Try next chunk
}
// Sort timestamps (most recent first)
timestamps.sort((a, b) => b.getTime() - a.getTime());
// Sort timestamps (most recent first)
timestamps.sort((a, b) => b.getTime() - a.getTime());
// Get most recent timestamp (only set once)
if (!mostRecentTimestamp && timestamps[0]) {
mostRecentTimestamp = timestamps[0];
// Get most recent timestamp (only set once)
if (!mostRecentTimestamp && timestamps[0]) {
mostRecentTimestamp = timestamps[0];
// Check if the most recent activity is within the current session period
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
if (timeSinceLastActivity > sessionDurationMs) {
// No activity within the current session period
return null;
}
}
// Check if the most recent activity is within the current session period
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
if (timeSinceLastActivity > sessionDurationMs) {
// No activity within the current session period
return null;
}
}
// Look for a session gap in this chunk
continuousWorkStart = mostRecentTimestamp;
for (let i = 1; i < timestamps.length; i++) {
const currentTimestamp = timestamps[i];
const previousTimestamp = timestamps[i - 1];
// Look for a session gap in this chunk
continuousWorkStart = mostRecentTimestamp;
for (let i = 1; i < timestamps.length; i++) {
const currentTimestamp = timestamps[i];
const previousTimestamp = timestamps[i - 1];
if (!currentTimestamp || !previousTimestamp)
continue;
if (!currentTimestamp || !previousTimestamp)
continue;
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
if (gap >= sessionDurationMs) {
// Found a true session boundary
foundSessionGap = true;
break;
}
if (gap >= sessionDurationMs) {
// Found a true session boundary
foundSessionGap = true;
break;
}
continuousWorkStart = currentTimestamp;
}
continuousWorkStart = currentTimestamp;
}
// If we found a gap, we're done
if (foundSessionGap) {
break;
}
// If we found a gap, we're done
if (foundSessionGap) {
break;
}
// If this was our last chunk, use what we have
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
break;
}
}
// If this was our last chunk, use what we have
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
break;
}
}
if (!mostRecentTimestamp || !continuousWorkStart) {
return null;
}
if (!mostRecentTimestamp || !continuousWorkStart) {
return null;
}
// Build actual blocks from timestamps going forward
const blocks: { start: Date; end: Date }[] = [];
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
// Build actual blocks from timestamps going forward
const blocks: { start: Date; end: Date }[] = [];
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
let currentBlockStart: Date | null = null;
let currentBlockEnd: Date | null = null;
let currentBlockStart: Date | null = null;
let currentBlockEnd: Date | null = null;
for (const timestamp of sortedTimestamps) {
if (timestamp.getTime() < continuousWorkStart.getTime())
continue;
for (const timestamp of sortedTimestamps) {
if (timestamp.getTime() < continuousWorkStart.getTime())
continue;
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
// Start new block
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
// Start new block
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
// Find current block
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
// Verify we have activity in this block
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
// Find current block
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
// Verify we have activity in this block
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
if (hasActivity) {
return {
startTime: block.start,
lastActivity: mostRecentTimestamp
};
}
}
}
if (hasActivity) {
return {
startTime: block.start,
lastActivity: mostRecentTimestamp
};
}
}
}
return null;
return null;
}
/**
* Gets all timestamps from a JSONL file
*/
function getAllTimestampsFromFile(filePath: string): Date[] {
const timestamps: Date[] = [];
try {
const content = readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.length > 0);
const timestamps: Date[] = [];
try {
const content = readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.length > 0);
for (const line of lines) {
try {
const json = JSON.parse(line) as {
timestamp?: string;
isSidechain?: boolean;
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
};
for (const line of lines) {
try {
const json = JSON.parse(line) as {
timestamp?: string;
isSidechain?: boolean;
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
};
// Only treat entries with real token usage as block activity
const usage = json.message?.usage;
if (!usage)
continue;
// Only treat entries with real token usage as block activity
const usage = json.message?.usage;
if (!usage)
continue;
const hasInputTokens = typeof usage.input_tokens === 'number';
const hasOutputTokens = typeof usage.output_tokens === 'number';
if (!hasInputTokens || !hasOutputTokens)
continue;
const hasInputTokens = typeof usage.input_tokens === 'number';
const hasOutputTokens = typeof usage.output_tokens === 'number';
if (!hasInputTokens || !hasOutputTokens)
continue;
if (json.isSidechain === true)
continue;
if (json.isSidechain === true)
continue;
const timestamp = json.timestamp;
if (typeof timestamp !== 'string')
continue;
const timestamp = json.timestamp;
if (typeof timestamp !== 'string')
continue;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime()))
timestamps.push(date);
} catch {
// Skip invalid JSON lines
continue;
}
}
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime()))
timestamps.push(date);
} catch {
// Skip invalid JSON lines
continue;
}
}
return timestamps;
} catch {
return [];
}
return timestamps;
} catch {
return [];
}
}
/**
* Floors a timestamp to the beginning of the hour (matching existing logic)
*/
function floorToHour(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}