mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 16:13:56 +00:00
Merge pull request #60 from AgentSeal/feat/optimize
feat: codeburn optimize -- find waste and get copy-paste fixes
This commit is contained in:
commit
f2d1753d3a
12 changed files with 2331 additions and 173 deletions
35
CHANGELOG.md
35
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
|
||||
|
|
|
|||
|
|
@ -75,3 +75,10 @@ gh pr comment <number> --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
|
||||
|
|
|
|||
32
README.md
32
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/optimize.jpg" alt="CodeBurn optimize output" width="720" />
|
||||
</p>
|
||||
|
||||
```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/<sanitized-path>/<session-id>.jsonl`. Each assistant entry contains model name, token usage (input, output, cache read, cache write), tool_use blocks, and timestamps.
|
||||
|
|
|
|||
BIN
assets/optimize.jpg
Normal file
BIN
assets/optimize.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
src/cli.ts
13
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 <period>', 'Analysis period: today, week, 30days, month, all', '30days')
|
||||
.option('--provider <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()
|
||||
|
|
|
|||
146
src/context-budget.ts
Normal file
146
src/context-budget.ts
Normal file
|
|
@ -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<Record<string, unknown> | null> {
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8'))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function countMcpTools(projectPath?: string): Promise<number> {
|
||||
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<string>()
|
||||
let toolCount = 0
|
||||
|
||||
for (const p of configPaths) {
|
||||
const config = await readConfigFile(p)
|
||||
if (!config) continue
|
||||
const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>
|
||||
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<number> {
|
||||
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<Array<{ name: string; tokens: number }>> {
|
||||
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<ContextBudget> {
|
||||
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<string, string>): Promise<Map<string, ContextBudget>> {
|
||||
const results = new Map<string, ContextBudget>()
|
||||
for (const [project, cwd] of projectPaths) {
|
||||
const budget = await estimateContextBudget(cwd)
|
||||
results.set(project, budget)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function discoverProjectCwd(sessionDir: string): Promise<string | null> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<Period, string> = {
|
||||
|
|
@ -71,6 +75,8 @@ const CATEGORY_COLORS: Record<TaskCategory, string> = {
|
|||
general: '#666666',
|
||||
}
|
||||
|
||||
const IMPACT_PANEL_COLORS: Record<string, string> = { 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/<org>/<env>/
|
||||
.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<string, ContextBudget> }) {
|
||||
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 (
|
||||
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}</Text>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
|
||||
</Text>
|
||||
{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
|
|||
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
|
||||
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
|
||||
<Text>{String(project.sessions.length).padStart(6)}</Text>
|
||||
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
|
@ -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 (
|
||||
<Panel title="By Activity" color={PANEL_COLORS.activity} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)}</Text>
|
||||
|
|
@ -328,9 +332,7 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p
|
|||
return (
|
||||
<Text key={cat} wrap="truncate-end">
|
||||
<HBar value={data.costUSD} max={maxCost} width={bw} />
|
||||
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}>
|
||||
{' '}{fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}
|
||||
</Text>
|
||||
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}> {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}</Text>
|
||||
<Text color={GOLD}>{formatCost(data.costUSD).padStart(8)}</Text>
|
||||
<Text>{String(data.turns).padStart(6)}</Text>
|
||||
<Text color={data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE}>{String(oneShotPct).padStart(7)}</Text>
|
||||
|
|
@ -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 (
|
||||
<Panel title={title ?? 'Core Tools'} color={PANEL_COLORS.tools} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
|
||||
|
|
@ -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<string, number> = {}
|
||||
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 <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
|
||||
}
|
||||
if (sorted.length === 0) return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
|
||||
return (
|
||||
<Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}</Text>
|
||||
{sorted.slice(0, 8).map(([server, calls]) => (
|
||||
<Text key={server} wrap="truncate-end">
|
||||
<HBar value={calls} max={maxCalls} width={bw} />
|
||||
<Text> {fit(server, nw)}</Text>
|
||||
<Text>{String(calls).padStart(6)}</Text>
|
||||
</Text>
|
||||
<Text key={server} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(server, nw)}</Text><Text>{String(calls).padStart(6)}</Text></Text>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -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<string, number> = {}
|
||||
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 <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
|
||||
}
|
||||
if (sorted.length === 0) return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
|
||||
return (
|
||||
<Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
|
||||
{sorted.slice(0, 10).map(([cmd, calls]) => (
|
||||
<Text key={cmd} wrap="truncate-end">
|
||||
<HBar value={calls} max={maxCalls} width={bw} />
|
||||
<Text> {fit(cmd, nw)}</Text>
|
||||
<Text>{String(calls).padStart(7)}</Text>
|
||||
</Text>
|
||||
<Text key={cmd} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(cmd, nw)}</Text><Text>{String(calls).padStart(7)}</Text></Text>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -483,16 +454,9 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
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 (
|
||||
<Box justifyContent="space-between" paddingX={1}>
|
||||
<Box gap={1}>
|
||||
|
|
@ -503,41 +467,82 @@ function PeriodTabs({ active, providerName, showProvider }: {
|
|||
))}
|
||||
</Box>
|
||||
{showProvider && providerName && (
|
||||
<Box>
|
||||
<Text color={DIM}>| </Text>
|
||||
<Text color={ORANGE} bold>[p]</Text>
|
||||
<Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text>
|
||||
</Box>
|
||||
<Box><Text color={DIM}>| </Text><Text color={ORANGE} bold>[p]</Text><Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text></Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (<><Text dimColor>{action.label}</Text>{lines.map((line, i) => <Text key={i} color="#5BF5E0"> {line}</Text>)}</>)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold>{index}. {finding.title}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={color}>{label}</Text>
|
||||
{trendBadge && <Text color="#5BF5A0">{trendBadge}</Text>}
|
||||
</Text>
|
||||
<Text dimColor wrap="wrap">{finding.explanation}</Text>
|
||||
<Text color={GOLD}>Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})</Text>
|
||||
<Text> </Text>
|
||||
<FindingAction action={finding.fix} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const GRADE_COLORS: Record<string, string> = { 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 (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={width}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold color={ORANGE}>CodeBurn Optimize</Text>
|
||||
<Text dimColor> {label} Setup: </Text>
|
||||
<Text bold color={gradeColor}>{healthGrade}</Text>
|
||||
<Text dimColor> ({healthScore}/100)</Text>
|
||||
</Text>
|
||||
<Text color="#5BF5A0" wrap="truncate-end">Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend)</Text>
|
||||
</Box>
|
||||
{findings.map((f, i) => <FindingPanel key={i} index={i + 1} finding={f} costRate={costRate} width={width} />)}
|
||||
<Box paddingX={1} width={width}><Text dimColor>Token estimates are approximate.</Text></Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean }) {
|
||||
const isOptimize = view === 'optimize'
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
|
||||
<Text>
|
||||
<Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text>
|
||||
<Text dimColor> switch </Text>
|
||||
<Text color={ORANGE} bold>q</Text>
|
||||
<Text dimColor> quit </Text>
|
||||
<Text color={ORANGE} bold>1</Text>
|
||||
<Text dimColor> today </Text>
|
||||
<Text color={ORANGE} bold>2</Text>
|
||||
<Text dimColor> week </Text>
|
||||
<Text color={ORANGE} bold>3</Text>
|
||||
<Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text>
|
||||
<Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>5</Text>
|
||||
<Text dimColor> all time</Text>
|
||||
{showProvider && (
|
||||
<>
|
||||
<Text dimColor> </Text>
|
||||
<Text color={ORANGE} bold>p</Text>
|
||||
<Text dimColor> provider</Text>
|
||||
</>
|
||||
{isOptimize
|
||||
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text></>
|
||||
: <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>}
|
||||
<Text color={ORANGE} bold>q</Text><Text dimColor> quit </Text>
|
||||
<Text color={ORANGE} bold>1</Text><Text dimColor> today </Text>
|
||||
<Text color={ORANGE} bold>2</Text><Text dimColor> week </Text>
|
||||
<Text color={ORANGE} bold>3</Text><Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text><Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>5</Text><Text dimColor> all time</Text>
|
||||
{!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && (
|
||||
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text><Text color="#F55B5B"> ({findingCount})</Text></>
|
||||
)}
|
||||
{showProvider && (<><Text dimColor> </Text><Text color={ORANGE} bold>p</Text><Text dimColor> provider</Text></>)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -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<string, ContextBudget> }) {
|
||||
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
||||
const isCursor = activeProvider === 'cursor'
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}>
|
||||
<Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
|
||||
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 (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
|
||||
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} />
|
||||
<ProjectBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
|
||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
||||
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<ActivityBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
<ModelBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
|
||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||
{isCursor ? (
|
||||
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
|
||||
) : (
|
||||
<>
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<ToolBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
<BashBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
<McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
</>
|
||||
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} /></>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -609,29 +588,59 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [activeProvider, setActiveProvider] = useState(initialProvider)
|
||||
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
const multipleProviders = detectedProviders.length > 1
|
||||
const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude'
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<string, ContextBudget>()
|
||||
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<ReturnType<typeof setTimeout> | 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 (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
|
||||
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}>
|
||||
<Text dimColor>Loading {PERIOD_LABELS[period]}...</Text>
|
||||
</Panel>
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} />
|
||||
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {PERIOD_LABELS[period]}...</Text></Panel>
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} view="dashboard" findingCount={0} optimizeAvailable={false} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -705,8 +700,10 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} />
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} />
|
||||
{view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} />}
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
|
||||
)
|
||||
await waitUntilExit()
|
||||
} else {
|
||||
const { unmount } = render(
|
||||
<StaticDashboard projects={projects} period={period} activeProvider={provider} />,
|
||||
{ patchConsole: false }
|
||||
)
|
||||
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })
|
||||
unmount()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1177
src/optimize.ts
Normal file
1177
src/optimize.ts
Normal file
File diff suppressed because it is too large
Load diff
445
tests/optimize-fs.test.ts
Normal file
445
tests/optimize-fs.test.ts
Normal file
|
|
@ -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<typeof import('os')>('os')
|
||||
const fs = await vi.importActual<typeof import('fs')>('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<string>()
|
||||
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 <command-name> tags in user messages', async () => {
|
||||
const messages = ['<command-name>review</command-name>']
|
||||
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<string, unknown>).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')
|
||||
})
|
||||
})
|
||||
311
tests/optimize.test.ts
Normal file
311
tests/optimize.test.ts
Normal file
|
|
@ -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<string, unknown>, 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue