mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
feat: ghost detectors, health grade, @-import expansion
Expanded the optimize engine with new detectors and scoring:
1. Health score + letter grade (A-F) in optimize header. Weighted
per-impact with caps. Gives users an instant "is my setup healthy"
read that doubles as a shareable number.
2. Urgency score replaces impact-enum sort. Weighted 0.7 * impact
+ 0.3 * normalized tokens. Produces better-ranked findings.
3. Three new ghost detectors:
- Ghost agents: files in ~/.claude/agents/ never invoked via
Agent/Task tool
- Ghost skills: SKILL.md directories never triggered
- Ghost slash-commands: ~/.claude/commands/ files never referenced
in user messages
4. @-import chain expansion for CLAUDE.md. Recursively follows
@path/to/file imports (max depth 5) so bloat detection counts
transitive load, not just the base file. Fixes undercounting for
users with modular CLAUDE.md setups.
9 new tests covering health scoring and import expansion.
This commit is contained in:
parent
710316053e
commit
b88f2cd730
3 changed files with 322 additions and 29 deletions
|
|
@ -470,16 +470,24 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find
|
|||
)
|
||||
}
|
||||
|
||||
function OptimizeView({ findings, costRate, projects, label, width }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number }) {
|
||||
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}</Text></Text>
|
||||
<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} />)}
|
||||
|
|
@ -652,7 +660,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
|
||||
{view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
|
||||
? <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>
|
||||
|
|
|
|||
257
src/optimize.ts
257
src/optimize.ts
|
|
@ -39,9 +39,13 @@ export type WasteFinding = {
|
|||
fix: WasteAction
|
||||
}
|
||||
|
||||
export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F'
|
||||
|
||||
export type OptimizeResult = {
|
||||
findings: WasteFinding[]
|
||||
costRate: number
|
||||
healthScore: number
|
||||
healthGrade: HealthGrade
|
||||
}
|
||||
|
||||
type ToolCall = {
|
||||
|
|
@ -61,6 +65,7 @@ type ScanData = {
|
|||
projectCwds: Set<string>
|
||||
apiCalls: ApiCallMeta[]
|
||||
versions: Set<string>
|
||||
userMessages: string[]
|
||||
}
|
||||
|
||||
const IMPACT_ORDER: Record<string, number> = { high: 3, medium: 2, low: 1 }
|
||||
|
|
@ -84,6 +89,7 @@ type ScanFileResult = {
|
|||
cwds: string[]
|
||||
apiCalls: ApiCallMeta[]
|
||||
versions: string[]
|
||||
userMessages: string[]
|
||||
}
|
||||
|
||||
async function scanJsonlFile(
|
||||
|
|
@ -94,12 +100,13 @@ async function scanJsonlFile(
|
|||
let content: string
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8')
|
||||
} catch { return { calls: [], cwds: [], apiCalls: [], versions: [] } }
|
||||
} catch { return { calls: [], cwds: [], apiCalls: [], versions: [], userMessages: [] } }
|
||||
|
||||
const calls: ToolCall[] = []
|
||||
const cwds: string[] = []
|
||||
const apiCalls: ApiCallMeta[] = []
|
||||
const versions: string[] = []
|
||||
const userMessages: string[] = []
|
||||
const sessionId = basename(filePath, '.jsonl')
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
|
|
@ -110,6 +117,21 @@ async function scanJsonlFile(
|
|||
if (entry.cwd && typeof entry.cwd === 'string') cwds.push(entry.cwd)
|
||||
if (entry.version && typeof entry.version === 'string') versions.push(entry.version)
|
||||
|
||||
if (entry.type === 'user') {
|
||||
const msg = entry.message as Record<string, unknown> | 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 (dateRange && typeof entry.timestamp === 'string') {
|
||||
|
|
@ -139,7 +161,7 @@ async function scanJsonlFile(
|
|||
}
|
||||
}
|
||||
|
||||
return { calls, cwds, apiCalls, versions }
|
||||
return { calls, cwds, apiCalls, versions, userMessages }
|
||||
}
|
||||
|
||||
async function scanSessions(dateRange?: DateRange): Promise<ScanData> {
|
||||
|
|
@ -148,19 +170,21 @@ async function scanSessions(dateRange?: DateRange): Promise<ScanData> {
|
|||
const allCwds = new Set<string>()
|
||||
const allApiCalls: ApiCallMeta[] = []
|
||||
const allVersions = new Set<string>()
|
||||
const allUserMessages: string[] = []
|
||||
|
||||
for (const source of sources) {
|
||||
const files = await collectJsonlFiles(source.path)
|
||||
for (const file of files) {
|
||||
const { calls, cwds, apiCalls, versions } = await scanJsonlFile(file, source.project, dateRange)
|
||||
const { calls, cwds, apiCalls, versions, userMessages } = await scanJsonlFile(file, source.project, dateRange)
|
||||
allCalls.push(...calls)
|
||||
for (const cwd of cwds) allCwds.add(cwd)
|
||||
allApiCalls.push(...apiCalls)
|
||||
for (const v of versions) if (v) allVersions.add(v)
|
||||
allUserMessages.push(...userMessages)
|
||||
}
|
||||
}
|
||||
|
||||
return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, versions: allVersions }
|
||||
return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, versions: allVersions, userMessages: allUserMessages }
|
||||
}
|
||||
|
||||
function detectJunkReads(calls: ToolCall[]): WasteFinding | null {
|
||||
|
|
@ -378,8 +402,36 @@ function detectMissingClaudeignore(projectCwds: Set<string>): WasteFinding | nul
|
|||
}
|
||||
}
|
||||
|
||||
const MAX_IMPORT_DEPTH = 5
|
||||
const IMPORT_PATTERN = /^@([^\s]+)/gm
|
||||
|
||||
function expandImports(filePath: string, seen: Set<string>, 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 } }
|
||||
|
||||
const lines = content.split('\n').length
|
||||
let totalLines = lines
|
||||
let importedFiles = 0
|
||||
const dir = join(filePath, '..')
|
||||
|
||||
const matches = content.matchAll(IMPORT_PATTERN)
|
||||
for (const match of matches) {
|
||||
const rawPath = match[1]
|
||||
if (!rawPath || rawPath.startsWith('http') || rawPath.includes('@')) 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 }
|
||||
}
|
||||
|
||||
function detectBloatedClaudeMd(projectCwds: Set<string>): WasteFinding | null {
|
||||
const bloated: { path: string; lines: number }[] = []
|
||||
const bloated: { path: string; lines: number; expandedLines: number; imports: number }[] = []
|
||||
|
||||
for (const cwd of projectCwds) {
|
||||
for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
|
||||
|
|
@ -388,9 +440,10 @@ function detectBloatedClaudeMd(projectCwds: Set<string>): WasteFinding | null {
|
|||
try {
|
||||
const content = readFileSync(fullPath, 'utf-8')
|
||||
const lineCount = content.split('\n').length
|
||||
if (lineCount > CLAUDEMD_HEALTHY_LINES) {
|
||||
const { totalLines, importedFiles } = expandImports(fullPath, new Set(), 0)
|
||||
if (totalLines > CLAUDEMD_HEALTHY_LINES) {
|
||||
const short = cwd.startsWith(homedir()) ? '~' + cwd.slice(homedir().length) : cwd
|
||||
bloated.push({ path: `${short}/${name}`, lines: lineCount })
|
||||
bloated.push({ path: `${short}/${name}`, lines: lineCount, expandedLines: totalLines, imports: importedFiles })
|
||||
}
|
||||
} catch { continue }
|
||||
}
|
||||
|
|
@ -398,23 +451,26 @@ function detectBloatedClaudeMd(projectCwds: Set<string>): WasteFinding | null {
|
|||
|
||||
if (bloated.length === 0) return null
|
||||
|
||||
const sorted = bloated.sort((a, b) => b.lines - a.lines)
|
||||
const sorted = bloated.sort((a, b) => b.expandedLines - a.expandedLines)
|
||||
const worst = sorted[0]
|
||||
const totalExtraLines = sorted.reduce((s, b) => s + (b.lines - CLAUDEMD_HEALTHY_LINES), 0)
|
||||
const totalExtraLines = sorted.reduce((s, b) => s + (b.expandedLines - CLAUDEMD_HEALTHY_LINES), 0)
|
||||
const tokensPerLine = 25
|
||||
const tokensSaved = totalExtraLines * tokensPerLine
|
||||
|
||||
const list = sorted.slice(0, 3).map(b => `${b.path} (${b.lines} lines)`).join(', ')
|
||||
const list = sorted.slice(0, 3).map(b => {
|
||||
const importNote = b.imports > 0 ? ` + ${b.imports} imported` : ''
|
||||
return `${b.path} (${b.expandedLines} lines${importNote})`
|
||||
}).join(', ')
|
||||
|
||||
return {
|
||||
title: 'TRIM BLOATED CLAUDE.md',
|
||||
explanation: `${list}. Every line loads into every API call as context. Beyond ${CLAUDEMD_HEALTHY_LINES} lines, the extra ~${totalExtraLines} lines cost ~${formatTokens(tokensSaved)} tokens per call.`,
|
||||
impact: worst.lines > 400 ? 'high' : 'medium',
|
||||
explanation: `${list}. Every line loads into every API call as context, including @-imports. Beyond ${CLAUDEMD_HEALTHY_LINES} lines, the extra ~${totalExtraLines} lines cost ~${formatTokens(tokensSaved)} tokens per call.`,
|
||||
impact: worst.expandedLines > 400 ? 'high' : 'medium',
|
||||
tokensSaved,
|
||||
fix: {
|
||||
type: 'paste',
|
||||
label: 'Ask Claude to trim it:',
|
||||
text: 'Review CLAUDE.md and cut it to under 200 lines. Remove anything Claude can figure out from the code itself: file paths, architecture, imports. Keep only: rules, gotchas, and non-obvious conventions.',
|
||||
text: 'Review CLAUDE.md and all @-imported files. Cut total expanded content to under 200 lines. Remove anything Claude can figure out from the code itself: file paths, architecture, imports. Keep only: rules, gotchas, and non-obvious conventions.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -503,6 +559,126 @@ function detectCacheBloat(apiCalls: ApiCallMeta[]): WasteFinding | null {
|
|||
}
|
||||
}
|
||||
|
||||
const TOKENS_PER_AGENT_DEF = 80
|
||||
const TOKENS_PER_COMMAND_DEF = 60
|
||||
const SKILL_FRONTMATTER_TOKENS = 80
|
||||
|
||||
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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 [] }
|
||||
}
|
||||
|
||||
async function detectGhostAgents(calls: ToolCall[]): Promise<WasteFinding | null> {
|
||||
const agentDir = join(homedir(), '.claude', 'agents')
|
||||
const defined = await listMarkdownFiles(agentDir)
|
||||
if (defined.length === 0) return null
|
||||
|
||||
const invoked = new Set<string>()
|
||||
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, 5).join(', ') + (ghosts.length > 5 ? `, +${ghosts.length - 5} more` : '')
|
||||
|
||||
return {
|
||||
title: 'REMOVE UNUSED CUSTOM AGENTS',
|
||||
explanation: `${ghosts.length} agent definition${ghosts.length > 1 ? 's' : ''} in ~/.claude/agents/ never invoked: ${list}. Each agent adds ~${TOKENS_PER_AGENT_DEF} tokens of description to the Task tool schema on every session.`,
|
||||
impact: ghosts.length >= 5 ? 'high' : ghosts.length >= 2 ? 'medium' : 'low',
|
||||
tokensSaved,
|
||||
fix: {
|
||||
type: 'command',
|
||||
label: `Archive unused agent${ghosts.length > 1 ? 's' : ''}:`,
|
||||
text: ghosts.slice(0, 10).map(name => `mv ~/.claude/agents/${name}.md ~/.claude/agents/.archived/`).join('\n'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function detectGhostSkills(calls: ToolCall[]): Promise<WasteFinding | null> {
|
||||
const skillDir = join(homedir(), '.claude', 'skills')
|
||||
const defined = await listSkillDirs(skillDir)
|
||||
if (defined.length === 0) return null
|
||||
|
||||
const invoked = new Set<string>()
|
||||
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 || ghosts.length < 3) return null
|
||||
|
||||
const tokensSaved = ghosts.length * SKILL_FRONTMATTER_TOKENS
|
||||
const list = ghosts.slice(0, 5).join(', ') + (ghosts.length > 5 ? `, +${ghosts.length - 5} more` : '')
|
||||
|
||||
return {
|
||||
title: 'REMOVE UNUSED SKILLS',
|
||||
explanation: `${ghosts.length} skill${ghosts.length > 1 ? 's' : ''} in ~/.claude/skills/ never invoked: ${list}. Each skill's frontmatter adds ~${SKILL_FRONTMATTER_TOKENS} tokens to the Skill tool invocation index on every session.`,
|
||||
impact: ghosts.length >= 10 ? 'high' : ghosts.length >= 5 ? 'medium' : 'low',
|
||||
tokensSaved,
|
||||
fix: {
|
||||
type: 'command',
|
||||
label: `Archive unused skill${ghosts.length > 1 ? 's' : ''}:`,
|
||||
text: ghosts.slice(0, 10).map(name => `mv ~/.claude/skills/${name} ~/.claude/skills/.archived/`).join('\n'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function detectGhostCommands(userMessages: string[]): Promise<WasteFinding | null> {
|
||||
const cmdDir = join(homedir(), '.claude', 'commands')
|
||||
const defined = await listMarkdownFiles(cmdDir)
|
||||
if (defined.length === 0) return null
|
||||
|
||||
const invoked = new Set<string>()
|
||||
const cmdPattern = /<command-name>([^<]+)<\/command-name>|\/(\w+[-\w]*)/g
|
||||
for (const msg of userMessages) {
|
||||
const matches = msg.matchAll(cmdPattern)
|
||||
for (const m of matches) {
|
||||
const name = (m[1] || m[2] || '').replace(/^\//, '')
|
||||
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, 5).join(', ') + (ghosts.length > 5 ? `, +${ghosts.length - 5} more` : '')
|
||||
|
||||
return {
|
||||
title: 'REMOVE UNUSED SLASH COMMANDS',
|
||||
explanation: `${ghosts.length} slash command${ghosts.length > 1 ? 's' : ''} in ~/.claude/commands/ never used: ${list}. Each adds ~${TOKENS_PER_COMMAND_DEF} tokens of definition per session.`,
|
||||
impact: ghosts.length >= 10 ? 'medium' : 'low',
|
||||
tokensSaved,
|
||||
fix: {
|
||||
type: 'command',
|
||||
label: `Archive unused command${ghosts.length > 1 ? 's' : ''}:`,
|
||||
text: ghosts.slice(0, 10).map(name => `mv ~/.claude/commands/${name}.md ~/.claude/commands/.archived/`).join('\n'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function detectBashBloat(): WasteFinding | null {
|
||||
const current = process.env['BASH_MAX_OUTPUT_LENGTH']
|
||||
if (current && parseInt(current, 10) <= 15000) return null
|
||||
|
|
@ -591,6 +767,8 @@ function renderFinding(n: number, f: WasteFinding, costRate: number, W: number):
|
|||
return lines
|
||||
}
|
||||
|
||||
const GRADE_COLORS: Record<HealthGrade, string> = { A: GREEN, B: GREEN, C: GOLD, D: ORANGE, F: '#F55B5B' }
|
||||
|
||||
function renderOptimize(
|
||||
findings: WasteFinding[],
|
||||
costRate: number,
|
||||
|
|
@ -598,6 +776,8 @@ function renderOptimize(
|
|||
periodCost: number,
|
||||
sessionCount: number,
|
||||
callCount: number,
|
||||
healthScore: number,
|
||||
healthGrade: HealthGrade,
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
const W = 62
|
||||
|
|
@ -611,6 +791,7 @@ function renderOptimize(
|
|||
`${sessionCount} sessions`,
|
||||
`${callCount.toLocaleString()} calls`,
|
||||
chalk.hex(GOLD)(formatCost(periodCost)),
|
||||
`Setup: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)} ${chalk.dim(`(${healthScore}/100)`)}`,
|
||||
].join(chalk.hex(DIM)(' ')))
|
||||
lines.push('')
|
||||
|
||||
|
|
@ -628,9 +809,7 @@ function renderOptimize(
|
|||
lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens (~${formatCost(totalCost)}, ~${pct}% of spend)`))
|
||||
lines.push('')
|
||||
|
||||
const sorted = findings.sort((a, b) =>
|
||||
(IMPACT_ORDER[b.impact] ?? 0) - (IMPACT_ORDER[a.impact] ?? 0) || b.tokensSaved - a.tokensSaved
|
||||
)
|
||||
const sorted = findings
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
lines.push(...renderFinding(i + 1, sorted[i], costRate, W))
|
||||
|
|
@ -643,15 +822,33 @@ function renderOptimize(
|
|||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function computeHealth(findings: WasteFinding[]): { score: number; grade: HealthGrade } {
|
||||
if (findings.length === 0) return { score: 100, grade: 'A' }
|
||||
|
||||
const impactWeight: Record<string, number> = { high: 15, medium: 7, low: 3 }
|
||||
let penalty = 0
|
||||
for (const f of findings) penalty += impactWeight[f.impact] ?? 0
|
||||
|
||||
const score = Math.max(0, 100 - Math.min(80, penalty))
|
||||
const grade: HealthGrade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 30 ? 'D' : 'F'
|
||||
return { score, grade }
|
||||
}
|
||||
|
||||
function urgencyScore(f: WasteFinding): number {
|
||||
const impactWeight: Record<string, number> = { high: 1, medium: 0.5, low: 0.2 }
|
||||
const normalizedTokens = Math.min(1, f.tokensSaved / 500_000)
|
||||
return (impactWeight[f.impact] ?? 0) * 0.7 + normalizedTokens * 0.3
|
||||
}
|
||||
|
||||
export async function scanAndDetect(
|
||||
projects: ProjectSummary[],
|
||||
dateRange?: DateRange,
|
||||
): Promise<OptimizeResult> {
|
||||
const costRate = computeInputCostRate(projects)
|
||||
const { toolCalls, projectCwds, apiCalls } = await scanSessions(dateRange)
|
||||
const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange)
|
||||
|
||||
const findings: WasteFinding[] = []
|
||||
const detectors = [
|
||||
const syncDetectors = [
|
||||
() => detectCacheBloat(apiCalls),
|
||||
() => detectLowReadEditRatio(toolCalls),
|
||||
() => detectJunkReads(toolCalls),
|
||||
|
|
@ -661,17 +858,25 @@ export async function scanAndDetect(
|
|||
() => detectBloatedClaudeMd(projectCwds),
|
||||
() => detectBashBloat(),
|
||||
]
|
||||
|
||||
for (const detect of detectors) {
|
||||
for (const detect of syncDetectors) {
|
||||
const finding = detect()
|
||||
if (finding) findings.push(finding)
|
||||
}
|
||||
|
||||
findings.sort((a, b) =>
|
||||
(IMPACT_ORDER[b.impact] ?? 0) - (IMPACT_ORDER[a.impact] ?? 0) || b.tokensSaved - a.tokensSaved
|
||||
)
|
||||
const asyncDetectors = [
|
||||
() => detectGhostAgents(toolCalls),
|
||||
() => detectGhostSkills(toolCalls),
|
||||
() => detectGhostCommands(userMessages),
|
||||
]
|
||||
for (const detect of asyncDetectors) {
|
||||
const finding = await detect()
|
||||
if (finding) findings.push(finding)
|
||||
}
|
||||
|
||||
return { findings, costRate }
|
||||
findings.sort((a, b) => urgencyScore(b) - urgencyScore(a))
|
||||
|
||||
const { score, grade } = computeHealth(findings)
|
||||
return { findings, costRate, healthScore: score, healthGrade: grade }
|
||||
}
|
||||
|
||||
export async function runOptimize(
|
||||
|
|
@ -686,11 +891,11 @@ export async function runOptimize(
|
|||
|
||||
process.stderr.write(chalk.dim(' Scanning sessions for waste patterns...\n'))
|
||||
|
||||
const { findings, costRate } = await scanAndDetect(projects, dateRange)
|
||||
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)
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade)
|
||||
console.log(output)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,3 +236,83 @@ describe('optimize: read:edit ratio detection', () => {
|
|||
expect(edits).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
function computeHealthLogic(impacts: Array<'high' | 'medium' | 'low'>): { score: number; grade: string } {
|
||||
if (impacts.length === 0) return { score: 100, grade: 'A' }
|
||||
const impactWeight: Record<string, number> = { high: 15, medium: 7, low: 3 }
|
||||
let penalty = 0
|
||||
for (const i of impacts) penalty += impactWeight[i] ?? 0
|
||||
const score = Math.max(0, 100 - Math.min(80, penalty))
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 30 ? 'D' : 'F'
|
||||
return { score, grade }
|
||||
}
|
||||
|
||||
describe('optimize: health score and grade', () => {
|
||||
it('returns A with no findings', () => {
|
||||
const { score, grade } = computeHealthLogic([])
|
||||
expect(score).toBe(100)
|
||||
expect(grade).toBe('A')
|
||||
})
|
||||
|
||||
it('one low finding keeps grade at A', () => {
|
||||
const { score, grade } = computeHealthLogic(['low'])
|
||||
expect(score).toBe(97)
|
||||
expect(grade).toBe('A')
|
||||
})
|
||||
|
||||
it('two high findings drop to C', () => {
|
||||
const { score, grade } = computeHealthLogic(['high', 'high'])
|
||||
expect(score).toBe(70)
|
||||
expect(grade).toBe('C')
|
||||
})
|
||||
|
||||
it('caps penalty at 80 to prevent going below 20', () => {
|
||||
const impacts = Array(20).fill('high' as const)
|
||||
const { score } = computeHealthLogic(impacts)
|
||||
expect(score).toBe(20)
|
||||
})
|
||||
|
||||
it('mix produces D grade', () => {
|
||||
const { score, grade } = computeHealthLogic(['high', 'high', 'medium', 'medium', 'low'])
|
||||
expect(score).toBe(100 - 15 - 15 - 7 - 7 - 3)
|
||||
expect(grade).toBe('D')
|
||||
})
|
||||
})
|
||||
|
||||
function expandImportsLogic(content: string, resolveMap: Record<string, string>, depth = 0): number {
|
||||
if (depth > 5) return 0
|
||||
let total = content.split('\n').length
|
||||
const matches = content.matchAll(/^@([^\s]+)/gm)
|
||||
for (const m of matches) {
|
||||
const key = m[1] || ''
|
||||
if (resolveMap[key]) {
|
||||
total += expandImportsLogic(resolveMap[key], resolveMap, depth + 1)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
describe('optimize: @-import expansion', () => {
|
||||
it('counts only the base file when no imports', () => {
|
||||
const total = expandImportsLogic('line 1\nline 2\nline 3', {})
|
||||
expect(total).toBe(3)
|
||||
})
|
||||
|
||||
it('expands single @-import', () => {
|
||||
const main = 'line 1\n@./imported.md\nline 3'
|
||||
const imported = 'i1\ni2\ni3\ni4\ni5'
|
||||
expect(expandImportsLogic(main, { './imported.md': imported })).toBe(3 + 5)
|
||||
})
|
||||
|
||||
it('expands nested @-imports recursively', () => {
|
||||
const main = 'a\n@b.md'
|
||||
const b = 'b1\nb2\n@c.md'
|
||||
const c = 'c1\nc2\nc3'
|
||||
expect(expandImportsLogic(main, { 'b.md': b, 'c.md': c })).toBe(2 + 3 + 3)
|
||||
})
|
||||
|
||||
it('caps recursion depth', () => {
|
||||
const circular = '@x.md'
|
||||
expect(expandImportsLogic(circular, { 'x.md': circular })).toBeLessThan(10)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue