diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0b560..aa90735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.7.0 - 2026-04-16 + +### Added +- **`codeburn optimize` command.** Scans your sessions and your `~/.claude/` + setup for 11 common waste patterns and hands back exact copy-paste fixes. + Detection-only, never writes to user files. Supports `--period` (today, + week, 30days, month, all) and `--provider` (all, claude, codex, cursor). +- **Setup health grade (A-F).** Urgency-weighted rollup of all findings, with + impact scored against observed waste so the most expensive issues rank + first. High findings penalise more, medium less, low least. +- **Trend tracking.** Repeat runs classify each finding as new, improving, + or resolved against a 48-hour recent window, so fixed issues disappear + instead of lingering as noise. +- **11 detectors:** files Claude re-reads across sessions, low Read:Edit + ratio, projects missing `.claudeignore`, uncapped `BASH_MAX_OUTPUT_LENGTH`, + unused MCP servers, ghost agents, ghost skills, ghost slash commands, + bloated `CLAUDE.md` files (with `@-import` expansion counted), cache + creation overhead, and junk directory reads. +- **Copy-paste fixes.** Each finding comes with a ready-to-paste remedy: a + `CLAUDE.md` line, a `.claudeignore` template, an environment variable, or + a `mv` command to archive unused items. +- **In-TUI optimize view.** Press `o` in the dashboard when the status bar + shows a finding count, `b` to return. Same engine as the standalone + command, scoped to the current period and provider. +- **Per-project context budget column.** By Project panel now shows the + estimated per-session context overhead for each project (system prompt + + tools + `CLAUDE.md` + skills). +- **34 filesystem-mocking tests.** Tmpdir fixtures with `os.homedir` mocked + via `vi.mock` cover the detector surface end to end. Total suite: 198 + tests across 13 files. + +### Performance +- **mtime pre-filter + parallel reads + 60s result cache** cut a cold scan + from 12-17s to 6-7s on a 10k-session history. + ## 0.6.1 - 2026-04-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f1cd611..1661a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,10 @@ gh pr comment --body "Merged, thanks!" - NEVER include personal names or usernames in commits - Small, focused commits. One feature per commit - Test locally before every commit + +### Public-facing language (commits, PRs, release notes, README) +- Commits and release notes are public. Write like you'd publish them. +- NEVER use words like "steal", "stealing", "copy", "rip off", "inspired by" in commit messages +- Describe what the code does, not where ideas came from +- If you must credit prior art, do it in code comments or docs, not commit messages +- No snark, no filler, no self-deprecation. Treat each commit as a product statement diff --git a/README.md b/README.md index 246aeec..5eaf2c4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ codeburn status # compact one-liner (today + month) codeburn status --format json codeburn export # CSV with today, 7 days, 30 days codeburn export -f json # JSON export +codeburn optimize # find waste, get copy-paste fixes +codeburn optimize -p week # scope the scan to last 7 days ``` Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects. @@ -206,6 +208,36 @@ CodeBurn surfaces the data, you read the story. A few patterns worth knowing: These are starting points, not verdicts. A 60% cache hit on a single experimental session is fine. A persistent 60% cache hit across weeks of work is a config issue. +## Optimize + +Once you know what to look for, `codeburn optimize` scans your sessions and your `~/.claude/` setup for the most common waste patterns and hands back exact, copy-paste fixes. It never writes to your files. + +

+ CodeBurn optimize output +

+ +```bash +codeburn optimize # scan the last 30 days +codeburn optimize -p today # today only +codeburn optimize -p week # last 7 days +codeburn optimize --provider claude # restrict to one provider +``` + +**What it detects** + +- Files Claude re-reads across sessions (same content, same context, over and over) +- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) +- Projects missing a `.claudeignore` (Claude wanders into `node_modules`, `.git`, build dirs) +- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise) +- Unused MCP servers still paying their tool-schema overhead every session +- Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked +- Bloated `CLAUDE.md` files (with `@-import` expansion counted) +- Cache creation overhead and junk directory reads + +Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, a `.claudeignore` template, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A-F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window. + +You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return. + ## How it reads data **Claude Code** stores session transcripts as JSONL at `~/.claude/projects//.jsonl`. Each assistant entry contains model name, token usage (input, output, cache read, cache write), tool_use blocks, and timestamps. diff --git a/assets/optimize.jpg b/assets/optimize.jpg new file mode 100644 index 0000000..9d8939d Binary files /dev/null and b/assets/optimize.jpg differ diff --git a/package-lock.json b/package-lock.json index d40c129..e9a6b9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { "chalk": "^5.4.1", diff --git a/package.json b/package.json index 35ad388..15cb1bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeburn", - "version": "0.6.1", + "version": "0.7.0", "description": "See where your AI coding tokens go - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js", diff --git a/src/cli.ts b/src/cli.ts index 3cfe752..c1d2308 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { renderStatusBar } from './format.js' import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { renderDashboard } from './dashboard.js' +import { runOptimize } from './optimize.js' import { getAllProviders } from './providers/index.js' import { readConfig, saveConfig, getConfigFilePath } from './config.js' import { createRequire } from 'node:module' @@ -445,4 +446,16 @@ program console.log(` Config saved to ${getConfigFilePath()}\n`) }) +program + .command('optimize') + .description('Find token waste and get exact fixes') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all') + .action(async (opts) => { + await loadPricing() + const { range, label } = getDateRange(opts.period) + const projects = await parseAllSessions(range, opts.provider) + await runOptimize(projects, label, range) + }) + program.parse() diff --git a/src/context-budget.ts b/src/context-budget.ts new file mode 100644 index 0000000..3db8e95 --- /dev/null +++ b/src/context-budget.ts @@ -0,0 +1,146 @@ +import { readdir, readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +const CHARS_PER_TOKEN = 4 +const SYSTEM_BASE_TOKENS = 10400 +const TOOL_TOKENS_OVERHEAD = 400 +const SKILL_FRONTMATTER_TOKENS = 80 + +export type ContextBudget = { + systemBase: number + mcpTools: { count: number; tokens: number } + skills: { count: number; tokens: number } + memory: { count: number; tokens: number; files: Array<{ name: string; tokens: number }> } + total: number + modelContext: number +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / CHARS_PER_TOKEN) +} + +async function readConfigFile(path: string): Promise | null> { + if (!existsSync(path)) return null + try { + return JSON.parse(await readFile(path, 'utf-8')) + } catch { return null } +} + +async function countMcpTools(projectPath?: string): Promise { + const home = homedir() + const configPaths = [ + join(home, '.claude', 'settings.json'), + join(home, '.claude', 'settings.local.json'), + ] + if (projectPath) { + configPaths.push(join(projectPath, '.mcp.json')) + configPaths.push(join(projectPath, '.claude', 'settings.json')) + configPaths.push(join(projectPath, '.claude', 'settings.local.json')) + } + + const servers = new Set() + let toolCount = 0 + + for (const p of configPaths) { + const config = await readConfigFile(p) + if (!config) continue + const mcpServers = (config.mcpServers ?? {}) as Record + for (const name of Object.keys(mcpServers)) { + if (servers.has(name)) continue + servers.add(name) + toolCount += 5 + } + } + + return toolCount +} + +async function countSkills(projectPath?: string): Promise { + const dirs = [join(homedir(), '.claude', 'skills')] + if (projectPath) dirs.push(join(projectPath, '.claude', 'skills')) + + let count = 0 + for (const dir of dirs) { + if (!existsSync(dir)) continue + try { + const entries = await readdir(dir) + for (const entry of entries) { + const skillFile = join(dir, entry, 'SKILL.md') + if (existsSync(skillFile)) count++ + } + } catch { continue } + } + + return count +} + +async function scanMemoryFiles(projectPath?: string): Promise> { + const home = homedir() + const files: Array<{ name: string; tokens: number }> = [] + const paths: Array<{ path: string; name: string }> = [ + { path: join(home, '.claude', 'CLAUDE.md'), name: '~/.claude/CLAUDE.md' }, + ] + + if (projectPath) { + paths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' }) + paths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' }) + paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' }) + } + + for (const { path, name } of paths) { + if (!existsSync(path)) continue + try { + const content = await readFile(path, 'utf-8') + files.push({ name, tokens: estimateTokens(content) }) + } catch { continue } + } + + return files +} + +export async function estimateContextBudget(projectPath?: string, modelContext = 1_000_000): Promise { + const mcpToolCount = await countMcpTools(projectPath) + const skillCount = await countSkills(projectPath) + const memoryFiles = await scanMemoryFiles(projectPath) + + const mcpTokens = mcpToolCount * TOOL_TOKENS_OVERHEAD + const skillTokens = skillCount * SKILL_FRONTMATTER_TOKENS + const memoryTokens = memoryFiles.reduce((s, f) => s + f.tokens, 0) + const total = SYSTEM_BASE_TOKENS + mcpTokens + skillTokens + memoryTokens + + return { + systemBase: SYSTEM_BASE_TOKENS, + mcpTools: { count: mcpToolCount, tokens: mcpTokens }, + skills: { count: skillCount, tokens: skillTokens }, + memory: { count: memoryFiles.length, tokens: memoryTokens, files: memoryFiles }, + total, + modelContext, + } +} + +export async function estimateBudgetsByProject(projectPaths: Map): Promise> { + const results = new Map() + for (const [project, cwd] of projectPaths) { + const budget = await estimateContextBudget(cwd) + results.set(project, budget) + } + return results +} + +export async function discoverProjectCwd(sessionDir: string): Promise { + try { + const files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl')) + if (files.length === 0) return null + const content = await readFile(join(sessionDir, files[0]), 'utf-8') + for (const line of content.split('\n')) { + if (!line.trim()) continue + try { + const entry = JSON.parse(line) + if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd + } catch { continue } + } + } catch { return null } + return null +} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 12990f7..21281f8 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,14 +1,18 @@ import { homedir } from 'os' -import React, { useState, useCallback, useEffect } from 'react' +import React, { useState, useCallback, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens } from './format.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { loadPricing } from './models.js' import { getAllProviders } from './providers/index.js' +import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' +import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js' +import { join } from 'path' type Period = 'today' | 'week' | '30days' | 'month' | 'all' +type View = 'dashboard' | 'optimize' const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] const PERIOD_LABELS: Record = { @@ -71,6 +75,8 @@ const CATEGORY_COLORS: Record = { general: '#666666', } +const IMPACT_PANEL_COLORS: Record = { high: '#F55B5B', medium: ORANGE, low: DIM } + function toHex(r: number, g: number, b: number): string { return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('') } @@ -79,7 +85,6 @@ function lerp(a: number, b: number, t: number): number { return a + t * (b - a) } -// Blue -> amber -> orange gradient across the bar width function gradientColor(pct: number): string { if (pct <= 0.33) { const t = pct / 0.33 @@ -218,32 +223,31 @@ const _homeEncoded = homedir().replace(/\//g, '-') function shortProject(encoded: string): string { let path = encoded.replace(/^-/, '') - if (path.startsWith(_homeEncoded.replace(/^-/, ''))) { path = path.slice(_homeEncoded.replace(/^-/, '').length).replace(/^-/, '') } - - path = path - .replace(/^private-tmp-[^-]+-[^-]+-/, '') // /private/tmp/// - .replace(/^private-tmp-/, '') - .replace(/^tmp-/, '') - + path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '') if (!path) return 'home' - const parts = path.split('-').filter(Boolean) if (parts.length <= 3) return parts.join('/') return parts.slice(-3).join('/') } const PROJECT_COL_AVG = 7 +const PROJECT_COL_BASE_WIDTH = 30 +const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40 -function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { +function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map }) { const maxCost = Math.max(...projects.map(p => p.totalCostUSD)) - const nw = Math.max(8, pw - bw - 30) + const hasBudgets = budgets && budgets.size > 0 + const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH)) return ( - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)} + + {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''} + {projects.slice(0, 8).map((project, i) => { + const budget = budgets?.get(project.project) const avgCost = project.sessions.length > 0 ? formatCost(project.totalCostUSD / project.sessions.length) : '-' @@ -254,6 +258,7 @@ function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw {formatCost(project.totalCostUSD).padStart(8)} {avgCost.padStart(PROJECT_COL_AVG)} {String(project.sessions.length).padStart(6)} + {hasBudgets && {(budget ? formatTokens(budget.total) : '-').padStart(10)}} ) })} @@ -319,7 +324,6 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p } const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 - return ( {''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} @@ -328,9 +332,7 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p return ( - - {' '}{fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)} - + {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)} {formatCost(data.costUSD).padStart(8)} {String(data.turns).padStart(6)} {String(oneShotPct).padStart(7)} @@ -346,11 +348,7 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr for (const project of projects) { for (const session of project.sessions) { for (const [tool, data] of Object.entries(session.toolBreakdown)) { - if (filterPrefix) { - if (!tool.startsWith(filterPrefix)) continue - } else { - if (tool.startsWith('lang:')) continue - } + if (filterPrefix) { if (!tool.startsWith(filterPrefix)) continue } else { if (tool.startsWith('lang:')) continue } toolTotals[tool] = (toolTotals[tool] ?? 0) + data.calls } } @@ -358,7 +356,6 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a) const maxCalls = sorted[0]?.[1] ?? 0 const nw = Math.max(6, pw - bw - 15) - return ( {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} @@ -417,29 +414,16 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const mcpTotals: Record = {} - for (const project of projects) { - for (const session of project.sessions) { - for (const [server, data] of Object.entries(session.mcpBreakdown)) { - mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls - } - } - } + for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } } const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a) - if (sorted.length === 0) { - return No MCP usage - } + if (sorted.length === 0) return No MCP usage const maxCalls = sorted[0]?.[1] ?? 0 const nw = Math.max(6, pw - bw - 15) - return ( {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)} {sorted.slice(0, 8).map(([server, calls]) => ( - - - {fit(server, nw)} - {String(calls).padStart(6)} - + {fit(server, nw)}{String(calls).padStart(6)} ))} ) @@ -447,29 +431,16 @@ function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: nu function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const bashTotals: Record = {} - for (const project of projects) { - for (const session of project.sessions) { - for (const [cmd, data] of Object.entries(session.bashBreakdown)) { - bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls - } - } - } + for (const project of projects) { for (const session of project.sessions) { for (const [cmd, data] of Object.entries(session.bashBreakdown)) { bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls } } } const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a) - if (sorted.length === 0) { - return No shell commands - } + if (sorted.length === 0) return No shell commands const maxCalls = sorted[0]?.[1] ?? 0 const nw = Math.max(6, pw - bw - 15) - return ( {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} {sorted.slice(0, 10).map(([cmd, calls]) => ( - - - {fit(cmd, nw)} - {String(calls).padStart(7)} - + {fit(cmd, nw)}{String(calls).padStart(7)} ))} ) @@ -483,16 +454,9 @@ const PROVIDER_DISPLAY_NAMES: Record = { opencode: 'OpenCode', pi: 'Pi', } +function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } -function getProviderDisplayName(name: string): string { - return PROVIDER_DISPLAY_NAMES[name] ?? name -} - -function PeriodTabs({ active, providerName, showProvider }: { - active: Period - providerName?: string - showProvider?: boolean -}) { +function PeriodTabs({ active, providerName, showProvider }: { active: Period; providerName?: string; showProvider?: boolean }) { return ( @@ -503,41 +467,82 @@ function PeriodTabs({ active, providerName, showProvider }: { ))} {showProvider && providerName && ( - - | - [p] - {getProviderDisplayName(providerName)} - + | [p] {getProviderDisplayName(providerName)} )} ) } -function StatusBar({ width, showProvider }: { width: number; showProvider?: boolean }) { +function FindingAction({ action }: { action: WasteAction }) { + const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text] + return (<>{action.label}{lines.map((line, i) => {line})}) +} + +function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) { + const costSaved = finding.tokensSaved * costRate + const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM + const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1) + const trendBadge = finding.trend === 'improving' ? ' improving \u2193' : '' + return ( + + + {index}. {finding.title} + + {label} + {trendBadge && {trendBadge}} + + {finding.explanation} + Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) + + + + ) +} + +const GRADE_COLORS: Record = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' } + +function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string }) { + const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0) + const totalCost = totalTokens * costRate + const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 + const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) + const gradeColor = GRADE_COLORS[healthGrade] ?? DIM + return ( + + + + CodeBurn Optimize + {label} Setup: + {healthGrade} + ({healthScore}/100) + + Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend) + + {findings.map((f, i) => )} + Token estimates are approximate. + + ) +} + +function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean }) { + const isOptimize = view === 'optimize' return ( - {'<'}{'>'} - switch - q - quit - 1 - today - 2 - week - 3 - 30 days - 4 - month - 5 - all time - {showProvider && ( - <> - - p - provider - + {isOptimize + ? <>b back + : <>{'<'}{'>'} switch } + q quit + 1 today + 2 week + 3 30 days + 4 month + 5 all time + {!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && ( + <> o optimize ({findingCount}) )} + {showProvider && (<> p provider)} ) @@ -548,48 +553,22 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children return <>{children} } -function DashboardContent({ projects, period, columns, activeProvider }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string }) { +function DashboardContent({ projects, period, columns, activeProvider, budgets }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map }) { const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) const isCursor = activeProvider === 'cursor' - - if (projects.length === 0) { - return ( - - No usage data found for {PERIOD_LABELS[period]}. - - ) - } - + if (projects.length === 0) return No usage data found for {PERIOD_LABELS[period]}. const pw = wide ? halfWidth : dashWidth - // undefined = no cutoff (show all days); 31 for month/30-day ranges; 14 for shorter periods const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) - return ( - - - - - - + - - - - - - + {isCursor ? ( ) : ( - <> - - - - - - + <> )} ) @@ -609,29 +588,59 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, const [loading, setLoading] = useState(false) const [activeProvider, setActiveProvider] = useState(initialProvider) const [detectedProviders, setDetectedProviders] = useState([]) + const [view, setView] = useState('dashboard') + const [optimizeResult, setOptimizeResult] = useState(null) + const [projectBudgets, setProjectBudgets] = useState>(new Map()) const { columns } = useWindowSize() const { dashWidth } = getLayout(columns) const multipleProviders = detectedProviders.length > 1 + const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude' + const debounceRef = useRef | null>(null) + const findingCount = optimizeResult?.findings.length ?? 0 useEffect(() => { let cancelled = false async function detect() { const found: string[] = [] - const allProviders = await getAllProviders() - for (const p of allProviders) { - const sessions = await p.discoverSessions() - if (sessions.length > 0) found.push(p.name) - } - if (!cancelled) { - setDetectedProviders(found) - } + for (const p of await getAllProviders()) { const s = await p.discoverSessions(); if (s.length > 0) found.push(p.name) } + if (!cancelled) setDetectedProviders(found) } detect() return () => { cancelled = true } }, []) + useEffect(() => { + let cancelled = false + async function loadBudgets() { + const claudeDir = join(homedir(), '.claude', 'projects') + const budgets = new Map() + for (const project of projects.slice(0, 8)) { + if (cancelled) return + const cwd = await discoverProjectCwd(join(claudeDir, project.project)) + if (!cwd) continue + budgets.set(project.project, await estimateContextBudget(cwd)) + } + if (!cancelled) setProjectBudgets(budgets) + } + loadBudgets() + return () => { cancelled = true } + }, [projects]) + + useEffect(() => { + if (!optimizeAvailable) { setOptimizeResult(null); return } + let cancelled = false + async function scan() { + if (projects.length === 0) { setOptimizeResult(null); return } + const result = await scanAndDetect(projects, getDateRange(period)) + if (!cancelled) setOptimizeResult(result) + } + scan() + return () => { cancelled = true } + }, [projects, period, optimizeAvailable]) + const reloadData = useCallback(async (p: Period, prov: string) => { setLoading(true) + setOptimizeResult(null) const range = getDateRange(p) const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter) setProjects(data) @@ -644,46 +653,34 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, return () => clearInterval(id) }, [refreshSeconds, period, activeProvider, reloadData]) - const debounceRef = React.useRef | null>(null) - - const switchPeriod = useCallback((newPeriod: Period) => { - if (newPeriod === period) return - setPeriod(newPeriod) + const switchPeriod = useCallback((np: Period) => { + if (np === period) return + setPeriod(np); setView('dashboard') if (debounceRef.current) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => { - reloadData(newPeriod, activeProvider) - }, 600) + debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600) }, [period, activeProvider, reloadData]) - const switchPeriodImmediate = useCallback(async (newPeriod: Period) => { - if (newPeriod === period) return - setPeriod(newPeriod) + const switchPeriodImmediate = useCallback(async (np: Period) => { + if (np === period) return + setPeriod(np); setView('dashboard') if (debounceRef.current) clearTimeout(debounceRef.current) - await reloadData(newPeriod, activeProvider) + await reloadData(np, activeProvider) }, [period, activeProvider, reloadData]) useInput((input, key) => { - if (input === 'q') { - exit() - return - } - + if (input === 'q') { exit(); return } + if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); return } + if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); return } if (input === 'p' && multipleProviders) { - const options = ['all', ...detectedProviders] - const idx = options.indexOf(activeProvider) - const next = options[(idx + 1) % options.length] - setActiveProvider(next) + const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length] + setActiveProvider(next); setView('dashboard') if (debounceRef.current) clearTimeout(debounceRef.current) - reloadData(period, next) - return + reloadData(period, next); return } - const idx = PERIODS.indexOf(period) - if (key.leftArrow) { - switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]) - } else if (key.rightArrow || key.tab) { - switchPeriod(PERIODS[(idx + 1) % PERIODS.length]) - } else if (input === '1') switchPeriodImmediate('today') + if (key.leftArrow && view === 'dashboard') switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]) + else if ((key.rightArrow || key.tab) && view === 'dashboard') switchPeriod(PERIODS[(idx + 1) % PERIODS.length]) + else if (input === '1') switchPeriodImmediate('today') else if (input === '2') switchPeriodImmediate('week') else if (input === '3') switchPeriodImmediate('30days') else if (input === '4') switchPeriodImmediate('month') @@ -694,10 +691,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, return ( - - Loading {PERIOD_LABELS[period]}... - - + Loading {PERIOD_LABELS[period]}... + ) } @@ -705,8 +700,10 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, return ( - - + {view === 'optimize' && optimizeResult + ? + : } + ) } @@ -726,19 +723,14 @@ export async function renderDashboard(period: Period = 'week', provider: string await loadPricing() const range = getDateRange(period) const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) - const isTTY = process.stdin.isTTY && process.stdout.isTTY - if (isTTY) { const { waitUntilExit } = render( ) await waitUntilExit() } else { - const { unmount } = render( - , - { patchConsole: false } - ) + const { unmount } = render(, { patchConsole: false }) unmount() } } diff --git a/src/optimize.ts b/src/optimize.ts new file mode 100644 index 0000000..fa8ff8e --- /dev/null +++ b/src/optimize.ts @@ -0,0 +1,1177 @@ +import chalk from 'chalk' +import { readdir, readFile, stat } from 'fs/promises' +import { existsSync, readFileSync, statSync } from 'fs' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { discoverAllSessions } from './providers/index.js' +import type { DateRange, ProjectSummary } from './types.js' +import { formatCost } from './currency.js' +import { formatTokens } from './format.js' + +// ============================================================================ +// Display constants +// ============================================================================ + +const ORANGE = '#FF8C42' +const DIM = '#666666' +const GOLD = '#FFD700' +const CYAN = '#5BF5E0' +const GREEN = '#5BF5A0' +const RED = '#F55B5B' + +// ============================================================================ +// Token estimation constants +// ============================================================================ + +const AVG_TOKENS_PER_READ = 600 +const TOKENS_PER_MCP_TOOL = 400 +const TOOLS_PER_MCP_SERVER = 5 +const TOKENS_PER_AGENT_DEF = 80 +const TOKENS_PER_SKILL_DEF = 80 +const TOKENS_PER_COMMAND_DEF = 60 +const CLAUDEMD_TOKENS_PER_LINE = 13 +const BASH_TOKENS_PER_CHAR = 0.25 +const ESTIMATED_READS_PER_MISSING_IGNORE = 10 + +// ============================================================================ +// Detector thresholds +// ============================================================================ + +const CLAUDEMD_HEALTHY_LINES = 200 +const CLAUDEMD_HIGH_THRESHOLD_LINES = 400 +const MIN_JUNK_READS_TO_FLAG = 3 +const JUNK_READS_HIGH_THRESHOLD = 20 +const JUNK_READS_MEDIUM_THRESHOLD = 5 +const MIN_DUPLICATE_READS_TO_FLAG = 5 +const DUPLICATE_READS_HIGH_THRESHOLD = 30 +const DUPLICATE_READS_MEDIUM_THRESHOLD = 10 +const MIN_EDITS_FOR_RATIO = 10 +const HEALTHY_READ_EDIT_RATIO = 4 +const LOW_RATIO_HIGH_THRESHOLD = 2 +const LOW_RATIO_MEDIUM_THRESHOLD = 3 +const MIN_API_CALLS_FOR_CACHE = 10 +const CACHE_EXCESS_HIGH_THRESHOLD = 15000 +const MISSING_IGNORE_HIGH_THRESHOLD = 3 +const UNUSED_MCP_HIGH_THRESHOLD = 3 +const GHOST_AGENTS_HIGH_THRESHOLD = 5 +const GHOST_AGENTS_MEDIUM_THRESHOLD = 2 +const GHOST_SKILLS_HIGH_THRESHOLD = 10 +const GHOST_SKILLS_MEDIUM_THRESHOLD = 5 +const GHOST_COMMANDS_MEDIUM_THRESHOLD = 10 +const MCP_NEW_CONFIG_GRACE_MS = 24 * 60 * 60 * 1000 +const BASH_DEFAULT_LIMIT = 30000 +const BASH_RECOMMENDED_LIMIT = 15000 + +// ============================================================================ +// Scoring constants +// ============================================================================ + +const HEALTH_WEIGHT_HIGH = 15 +const HEALTH_WEIGHT_MEDIUM = 7 +const HEALTH_WEIGHT_LOW = 3 +const HEALTH_MAX_PENALTY = 80 +const GRADE_A_MIN = 90 +const GRADE_B_MIN = 75 +const GRADE_C_MIN = 55 +const GRADE_D_MIN = 30 +const URGENCY_IMPACT_WEIGHT = 0.7 +const URGENCY_TOKEN_WEIGHT = 0.3 +const URGENCY_TOKEN_NORMALIZE = 500_000 + +// ============================================================================ +// File system constants +// ============================================================================ + +const MAX_IMPORT_DEPTH = 5 +const IMPORT_PATTERN = /^@(\.\.?\/[^\s]+|\/[^\s]+)/gm +const COMMAND_PATTERN = /([^<]+)<\/command-name>|(?:^|\s)\/([a-zA-Z][\w-]*)/gm + +const JUNK_DIRS = [ + 'node_modules', '.git', 'dist', 'build', '__pycache__', '.next', + '.nuxt', '.output', 'coverage', '.cache', '.tsbuildinfo', + '.venv', 'venv', '.svn', '.hg', +] +const JUNK_PATTERN = new RegExp(`/(?:${JUNK_DIRS.join('|')})/`) + +const SHELL_PROFILES = ['.zshrc', '.bashrc', '.bash_profile', '.profile'] + +const TOP_ITEMS_PREVIEW = 3 +const MISSING_IGNORE_PATHS_PREVIEW = 2 +const JUNK_DIRS_IGNORE_PREVIEW = 8 +const GHOST_NAMES_PREVIEW = 5 +const GHOST_CLEANUP_COMMANDS_LIMIT = 10 + +// ============================================================================ +// Types +// ============================================================================ + +export type Impact = 'high' | 'medium' | 'low' +export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F' + +export type WasteAction = + | { type: 'paste'; label: string; text: string } + | { type: 'command'; label: string; text: string } + | { type: 'file-content'; label: string; path: string; content: string } + +export type Trend = 'active' | 'improving' + +export type WasteFinding = { + title: string + explanation: string + impact: Impact + tokensSaved: number + fix: WasteAction + trend?: Trend +} + +export type OptimizeResult = { + findings: WasteFinding[] + costRate: number + healthScore: number + healthGrade: HealthGrade +} + +export type ToolCall = { + name: string + input: Record + sessionId: string + project: string + recent?: boolean +} + +export type ApiCallMeta = { + cacheCreationTokens: number + version: string + recent?: boolean +} + +type ScanData = { + toolCalls: ToolCall[] + projectCwds: Set + apiCalls: ApiCallMeta[] + userMessages: string[] +} + +// ============================================================================ +// JSONL scanner +// ============================================================================ + +const FILE_READ_CONCURRENCY = 16 +const RESULT_CACHE_TTL_MS = 60_000 +const RECENT_WINDOW_HOURS = 48 +const RECENT_WINDOW_MS = RECENT_WINDOW_HOURS * 60 * 60 * 1000 +const DEFAULT_TREND_PERIOD_DAYS = 30 +const DEFAULT_TREND_PERIOD_MS = DEFAULT_TREND_PERIOD_DAYS * 24 * 60 * 60 * 1000 +const IMPROVING_THRESHOLD = 0.5 + +async function collectJsonlFiles(dirPath: string): Promise { + const files = await readdir(dirPath).catch(() => []) + const result = files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f)) + for (const entry of files) { + if (entry.endsWith('.jsonl')) continue + const subPath = join(dirPath, entry, 'subagents') + const subFiles = await readdir(subPath).catch(() => []) + for (const sf of subFiles) { + if (sf.endsWith('.jsonl')) result.push(join(subPath, sf)) + } + } + return result +} + +async function isFileStaleForRange(filePath: string, range: DateRange | undefined): Promise { + if (!range) return false + try { + const s = await stat(filePath) + return s.mtimeMs < range.start.getTime() + } catch { return false } +} + +async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + let idx = 0 + async function next(): Promise { + while (idx < items.length) { + const current = idx++ + await worker(items[current]) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => next())) +} + +type ScanFileResult = { + calls: ToolCall[] + cwds: string[] + apiCalls: ApiCallMeta[] + userMessages: string[] +} + +function inRange(timestamp: string | undefined, range: DateRange | undefined): boolean { + if (!range) return true + if (!timestamp) return false + const ts = new Date(timestamp) + return ts >= range.start && ts <= range.end +} + +function isRecent(timestamp: string | undefined, cutoff: number): boolean { + if (!timestamp) return false + return new Date(timestamp).getTime() >= cutoff +} + +export async function scanJsonlFile( + filePath: string, + project: string, + dateRange: DateRange | undefined, + recentCutoffMs = Date.now() - RECENT_WINDOW_MS, +): Promise { + let content: string + try { + content = await readFile(filePath, 'utf-8') + } catch { return { calls: [], cwds: [], apiCalls: [], userMessages: [] } } + + const calls: ToolCall[] = [] + const cwds: string[] = [] + const apiCalls: ApiCallMeta[] = [] + const userMessages: string[] = [] + const sessionId = basename(filePath, '.jsonl') + let lastVersion = '' + + for (const line of content.split('\n')) { + if (!line.trim()) continue + let entry: Record + try { entry = JSON.parse(line) } catch { continue } + + if (entry.version && typeof entry.version === 'string') lastVersion = entry.version + + const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined + const withinRange = inRange(ts, dateRange) + const recent = isRecent(ts, recentCutoffMs) + + if (entry.cwd && typeof entry.cwd === 'string' && withinRange) cwds.push(entry.cwd) + + if (entry.type === 'user') { + if (!withinRange) continue + const msg = entry.message as Record | undefined + const msgContent = msg?.content + if (typeof msgContent === 'string') { + userMessages.push(msgContent) + } else if (Array.isArray(msgContent)) { + for (const block of msgContent) { + if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') { + userMessages.push(block.text) + } + } + } + continue + } + + if (entry.type !== 'assistant') continue + if (!withinRange) continue + + const msg = entry.message as Record | undefined + const usage = msg?.usage as Record | undefined + if (usage) { + const cacheCreate = (usage.cache_creation_input_tokens as number) ?? 0 + if (cacheCreate > 0) apiCalls.push({ cacheCreationTokens: cacheCreate, version: lastVersion, recent }) + } + + const blocks = msg?.content + if (!Array.isArray(blocks)) continue + + for (const block of blocks) { + if (block.type !== 'tool_use') continue + calls.push({ + name: block.name as string, + input: (block.input as Record) ?? {}, + sessionId, + project, + recent, + }) + } + } + + return { calls, cwds, apiCalls, userMessages } +} + +async function scanSessions(dateRange?: DateRange): Promise { + const sources = await discoverAllSessions('claude') + const allCalls: ToolCall[] = [] + const allCwds = new Set() + const allApiCalls: ApiCallMeta[] = [] + const allUserMessages: string[] = [] + + const tasks: Array<{ file: string; project: string }> = [] + for (const source of sources) { + const files = await collectJsonlFiles(source.path) + for (const file of files) { + if (await isFileStaleForRange(file, dateRange)) continue + tasks.push({ file, project: source.project }) + } + } + + await runWithConcurrency(tasks, FILE_READ_CONCURRENCY, async ({ file, project }) => { + const { calls, cwds, apiCalls, userMessages } = await scanJsonlFile(file, project, dateRange) + allCalls.push(...calls) + for (const cwd of cwds) allCwds.add(cwd) + allApiCalls.push(...apiCalls) + allUserMessages.push(...userMessages) + }) + + return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, userMessages: allUserMessages } +} + +// ============================================================================ +// Shared helpers +// ============================================================================ + +function readJsonFile(path: string): Record | null { + try { return JSON.parse(readFileSync(path, 'utf-8')) } catch { return null } +} + +function shortHomePath(absPath: string): string { + const home = homedir() + return absPath.startsWith(home) ? '~' + absPath.slice(home.length) : absPath +} + +function isReadTool(name: string): boolean { + return name === 'Read' || name === 'FileReadTool' +} + +type McpConfigEntry = { normalized: string; original: string; mtime: number } + +export function loadMcpConfigs(projectCwds: Iterable): Map { + const servers = new Map() + const configPaths = [ + join(homedir(), '.claude', 'settings.json'), + join(homedir(), '.claude', 'settings.local.json'), + ] + for (const cwd of projectCwds) { + configPaths.push(join(cwd, '.mcp.json')) + configPaths.push(join(cwd, '.claude', 'settings.json')) + configPaths.push(join(cwd, '.claude', 'settings.local.json')) + } + + for (const p of configPaths) { + if (!existsSync(p)) continue + const config = readJsonFile(p) + if (!config) continue + let mtime = 0 + try { mtime = statSync(p).mtimeMs } catch {} + const serversObj = (config.mcpServers ?? {}) as Record + for (const name of Object.keys(serversObj)) { + const normalized = name.replace(/:/g, '_') + const existing = servers.get(normalized) + if (!existing || existing.mtime < mtime) { + servers.set(normalized, { normalized, original: name, mtime }) + } + } + } + return servers +} + +// ============================================================================ +// Detectors +// ============================================================================ + +export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + const dirCounts = new Map() + let totalJunkReads = 0 + let recentJunkReads = 0 + + for (const call of calls) { + if (!isReadTool(call.name)) continue + const filePath = call.input.file_path as string | undefined + if (!filePath || !JUNK_PATTERN.test(filePath)) continue + totalJunkReads++ + if (call.recent) recentJunkReads++ + for (const dir of JUNK_DIRS) { + if (filePath.includes(`/${dir}/`)) { + dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1) + break + } + } + } + + if (totalJunkReads < MIN_JUNK_READS_TO_FLAG) return null + + const hasRecentActivity = calls.some(c => c.recent) + const trend = sessionTrend(recentJunkReads, totalJunkReads, dateRange, hasRecentActivity) + if (trend === 'resolved') return null + + const sorted = [...dirCounts.entries()].sort((a, b) => b[1] - a[1]) + const dirList = sorted.slice(0, TOP_ITEMS_PREVIEW).map(([d, n]) => `${d}/ (${n}x)`).join(', ') + const tokensSaved = totalJunkReads * AVG_TOKENS_PER_READ + + const detected = sorted.map(([d]) => d) + const commonDefaults = ['node_modules', '.git', 'dist', '__pycache__'] + const extras = commonDefaults.filter(d => !dirCounts.has(d)).slice(0, Math.max(0, 6 - detected.length)) + const ignoreContent = [...detected, ...extras].join('\n') + + return { + title: 'Claude is reading build/dependency folders', + explanation: `Claude read into ${dirList} (${totalJunkReads} reads). These are generated or dependency directories, not your code. A .claudeignore tells Claude to skip them.`, + impact: totalJunkReads > JUNK_READS_HIGH_THRESHOLD ? 'high' : totalJunkReads > JUNK_READS_MEDIUM_THRESHOLD ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'file-content', + label: 'Create .claudeignore in your project root:', + path: '.claudeignore', + content: ignoreContent, + }, + trend, + } +} + +export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + const sessionFiles = new Map>() + + for (const call of calls) { + if (!isReadTool(call.name)) continue + const filePath = call.input.file_path as string | undefined + if (!filePath || JUNK_PATTERN.test(filePath)) continue + const key = `${call.project}:${call.sessionId}` + if (!sessionFiles.has(key)) sessionFiles.set(key, new Map()) + const fm = sessionFiles.get(key)! + const entry = fm.get(filePath) ?? { count: 0, recent: 0 } + entry.count++ + if (call.recent) entry.recent++ + fm.set(filePath, entry) + } + + let totalDuplicates = 0 + let recentDuplicates = 0 + const fileDupes = new Map() + + for (const fm of sessionFiles.values()) { + for (const [file, entry] of fm) { + if (entry.count <= 1) continue + const extra = entry.count - 1 + totalDuplicates += extra + if (entry.recent > 1) recentDuplicates += entry.recent - 1 + const name = basename(file) + fileDupes.set(name, (fileDupes.get(name) ?? 0) + extra) + } + } + + if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return null + + const hasRecentActivity = calls.some(c => c.recent) + const trend = sessionTrend(recentDuplicates, totalDuplicates, dateRange, hasRecentActivity) + if (trend === 'resolved') return null + + const worst = [...fileDupes.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_ITEMS_PREVIEW) + .map(([name, n]) => `${name} (${n + 1}x)`) + .join(', ') + + const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ + + return { + title: 'Claude is re-reading the same files', + explanation: `${totalDuplicates} redundant re-reads across sessions. Top repeats: ${worst}. Each re-read loads the same content into context again.`, + impact: totalDuplicates > DUPLICATE_READS_HIGH_THRESHOLD ? 'high' : totalDuplicates > DUPLICATE_READS_MEDIUM_THRESHOLD ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'paste', + label: 'Point Claude at exact locations in your prompt, for example:', + text: 'In lines -, look at the function.', + }, + trend, + } +} + +export function detectUnusedMcp( + calls: ToolCall[], + projects: ProjectSummary[], + projectCwds: Set, +): WasteFinding | null { + const configured = loadMcpConfigs(projectCwds) + if (configured.size === 0) return null + + const calledServers = new Set() + for (const call of calls) { + if (!call.name.startsWith('mcp__')) continue + const seg = call.name.split('__')[1] + if (seg) calledServers.add(seg) + } + for (const p of projects) { + for (const s of p.sessions) { + for (const server of Object.keys(s.mcpBreakdown)) calledServers.add(server) + } + } + + const now = Date.now() + const unused: string[] = [] + for (const entry of configured.values()) { + if (calledServers.has(entry.normalized)) continue + if (entry.mtime > 0 && now - entry.mtime < MCP_NEW_CONFIG_GRACE_MS) continue + unused.push(entry.original) + } + + if (unused.length === 0) return null + + const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) + const schemaTokensPerSession = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL + const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1) + + return { + title: `${unused.length} MCP server${unused.length > 1 ? 's' : ''} configured but never used`, + explanation: `Never called in this period: ${unused.join(', ')}. Each server loads ~${TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL} tokens of tool schema into every session.`, + impact: unused.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium', + tokensSaved, + fix: { + type: 'command', + label: `Remove unused server${unused.length > 1 ? 's' : ''}:`, + text: unused.map(s => `claude mcp remove ${s}`).join('\n'), + }, + } +} + +export function detectMissingClaudeignore(projectCwds: Set): WasteFinding | null { + const missing: string[] = [] + + for (const cwd of projectCwds) { + if (!existsSync(cwd)) continue + if (existsSync(join(cwd, '.claudeignore'))) continue + for (const dir of JUNK_DIRS) { + if (existsSync(join(cwd, dir))) { + missing.push(cwd) + break + } + } + } + + if (missing.length === 0) return null + + const shortPaths = missing.map(shortHomePath) + const display = shortPaths.length <= MISSING_IGNORE_PATHS_PREVIEW + 1 + ? shortPaths.join(', ') + : `${shortPaths.slice(0, MISSING_IGNORE_PATHS_PREVIEW).join(', ')} + ${shortPaths.length - MISSING_IGNORE_PATHS_PREVIEW} more` + + const tokensSaved = missing.length * ESTIMATED_READS_PER_MISSING_IGNORE * AVG_TOKENS_PER_READ + + return { + title: `Add .claudeignore to ${missing.length} project${missing.length > 1 ? 's' : ''}`, + explanation: `${missing.length} project${missing.length > 1 ? 's have' : ' has'} build/dependency folders (node_modules, .git, etc.) but no .claudeignore: ${display}. Without it, Claude can wander into them.`, + impact: missing.length >= MISSING_IGNORE_HIGH_THRESHOLD ? 'high' : 'medium', + tokensSaved, + fix: { + type: 'file-content', + label: 'Create .claudeignore in each project root:', + path: '.claudeignore', + content: JUNK_DIRS.slice(0, JUNK_DIRS_IGNORE_PREVIEW).join('\n'), + }, + } +} + +function expandImports(filePath: string, seen: Set, depth: number): { totalLines: number; importedFiles: number } { + if (depth > MAX_IMPORT_DEPTH || seen.has(filePath)) return { totalLines: 0, importedFiles: 0 } + seen.add(filePath) + let content: string + try { content = readFileSync(filePath, 'utf-8') } catch { return { totalLines: 0, importedFiles: 0 } } + + let totalLines = content.split('\n').length + let importedFiles = 0 + const dir = join(filePath, '..') + + IMPORT_PATTERN.lastIndex = 0 + for (const match of content.matchAll(IMPORT_PATTERN)) { + const rawPath = match[1] + if (!rawPath) continue + const resolved = rawPath.startsWith('/') ? rawPath : join(dir, rawPath) + if (!existsSync(resolved)) continue + const nested = expandImports(resolved, seen, depth + 1) + totalLines += nested.totalLines + importedFiles += 1 + nested.importedFiles + } + + return { totalLines, importedFiles } +} + +export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | null { + const bloated: { path: string; expandedLines: number; imports: number }[] = [] + + for (const cwd of projectCwds) { + for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) { + const fullPath = join(cwd, name) + if (!existsSync(fullPath)) continue + const { totalLines, importedFiles } = expandImports(fullPath, new Set(), 0) + if (totalLines > CLAUDEMD_HEALTHY_LINES) { + bloated.push({ path: `${shortHomePath(cwd)}/${name}`, expandedLines: totalLines, imports: importedFiles }) + } + } + } + + if (bloated.length === 0) return null + + const sorted = bloated.sort((a, b) => b.expandedLines - a.expandedLines) + const worst = sorted[0] + const totalExtraLines = sorted.reduce((s, b) => s + (b.expandedLines - CLAUDEMD_HEALTHY_LINES), 0) + const tokensSaved = totalExtraLines * CLAUDEMD_TOKENS_PER_LINE + + const list = sorted.slice(0, TOP_ITEMS_PREVIEW).map(b => { + const importNote = b.imports > 0 ? ` with ${b.imports} @-import${b.imports > 1 ? 's' : ''}` : '' + return `${b.path} (${b.expandedLines} lines${importNote})` + }).join(', ') + + return { + title: `Your CLAUDE.md is too long`, + explanation: `${list}. CLAUDE.md plus all @-imported files load into every API call. Trimming below ${CLAUDEMD_HEALTHY_LINES} lines saves ~${formatTokens(tokensSaved)} tokens per call.`, + impact: worst.expandedLines > CLAUDEMD_HIGH_THRESHOLD_LINES ? 'high' : 'medium', + tokensSaved, + fix: { + type: 'paste', + label: 'Ask Claude to trim it:', + text: `Review CLAUDE.md and all @-imported files. Cut total expanded content to under ${CLAUDEMD_HEALTHY_LINES} lines. Remove anything Claude can figure out from the code itself. Keep only rules, gotchas, and non-obvious conventions.`, + }, + } +} + +const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) +const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit']) + +export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { + let reads = 0 + let edits = 0 + let recentEdits = 0 + let recentReads = 0 + for (const call of calls) { + if (READ_TOOL_NAMES.has(call.name)) { + reads++ + if (call.recent) recentReads++ + } else if (EDIT_TOOL_NAMES.has(call.name)) { + edits++ + if (call.recent) recentEdits++ + } + } + + if (edits < MIN_EDITS_FOR_RATIO) return null + const ratio = reads / edits + if (ratio >= HEALTHY_READ_EDIT_RATIO) return null + + const impact: Impact = ratio < LOW_RATIO_HIGH_THRESHOLD ? 'high' : ratio < LOW_RATIO_MEDIUM_THRESHOLD ? 'medium' : 'low' + const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0) + const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ + + let trend: Trend | 'resolved' = 'active' + if (recentEdits >= MIN_EDITS_FOR_RATIO) { + const recentRatio = recentReads / recentEdits + if (recentRatio >= HEALTHY_READ_EDIT_RATIO) trend = 'resolved' + else if (recentRatio > ratio * (1 / IMPROVING_THRESHOLD)) trend = 'improving' + } + if (trend === 'resolved') return null + + return { + title: 'Claude edits more than it reads', + explanation: `Claude made ${reads} reads and ${edits} edits (ratio ${ratio.toFixed(1)}:1). A healthy ratio is ${HEALTHY_READ_EDIT_RATIO}+ reads per edit. Editing without reading leads to retries and wasted tokens.`, + impact, + tokensSaved, + fix: { + type: 'paste', + label: 'Add to your CLAUDE.md:', + text: 'Before editing any file, read it first. Before modifying a function, grep for all callers. Research before you edit.', + }, + trend, + } +} + +const DEFAULT_CACHE_BASELINE_TOKENS = 50_000 +const CACHE_BASELINE_QUANTILE = 0.25 +const CACHE_BLOAT_MULTIPLIER = 1.4 +const CACHE_VERSION_MIN_SAMPLES = 5 +const CACHE_VERSION_DIFF_THRESHOLD = 10_000 + +function computeBudgetAwareCacheBaseline(projects: ProjectSummary[]): number { + const sessions = projects.flatMap(p => p.sessions) + if (sessions.length === 0) return DEFAULT_CACHE_BASELINE_TOKENS + const cacheWrites = sessions.map(s => s.totalCacheWriteTokens).filter(n => n > 0) + if (cacheWrites.length < MIN_API_CALLS_FOR_CACHE) return DEFAULT_CACHE_BASELINE_TOKENS + const sorted = cacheWrites.sort((a, b) => a - b) + return sorted[Math.floor(sorted.length * CACHE_BASELINE_QUANTILE)] || DEFAULT_CACHE_BASELINE_TOKENS +} + +export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSummary[], dateRange?: DateRange): WasteFinding | null { + if (apiCalls.length < MIN_API_CALLS_FOR_CACHE) return null + + const sorted = apiCalls.map(c => c.cacheCreationTokens).sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] + const baseline = computeBudgetAwareCacheBaseline(projects) + const bloatThreshold = baseline * CACHE_BLOAT_MULTIPLIER + + if (median < bloatThreshold) return null + + const recentCalls = apiCalls.filter(c => c.recent) + const totalBloated = apiCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length + const recentBloated = recentCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length + const trend = sessionTrend(recentBloated, totalBloated, dateRange, recentCalls.length > 0) + if (trend === 'resolved') return null + + const versionCounts = new Map() + for (const call of apiCalls) { + if (!call.version) continue + const entry = versionCounts.get(call.version) ?? { total: 0, count: 0 } + entry.total += call.cacheCreationTokens + entry.count++ + versionCounts.set(call.version, entry) + } + const versionAvgs = [...versionCounts.entries()] + .filter(([, d]) => d.count >= CACHE_VERSION_MIN_SAMPLES) + .map(([v, d]) => ({ version: v, avg: Math.round(d.total / d.count) })) + .sort((a, b) => b.avg - a.avg) + + const excess = median - baseline + const tokensSaved = excess * apiCalls.length + + let versionNote = '' + if (versionAvgs.length >= 2) { + const [high, ...rest] = versionAvgs + const low = rest[rest.length - 1] + if (high.avg - low.avg > CACHE_VERSION_DIFF_THRESHOLD) { + versionNote = ` Version ${high.version} averages ${formatTokens(high.avg)} vs ${low.version} at ${formatTokens(low.avg)}.` + } + } + + return { + title: 'Session warmup is unusually large', + explanation: `Median cache_creation per call is ${formatTokens(median)} tokens, about ${formatTokens(excess)} above your baseline of ${formatTokens(baseline)}.${versionNote}`, + impact: excess > CACHE_EXCESS_HIGH_THRESHOLD ? 'high' : 'medium', + tokensSaved, + fix: { + type: 'paste', + label: 'Check for recent Claude Code updates or heavy MCP/skill additions. As a workaround (not officially supported):', + text: 'export ANTHROPIC_CUSTOM_HEADERS=\'User-Agent: claude-cli/2.1.98 (external, sdk-cli)\'', + }, + trend, + } +} + +async function listMarkdownFiles(dir: string): Promise { + if (!existsSync(dir)) return [] + try { + const entries = await readdir(dir) + return entries.filter(e => e.endsWith('.md')).map(e => e.replace(/\.md$/, '')) + } catch { return [] } +} + +async function listSkillDirs(dir: string): Promise { + if (!existsSync(dir)) return [] + try { + const entries = await readdir(dir) + const names: string[] = [] + for (const entry of entries) { + if (existsSync(join(dir, entry, 'SKILL.md'))) names.push(entry) + } + return names + } catch { return [] } +} + +export async function detectGhostAgents(calls: ToolCall[]): Promise { + const defined = await listMarkdownFiles(join(homedir(), '.claude', 'agents')) + if (defined.length === 0) return null + + const invoked = new Set() + for (const call of calls) { + if (call.name !== 'Agent' && call.name !== 'Task') continue + const subType = call.input.subagent_type as string | undefined + if (subType) invoked.add(subType) + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_AGENT_DEF + const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') + + return { + title: `${ghosts.length} custom agent${ghosts.length > 1 ? 's' : ''} you never use`, + explanation: `Defined in ~/.claude/agents/ but never invoked in this period: ${list}. Each adds ~${TOKENS_PER_AGENT_DEF} tokens to the Task tool schema on every session.`, + impact: ghosts.length >= GHOST_AGENTS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_AGENTS_MEDIUM_THRESHOLD ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused agent${ghosts.length > 1 ? 's' : ''}:`, + text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/agents/${name}.md ~/.claude/agents/.archived/`).join('\n'), + }, + } +} + +export async function detectGhostSkills(calls: ToolCall[]): Promise { + const defined = await listSkillDirs(join(homedir(), '.claude', 'skills')) + if (defined.length === 0) return null + + const invoked = new Set() + for (const call of calls) { + if (call.name !== 'Skill') continue + const skillName = (call.input.skill as string) || (call.input.name as string) + if (skillName) invoked.add(skillName) + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_SKILL_DEF + const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') + + return { + title: `${ghosts.length} skill${ghosts.length > 1 ? 's' : ''} you never use`, + explanation: `In ~/.claude/skills/ but not invoked this period: ${list}. Each adds ~${TOKENS_PER_SKILL_DEF} tokens of metadata to every session.`, + impact: ghosts.length >= GHOST_SKILLS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_SKILLS_MEDIUM_THRESHOLD ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused skill${ghosts.length > 1 ? 's' : ''}:`, + text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/skills/${name} ~/.claude/skills/.archived/`).join('\n'), + }, + } +} + +export async function detectGhostCommands(userMessages: string[]): Promise { + const defined = await listMarkdownFiles(join(homedir(), '.claude', 'commands')) + if (defined.length === 0) return null + + const invoked = new Set() + for (const msg of userMessages) { + COMMAND_PATTERN.lastIndex = 0 + for (const m of msg.matchAll(COMMAND_PATTERN)) { + const name = (m[1] || m[2] || '').trim() + if (name) invoked.add(name) + } + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_COMMAND_DEF + const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') + + return { + title: `${ghosts.length} slash command${ghosts.length > 1 ? 's' : ''} you never use`, + explanation: `In ~/.claude/commands/ but not referenced this period: ${list}. Each adds ~${TOKENS_PER_COMMAND_DEF} tokens of definition per session.`, + impact: ghosts.length >= GHOST_COMMANDS_MEDIUM_THRESHOLD ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused command${ghosts.length > 1 ? 's' : ''}:`, + text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/commands/${name}.md ~/.claude/commands/.archived/`).join('\n'), + }, + } +} + +function readShellProfileLimit(): number | null { + for (const profile of SHELL_PROFILES) { + const path = join(homedir(), profile) + if (!existsSync(path)) continue + try { + const content = readFileSync(path, 'utf-8') + const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m) + if (match) return parseInt(match[1], 10) + } catch { continue } + } + return null +} + +export function detectBashBloat(): WasteFinding | null { + const profileLimit = readShellProfileLimit() + const envLimit = process.env['BASH_MAX_OUTPUT_LENGTH'] + const configured = profileLimit ?? (envLimit ? parseInt(envLimit, 10) : null) + + if (configured !== null && configured <= BASH_RECOMMENDED_LIMIT) return null + + const limit = configured ?? BASH_DEFAULT_LIMIT + const extraChars = limit - BASH_RECOMMENDED_LIMIT + const tokensSaved = Math.round(extraChars * BASH_TOKENS_PER_CHAR) + + return { + title: 'Shrink bash output limit', + explanation: `Your bash output cap is ${(limit / 1000).toFixed(0)}K chars (${configured ? 'configured' : 'default'}). Most output fits in ${(BASH_RECOMMENDED_LIMIT / 1000).toFixed(0)}K. The extra ~${formatTokens(tokensSaved)} tokens per bash call is trailing noise.`, + impact: 'medium', + tokensSaved, + fix: { + type: 'paste', + label: 'Add to ~/.zshrc or ~/.bashrc:', + text: `export BASH_MAX_OUTPUT_LENGTH=${BASH_RECOMMENDED_LIMIT}`, + }, + } +} + +// ============================================================================ +// Scoring +// ============================================================================ + +const HEALTH_WEIGHTS: Record = { + high: HEALTH_WEIGHT_HIGH, + medium: HEALTH_WEIGHT_MEDIUM, + low: HEALTH_WEIGHT_LOW, +} + +export function computeHealth(findings: WasteFinding[]): { score: number; grade: HealthGrade } { + if (findings.length === 0) return { score: 100, grade: 'A' } + let penalty = 0 + for (const f of findings) penalty += HEALTH_WEIGHTS[f.impact] ?? 0 + const score = Math.max(0, 100 - Math.min(HEALTH_MAX_PENALTY, penalty)) + const grade: HealthGrade = + score >= GRADE_A_MIN ? 'A' : + score >= GRADE_B_MIN ? 'B' : + score >= GRADE_C_MIN ? 'C' : + score >= GRADE_D_MIN ? 'D' : 'F' + return { score, grade } +} + +const URGENCY_WEIGHTS: Record = { high: 1, medium: 0.5, low: 0.2 } + +function urgencyScore(f: WasteFinding): number { + const normalizedTokens = Math.min(1, f.tokensSaved / URGENCY_TOKEN_NORMALIZE) + return URGENCY_WEIGHTS[f.impact] * URGENCY_IMPACT_WEIGHT + normalizedTokens * URGENCY_TOKEN_WEIGHT +} + +type TrendInputs = { + recentCount: number + recentWindowMs: number + baselineCount: number + baselineWindowMs: number + hasRecentActivity: boolean +} + +export function computeTrend(inputs: TrendInputs): Trend | 'resolved' { + const { recentCount, recentWindowMs, baselineCount, baselineWindowMs, hasRecentActivity } = inputs + if (baselineCount === 0) return 'active' + if (recentCount === 0 && hasRecentActivity) return 'resolved' + if (!hasRecentActivity) return 'active' + const baselineRate = baselineCount / baselineWindowMs + const recentRate = recentCount / Math.max(recentWindowMs, 1) + if (recentRate < baselineRate * IMPROVING_THRESHOLD) return 'improving' + return 'active' +} + +function sessionTrend( + recentItemCount: number, + totalItemCount: number, + dateRange: DateRange | undefined, + hasRecentActivity: boolean, +): Trend | 'resolved' { + const now = Date.now() + const baselineCount = totalItemCount - recentItemCount + const periodStart = dateRange ? dateRange.start.getTime() : now - DEFAULT_TREND_PERIOD_MS + const recentStart = now - RECENT_WINDOW_MS + const baselineWindowMs = Math.max(recentStart - periodStart, 1) + return computeTrend({ + recentCount: recentItemCount, + recentWindowMs: RECENT_WINDOW_MS, + baselineCount, + baselineWindowMs, + hasRecentActivity, + }) +} + +// ============================================================================ +// Cost estimation +// ============================================================================ + +const INPUT_COST_RATIO = 0.7 +const DEFAULT_COST_PER_TOKEN = 0 + +function computeInputCostRate(projects: ProjectSummary[]): number { + const sessions = projects.flatMap(p => p.sessions) + const totalCost = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0) + const totalTokens = sessions.reduce((s, sess) => + s + sess.totalInputTokens + sess.totalCacheReadTokens + sess.totalCacheWriteTokens, 0) + if (totalTokens === 0 || totalCost === 0) return DEFAULT_COST_PER_TOKEN + return (totalCost * INPUT_COST_RATIO) / totalTokens +} + +// ============================================================================ +// Main entry points +// ============================================================================ + +type CacheEntry = { data: OptimizeResult; ts: number } +const resultCache = new Map() + +function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { + const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' + const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0) + return `${dr}:${fingerprint}` +} + +export async function scanAndDetect( + projects: ProjectSummary[], + dateRange?: DateRange, +): Promise { + if (projects.length === 0) { + return { findings: [], costRate: 0, healthScore: 100, healthGrade: 'A' } + } + + const key = cacheKey(projects, dateRange) + const cached = resultCache.get(key) + if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data + + const costRate = computeInputCostRate(projects) + const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) + + const findings: WasteFinding[] = [] + const syncDetectors: Array<() => WasteFinding | null> = [ + () => detectCacheBloat(apiCalls, projects, dateRange), + () => detectLowReadEditRatio(toolCalls), + () => detectJunkReads(toolCalls, dateRange), + () => detectDuplicateReads(toolCalls, dateRange), + () => detectUnusedMcp(toolCalls, projects, projectCwds), + () => detectMissingClaudeignore(projectCwds), + () => detectBloatedClaudeMd(projectCwds), + () => detectBashBloat(), + ] + for (const detect of syncDetectors) { + const finding = detect() + if (finding) findings.push(finding) + } + + const ghostResults = await Promise.all([ + detectGhostAgents(toolCalls), + detectGhostSkills(toolCalls), + detectGhostCommands(userMessages), + ]) + for (const f of ghostResults) if (f) findings.push(f) + + findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) + const { score, grade } = computeHealth(findings) + const result: OptimizeResult = { findings, costRate, healthScore: score, healthGrade: grade } + resultCache.set(key, { data: result, ts: Date.now() }) + return result +} + +// ============================================================================ +// CLI rendering +// ============================================================================ + +const PANEL_WIDTH = 62 +const SEP = '\u2500' +const IMPACT_COLORS: Record = { high: RED, medium: ORANGE, low: DIM } +const GRADE_COLORS: Record = { A: GREEN, B: GREEN, C: GOLD, D: ORANGE, F: RED } + +function wrap(text: string, width: number, indent: string): string { + const words = text.split(' ') + const lines: string[] = [] + let current = '' + for (const word of words) { + if (current && current.length + word.length + 1 > width) { + lines.push(indent + current) + current = word + } else { + current = current ? current + ' ' + word : word + } + } + if (current) lines.push(indent + current) + return lines.join('\n') +} + +function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { + const lines: string[] = [] + const costSaved = f.tokensSaved * costRate + const impactLabel = f.impact.charAt(0).toUpperCase() + f.impact.slice(1) + const trendBadge = f.trend === 'improving' ? ' improving \u2193 ' : '' + const savings = `~${formatTokens(f.tokensSaved)} tokens (~${formatCost(costSaved)})` + const titlePad = PANEL_WIDTH - f.title.length - impactLabel.length - trendBadge.length - 8 + const pad = titlePad > 0 ? ' ' + SEP.repeat(titlePad) + ' ' : ' ' + + lines.push(chalk.hex(DIM)(` ${SEP}${SEP}${SEP} `) + + chalk.bold(`${n}. ${f.title}`) + + chalk.hex(DIM)(pad) + + chalk.hex(IMPACT_COLORS[f.impact])(impactLabel) + + (trendBadge ? chalk.hex(GREEN)(trendBadge) : '') + + chalk.hex(DIM)(` ${SEP}${SEP}${SEP}`)) + lines.push('') + lines.push(wrap(f.explanation, PANEL_WIDTH - 4, ' ')) + lines.push('') + lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`)) + lines.push('') + + const a = f.fix + if (a.type === 'file-content') { + lines.push(chalk.hex(DIM)(` ${a.label}`)) + for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) + } else if (a.type === 'command') { + lines.push(chalk.hex(DIM)(` ${a.label}`)) + for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) + } else { + lines.push(chalk.hex(DIM)(` ${a.label}`)) + lines.push(chalk.hex(CYAN)(` ${a.text}`)) + } + lines.push('') + return lines +} + +function renderOptimize( + findings: WasteFinding[], + costRate: number, + periodLabel: string, + periodCost: number, + sessionCount: number, + callCount: number, + healthScore: number, + healthGrade: HealthGrade, +): string { + const lines: string[] = [] + lines.push('') + lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn config health')}${chalk.dim(' ' + periodLabel)}`) + lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) + + const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' + lines.push(' ' + [ + `${sessionCount} sessions`, + `${callCount.toLocaleString()} calls`, + chalk.hex(GOLD)(formatCost(periodCost)), + `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, + ].join(chalk.hex(DIM)(' '))) + lines.push('') + + if (findings.length === 0) { + lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.')) + lines.push('') + lines.push(chalk.dim(' CodeBurn optimize scans your Claude Code sessions and config for')) + lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused')) + lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.')) + lines.push('') + return lines.join('\n') + } + + const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0) + const totalCost = totalTokens * costRate + const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 + const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) + + const costText = costRate > 0 ? ` (~${formatCost(totalCost)}, ~${pct}% of spend)` : '' + lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens${costText}`)) + lines.push('') + + for (let i = 0; i < findings.length; i++) { + lines.push(...renderFinding(i + 1, findings[i], costRate)) + } + + lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) + lines.push(chalk.dim(' Estimates only.')) + lines.push('') + return lines.join('\n') +} + +export async function runOptimize( + projects: ProjectSummary[], + periodLabel: string, + dateRange?: DateRange, +): Promise { + if (projects.length === 0) { + console.log(chalk.dim('\n No usage data found for this period.\n')) + return + } + + process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + + const { findings, costRate, healthScore, healthGrade } = await scanAndDetect(projects, dateRange) + const sessions = projects.flatMap(p => p.sessions) + const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) + + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade) + console.log(output) +} diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts new file mode 100644 index 0000000..a476042 --- /dev/null +++ b/tests/optimize-fs.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +vi.mock('os', async () => { + const actual = await vi.importActual('os') + const fs = await vi.importActual('fs') + const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/codeburn-home-') + fs.mkdirSync(fakeHome + '/.claude', { recursive: true }) + process.env['CODEBURN_TEST_FAKE_HOME'] = fakeHome + return { ...actual, homedir: () => fakeHome } +}) + +const FAKE_HOME_FOR_MOCK = process.env['CODEBURN_TEST_FAKE_HOME']! + +import { + detectMissingClaudeignore, + detectBloatedClaudeMd, + detectUnusedMcp, + detectBashBloat, + detectGhostAgents, + detectGhostSkills, + detectGhostCommands, + loadMcpConfigs, + scanJsonlFile, + scanAndDetect, + type ToolCall, +} from '../src/optimize.js' +import { + estimateContextBudget, + discoverProjectCwd, +} from '../src/context-budget.js' + +// ============================================================================ +// Helpers for filesystem fixtures +// ============================================================================ + +const FIXTURE_ROOTS: string[] = [FAKE_HOME_FOR_MOCK] + +function makeFixtureRoot(): string { + const dir = mkdtempSync(join(tmpdir(), 'codeburn-test-')) + FIXTURE_ROOTS.push(dir) + return dir +} + +function writeFile(path: string, content: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, content) +} + +function touchOld(path: string, daysAgo: number): void { + const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000) + utimesSync(path, past, past) +} + +afterAll(() => { + for (const dir of FIXTURE_ROOTS) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +// ============================================================================ +// detectMissingClaudeignore +// ============================================================================ + +describe('detectMissingClaudeignore', () => { + it('flags a project with node_modules but no .claudeignore', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(join(projectDir, 'node_modules'), { recursive: true }) + const finding = detectMissingClaudeignore(new Set([projectDir])) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('medium') + }) + + it('does not flag when .claudeignore exists', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(join(projectDir, 'node_modules'), { recursive: true }) + writeFile(join(projectDir, '.claudeignore'), 'node_modules\n') + expect(detectMissingClaudeignore(new Set([projectDir]))).toBeNull() + }) + + it('does not flag project without junk dirs', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(join(projectDir, 'src'), { recursive: true }) + expect(detectMissingClaudeignore(new Set([projectDir]))).toBeNull() + }) + + it('escalates to high when three or more projects need it', () => { + const root = makeFixtureRoot() + const cwds = new Set() + for (let i = 0; i < 3; i++) { + const p = join(root, `proj-${i}`) + mkdirSync(join(p, 'node_modules'), { recursive: true }) + cwds.add(p) + } + const finding = detectMissingClaudeignore(cwds) + expect(finding!.impact).toBe('high') + }) +}) + +// ============================================================================ +// detectBloatedClaudeMd (including @-import expansion) +// ============================================================================ + +describe('detectBloatedClaudeMd', () => { + it('flags a CLAUDE.md with more than 200 lines', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + const content = Array.from({ length: 300 }, (_, i) => `line ${i}`).join('\n') + writeFile(join(projectDir, 'CLAUDE.md'), content) + const finding = detectBloatedClaudeMd(new Set([projectDir])) + expect(finding).not.toBeNull() + }) + + it('expands @-imports and counts transitive load', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile( + join(projectDir, 'CLAUDE.md'), + 'line 1\nline 2\n@./rules.md\n@./conventions.md\n', + ) + writeFile(join(projectDir, 'rules.md'), Array.from({ length: 120 }, (_, i) => `rule ${i}`).join('\n')) + writeFile(join(projectDir, 'conventions.md'), Array.from({ length: 120 }, (_, i) => `conv ${i}`).join('\n')) + const finding = detectBloatedClaudeMd(new Set([projectDir])) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('2 @-imports') + }) + + it('does not flag a lean CLAUDE.md under 200 lines with no imports', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, 'CLAUDE.md'), 'just a few\nlines\nhere\n') + expect(detectBloatedClaudeMd(new Set([projectDir]))).toBeNull() + }) + + it('does not recurse infinitely on circular @-imports', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, 'CLAUDE.md'), '@./a.md\n') + writeFile(join(projectDir, 'a.md'), '@./b.md\n') + writeFile(join(projectDir, 'b.md'), '@./a.md\n') + expect(() => detectBloatedClaudeMd(new Set([projectDir]))).not.toThrow() + }) + + it('ignores @ tokens that are not paths (emails, npm scopes)', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile( + join(projectDir, 'CLAUDE.md'), + Array.from({ length: 250 }, (_, i) => + i === 10 ? '@user@example.com' : + i === 20 ? '@org/package' : + `line ${i}` + ).join('\n'), + ) + const finding = detectBloatedClaudeMd(new Set([projectDir])) + expect(finding).not.toBeNull() + // "with N @-imports" suffix appears only when non-zero imports were resolved + expect(finding!.explanation).not.toMatch(/with \d+ @-import/) + }) +}) + +// ============================================================================ +// loadMcpConfigs + detectUnusedMcp +// ============================================================================ + +describe('loadMcpConfigs', () => { + it('returns empty map when no configs exist', () => { + const root = makeFixtureRoot() + const servers = loadMcpConfigs([root]) + expect(servers.size).toBe(0) + }) + + it('reads servers from project .mcp.json', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { foo: { command: 'foo' }, bar: { command: 'bar' } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('foo')).toBe(true) + expect(servers.has('bar')).toBe(true) + }) + + it('normalizes server names by replacing colons with underscores', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { 'plugin:context7:context7': { command: 'ctx' } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('plugin_context7_context7')).toBe(true) + expect(servers.get('plugin_context7_context7')!.original).toBe('plugin:context7:context7') + }) + + it('handles malformed JSON without crashing', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), '{ not valid json') + expect(() => loadMcpConfigs([projectDir])).not.toThrow() + expect(loadMcpConfigs([projectDir]).size).toBe(0) + }) +}) + +describe('detectUnusedMcp', () => { + it('flags servers configured but never called', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { ghost: { command: 'x' } }, + })) + const configFile = join(projectDir, '.mcp.json') + touchOld(configFile, 30) + const finding = detectUnusedMcp([], [], new Set([projectDir])) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('ghost') + }) + + it('does not flag servers configured within 24 hours', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { freshly_added: { command: 'x' } }, + })) + expect(detectUnusedMcp([], [], new Set([projectDir]))).toBeNull() + }) + + it('does not flag servers that were called', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { used: { command: 'x' } }, + })) + touchOld(join(projectDir, '.mcp.json'), 30) + const calls: ToolCall[] = [ + { name: 'mcp__used__some_tool', input: {}, sessionId: 's1', project: 'p1' }, + ] + expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull() + }) +}) + +// ============================================================================ +// detectBashBloat +// ============================================================================ + +describe('detectBashBloat', () => { + const originalEnv = process.env['BASH_MAX_OUTPUT_LENGTH'] + + beforeEach(() => { + delete process.env['BASH_MAX_OUTPUT_LENGTH'] + }) + + afterAll(() => { + if (originalEnv !== undefined) process.env['BASH_MAX_OUTPUT_LENGTH'] = originalEnv + }) + + it('flags when env var is unset (uses default 30K)', () => { + const finding = detectBashBloat() + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('medium') + }) + + it('does not flag when env var is at recommended 15K', () => { + process.env['BASH_MAX_OUTPUT_LENGTH'] = '15000' + expect(detectBashBloat()).toBeNull() + }) + + it('does not flag when env var is below recommended', () => { + process.env['BASH_MAX_OUTPUT_LENGTH'] = '10000' + expect(detectBashBloat()).toBeNull() + }) + + it('flags when env var is above 15K', () => { + process.env['BASH_MAX_OUTPUT_LENGTH'] = '50000' + const finding = detectBashBloat() + expect(finding).not.toBeNull() + }) +}) + +// ============================================================================ +// detectGhostCommands (the pure-function ghost detector) +// ============================================================================ + +describe('detectGhostCommands', () => { + it('returns null when no commands are defined', async () => { + expect(await detectGhostCommands([])).toBeNull() + }) + + it('does not match /tmp or /usr or other path prefixes as command usage', async () => { + const messages = [ + 'check /tmp/debug.log', + 'look at /usr/local/bin', + 'rm -rf /var/cache', + ] + expect(await detectGhostCommands(messages)).toBeNull() + }) + + it('matches tags in user messages', async () => { + const messages = ['review'] + expect(await detectGhostCommands(messages)).toBeNull() + }) +}) + +// ============================================================================ +// scanJsonlFile +// ============================================================================ + +describe('scanJsonlFile', () => { + it('returns empty result for nonexistent file', async () => { + const result = await scanJsonlFile('/nonexistent/path.jsonl', 'p1', undefined) + expect(result.calls).toEqual([]) + expect(result.cwds).toEqual([]) + expect(result.apiCalls).toEqual([]) + expect(result.userMessages).toEqual([]) + }) + + it('parses tool_use blocks from assistant entries', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + const now = new Date().toISOString() + const lines = [ + JSON.stringify({ + type: 'assistant', + timestamp: now, + message: { + content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/x/foo.ts' } }], + }, + }), + ] + writeFile(filePath, lines.join('\n')) + const result = await scanJsonlFile(filePath, 'p1', undefined) + expect(result.calls).toHaveLength(1) + expect(result.calls[0].name).toBe('Read') + }) + + it('skips malformed JSONL lines without crashing', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + writeFile(filePath, 'this is not json\n{broken\n{"type":"assistant","message":{"content":[]}}\n') + const result = await scanJsonlFile(filePath, 'p1', undefined) + expect(result.calls).toEqual([]) + }) + + it('respects date-range filter for assistant entries', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + const old = '2020-01-01T00:00:00Z' + const now = new Date().toISOString() + writeFile(filePath, [ + JSON.stringify({ + type: 'assistant', timestamp: old, + message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/old' } }] }, + }), + JSON.stringify({ + type: 'assistant', timestamp: now, + message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/new' } }] }, + }), + ].join('\n')) + const today = new Date() + const start = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + const result = await scanJsonlFile(filePath, 'p1', { start, end: today }) + expect(result.calls).toHaveLength(1) + expect((result.calls[0].input as Record).file_path).toBe('/new') + }) +}) + +// ============================================================================ +// scanAndDetect (top-level integration) +// ============================================================================ + +describe('scanAndDetect', () => { + it('returns healthy result for empty projects', async () => { + const result = await scanAndDetect([]) + expect(result.findings).toEqual([]) + expect(result.healthScore).toBe(100) + expect(result.healthGrade).toBe('A') + expect(result.costRate).toBe(0) + }) +}) + +// ============================================================================ +// context-budget +// ============================================================================ + +describe('estimateContextBudget', () => { + it('returns only system base when project has no config', async () => { + const root = makeFixtureRoot() + const budget = await estimateContextBudget(root) + expect(budget.total).toBeGreaterThan(0) + expect(budget.mcpTools.count).toBe(0) + expect(budget.skills.count).toBe(0) + }) + + it('includes MCP tools from project .mcp.json', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { a: { command: 'x' }, b: { command: 'x' } }, + })) + const budget = await estimateContextBudget(root) + expect(budget.mcpTools.count).toBeGreaterThan(0) + }) + + it('includes memory file tokens from CLAUDE.md', async () => { + const root = makeFixtureRoot() + writeFile(join(root, 'CLAUDE.md'), 'Project context for Claude.\n') + const budget = await estimateContextBudget(root) + expect(budget.memory.count).toBeGreaterThan(0) + expect(budget.memory.tokens).toBeGreaterThan(0) + }) +}) + +describe('discoverProjectCwd', () => { + it('returns null for empty directory', async () => { + const root = makeFixtureRoot() + expect(await discoverProjectCwd(root)).toBeNull() + }) + + it('returns null for directory with no jsonl files', async () => { + const root = makeFixtureRoot() + writeFile(join(root, 'readme.txt'), 'hi') + expect(await discoverProjectCwd(root)).toBeNull() + }) + + it('extracts cwd from the first jsonl entry', async () => { + const root = makeFixtureRoot() + const entry = JSON.stringify({ type: 'assistant', cwd: '/Users/test/project', timestamp: new Date().toISOString() }) + writeFile(join(root, 'session.jsonl'), entry + '\n') + expect(await discoverProjectCwd(root)).toBe('/Users/test/project') + }) +}) diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts new file mode 100644 index 0000000..5ebcab8 --- /dev/null +++ b/tests/optimize.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect } from 'vitest' + +import { + detectJunkReads, + detectDuplicateReads, + detectLowReadEditRatio, + detectCacheBloat, + detectBloatedClaudeMd, + detectMissingClaudeignore, + computeHealth, + computeTrend, + type ToolCall, + type ApiCallMeta, + type WasteFinding, +} from '../src/optimize.js' +import type { ProjectSummary } from '../src/types.js' + +function call(name: string, input: Record, sessionId = 's1', project = 'p1'): ToolCall { + return { name, input, sessionId, project } +} + +function emptyProjects(): ProjectSummary[] { + return [] +} + +describe('detectJunkReads', () => { + it('returns null below minimum threshold', () => { + const calls = [ + call('Read', { file_path: '/x/node_modules/a.js' }), + call('Read', { file_path: '/x/node_modules/b.js' }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('flags when threshold is met', () => { + const calls = [ + call('Read', { file_path: '/x/node_modules/a.js' }), + call('Read', { file_path: '/x/node_modules/b.js' }), + call('Read', { file_path: '/x/.git/config' }), + ] + const finding = detectJunkReads(calls) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('low') + }) + + it('scales impact with read count', () => { + const make = (n: number) => Array.from({ length: n }, (_, i) => + call('Read', { file_path: `/x/node_modules/file-${i}.js` }) + ) + expect(detectJunkReads(make(25))!.impact).toBe('high') + expect(detectJunkReads(make(10))!.impact).toBe('medium') + }) + + it('ignores non-junk paths', () => { + const calls = [ + call('Read', { file_path: '/x/src/a.ts' }), + call('Read', { file_path: '/x/src/b.ts' }), + call('Read', { file_path: '/x/README.md' }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('ignores non-read tools', () => { + const calls = [ + call('Edit', { file_path: '/x/node_modules/a.js' }), + call('Bash', { command: 'ls node_modules' }), + call('Grep', { pattern: 'test', path: '/x/node_modules' }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('handles missing file_path gracefully', () => { + const calls = [ + call('Read', {}), + call('Read', { file_path: null as unknown as string }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('builds .claudeignore content from detected + common extras', () => { + const calls = Array.from({ length: 5 }, () => call('Read', { file_path: '/x/node_modules/a.js' })) + const finding = detectJunkReads(calls)! + expect(finding.fix.type).toBe('file-content') + if (finding.fix.type === 'file-content') { + expect(finding.fix.content).toContain('node_modules') + } + }) +}) + +describe('detectDuplicateReads', () => { + it('counts same file read multiple times in same session', () => { + const calls = [ + ...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/a.ts' }, 's1')), + ...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/b.ts' }, 's1')), + ] + const finding = detectDuplicateReads(calls) + expect(finding).not.toBeNull() + }) + + it('does not count across sessions', () => { + const calls = [ + call('Read', { file_path: '/src/a.ts' }, 's1'), + call('Read', { file_path: '/src/a.ts' }, 's2'), + call('Read', { file_path: '/src/a.ts' }, 's3'), + ] + expect(detectDuplicateReads(calls)).toBeNull() + }) + + it('excludes junk directory reads', () => { + const calls = Array.from({ length: 10 }, () => + call('Read', { file_path: '/x/node_modules/foo.js' }, 's1') + ) + expect(detectDuplicateReads(calls)).toBeNull() + }) + + it('returns null for single reads', () => { + const calls = [ + call('Read', { file_path: '/src/a.ts' }, 's1'), + call('Read', { file_path: '/src/b.ts' }, 's1'), + ] + expect(detectDuplicateReads(calls)).toBeNull() + }) +}) + +describe('detectLowReadEditRatio', () => { + it('returns null below minimum edit count', () => { + const calls = [ + call('Edit', {}), + call('Edit', {}), + call('Read', {}), + ] + expect(detectLowReadEditRatio(calls)).toBeNull() + }) + + it('returns null when ratio is healthy', () => { + const calls = [ + ...Array.from({ length: 40 }, () => call('Read', {})), + ...Array.from({ length: 10 }, () => call('Edit', {})), + ] + expect(detectLowReadEditRatio(calls)).toBeNull() + }) + + it('flags when edits outpace reads', () => { + const calls = [ + ...Array.from({ length: 5 }, () => call('Read', {})), + ...Array.from({ length: 10 }, () => call('Edit', {})), + ] + const finding = detectLowReadEditRatio(calls) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('counts Grep and Glob as reads for ratio', () => { + const calls = [ + ...Array.from({ length: 40 }, () => call('Grep', {})), + ...Array.from({ length: 10 }, () => call('Edit', {})), + ] + expect(detectLowReadEditRatio(calls)).toBeNull() + }) + + it('counts Write as edit', () => { + const calls = [ + ...Array.from({ length: 15 }, () => call('Read', {})), + ...Array.from({ length: 10 }, () => call('Write', {})), + ] + const finding = detectLowReadEditRatio(calls) + expect(finding).not.toBeNull() + }) +}) + +describe('detectCacheBloat', () => { + it('returns null below minimum api calls', () => { + const apiCalls: ApiCallMeta[] = [ + { cacheCreationTokens: 80000, version: '2.1.100' }, + { cacheCreationTokens: 80000, version: '2.1.100' }, + ] + expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull() + }) + + it('returns null when median is close to baseline', () => { + const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({ + cacheCreationTokens: 50000, + version: '2.1.98', + })) + expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull() + }) + + it('flags when median exceeds 1.4x baseline', () => { + const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({ + cacheCreationTokens: 80000, + version: '2.1.100', + })) + const finding = detectCacheBloat(apiCalls, emptyProjects()) + expect(finding).not.toBeNull() + }) +}) + +describe('detectBloatedClaudeMd', () => { + it('returns null when no projects have CLAUDE.md', () => { + const result = detectBloatedClaudeMd(new Set(['/nonexistent/path'])) + expect(result).toBeNull() + }) + + it('returns null for empty project set', () => { + const result = detectBloatedClaudeMd(new Set()) + expect(result).toBeNull() + }) +}) + +describe('detectMissingClaudeignore', () => { + it('returns null for empty set', () => { + expect(detectMissingClaudeignore(new Set())).toBeNull() + }) + + it('returns null for non-existent cwds', () => { + expect(detectMissingClaudeignore(new Set(['/does/not/exist']))).toBeNull() + }) +}) + +describe('computeHealth', () => { + it('returns A with 100 for no findings', () => { + const { score, grade } = computeHealth([]) + expect(score).toBe(100) + expect(grade).toBe('A') + }) + + function mockFinding(impact: 'high' | 'medium' | 'low'): WasteFinding { + return { + title: 't', explanation: 'e', impact, tokensSaved: 1000, + fix: { type: 'paste', label: 'l', text: 't' }, + } + } + + it('one low finding stays at A', () => { + const { score, grade } = computeHealth([mockFinding('low')]) + expect(score).toBe(97) + expect(grade).toBe('A') + }) + + it('two high findings drop to C', () => { + const { score, grade } = computeHealth([mockFinding('high'), mockFinding('high')]) + expect(score).toBe(70) + expect(grade).toBe('C') + }) + + it('caps penalty at 80 to prevent score below 20', () => { + const findings = Array.from({ length: 20 }, () => mockFinding('high')) + const { score } = computeHealth(findings) + expect(score).toBe(20) + }) + + it('progresses grades predictably', () => { + expect(computeHealth([mockFinding('low')]).grade).toBe('A') + expect(computeHealth([mockFinding('medium')]).grade).toBe('A') + expect(computeHealth([mockFinding('medium'), mockFinding('medium')]).grade).toBe('B') + expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('C') + expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('F') + }) +}) + +describe('computeTrend', () => { + const window = 48 * 60 * 60 * 1000 + const baselineWindow = 5 * 24 * 60 * 60 * 1000 + + it('returns active when no recent activity detected', () => { + const trend = computeTrend({ + recentCount: 0, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: false, + }) + expect(trend).toBe('active') + }) + + it('returns resolved when recent activity exists but zero waste in it', () => { + const trend = computeTrend({ + recentCount: 0, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('resolved') + }) + + it('returns improving when recent rate is less than half of baseline rate', () => { + const trend = computeTrend({ + recentCount: 5, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('improving') + }) + + it('returns active when recent rate matches baseline rate', () => { + const recentRate = 100 / baselineWindow + const recentCount = Math.ceil(recentRate * window) + const trend = computeTrend({ + recentCount, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('active') + }) + + it('returns active when baseline is empty (new finding)', () => { + const trend = computeTrend({ + recentCount: 10, recentWindowMs: window, + baselineCount: 0, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('active') + }) +})