From d4b8e144a660c01245fa50de3a88cf16177ba767 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 18 Apr 2026 21:27:38 +0800 Subject: [PATCH] Fix 10 bugs: model name matching, CSV injection, resource leaks, and more - models.ts: sort prefix matches by length descending so gpt-4o-mini matches before gpt-4o, preventing wrong display names and pricing - models.ts: use explicit undefined check instead of falsy to avoid silently dropping zero-cost/free models from pricing database - models.ts: remove overly aggressive bidirectional fuzzy matching in getModelCosts that could return wrong pricing for short names - fs-utils.ts: destroy stream in finally block to prevent file descriptor leaks when readSessionLines generator is abandoned early - codex.ts: clamp cumulative delta subtraction to 0 to prevent negative token counts and negative costs from corrupting aggregates - codex.ts: sort model display name entries by key length descending for correct prefix matching (consistent with pi.ts pattern) - export.ts: extend CSV injection protection to cover tab/CR/LF prefixes that bypass the original =+-@ regex - export.ts: add safe fallback (?? []) for empty/missing periods to prevent TypeError crash in exportCsv and exportJson - optimize.ts: fix detectDuplicateReads displaying inflated counts for cross-session duplicates by tracking total reads directly - day-aggregator.ts: skip turns with empty/missing timestamps to prevent phantom empty-date ("") bucket in daily cache - config.ts: validate parsed JSON to prevent crash when currency.code is not a string in malformed config files - parser.ts: use Object.create(null) for categoryBreakdown to be consistent with other breakdown maps and avoid prototype pollution Co-Authored-By: Claude Opus 4.7 --- src/config.ts | 5 ++++- src/day-aggregator.ts | 8 +++++--- src/export.ts | 6 +++--- src/fs-utils.ts | 2 ++ src/models.ts | 9 ++++++--- src/optimize.ts | 4 ++-- src/parser.ts | 2 +- src/providers/codex.ts | 13 ++++++++----- 8 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/config.ts b/src/config.ts index 19ad4561..f916b0ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,7 +20,10 @@ function getConfigPath(): string { export async function readConfig(): Promise { try { const raw = await readFile(getConfigPath(), 'utf-8') - return JSON.parse(raw) as CodeburnConfig + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return {} + if (parsed.currency && typeof parsed.currency.code !== 'string') delete parsed.currency + return parsed as CodeburnConfig } catch { return {} } diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 5030f8d8..79cd5c64 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -34,12 +34,14 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr for (const project of projects) { for (const session of project.sessions) { - const sessionDate = dateKey(session.firstTimestamp) - ensure(sessionDate).sessions += 1 + const sessionDate = dateKey(session.firstTimestamp || '') + if (sessionDate) ensure(sessionDate).sessions += 1 for (const turn of session.turns) { if (turn.assistantCalls.length === 0) continue - const turnDate = dateKey(turn.assistantCalls[0]!.timestamp) + const rawTs = turn.assistantCalls[0]!.timestamp + if (!rawTs) continue + const turnDate = dateKey(rawTs) const turnDay = ensure(turnDate) const editTurns = turn.hasEdits ? 1 : 0 diff --git a/src/export.ts b/src/export.ts index d07f08d8..1ac34816 100644 --- a/src/export.ts +++ b/src/export.ts @@ -5,7 +5,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types import { getCurrency, convertCost } from './currency.js' function escCsv(s: string): string { - const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s + const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n')) { return `"${sanitized.replace(/"/g, '""')}"` } @@ -281,7 +281,7 @@ async function clearCodeburnExportFolder(path: string): Promise { /// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally). export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') - const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects + const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] let folder = resolve(outputPath) if (folder.toLowerCase().endsWith('.csv')) { @@ -323,7 +323,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr export async function exportJson(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') - const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects + const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] const { code, rate, symbol } = getCurrency() const data = { diff --git a/src/fs-utils.ts b/src/fs-utils.ts index 49eff20b..823a630a 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -89,5 +89,7 @@ export async function* readSessionLines(filePath: string): AsyncGenerator b[0].length - a[0].length) + for (const [key, name] of sorted) { if (canonical.startsWith(key)) return name } return canonical diff --git a/src/optimize.ts b/src/optimize.ts index 2e8913c7..748e0c5f 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -448,7 +448,7 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): totalDuplicates += extra if (entry.recent > 1) recentDuplicates += entry.recent - 1 const name = basename(file) - fileDupes.set(name, (fileDupes.get(name) ?? 0) + extra) + fileDupes.set(name, (fileDupes.get(name) ?? 0) + entry.count) } } @@ -461,7 +461,7 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): const worst = [...fileDupes.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, TOP_ITEMS_PREVIEW) - .map(([name, n]) => `${name} (${n + 1}x)`) + .map(([name, n]) => `${name} (${n}x)`) .join(', ') const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ diff --git a/src/parser.ts b/src/parser.ts index 4adcf3bc..fcb2fabc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -173,7 +173,7 @@ function buildSessionSummary( const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null) const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null) const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null) - const categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown'] + const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null) as SessionSummary['categoryBreakdown'] let totalCost = 0 let totalInput = 0 diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 01d48b70..eb3f836b 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -15,6 +15,9 @@ const modelDisplayNames: Record = { 'gpt-4o': 'GPT-4o', } +// Sort by key length descending so longer prefixes match first +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + const toolNameMap: Record = { exec_command: 'Bash', read_file: 'Read', @@ -205,10 +208,10 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars } else if (cumulativeTotal > 0) { const total = info.total_token_usage if (!total) continue - inputTokens = (total.input_tokens ?? 0) - prevInput - cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached - outputTokens = (total.output_tokens ?? 0) - prevOutput - reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning + inputTokens = Math.max(0, (total.input_tokens ?? 0) - prevInput) + cachedInputTokens = Math.max(0, (total.cached_input_tokens ?? 0) - prevCached) + outputTokens = Math.max(0, (total.output_tokens ?? 0) - prevOutput) + reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - prevReasoning) } if (!last) { @@ -280,7 +283,7 @@ export function createCodexProvider(codexDir?: string): Provider { displayName: 'Codex', modelDisplayName(model: string): string { - for (const [key, name] of Object.entries(modelDisplayNames)) { + for (const [key, name] of modelDisplayEntries) { if (model.startsWith(key)) return name } return model