(apiCalls: ApiCallMeta[], projects: ProjectSummary[], dateRange?: DateRange)
| 1619 | } |
| 1620 | |
| 1621 | export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSummary[], dateRange?: DateRange): WasteFinding | null { |
| 1622 | if (apiCalls.length < MIN_API_CALLS_FOR_CACHE) return null |
| 1623 | |
| 1624 | const sorted = apiCalls.map(c => c.cacheCreationTokens).sort((a, b) => a - b) |
| 1625 | const median = sorted[Math.floor(sorted.length / 2)] |
| 1626 | const baseline = computeBudgetAwareCacheBaseline(projects) |
| 1627 | const bloatThreshold = baseline * CACHE_BLOAT_MULTIPLIER |
| 1628 | |
| 1629 | if (median < bloatThreshold) return null |
| 1630 | |
| 1631 | const recentCalls = apiCalls.filter(c => c.recent) |
| 1632 | const totalBloated = apiCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length |
| 1633 | const recentBloated = recentCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length |
| 1634 | const trend = sessionTrend(recentBloated, totalBloated, dateRange, recentCalls.length > 0) |
| 1635 | if (trend === 'resolved') return null |
| 1636 | |
| 1637 | const versionCounts = new Map<string, { total: number; count: number }>() |
| 1638 | for (const call of apiCalls) { |
| 1639 | if (!call.version) continue |
| 1640 | const entry = versionCounts.get(call.version) ?? { total: 0, count: 0 } |
| 1641 | entry.total += call.cacheCreationTokens |
| 1642 | entry.count++ |
| 1643 | versionCounts.set(call.version, entry) |
| 1644 | } |
| 1645 | const versionAvgs = [...versionCounts.entries()] |
| 1646 | .filter(([, d]) => d.count >= CACHE_VERSION_MIN_SAMPLES) |
| 1647 | .map(([v, d]) => ({ version: v, avg: Math.round(d.total / d.count) })) |
| 1648 | .sort((a, b) => b.avg - a.avg) |
| 1649 | |
| 1650 | const excess = median - baseline |
| 1651 | const tokensSaved = excess * apiCalls.length |
| 1652 | |
| 1653 | let versionNote = '' |
| 1654 | if (versionAvgs.length >= 2) { |
| 1655 | const [high, ...rest] = versionAvgs |
| 1656 | const low = rest[rest.length - 1] |
| 1657 | if (high.avg - low.avg > CACHE_VERSION_DIFF_THRESHOLD) { |
| 1658 | versionNote = ` Version ${high.version} averages ${formatTokens(high.avg)} vs ${low.version} at ${formatTokens(low.avg)}.` |
| 1659 | } |
| 1660 | } |
| 1661 | |
| 1662 | return { |
| 1663 | title: 'Session warmup is unusually large', |
| 1664 | explanation: `Median cache_creation per call is ${formatTokens(median)} tokens, about ${formatTokens(excess)} above your baseline of ${formatTokens(baseline)}.${versionNote}`, |
| 1665 | impact: excess > CACHE_EXCESS_HIGH_THRESHOLD ? 'high' : 'medium', |
| 1666 | tokensSaved, |
| 1667 | fix: { |
| 1668 | type: 'paste', |
| 1669 | destination: 'shell-config', |
| 1670 | label: 'Check for recent Claude Code updates or heavy MCP/skill additions. As a workaround (not officially supported), add to ~/.zshrc or ~/.bashrc:', |
| 1671 | text: 'export ANTHROPIC_CUSTOM_HEADERS=\'User-Agent: claude-cli/2.1.98 (external, sdk-cli)\'', |
| 1672 | }, |
| 1673 | trend, |
| 1674 | } |
| 1675 | } |
| 1676 | |
| 1677 | async function listMarkdownFiles(dir: string): Promise<string[]> { |
| 1678 | if (!existsSync(dir)) return [] |
no test coverage detected