mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 11:25:08 +00:00
* feat(optimize): mcp-deferral-gaps finding family Add three read-only detectors for MCP tool-def deferral coverage gaps: - mcp-deferral-off: sessions carrying MCP tool-def overhead with zero ToolSearch invocations and no deferred_tools_delta inventory across the window. Cause attribution, each with its own message and fix: stale ENABLE_TOOL_SEARCH=false (settings env, any scope, or shell profile), non-first-party ANTHROPIC_BASE_URL (reported as unknown proxy — never assumes capability), Vertex AI config, all observed Claude Code versions predating default-on tool search (v2.1.7), or a generic deferral-appears-inactive fallback. - mcp-alwaysload-hygiene: alwaysLoad-pinned servers whose observed call rate is below 1 call per 5 sessions (named constant). Notes the 5s startup-blocking cost of alwaysLoad in the explanation. - mcp-defer-threshold: ENABLE_TOOL_SEARCH=auto/auto:N overrides whose threshold the estimated def volume never reaches, so tools load upfront; recommends the tightest auto:N that would defer. Detection only: no FindingApply payloads, no act-layer changes, no file mutation. Users without deferral gaps see zero new output — deferral-active evidence (ToolSearch calls or inventory) suppresses the findings, and each detector gates on conservative named thresholds. Config readers take an injectable homeDir (PlanContext style) for hermetic tests. Discrepancies vs the design notes, codebase/live-docs win: - The Claude Code changelog records default-on (2.1.7), not first ship, so the version cause uses the default-on boundary. - Per-tool "anthropic/alwaysLoad" lives in server-served tool _meta, not static config; server-level only, limitation documented. - Live docs confirm auto default threshold 10% of context window and the alwaysLoad v2.1.121+ requirement. Refs getagentseal/codeburn#614 * feat(act): defer-* plan kinds for native deferral config Wire the mcp-deferral-gaps findings to the act machinery with three new plan kinds, all through the existing ConfigDocs/runAction path (journaled, backed up, stale-guarded, dry-run previewable, undo restores byte-identical files): - defer-enable: removes a stale ENABLE_TOOL_SEARCH=false from the settings env of the scope where the finding recorded it. Refusal paths render as manual notes instead of plans: shell-profile lines (codeburn only appends marker blocks to shell files, never edits user lines), unknown proxies (setting the override blind makes requests fail outright on proxies that don't forward tool_reference blocks — the note says to verify first), Vertex, and old versions. A proxy-verified cause (set by the part-3 verifier) produces a real ENABLE_TOOL_SEARCH=true plan in user settings. - defer-alwaysload: strips alwaysLoad: true from the named servers in the exact config files the finding recorded. Gated on an injectable installed-version probe (default: claude --version); below v2.1.121, unparseable, or probe failure all refuse with a note naming the required version. Preview notes the removed up-to-5s startup block. - defer-threshold: rewrites the auto override to the recommended auto:N, or deletes it when the finding says the default already defers (removeOverride). Findings now carry FindingApply payloads (path, scope, cause, servers, recommended N); detector text is unchanged, so plain optimize renders byte-identically to before. Every plan links its findingId into the ActionRecord and states that changes take effect on the next session (the config is read at Claude Code start). Discrepancy vs the design notes: no existing version-check helper was found in guard/ or act/ to reuse, so the detector's parseVersion/ versionPredates are exported and shared instead of adding a parallel comparator. Refs getagentseal/codeburn#614 --------- Co-authored-by: AgentSeal <hello@agentseal.org>
3282 lines
132 KiB
TypeScript
3282 lines
132 KiB
TypeScript
import chalk from 'chalk'
|
|
import { readdir, stat } from 'fs/promises'
|
|
import { existsSync, statSync } from 'fs'
|
|
import { basename, join } from 'path'
|
|
import { homedir } from 'os'
|
|
|
|
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
|
|
import { discoverAllSessions } from './providers/index.js'
|
|
import { parseJsonlLine, shouldSkipLine } from './parser.js'
|
|
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
|
|
import { formatCost } from './currency.js'
|
|
import { formatTokens } from './format.js'
|
|
import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js'
|
|
|
|
// ============================================================================
|
|
// Display constants
|
|
// ============================================================================
|
|
|
|
const ORANGE = '#FF8C42'
|
|
const DIM = '#666666'
|
|
const GOLD = '#FFD700'
|
|
const CYAN = '#5BF5E0'
|
|
const GREEN = '#5BF5A0'
|
|
const RED = '#F55B5B'
|
|
|
|
// ============================================================================
|
|
// Token estimation constants
|
|
// ============================================================================
|
|
|
|
export const AVG_TOKENS_PER_READ = 600
|
|
export const TOKENS_PER_MCP_TOOL = 400
|
|
export const TOOLS_PER_MCP_SERVER = 5
|
|
export const TOKENS_PER_AGENT_DEF = 80
|
|
export const TOKENS_PER_SKILL_DEF = 80
|
|
export const TOKENS_PER_COMMAND_DEF = 60
|
|
const CLAUDEMD_TOKENS_PER_LINE = 13
|
|
const BASH_TOKENS_PER_CHAR = 0.25
|
|
|
|
// ============================================================================
|
|
// Detector thresholds
|
|
// ============================================================================
|
|
|
|
const CLAUDEMD_HEALTHY_LINES = 200
|
|
const CLAUDEMD_HIGH_THRESHOLD_LINES = 400
|
|
const MIN_JUNK_READS_TO_FLAG = 3
|
|
const JUNK_READS_HIGH_THRESHOLD = 20
|
|
const JUNK_READS_MEDIUM_THRESHOLD = 5
|
|
const MIN_DUPLICATE_READS_TO_FLAG = 5
|
|
const DUPLICATE_READS_HIGH_THRESHOLD = 30
|
|
const DUPLICATE_READS_MEDIUM_THRESHOLD = 10
|
|
const MIN_EDITS_FOR_RATIO = 10
|
|
export const HEALTHY_READ_EDIT_RATIO = 4
|
|
const LOW_RATIO_HIGH_THRESHOLD = 2
|
|
const LOW_RATIO_MEDIUM_THRESHOLD = 3
|
|
const MIN_API_CALLS_FOR_CACHE = 10
|
|
const CACHE_EXCESS_HIGH_THRESHOLD = 15000
|
|
const UNUSED_MCP_HIGH_THRESHOLD = 3
|
|
// MCP tool coverage detector thresholds. A server only earns a finding when
|
|
// every condition holds: the inventory is large enough to matter, real-world
|
|
// usage is poor, and we observed it in enough sessions to trust the signal.
|
|
const MCP_COVERAGE_MIN_TOOLS = 10
|
|
const MCP_COVERAGE_MIN_SESSIONS = 2
|
|
const MCP_COVERAGE_LOW_THRESHOLD = 0.20
|
|
const MCP_COVERAGE_HIGH_IMPACT_TOKENS = 200_000
|
|
const MCP_PROFILE_MIN_PROJECTS = 3
|
|
const MCP_PROFILE_MIN_HOT_INVOCATIONS = 2
|
|
const MCP_PROFILE_HOT_INVOCATION_SHARE = 0.80
|
|
const MCP_PROFILE_MIN_COLD_LOADED_SESSIONS = 2
|
|
const MCP_PROFILE_HIGH_IMPACT_TOKENS = 200_000
|
|
const MCP_PROFILE_PREVIEW = 3
|
|
// Anthropic prices cache writes at 125% of base input and cache reads at
|
|
// roughly 10% of base input. We use these to keep overhead estimates honest:
|
|
// most MCP schema bytes live in the cached prefix and only get charged at
|
|
// the discount rate after the first turn of a session.
|
|
const CACHE_WRITE_MULTIPLIER = 1.25
|
|
const CACHE_READ_DISCOUNT = 0.10
|
|
const GHOST_AGENTS_HIGH_THRESHOLD = 5
|
|
const GHOST_AGENTS_MEDIUM_THRESHOLD = 2
|
|
const GHOST_SKILLS_HIGH_THRESHOLD = 10
|
|
const GHOST_SKILLS_MEDIUM_THRESHOLD = 5
|
|
const GHOST_COMMANDS_MEDIUM_THRESHOLD = 10
|
|
const MCP_NEW_CONFIG_GRACE_MS = 24 * 60 * 60 * 1000
|
|
const BASH_DEFAULT_LIMIT = 30000
|
|
const BASH_RECOMMENDED_LIMIT = 15000
|
|
const MIN_SESSIONS_FOR_OUTLIER = 3
|
|
// A project is still bootstrapping until it has twice the minimum sessions
|
|
// needed to evaluate outliers; below that, its peer average is too thin to
|
|
// distinguish founding work from waste.
|
|
const YOUNG_PROJECT_SESSION_LIMIT = 2 * MIN_SESSIONS_FOR_OUTLIER
|
|
const SESSION_OUTLIER_MULTIPLIER = 2
|
|
const MIN_SESSION_OUTLIER_COST_USD = 1
|
|
const SESSION_OUTLIER_PREVIEW = 5
|
|
const CONTEXT_BLOAT_MIN_INPUT_TOKENS = 75_000
|
|
const CONTEXT_BLOAT_MIN_RATIO = 25
|
|
const CONTEXT_BLOAT_TARGET_RATIO = 15
|
|
const CONTEXT_BLOAT_PREVIEW = 5
|
|
const CONTEXT_BLOAT_LOW_INPUT_TOKENS = 200_000
|
|
const CONTEXT_BLOAT_HIGH_INPUT_TOKENS = 500_000
|
|
const CONTEXT_BLOAT_LOW_MAX_CANDIDATES = 2
|
|
const CONTEXT_BLOAT_HIGH_MIN_CANDIDATES = 10
|
|
const CONTEXT_BLOAT_GROWTH_RATIO = 2
|
|
const CONTEXT_BLOAT_GROWTH_MAX_GAP_MS = 7 * 24 * 60 * 60 * 1000
|
|
const CONTEXT_BLOAT_RATIO_DISPLAY_CAP = 1000
|
|
const WORTH_IT_MIN_COST_USD = 2
|
|
const WORTH_IT_NO_EDIT_MIN_COST_USD = 3
|
|
// A zero-edit session's full cost is an upper bound on recoverable waste, not
|
|
// a point estimate, so use a bounded fraction for honest savings numbers.
|
|
const WORTH_IT_NO_EDIT_RECOVERY_FRACTION = 0.5
|
|
const WORTH_IT_MIN_RETRIES = 3
|
|
const WORTH_IT_RETRY_WITH_EDIT_MIN_RETRIES = 2
|
|
const WORTH_IT_PREVIEW = 5
|
|
const WORTH_IT_LOW_MAX_CANDIDATES = 2
|
|
const WORTH_IT_LOW_MAX_TOTAL_COST_USD = 10
|
|
const WORTH_IT_HIGH_MIN_CANDIDATES = 10
|
|
const WORTH_IT_HIGH_TOTAL_COST_USD = 50
|
|
const CAPABILITY_RELIABILITY_MIN_EDIT_TURNS = 5
|
|
const CAPABILITY_RELIABILITY_MIN_RETRY_TURNS = 3
|
|
const CAPABILITY_RELIABILITY_MIN_RETRY_RATE = 0.50
|
|
const CAPABILITY_RELIABILITY_RECOVERY_FRACTION = 0.50
|
|
const CAPABILITY_RELIABILITY_PREVIEW = 5
|
|
const CAPABILITY_RELIABILITY_LOW_MAX_CANDIDATES = 1
|
|
const CAPABILITY_RELIABILITY_LOW_MAX_TOKENS = 50_000
|
|
const CAPABILITY_RELIABILITY_HIGH_MIN_CANDIDATES = 5
|
|
const CAPABILITY_RELIABILITY_HIGH_IMPACT_TOKENS = 200_000
|
|
// "mcp-deferral-gaps" finding family (#614): MCP tool schemas that sit in the
|
|
// prefix because tool search / deferral is off, overridden, or mistuned.
|
|
// Claude Code's tool search tool is literally named "ToolSearch"; seeing it
|
|
// invoked is direct transcript evidence that deferral was active.
|
|
const TOOL_SEARCH_TOOL_NAME = 'ToolSearch'
|
|
// Exported: the defer-enable / defer-threshold plan builders in
|
|
// src/act/plans.ts edit exactly this settings env key.
|
|
export const ENABLE_TOOL_SEARCH_VAR = 'ENABLE_TOOL_SEARCH'
|
|
const ANTHROPIC_BASE_URL_VAR = 'ANTHROPIC_BASE_URL'
|
|
const CLAUDE_CODE_USE_VERTEX_VAR = 'CLAUDE_CODE_USE_VERTEX'
|
|
// The only first-party API host. An unset ANTHROPIC_BASE_URL counts as
|
|
// first-party; any other host is an *unknown* proxy — deferral auto-disables
|
|
// there because most proxies don't forward tool_reference blocks, but we
|
|
// never claim the proxy is incapable, only unknown.
|
|
const FIRST_PARTY_API_HOST = 'api.anthropic.com'
|
|
// Claude Code changelog v2.1.7: "Enabled MCP tool search auto mode by
|
|
// default for all users." Sessions produced entirely by older versions
|
|
// could not have had deferral on without explicit opt-in.
|
|
const TOOL_SEARCH_DEFAULT_ON_VERSION = '2.1.7'
|
|
const DEFERRAL_OFF_MIN_MCP_SESSIONS = 2
|
|
const DEFERRAL_OFF_HIGH_IMPACT_TOKENS = 200_000
|
|
// Conservative: flag alwaysLoad servers only when strictly below one call
|
|
// per five sessions.
|
|
const ALWAYSLOAD_MAX_CALLS_PER_SESSION = 0.2
|
|
// Server-level alwaysLoad shipped in Claude Code v2.1.121; on older
|
|
// versions the key is inert and the server's tools defer normally, so the
|
|
// pin costs nothing there. Exported: the defer-alwaysload plan builder
|
|
// (src/act/plans.ts) gates on the same boundary against the INSTALLED
|
|
// version before offering to remove the pin.
|
|
export const ALWAYSLOAD_MIN_VERSION = '2.1.121'
|
|
const ALWAYSLOAD_HIGH_IMPACT_TOKENS = 200_000
|
|
// alwaysLoad blocks session startup on the server's connection, capped at 5s.
|
|
// Exported so the defer-alwaysload plan preview quotes the same cap.
|
|
export const ALWAYSLOAD_STARTUP_CAP_SECONDS = 5
|
|
// ENABLE_TOOL_SEARCH=auto:N defers only when defs exceed N% of the context
|
|
// window (default 10). Tuning advice is only worth emitting when the
|
|
// upfront-loaded defs are substantial.
|
|
const DEFER_THRESHOLD_CONTEXT_WINDOW_TOKENS = 200_000
|
|
const DEFER_THRESHOLD_DEFAULT_PERCENT = 10
|
|
// auto:N is documented for N 0-100; larger values clamp rather than reject
|
|
// so a nonsense override still gets tuning advice instead of silence.
|
|
const DEFER_THRESHOLD_MAX_PERCENT = 100
|
|
const DEFER_THRESHOLD_MIN_TOKENS_PER_SESSION = 5_000
|
|
const DEFER_THRESHOLD_MEDIUM_IMPACT_TOKENS = 200_000
|
|
|
|
// ============================================================================
|
|
// Scoring constants
|
|
// ============================================================================
|
|
|
|
const HEALTH_WEIGHT_HIGH = 15
|
|
const HEALTH_WEIGHT_MEDIUM = 7
|
|
const HEALTH_WEIGHT_LOW = 3
|
|
const HEALTH_MAX_PENALTY = 80
|
|
const GRADE_A_MIN = 90
|
|
const GRADE_B_MIN = 75
|
|
const GRADE_C_MIN = 55
|
|
const GRADE_D_MIN = 30
|
|
// Rebalanced so a high-impact finding with zero observed tokens (e.g.
|
|
// detectGhostAgents firing on five files but tokensSaved=400) cannot
|
|
// outrank a medium-impact finding with many millions of tokens.
|
|
// Old: 0.7/0.3 → high+0 = 0.70, medium+1B = 0.65 (high+0 won).
|
|
// New: 0.5/0.5 → high+0 = 0.50, medium+1B = 0.75 (medium+1B wins).
|
|
// Token normalize lifted to 5M so the rank scales over a realistic range.
|
|
const URGENCY_IMPACT_WEIGHT = 0.5
|
|
const URGENCY_TOKEN_WEIGHT = 0.5
|
|
const URGENCY_TOKEN_NORMALIZE = 5_000_000
|
|
|
|
// ============================================================================
|
|
// File system constants
|
|
// ============================================================================
|
|
|
|
const MAX_IMPORT_DEPTH = 5
|
|
const IMPORT_PATTERN = /^@(\.\.?\/[^\s]+|\/[^\s]+)/gm
|
|
const COMMAND_PATTERN = /<command-name>([^<]+)<\/command-name>|(?:^|\s)\/([a-zA-Z][\w-]*)/gm
|
|
|
|
const JUNK_DIRS = [
|
|
'node_modules', '.git', 'dist', 'build', '__pycache__', '.next',
|
|
'.nuxt', '.output', 'coverage', '.cache', '.tsbuildinfo',
|
|
'.venv', 'venv', '.svn', '.hg',
|
|
]
|
|
const JUNK_PATTERN = new RegExp(`/(?:${JUNK_DIRS.join('|')})/`)
|
|
|
|
const SHELL_PROFILES = ['.zshrc', '.bashrc', '.bash_profile', '.profile']
|
|
|
|
const TOP_ITEMS_PREVIEW = 3
|
|
const GHOST_NAMES_PREVIEW = 5
|
|
const GHOST_CLEANUP_COMMANDS_LIMIT = 10
|
|
const OPTIMIZE_TEXT_CAP = 2000
|
|
const OPTIMIZE_FIELD_CAP = 500
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
export type Impact = 'high' | 'medium' | 'low'
|
|
export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F'
|
|
|
|
/// Where a paste-style suggestion belongs. Without this, users couldn't tell
|
|
/// whether a prompt should go into CLAUDE.md (permanent rule), be pasted at
|
|
/// the start of a future session (one-time constraint), be asked of Claude
|
|
/// in the current chat (one-time prompt), or be added to a shell config file.
|
|
/// Issue #277 — users were dropping one-time session openers into CLAUDE.md
|
|
/// permanently because the destination wasn't clearly stated.
|
|
export type PasteDestination =
|
|
| 'claude-md' // permanent project rule, append to CLAUDE.md
|
|
| 'session-opener' // one-time paste at the start of a NEW session
|
|
| 'prompt' // one-time ask in the current Claude conversation
|
|
| 'shell-config' // append to ~/.zshrc / ~/.bashrc
|
|
|
|
export type WasteAction =
|
|
| { type: 'paste'; label: string; text: string; destination?: PasteDestination }
|
|
| { type: 'command'; label: string; text: string }
|
|
| { type: 'file-content'; label: string; path: string; content: string }
|
|
|
|
export type Trend = 'active' | 'improving'
|
|
|
|
// Stable, kebab-case identifier per detector. Used to route findings to
|
|
// appliable plans (src/act/plans.ts) and for the `--only` filter, so these
|
|
// strings must not change once shipped.
|
|
export type FindingId =
|
|
| 'read-edit-ratio'
|
|
| 'build-folder-reads'
|
|
| 'redundant-rereads'
|
|
| 'warmup-heavy'
|
|
| 'unused-mcp'
|
|
| 'mcp-low-coverage'
|
|
| 'mcp-project-scope'
|
|
| 'mcp-deferral-off'
|
|
| 'mcp-alwaysload-hygiene'
|
|
| 'mcp-defer-threshold'
|
|
| 'retry-heavy-capabilities'
|
|
| 'low-worth-sessions'
|
|
| 'context-heavy-sessions'
|
|
| 'cost-outliers'
|
|
| 'claude-md-too-long'
|
|
| 'bash-output-cap'
|
|
| 'unused-agents'
|
|
| 'unused-skills'
|
|
| 'unused-commands'
|
|
|
|
// Cause taxonomy for defer-enable plans (mcp-deferral-off findings).
|
|
// 'proxy-verified' is never produced by the detector today: it is reserved
|
|
// for the #614 part-3 proxy verifier, which upgrades 'proxy-unknown' once a
|
|
// proxy provably forwards tool_reference blocks. The plan layer already
|
|
// accepts it and produces a real ENABLE_TOOL_SEARCH=true plan for it.
|
|
export type DeferEnableCause = 'env-false' | 'proxy-unknown' | 'proxy-verified' | 'vertex' | 'old-version'
|
|
|
|
// Machine-readable payload the apply layer needs but the human-facing `fix`
|
|
// text can't carry losslessly (full lists, per-server keeper paths). Only set
|
|
// on findings that have an appliable plan; absent otherwise.
|
|
export type FindingApply =
|
|
| { kind: 'mcp-remove'; servers: string[] }
|
|
// keepProjects gain the entry; removeProjects (the cold projects) are the
|
|
// only per-project containers a scoped removal may touch.
|
|
| { kind: 'mcp-project-scope'; servers: Array<{ server: string; keepProjects: string[]; removeProjects: string[] }> }
|
|
| { kind: 'archive'; names: string[] }
|
|
// settingPath/settingScope name the config file carrying the setting the
|
|
// cause refers to (the ENABLE_TOOL_SEARCH override for 'env-false', the
|
|
// ANTHROPIC_BASE_URL for the proxy causes, CLAUDE_CODE_USE_VERTEX for
|
|
// 'vertex'); 'old-version' carries none. value is the observed setting
|
|
// value, quoted in plan descriptions.
|
|
| { kind: 'defer-enable'; cause: DeferEnableCause; settingPath?: string; settingScope?: string; value?: string }
|
|
// One entry per flagged server; paths are the exact config files whose
|
|
// entry carries `"alwaysLoad": true` (union across readable scopes).
|
|
| { kind: 'defer-alwaysload'; servers: Array<{ server: string; paths: string[] }> }
|
|
// recommendedPercent is the tightest auto:N that still defers; when
|
|
// removeOverride is set the defs already exceed the default threshold, so
|
|
// deleting the override (restoring default auto) is the fix instead.
|
|
| { kind: 'defer-threshold'; settingPath: string; settingScope: string; value: string; recommendedPercent: number; removeOverride: boolean }
|
|
|
|
export type WasteFinding = {
|
|
id: FindingId
|
|
title: string
|
|
explanation: string
|
|
impact: Impact
|
|
tokensSaved: number
|
|
fix: WasteAction
|
|
trend?: Trend
|
|
apply?: FindingApply
|
|
}
|
|
|
|
export type OptimizeResult = {
|
|
findings: WasteFinding[]
|
|
costRate: number
|
|
healthScore: number
|
|
healthGrade: HealthGrade
|
|
modelRecommendations?: ModelDefaultRecommendation[]
|
|
}
|
|
|
|
export type OptimizeJsonReport = {
|
|
period: {
|
|
label: string
|
|
start: string | null
|
|
end: string | null
|
|
}
|
|
summary: {
|
|
healthScore: number
|
|
healthGrade: HealthGrade
|
|
findingCount: number
|
|
periodCostUSD: number
|
|
sessions: number
|
|
calls: number
|
|
potentialSavingsTokens: number
|
|
potentialSavingsCostUSD: number
|
|
potentialSavingsPercent: number | null
|
|
costRateUSD: number
|
|
}
|
|
findings: Array<{
|
|
id: FindingId
|
|
title: string
|
|
explanation: string
|
|
severity: Impact
|
|
trend: Trend | null
|
|
tokensSaved: number
|
|
estimatedSavingsUSD: number
|
|
fix: WasteAction
|
|
}>
|
|
modelRecommendations?: Array<ModelDefaultRecommendation>
|
|
}
|
|
|
|
export type ToolCall = {
|
|
name: string
|
|
input: Record<string, unknown>
|
|
sessionId: string
|
|
project: string
|
|
recent?: boolean
|
|
}
|
|
|
|
export type ApiCallMeta = {
|
|
cacheCreationTokens: number
|
|
version: string
|
|
recent?: boolean
|
|
}
|
|
|
|
type ScanData = {
|
|
toolCalls: ToolCall[]
|
|
projectCwds: Set<string>
|
|
apiCalls: ApiCallMeta[]
|
|
userMessages: string[]
|
|
}
|
|
|
|
// ============================================================================
|
|
// JSONL scanner
|
|
// ============================================================================
|
|
|
|
function cappedString(value: unknown, cap = OPTIMIZE_FIELD_CAP): string | undefined {
|
|
return typeof value === 'string' ? value.slice(0, cap) : undefined
|
|
}
|
|
|
|
function compactOptimizeInput(name: string, input: unknown): Record<string, unknown> {
|
|
if (!input || typeof input !== 'object') return {}
|
|
const raw = input as Record<string, unknown>
|
|
if (isReadTool(name)) {
|
|
const filePath = cappedString(raw['file_path'], OPTIMIZE_TEXT_CAP)
|
|
return filePath ? { file_path: filePath } : {}
|
|
}
|
|
if (name === 'Agent' || name === 'Task') {
|
|
const subagentType = cappedString(raw['subagent_type'])
|
|
return subagentType ? { subagent_type: subagentType } : {}
|
|
}
|
|
if (name === 'Skill') {
|
|
const skill = cappedString(raw['skill'])
|
|
const skillName = cappedString(raw['name'])
|
|
return {
|
|
...(skill ? { skill } : {}),
|
|
...(skillName ? { name: skillName } : {}),
|
|
}
|
|
}
|
|
return {}
|
|
}
|
|
|
|
const FILE_READ_CONCURRENCY = 4
|
|
const RESULT_CACHE_TTL_MS = 60_000
|
|
const RECENT_WINDOW_HOURS = 48
|
|
const RECENT_WINDOW_MS = RECENT_WINDOW_HOURS * 60 * 60 * 1000
|
|
const DEFAULT_TREND_PERIOD_DAYS = 30
|
|
const DEFAULT_TREND_PERIOD_MS = DEFAULT_TREND_PERIOD_DAYS * 24 * 60 * 60 * 1000
|
|
const IMPROVING_THRESHOLD = 0.5
|
|
|
|
async function collectJsonlFiles(dirPath: string): Promise<string[]> {
|
|
const files = await readdir(dirPath).catch(() => [])
|
|
const result = files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f))
|
|
for (const entry of files) {
|
|
if (entry.endsWith('.jsonl')) continue
|
|
const subPath = join(dirPath, entry, 'subagents')
|
|
const subFiles = await readdir(subPath).catch(() => [])
|
|
for (const sf of subFiles) {
|
|
if (sf.endsWith('.jsonl')) result.push(join(subPath, sf))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function isFileStaleForRange(filePath: string, range: DateRange | undefined): Promise<boolean> {
|
|
if (!range) return false
|
|
try {
|
|
const s = await stat(filePath)
|
|
return s.mtimeMs < range.start.getTime()
|
|
} catch { return false }
|
|
}
|
|
|
|
async function runWithConcurrency<T>(
|
|
items: T[],
|
|
limit: number,
|
|
worker: (item: T) => Promise<void>,
|
|
): Promise<void> {
|
|
let idx = 0
|
|
async function next(): Promise<void> {
|
|
while (idx < items.length) {
|
|
const current = idx++
|
|
await worker(items[current])
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => next()))
|
|
}
|
|
|
|
type ScanFileResult = {
|
|
calls: ToolCall[]
|
|
cwds: string[]
|
|
apiCalls: ApiCallMeta[]
|
|
userMessages: string[]
|
|
}
|
|
|
|
function inRange(timestamp: string | undefined, range: DateRange | undefined): boolean {
|
|
if (!range) return true
|
|
if (!timestamp) return false
|
|
const ts = new Date(timestamp)
|
|
return ts >= range.start && ts <= range.end
|
|
}
|
|
|
|
function isRecent(timestamp: string | undefined, cutoff: number): boolean {
|
|
if (!timestamp) return false
|
|
return new Date(timestamp).getTime() >= cutoff
|
|
}
|
|
|
|
export async function scanJsonlFile(
|
|
filePath: string,
|
|
project: string,
|
|
dateRange: DateRange | undefined,
|
|
recentCutoffMs = Date.now() - RECENT_WINDOW_MS,
|
|
): Promise<ScanFileResult> {
|
|
const calls: ToolCall[] = []
|
|
const cwds: string[] = []
|
|
const apiCalls: ApiCallMeta[] = []
|
|
const userMessages: string[] = []
|
|
const sessionId = basename(filePath, '.jsonl')
|
|
let lastVersion = ''
|
|
|
|
const skipThreshold = dateRange
|
|
? new Date(dateRange.start.getTime() - 86_400_000).toISOString()
|
|
: null
|
|
const skipFn = dateRange
|
|
? (head: string) => shouldSkipLine(head, skipThreshold!)
|
|
: undefined
|
|
const lines = readSessionLines(filePath, skipFn, { largeLineAsBuffer: true })
|
|
for await (const line of lines) {
|
|
if (typeof line === 'string' && !line.trim()) continue
|
|
if (Buffer.isBuffer(line) && line.length === 0) continue
|
|
const parsed = parseJsonlLine(line)
|
|
if (!parsed) continue
|
|
const entry = parsed as Record<string, unknown>
|
|
|
|
if (entry.version && typeof entry.version === 'string') lastVersion = entry.version
|
|
|
|
const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined
|
|
const withinRange = inRange(ts, dateRange)
|
|
const recent = isRecent(ts, recentCutoffMs)
|
|
|
|
if (entry.cwd && typeof entry.cwd === 'string' && withinRange) cwds.push(entry.cwd)
|
|
|
|
if (entry.type === 'user') {
|
|
if (!withinRange) continue
|
|
const msg = entry.message as Record<string, unknown> | undefined
|
|
const msgContent = msg?.content
|
|
if (typeof msgContent === 'string') {
|
|
userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP))
|
|
} else if (Array.isArray(msgContent)) {
|
|
let remaining = OPTIMIZE_TEXT_CAP
|
|
for (const block of msgContent) {
|
|
if (remaining <= 0) break
|
|
if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
const text = block.text.slice(0, remaining)
|
|
userMessages.push(text)
|
|
remaining -= text.length
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (entry.type !== 'assistant') continue
|
|
if (!withinRange) continue
|
|
|
|
const msg = entry.message as Record<string, unknown> | undefined
|
|
const usage = msg?.usage as Record<string, unknown> | undefined
|
|
if (usage) {
|
|
const cacheCreate = (usage.cache_creation_input_tokens as number) ?? 0
|
|
if (cacheCreate > 0) apiCalls.push({ cacheCreationTokens: cacheCreate, version: lastVersion, recent })
|
|
}
|
|
|
|
const blocks = msg?.content
|
|
if (!Array.isArray(blocks)) continue
|
|
|
|
for (const block of blocks) {
|
|
if (block.type !== 'tool_use') continue
|
|
const name = typeof block.name === 'string' ? block.name : ''
|
|
calls.push({
|
|
name,
|
|
input: compactOptimizeInput(name, block.input),
|
|
sessionId,
|
|
project,
|
|
recent,
|
|
})
|
|
}
|
|
}
|
|
|
|
return { calls, cwds, apiCalls, userMessages }
|
|
}
|
|
|
|
async function scanSessions(dateRange?: DateRange): Promise<ScanData> {
|
|
const sources = await discoverAllSessions('claude')
|
|
const allCalls: ToolCall[] = []
|
|
const allCwds = new Set<string>()
|
|
const allApiCalls: ApiCallMeta[] = []
|
|
const allUserMessages: string[] = []
|
|
|
|
const tasks: Array<{ file: string; project: string }> = []
|
|
for (const source of sources) {
|
|
const files = await collectJsonlFiles(source.path)
|
|
for (const file of files) {
|
|
if (await isFileStaleForRange(file, dateRange)) continue
|
|
tasks.push({ file, project: source.project })
|
|
}
|
|
}
|
|
|
|
await runWithConcurrency(tasks, FILE_READ_CONCURRENCY, async ({ file, project }) => {
|
|
const { calls, cwds, apiCalls, userMessages } = await scanJsonlFile(file, project, dateRange)
|
|
allCalls.push(...calls)
|
|
for (const cwd of cwds) allCwds.add(cwd)
|
|
allApiCalls.push(...apiCalls)
|
|
allUserMessages.push(...userMessages)
|
|
})
|
|
|
|
return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, userMessages: allUserMessages }
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared helpers
|
|
// ============================================================================
|
|
|
|
function readJsonFile(path: string): Record<string, unknown> | null {
|
|
const raw = readSessionFileSync(path)
|
|
if (raw === null) return null
|
|
try { return JSON.parse(raw) } catch { return null }
|
|
}
|
|
|
|
function shortHomePath(absPath: string): string {
|
|
const home = homedir()
|
|
return absPath.startsWith(home) ? '~' + absPath.slice(home.length) : absPath
|
|
}
|
|
|
|
function isReadTool(name: string): boolean {
|
|
return name === 'Read' || name === 'FileReadTool'
|
|
}
|
|
|
|
type McpConfigEntry = {
|
|
normalized: string
|
|
original: string
|
|
mtime: number
|
|
// Config files where this server entry carries `"alwaysLoad": true`
|
|
// (union across all readable scopes, so the hygiene fix can name every
|
|
// file to edit). Server-level only: the per-tool "anthropic/alwaysLoad"
|
|
// variant lives in tool _meta served by the MCP server at runtime and is
|
|
// not observable in static config. Requires Claude Code v2.1.121+.
|
|
alwaysLoadPaths: string[]
|
|
}
|
|
|
|
// `homeDir` is injectable for tests (mirrors src/act/plans.ts PlanContext);
|
|
// production callers omit it.
|
|
export function loadMcpConfigs(projectCwds: Iterable<string>, homeDir = homedir()): Map<string, McpConfigEntry> {
|
|
const servers = new Map<string, McpConfigEntry>()
|
|
const configPaths = [
|
|
join(homeDir, '.claude', 'settings.json'),
|
|
join(homeDir, '.claude', 'settings.local.json'),
|
|
]
|
|
for (const cwd of projectCwds) {
|
|
configPaths.push(join(cwd, '.mcp.json'))
|
|
configPaths.push(join(cwd, '.claude', 'settings.json'))
|
|
configPaths.push(join(cwd, '.claude', 'settings.local.json'))
|
|
}
|
|
|
|
for (const p of configPaths) {
|
|
if (!existsSync(p)) continue
|
|
const config = readJsonFile(p)
|
|
if (!config) continue
|
|
let mtime = 0
|
|
try { mtime = statSync(p).mtimeMs } catch {}
|
|
const serversObj = (config.mcpServers ?? {}) as Record<string, unknown>
|
|
for (const [name, rawEntry] of Object.entries(serversObj)) {
|
|
const normalized = name.replace(/:/g, '_')
|
|
const existing = servers.get(normalized)
|
|
if (!existing || existing.mtime < mtime) {
|
|
servers.set(normalized, {
|
|
normalized,
|
|
original: name,
|
|
mtime,
|
|
alwaysLoadPaths: existing?.alwaysLoadPaths ?? [],
|
|
})
|
|
}
|
|
const entry = rawEntry as Record<string, unknown> | null
|
|
if (entry && typeof entry === 'object' && entry.alwaysLoad === true) {
|
|
servers.get(normalized)!.alwaysLoadPaths.push(p)
|
|
}
|
|
}
|
|
}
|
|
return servers
|
|
}
|
|
|
|
// ============================================================================
|
|
// Detectors
|
|
// ============================================================================
|
|
|
|
export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null {
|
|
const dirCounts = new Map<string, number>()
|
|
let totalJunkReads = 0
|
|
let recentJunkReads = 0
|
|
|
|
for (const call of calls) {
|
|
if (!isReadTool(call.name)) continue
|
|
const filePath = call.input.file_path as string | undefined
|
|
if (!filePath || !JUNK_PATTERN.test(filePath)) continue
|
|
totalJunkReads++
|
|
if (call.recent) recentJunkReads++
|
|
for (const dir of JUNK_DIRS) {
|
|
if (filePath.includes(`/${dir}/`)) {
|
|
dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalJunkReads < MIN_JUNK_READS_TO_FLAG) return null
|
|
|
|
const hasRecentActivity = calls.some(c => c.recent)
|
|
const trend = sessionTrend(recentJunkReads, totalJunkReads, dateRange, hasRecentActivity)
|
|
if (trend === 'resolved') return null
|
|
|
|
const sorted = [...dirCounts.entries()].sort((a, b) => b[1] - a[1])
|
|
const dirList = sorted.slice(0, TOP_ITEMS_PREVIEW).map(([d, n]) => `${d}/ (${n}x)`).join(', ')
|
|
const tokensSaved = totalJunkReads * AVG_TOKENS_PER_READ
|
|
|
|
const detected = sorted.map(([d]) => d)
|
|
const commonDefaults = ['node_modules', '.git', 'dist', '__pycache__']
|
|
const extras = commonDefaults.filter(d => !dirCounts.has(d)).slice(0, Math.max(0, 6 - detected.length))
|
|
const dirsToAvoid = [...detected, ...extras].join(', ')
|
|
|
|
return {
|
|
id: 'build-folder-reads',
|
|
title: 'Claude is reading build/dependency folders',
|
|
explanation: `Claude read into ${dirList} (${totalJunkReads} reads). These are generated or dependency directories, not your code. Tell Claude in CLAUDE.md to avoid them.`,
|
|
impact: totalJunkReads > JUNK_READS_HIGH_THRESHOLD ? 'high' : totalJunkReads > JUNK_READS_MEDIUM_THRESHOLD ? 'medium' : 'low',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'claude-md',
|
|
label: 'Append to your project CLAUDE.md:',
|
|
text: `Do not read or search files under these directories unless I explicitly ask: ${dirsToAvoid}.`,
|
|
},
|
|
trend,
|
|
}
|
|
}
|
|
|
|
export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null {
|
|
const sessionFiles = new Map<string, Map<string, { count: number; recent: number }>>()
|
|
|
|
for (const call of calls) {
|
|
if (!isReadTool(call.name)) continue
|
|
const filePath = call.input.file_path as string | undefined
|
|
if (!filePath || JUNK_PATTERN.test(filePath)) continue
|
|
const key = `${call.project}:${call.sessionId}`
|
|
if (!sessionFiles.has(key)) sessionFiles.set(key, new Map())
|
|
const fm = sessionFiles.get(key)!
|
|
const entry = fm.get(filePath) ?? { count: 0, recent: 0 }
|
|
entry.count++
|
|
if (call.recent) entry.recent++
|
|
fm.set(filePath, entry)
|
|
}
|
|
|
|
let totalDuplicates = 0
|
|
let recentDuplicates = 0
|
|
const fileDupes = new Map<string, number>()
|
|
|
|
for (const fm of sessionFiles.values()) {
|
|
for (const [file, entry] of fm) {
|
|
if (entry.count <= 1) continue
|
|
const extra = entry.count - 1
|
|
totalDuplicates += extra
|
|
if (entry.recent > 1) recentDuplicates += entry.recent - 1
|
|
const name = basename(file)
|
|
fileDupes.set(name, (fileDupes.get(name) ?? 0) + extra)
|
|
}
|
|
}
|
|
|
|
if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return null
|
|
|
|
const hasRecentActivity = calls.some(c => c.recent)
|
|
const trend = sessionTrend(recentDuplicates, totalDuplicates, dateRange, hasRecentActivity)
|
|
if (trend === 'resolved') return null
|
|
|
|
const worst = [...fileDupes.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, TOP_ITEMS_PREVIEW)
|
|
.map(([name, n]) => `${name} (${n + 1}x)`)
|
|
.join(', ')
|
|
|
|
const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ
|
|
|
|
return {
|
|
id: 'redundant-rereads',
|
|
title: 'Claude is re-reading the same files',
|
|
explanation: `${totalDuplicates} redundant re-reads across sessions. Top repeats: ${worst}. Each re-read loads the same content into context again.`,
|
|
impact: totalDuplicates > DUPLICATE_READS_HIGH_THRESHOLD ? 'high' : totalDuplicates > DUPLICATE_READS_MEDIUM_THRESHOLD ? 'medium' : 'low',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Point Claude at exact locations in your prompt, for example:',
|
|
text: 'In <file> lines <start>-<end>, look at the <function> function.',
|
|
},
|
|
trend,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-server breakdown of MCP tool inventory vs invocations, computed from the
|
|
* `mcpInventory` field captured by the Claude parser.
|
|
*
|
|
* Each session that loaded a server contributes its observed tool list to
|
|
* the union for that server. Invocations come from the existing
|
|
* `mcpBreakdown` per-call counts plus the parser's `call.tools` stream.
|
|
*/
|
|
export type McpServerCoverage = {
|
|
server: string
|
|
toolsAvailable: number
|
|
toolsInvoked: number
|
|
unusedTools: string[]
|
|
invocations: number
|
|
loadedSessions: number
|
|
coverageRatio: number
|
|
}
|
|
|
|
type McpSchemaCostEstimate = {
|
|
cacheWriteTokens: number
|
|
cacheReadTokens: number
|
|
effectiveInputTokens: number
|
|
}
|
|
|
|
/**
|
|
* Aggregate MCP inventory and invocations across the projects in scope.
|
|
*
|
|
* Returns one entry per `mcp__<server>__*` namespace observed in any
|
|
* session's `mcpInventory`. Counts of invocations come from
|
|
* `session.mcpBreakdown` (per-server call totals already maintained by the
|
|
* parser).
|
|
*/
|
|
export function aggregateMcpCoverage(projects: ProjectSummary[]): McpServerCoverage[] {
|
|
type ServerAcc = {
|
|
inventory: Set<string>
|
|
invokedTools: Set<string>
|
|
invocations: number
|
|
loadedSessions: number
|
|
}
|
|
const servers = new Map<string, ServerAcc>()
|
|
|
|
function getOrInit(server: string): ServerAcc {
|
|
let acc = servers.get(server)
|
|
if (!acc) {
|
|
acc = { inventory: new Set(), invokedTools: new Set(), invocations: 0, loadedSessions: 0 }
|
|
servers.set(server, acc)
|
|
}
|
|
return acc
|
|
}
|
|
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
// Only sessions with an observed inventory count toward `loadedSessions`.
|
|
// Pure invocation-only sessions (server seen via `call.mcpTools` or
|
|
// `session.mcpBreakdown` without any matching `deferred_tools_delta`)
|
|
// could otherwise satisfy the `MCP_COVERAGE_MIN_SESSIONS` threshold
|
|
// without giving us evidence that the schema was actually loaded.
|
|
const inventoriedServers = new Set<string>()
|
|
const sessionInvoked = new Map<string, Set<string>>()
|
|
|
|
// Inventory: union of tools observed available in this session.
|
|
for (const fqn of session.mcpInventory ?? []) {
|
|
const parts = fqn.split('__')
|
|
if (parts.length < 3 || parts[0] !== 'mcp') continue
|
|
const server = parts[1]
|
|
if (!server) continue
|
|
const tool = parts.slice(2).join('__')
|
|
if (!tool) continue
|
|
const acc = getOrInit(server)
|
|
acc.inventory.add(fqn)
|
|
inventoriedServers.add(server)
|
|
}
|
|
|
|
// Invoked tools: walk turns to collect per-tool invocations. We can't
|
|
// get this from session.mcpBreakdown alone because that's keyed by
|
|
// server, not tool.
|
|
for (const turn of session.turns) {
|
|
for (const call of turn.assistantCalls) {
|
|
for (const fqn of call.mcpTools) {
|
|
const parts = fqn.split('__')
|
|
if (parts.length < 3 || parts[0] !== 'mcp') continue
|
|
const server = parts[1]
|
|
if (!server) continue
|
|
let invoked = sessionInvoked.get(server)
|
|
if (!invoked) {
|
|
invoked = new Set()
|
|
sessionInvoked.set(server, invoked)
|
|
}
|
|
invoked.add(fqn)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invocation totals: trust mcpBreakdown which was already aggregated
|
|
// turn-by-turn, including any invocations the inventory pass missed.
|
|
for (const [server, data] of Object.entries(session.mcpBreakdown)) {
|
|
const acc = getOrInit(server)
|
|
acc.invocations += data.calls
|
|
}
|
|
|
|
for (const [server, invoked] of sessionInvoked) {
|
|
const acc = getOrInit(server)
|
|
for (const fqn of invoked) acc.invokedTools.add(fqn)
|
|
}
|
|
|
|
for (const server of inventoriedServers) {
|
|
getOrInit(server).loadedSessions += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
const result: McpServerCoverage[] = []
|
|
for (const [server, acc] of servers) {
|
|
if (acc.inventory.size === 0) continue
|
|
// Coverage is only meaningful against tools we actually observed in the
|
|
// inventory: invocations of tools never inventoried (older config, typo,
|
|
// etc.) would otherwise inflate the numerator and could even drive
|
|
// `unusedCount` negative.
|
|
const invokedInInventory = new Set<string>()
|
|
for (const fqn of acc.invokedTools) {
|
|
if (acc.inventory.has(fqn)) invokedInInventory.add(fqn)
|
|
}
|
|
const unusedTools = Array.from(acc.inventory).filter(t => !invokedInInventory.has(t)).sort()
|
|
const toolsInvoked = acc.inventory.size - unusedTools.length
|
|
result.push({
|
|
server,
|
|
toolsAvailable: acc.inventory.size,
|
|
toolsInvoked,
|
|
unusedTools,
|
|
invocations: acc.invocations,
|
|
loadedSessions: acc.loadedSessions,
|
|
coverageRatio: acc.inventory.size === 0 ? 0 : toolsInvoked / acc.inventory.size,
|
|
})
|
|
}
|
|
result.sort((a, b) => b.toolsAvailable - a.toolsAvailable)
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Cache-aware token cost estimate for the unused-tool overhead of one or
|
|
* more servers, summed across all sessions that loaded any of them.
|
|
*
|
|
* Returns three buckets:
|
|
* - `cacheWriteTokens`: schema bytes paid at full input price (each
|
|
* cache-creation event in a session that loaded one of the servers).
|
|
* - `cacheReadTokens`: schema bytes carried at the cache-read discount on
|
|
* subsequent turns (ongoing overhead).
|
|
* - `effectiveInputTokens`: equivalent fresh-input tokens, weighted by
|
|
* cache pricing. Used to estimate dollar cost downstream by multiplying
|
|
* by the project's input rate.
|
|
*
|
|
* We cap each call's contribution at the observed cache-creation /
|
|
* cache-read totals for that call: it is not meaningful to claim more MCP
|
|
* overhead than the call's own cache bucket could possibly contain. The
|
|
* cap is applied once across the combined unused-schema budget for all
|
|
* flagged servers, not per server, so two flagged servers cannot both
|
|
* independently claim the same call's cache bucket.
|
|
*
|
|
* Anthropic caches expire after roughly 5 minutes of inactivity, so a long
|
|
* session can rebuild the cache multiple times. Every call that reports
|
|
* `cacheCreationInputTokens > 0` is treated as another rebuild, not just
|
|
* the very first one.
|
|
*
|
|
* "Loaded" is defined exclusively by observed inventory: a session that
|
|
* invoked a server without ever emitting a `deferred_tools_delta` for it
|
|
* does not count, matching the invariant `aggregateMcpCoverage` uses for
|
|
* `loadedSessions`.
|
|
*/
|
|
export function estimateMcpSchemaCost(
|
|
unusedToolCount: number,
|
|
projects: ProjectSummary[],
|
|
server: string,
|
|
): McpSchemaCostEstimate
|
|
export function estimateMcpSchemaCost(
|
|
unusedToolCountsByServer: Record<string, number>,
|
|
projects: ProjectSummary[],
|
|
servers: string[],
|
|
): McpSchemaCostEstimate
|
|
export function estimateMcpSchemaCost(
|
|
unusedToolCounts: Record<string, number> | number,
|
|
projects: ProjectSummary[],
|
|
serverOrServers: string | string[],
|
|
): McpSchemaCostEstimate {
|
|
let servers: string[]
|
|
let counts: Record<string, number>
|
|
if (typeof unusedToolCounts === 'number') {
|
|
if (typeof serverOrServers !== 'string') {
|
|
throw new TypeError('single-server MCP cost estimates require a string server name')
|
|
}
|
|
servers = [serverOrServers]
|
|
counts = { [serverOrServers]: unusedToolCounts }
|
|
} else {
|
|
if (!Array.isArray(serverOrServers)) {
|
|
throw new TypeError('multi-server MCP cost estimates require a string[] server list')
|
|
}
|
|
servers = serverOrServers
|
|
counts = unusedToolCounts
|
|
}
|
|
|
|
const totalUnusedSchemaTokens = servers.reduce(
|
|
(s, srv) => s + (counts[srv] ?? 0) * TOKENS_PER_MCP_TOOL,
|
|
0,
|
|
)
|
|
if (totalUnusedSchemaTokens === 0) {
|
|
return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 }
|
|
}
|
|
|
|
const serverSet = new Set(servers)
|
|
let cacheWriteTokens = 0
|
|
let cacheReadTokens = 0
|
|
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
// A session counts only if its observed inventory included at least
|
|
// one of the flagged servers — same invariant `aggregateMcpCoverage`
|
|
// uses for `loadedSessions`.
|
|
let loaded = false
|
|
for (const fqn of session.mcpInventory ?? []) {
|
|
const seg = fqn.split('__')[1]
|
|
if (seg && serverSet.has(seg)) { loaded = true; break }
|
|
}
|
|
if (!loaded) continue
|
|
|
|
for (const turn of session.turns) {
|
|
for (const call of turn.assistantCalls) {
|
|
// Both buckets can be non-zero on the same call (cache rebuild
|
|
// alongside a partial read), so account for them independently.
|
|
// The cap is applied to the combined unused-schema budget so
|
|
// multiple flagged servers cannot all claim the same call.
|
|
if (call.usage.cacheCreationInputTokens > 0) {
|
|
cacheWriteTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheCreationInputTokens)
|
|
}
|
|
if (call.usage.cacheReadInputTokens > 0) {
|
|
cacheReadTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheReadInputTokens)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT
|
|
return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens }
|
|
}
|
|
|
|
/**
|
|
* Find MCP servers whose tool inventory is largely unused. Replaces the
|
|
* older server-only `detectUnusedMcp` (which only flagged servers with
|
|
* literal zero invocations).
|
|
*
|
|
* A server is flagged when, taken together:
|
|
* - it exposed more than `MCP_COVERAGE_MIN_TOOLS` tools,
|
|
* - we saw it loaded in at least `MCP_COVERAGE_MIN_SESSIONS` sessions,
|
|
* - the coverage ratio is below `MCP_COVERAGE_LOW_THRESHOLD`.
|
|
*
|
|
* Token-savings estimates use the cache-aware accounting from
|
|
* `estimateMcpSchemaCost` so we don't mistake cached-prefix carry-over for
|
|
* fresh-input billing.
|
|
*/
|
|
export function detectMcpToolCoverage(
|
|
projects: ProjectSummary[],
|
|
coverage = aggregateMcpCoverage(projects),
|
|
): WasteFinding | null {
|
|
if (coverage.length === 0) return null
|
|
|
|
const flagged = coverage.filter(c =>
|
|
c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS
|
|
&& c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS
|
|
&& c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD,
|
|
)
|
|
if (flagged.length === 0) return null
|
|
|
|
flagged.sort((a, b) => (b.toolsAvailable - b.toolsInvoked) - (a.toolsAvailable - a.toolsInvoked))
|
|
|
|
const lines: string[] = []
|
|
const removeCommands: string[] = []
|
|
const unusedCountsByServer: Record<string, number> = {}
|
|
const flaggedServers: string[] = []
|
|
|
|
for (const c of flagged) {
|
|
unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked
|
|
flaggedServers.push(c.server)
|
|
const pct = Math.round(c.coverageRatio * 100)
|
|
lines.push(
|
|
`${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`,
|
|
)
|
|
removeCommands.push(`claude mcp remove '${c.server}'`)
|
|
}
|
|
|
|
// Single combined cost pass: caps each call's contribution at the
|
|
// total unused-schema budget across all flagged servers, so two
|
|
// flagged servers cannot independently claim the same call's cache
|
|
// bucket and overstate `tokensSaved`.
|
|
const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers)
|
|
const tokensSaved = Math.round(cost.effectiveInputTokens)
|
|
const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS
|
|
? 'high'
|
|
: flagged.length >= UNUSED_MCP_HIGH_THRESHOLD
|
|
? 'high'
|
|
: 'medium'
|
|
|
|
return {
|
|
id: 'mcp-low-coverage',
|
|
title: `${flagged.length} MCP server${flagged.length === 1 ? '' : 's'} with low tool coverage`,
|
|
explanation:
|
|
`Schema for unused tools is loaded into the system prompt every session and ` +
|
|
`carried in the cached prefix on every turn. ` +
|
|
`${lines.join('; ')}.`,
|
|
impact,
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'command',
|
|
label: flagged.length === 1
|
|
? 'Remove the underused server, or trim its tools in your MCP config:'
|
|
: 'Remove underused servers, or trim their tools in your MCP config:',
|
|
text: removeCommands.join('\n'),
|
|
},
|
|
apply: { kind: 'mcp-remove', servers: flaggedServers },
|
|
}
|
|
}
|
|
|
|
type McpProjectProfileStats = {
|
|
project: string
|
|
projectKey: string
|
|
projectPath: string
|
|
loadedSessions: number
|
|
invocations: number
|
|
}
|
|
|
|
type McpProfileCandidate = {
|
|
server: string
|
|
toolsAvailable: number
|
|
hotProjects: McpProjectProfileStats[]
|
|
coldProjects: McpProjectProfileStats[]
|
|
coldProjectKeys: Set<string>
|
|
loadedProjects: number
|
|
loadedSessions: number
|
|
invocations: number
|
|
hotShare: number
|
|
estimatedTokensSaved: number
|
|
}
|
|
|
|
function projectProfileLabel(project: ProjectSummary): string {
|
|
return project.projectPath || project.project
|
|
}
|
|
|
|
function projectProfileKey(project: ProjectSummary): string {
|
|
return projectProfileLabel(project)
|
|
}
|
|
|
|
function sessionLoadedMcpServer(
|
|
session: ProjectSummary['sessions'][number],
|
|
server: string,
|
|
): boolean {
|
|
for (const fqn of session.mcpInventory ?? []) {
|
|
const parts = fqn.split('__')
|
|
if (parts.length >= 3 && parts[0] === 'mcp' && parts[1] === server) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function lowCoverageMcpServers(coverage: McpServerCoverage[]): Set<string> {
|
|
return new Set(
|
|
coverage
|
|
.filter(c =>
|
|
c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS
|
|
&& c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS
|
|
&& c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD,
|
|
)
|
|
.map(c => c.server),
|
|
)
|
|
}
|
|
|
|
function estimateMcpProfileColdSchemaCost(
|
|
projects: ProjectSummary[],
|
|
serverToolCounts: Map<string, number>,
|
|
coldProjectKeysByServer: Map<string, Set<string>>,
|
|
): McpSchemaCostEstimate {
|
|
if (serverToolCounts.size === 0 || coldProjectKeysByServer.size === 0) {
|
|
return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 }
|
|
}
|
|
|
|
let cacheWriteTokens = 0
|
|
let cacheReadTokens = 0
|
|
for (const project of projects) {
|
|
const projectKey = projectProfileKey(project)
|
|
for (const session of project.sessions) {
|
|
let schemaTokens = 0
|
|
for (const [server, toolsAvailable] of serverToolCounts) {
|
|
if (!coldProjectKeysByServer.get(server)?.has(projectKey)) continue
|
|
if (!sessionLoadedMcpServer(session, server)) continue
|
|
schemaTokens += toolsAvailable * TOKENS_PER_MCP_TOOL
|
|
}
|
|
if (schemaTokens === 0) continue
|
|
|
|
for (const turn of session.turns) {
|
|
for (const call of turn.assistantCalls) {
|
|
if (call.usage.cacheCreationInputTokens > 0) {
|
|
cacheWriteTokens += Math.min(schemaTokens, call.usage.cacheCreationInputTokens)
|
|
}
|
|
if (call.usage.cacheReadInputTokens > 0) {
|
|
cacheReadTokens += Math.min(schemaTokens, call.usage.cacheReadInputTokens)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT
|
|
return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens }
|
|
}
|
|
|
|
function collectMcpProjectProfiles(
|
|
projects: ProjectSummary[],
|
|
coverage: McpServerCoverage[],
|
|
): McpProfileCandidate[] {
|
|
const suppressedServers = lowCoverageMcpServers(coverage)
|
|
const coverageByServer = new Map(coverage.map(c => [c.server, c]))
|
|
const byServer = new Map<string, Map<string, McpProjectProfileStats>>()
|
|
|
|
function getProjectStats(server: string, project: ProjectSummary): McpProjectProfileStats {
|
|
let serverProjects = byServer.get(server)
|
|
if (!serverProjects) {
|
|
serverProjects = new Map()
|
|
byServer.set(server, serverProjects)
|
|
}
|
|
const key = projectProfileKey(project)
|
|
let stats = serverProjects.get(key)
|
|
if (!stats) {
|
|
stats = {
|
|
project: project.project,
|
|
projectKey: key,
|
|
projectPath: projectProfileLabel(project),
|
|
loadedSessions: 0,
|
|
invocations: 0,
|
|
}
|
|
serverProjects.set(key, stats)
|
|
}
|
|
return stats
|
|
}
|
|
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
const loadedServers = new Set<string>()
|
|
for (const fqn of session.mcpInventory ?? []) {
|
|
const parts = fqn.split('__')
|
|
if (parts.length >= 3 && parts[0] === 'mcp' && parts[1]) loadedServers.add(parts[1])
|
|
}
|
|
for (const server of loadedServers) {
|
|
getProjectStats(server, project).loadedSessions++
|
|
}
|
|
for (const [server, data] of Object.entries(session.mcpBreakdown)) {
|
|
getProjectStats(server, project).invocations += data.calls
|
|
}
|
|
}
|
|
}
|
|
|
|
const candidates: McpProfileCandidate[] = []
|
|
for (const [server, projectStats] of byServer) {
|
|
if (suppressedServers.has(server)) continue
|
|
const coverageStats = coverageByServer.get(server)
|
|
if (!coverageStats) continue
|
|
if (coverageStats.toolsAvailable === 0) continue
|
|
|
|
const loaded = Array.from(projectStats.values()).filter(p => p.loadedSessions > 0)
|
|
if (loaded.length < MCP_PROFILE_MIN_PROJECTS) continue
|
|
const invocations = loaded.reduce((sum, p) => sum + p.invocations, 0)
|
|
if (invocations < MCP_PROFILE_MIN_HOT_INVOCATIONS) continue
|
|
|
|
loaded.sort((a, b) =>
|
|
b.invocations - a.invocations
|
|
|| b.loadedSessions - a.loadedSessions
|
|
|| a.projectPath.localeCompare(b.projectPath),
|
|
)
|
|
const invokedProjects = loaded.filter(p => p.invocations > 0)
|
|
if (invokedProjects.length === 0) continue
|
|
const hotProjects = invokedProjects.slice(0, 2)
|
|
const hotInvocations = hotProjects.reduce((sum, p) => sum + p.invocations, 0)
|
|
const hotShare = hotInvocations / invocations
|
|
if (hotShare < MCP_PROFILE_HOT_INVOCATION_SHARE) continue
|
|
|
|
const coldProjects = loaded.filter(p => p.invocations === 0)
|
|
const coldLoadedSessions = coldProjects.reduce((sum, p) => sum + p.loadedSessions, 0)
|
|
if (coldLoadedSessions < MCP_PROFILE_MIN_COLD_LOADED_SESSIONS) continue
|
|
|
|
const coldProjectKeys = new Set(coldProjects.map(project => project.projectKey))
|
|
const cost = estimateMcpProfileColdSchemaCost(
|
|
projects,
|
|
new Map([[server, coverageStats.toolsAvailable]]),
|
|
new Map([[server, coldProjectKeys]]),
|
|
)
|
|
|
|
candidates.push({
|
|
server,
|
|
toolsAvailable: coverageStats.toolsAvailable,
|
|
hotProjects,
|
|
coldProjects,
|
|
coldProjectKeys,
|
|
loadedProjects: loaded.length,
|
|
loadedSessions: loaded.reduce((sum, p) => sum + p.loadedSessions, 0),
|
|
invocations,
|
|
hotShare,
|
|
estimatedTokensSaved: Math.round(cost.effectiveInputTokens),
|
|
})
|
|
}
|
|
|
|
candidates.sort((a, b) =>
|
|
b.estimatedTokensSaved - a.estimatedTokensSaved
|
|
|| b.coldProjects.length - a.coldProjects.length
|
|
|| b.loadedSessions - a.loadedSessions
|
|
|| a.server.localeCompare(b.server),
|
|
)
|
|
return candidates
|
|
}
|
|
|
|
export function detectMcpProfileAdvisor(
|
|
projects: ProjectSummary[],
|
|
coverage = aggregateMcpCoverage(projects),
|
|
): WasteFinding | null {
|
|
const candidates = collectMcpProjectProfiles(projects, coverage)
|
|
if (candidates.length === 0) return null
|
|
|
|
const preview = candidates.slice(0, MCP_PROFILE_PREVIEW)
|
|
const lines = preview.map(candidate => {
|
|
const hot = candidate.hotProjects
|
|
.slice(0, 2)
|
|
.map(p => `${p.projectPath} (${p.invocations} call${p.invocations === 1 ? '' : 's'})`)
|
|
.join(', ')
|
|
const cold = candidate.coldProjects
|
|
.slice(0, 3)
|
|
.map(p => `${p.projectPath} (${p.loadedSessions} loaded session${p.loadedSessions === 1 ? '' : 's'})`)
|
|
.join(', ')
|
|
const coldExtra = candidate.coldProjects.length > 3 ? `, +${candidate.coldProjects.length - 3} more` : ''
|
|
return `${candidate.server}: ${Math.round(candidate.hotShare * 100)}% of ${candidate.invocations} calls in ${hot}; loaded but unused in ${cold}${coldExtra}`
|
|
})
|
|
const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : ''
|
|
const serverToolCounts = new Map(candidates.map(c => [c.server, c.toolsAvailable]))
|
|
const coldProjectKeysByServer = new Map(candidates.map(c => [c.server, c.coldProjectKeys]))
|
|
const combinedCost = estimateMcpProfileColdSchemaCost(projects, serverToolCounts, coldProjectKeysByServer)
|
|
const tokensSaved = Math.round(combinedCost.effectiveInputTokens)
|
|
const impact: Impact = tokensSaved >= MCP_PROFILE_HIGH_IMPACT_TOKENS
|
|
|| candidates.length >= UNUSED_MCP_HIGH_THRESHOLD
|
|
? 'high'
|
|
: 'medium'
|
|
|
|
return {
|
|
id: 'mcp-project-scope',
|
|
title: `${candidates.length} MCP server${candidates.length === 1 ? '' : 's'} should be project-scoped`,
|
|
explanation:
|
|
`These MCP servers look useful in a small set of projects but are loaded into other projects where they are not invoked. ` +
|
|
`Project-scoping them keeps the hot-project workflow while avoiding schema overhead elsewhere. ${lines.join('; ')}${extra}.`,
|
|
impact,
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Ask Claude to turn this into a project-scoped MCP profile:',
|
|
text: [
|
|
`Review these MCP profile recommendations before changing config (${preview.length} of ${candidates.length} shown):`,
|
|
...preview.map(candidate => {
|
|
const hot = candidate.hotProjects.map(p => p.projectPath).join(', ')
|
|
const cold = candidate.coldProjects.slice(0, 3).map(p => p.projectPath).join(', ')
|
|
return `- Keep ${candidate.server} available for ${hot}; remove or project-scope it away from ${cold}. Re-add it only in projects that actually need it.`
|
|
}),
|
|
].join('\n'),
|
|
},
|
|
apply: {
|
|
kind: 'mcp-project-scope',
|
|
servers: candidates.map(c => ({
|
|
server: c.server,
|
|
keepProjects: c.hotProjects.map(p => p.projectPath),
|
|
removeProjects: c.coldProjects.map(p => p.projectPath),
|
|
})),
|
|
},
|
|
}
|
|
}
|
|
|
|
type CapabilityKind = 'mcp' | 'skill'
|
|
|
|
type CapabilityRef = {
|
|
kind: CapabilityKind
|
|
name: string
|
|
}
|
|
|
|
type CapabilityReliabilityAccumulator = CapabilityRef & {
|
|
editTurns: number
|
|
retryTurns: number
|
|
oneShotTurns: number
|
|
retries: number
|
|
tokensTouched: number
|
|
projects: Set<string>
|
|
retryTurnSavings: Map<string, number>
|
|
}
|
|
|
|
export type CapabilityReliabilityCandidate = {
|
|
kind: CapabilityKind
|
|
name: string
|
|
editTurns: number
|
|
retryTurns: number
|
|
oneShotTurns: number
|
|
retries: number
|
|
retryRate: number
|
|
tokensTouched: number
|
|
tokensSaved: number
|
|
projects: string[]
|
|
}
|
|
|
|
function capabilityKey(ref: CapabilityRef): string {
|
|
return `${ref.kind}:${ref.name}`
|
|
}
|
|
|
|
function formatCapabilityKind(kind: CapabilityKind): string {
|
|
return kind === 'mcp' ? 'MCP server' : 'skill'
|
|
}
|
|
|
|
function mcpServerFromToolName(fqn: string): string | null {
|
|
const parts = fqn.split('__')
|
|
if (parts.length < 3 || parts[0] !== 'mcp') return null
|
|
return parts[1] || null
|
|
}
|
|
|
|
function collectReliabilityCapabilities(turn: ProjectSummary['sessions'][number]['turns'][number]): Map<string, CapabilityRef> {
|
|
const capabilities = new Map<string, CapabilityRef>()
|
|
|
|
for (const call of turn.assistantCalls) {
|
|
for (const fqn of call.mcpTools) {
|
|
const server = mcpServerFromToolName(fqn)
|
|
if (!server) continue
|
|
const ref: CapabilityRef = { kind: 'mcp', name: server }
|
|
capabilities.set(capabilityKey(ref), ref)
|
|
}
|
|
for (const rawSkill of call.skills ?? []) {
|
|
const skill = rawSkill.trim()
|
|
if (!skill) continue
|
|
const ref: CapabilityRef = { kind: 'skill', name: skill }
|
|
capabilities.set(capabilityKey(ref), ref)
|
|
}
|
|
}
|
|
|
|
return capabilities
|
|
}
|
|
|
|
function turnEffectiveTokenTotal(turn: ProjectSummary['sessions'][number]['turns'][number]): number {
|
|
return Math.round(turn.assistantCalls.reduce((sum, call) =>
|
|
sum
|
|
+ call.usage.inputTokens
|
|
+ call.usage.outputTokens
|
|
+ call.usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER
|
|
+ call.usage.cacheReadInputTokens * CACHE_READ_DISCOUNT,
|
|
0))
|
|
}
|
|
|
|
function reliabilityTurnKey(
|
|
project: ProjectSummary,
|
|
session: ProjectSummary['sessions'][number],
|
|
turn: ProjectSummary['sessions'][number]['turns'][number],
|
|
turnIndex: number,
|
|
): string {
|
|
return `${project.projectPath || project.project}:${session.sessionId}:${turn.timestamp}:${turnIndex}`
|
|
}
|
|
|
|
function getReliabilityAccumulator(
|
|
stats: Map<string, CapabilityReliabilityAccumulator>,
|
|
ref: CapabilityRef,
|
|
): CapabilityReliabilityAccumulator {
|
|
const key = capabilityKey(ref)
|
|
let acc = stats.get(key)
|
|
if (!acc) {
|
|
acc = {
|
|
...ref,
|
|
editTurns: 0,
|
|
retryTurns: 0,
|
|
oneShotTurns: 0,
|
|
retries: 0,
|
|
tokensTouched: 0,
|
|
projects: new Set(),
|
|
retryTurnSavings: new Map(),
|
|
}
|
|
stats.set(key, acc)
|
|
}
|
|
return acc
|
|
}
|
|
|
|
function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): CapabilityReliabilityCandidate[] {
|
|
const stats = new Map<string, CapabilityReliabilityAccumulator>()
|
|
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
for (let turnIndex = 0; turnIndex < session.turns.length; turnIndex++) {
|
|
const turn = session.turns[turnIndex]!
|
|
if (!turn.hasEdits) continue
|
|
|
|
const capabilities = collectReliabilityCapabilities(turn)
|
|
if (capabilities.size === 0) continue
|
|
|
|
const turnTokens = turnEffectiveTokenTotal(turn)
|
|
const turnKey = reliabilityTurnKey(project, session, turn, turnIndex)
|
|
const recoverableTokens = turn.retries > 0
|
|
? Math.round(turnTokens * CAPABILITY_RELIABILITY_RECOVERY_FRACTION)
|
|
: 0
|
|
|
|
for (const ref of capabilities.values()) {
|
|
const acc = getReliabilityAccumulator(stats, ref)
|
|
acc.editTurns++
|
|
acc.tokensTouched += turnTokens
|
|
acc.projects.add(project.project)
|
|
if (turn.retries > 0) {
|
|
acc.retryTurns++
|
|
acc.retries += turn.retries
|
|
acc.retryTurnSavings.set(turnKey, recoverableTokens)
|
|
} else {
|
|
acc.oneShotTurns++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const candidates: CapabilityReliabilityCandidate[] = []
|
|
for (const acc of stats.values()) {
|
|
if (acc.editTurns < CAPABILITY_RELIABILITY_MIN_EDIT_TURNS) continue
|
|
if (acc.retryTurns < CAPABILITY_RELIABILITY_MIN_RETRY_TURNS) continue
|
|
const retryRate = acc.retryTurns / acc.editTurns
|
|
if (retryRate < CAPABILITY_RELIABILITY_MIN_RETRY_RATE) continue
|
|
|
|
candidates.push({
|
|
kind: acc.kind,
|
|
name: acc.name,
|
|
editTurns: acc.editTurns,
|
|
retryTurns: acc.retryTurns,
|
|
oneShotTurns: acc.oneShotTurns,
|
|
retries: acc.retries,
|
|
retryRate,
|
|
tokensTouched: acc.tokensTouched,
|
|
tokensSaved: Array.from(acc.retryTurnSavings.values()).reduce((sum, tokens) => sum + tokens, 0),
|
|
projects: Array.from(acc.projects).sort(),
|
|
})
|
|
}
|
|
|
|
candidates.sort((a, b) =>
|
|
b.retryRate - a.retryRate
|
|
|| b.retries - a.retries
|
|
|| b.tokensSaved - a.tokensSaved
|
|
|| a.kind.localeCompare(b.kind)
|
|
|| a.name.localeCompare(b.name)
|
|
)
|
|
return candidates
|
|
}
|
|
|
|
export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null {
|
|
const candidates = findCapabilityReliabilityCandidates(projects)
|
|
if (candidates.length === 0) return null
|
|
|
|
const candidateKeys = new Set(candidates.map(c => capabilityKey(c)))
|
|
const uniqueRetryTurnSavings = new Map<string, number>()
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
for (let turnIndex = 0; turnIndex < session.turns.length; turnIndex++) {
|
|
const turn = session.turns[turnIndex]!
|
|
if (!turn.hasEdits || turn.retries <= 0) continue
|
|
const capabilities = collectReliabilityCapabilities(turn)
|
|
if (capabilities.size === 0) continue
|
|
|
|
const hasFlaggedCapability = Array.from(capabilities.keys()).some(key => candidateKeys.has(key))
|
|
if (!hasFlaggedCapability) continue
|
|
|
|
const key = reliabilityTurnKey(project, session, turn, turnIndex)
|
|
const tokens = Math.round(turnEffectiveTokenTotal(turn) * CAPABILITY_RELIABILITY_RECOVERY_FRACTION)
|
|
uniqueRetryTurnSavings.set(key, Math.max(uniqueRetryTurnSavings.get(key) ?? 0, tokens))
|
|
}
|
|
}
|
|
}
|
|
|
|
const tokensSaved = Array.from(uniqueRetryTurnSavings.values()).reduce((sum, tokens) => sum + tokens, 0)
|
|
const preview = candidates.slice(0, CAPABILITY_RELIABILITY_PREVIEW)
|
|
const list = preview.map(c => {
|
|
const percent = Math.round(c.retryRate * 100)
|
|
const projects = c.projects.length > 1 ? ` across ${c.projects.length} projects` : ` in ${c.projects[0] ?? 'one project'}`
|
|
return `${formatCapabilityKind(c.kind)} ${c.name}: ${c.retryTurns}/${c.editTurns} edit turns retried (${percent}%), ${c.retries} retries${projects}`
|
|
}).join('; ')
|
|
const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : ''
|
|
|
|
const names = preview
|
|
.map(c => `${formatCapabilityKind(c.kind)} ${c.name}`)
|
|
.join(', ')
|
|
|
|
let impact: Impact
|
|
if (candidates.length >= CAPABILITY_RELIABILITY_HIGH_MIN_CANDIDATES || tokensSaved >= CAPABILITY_RELIABILITY_HIGH_IMPACT_TOKENS) {
|
|
impact = 'high'
|
|
} else if (candidates.length <= CAPABILITY_RELIABILITY_LOW_MAX_CANDIDATES && tokensSaved < CAPABILITY_RELIABILITY_LOW_MAX_TOKENS) {
|
|
impact = 'low'
|
|
} else {
|
|
impact = 'medium'
|
|
}
|
|
|
|
const kindSet = new Set(candidates.map(c => c.kind))
|
|
const noun = kindSet.size === 1
|
|
? (kindSet.has('mcp') ? 'MCP server' : 'skill')
|
|
: 'MCP/skill capability'
|
|
const pluralNoun = noun === 'MCP/skill capability' ? 'MCP/skill capabilities' : `${noun}s`
|
|
const verb = candidates.length === 1 ? 'correlates' : 'correlate'
|
|
|
|
return {
|
|
id: 'retry-heavy-capabilities',
|
|
title: `${candidates.length} ${candidates.length === 1 ? noun : pluralNoun} ${verb} with retry-heavy edits`,
|
|
explanation: `Edit turns using these capabilities are retry-heavy: ${list}${extra}. This is a correlation report, not proof of causation; compare the retry-heavy turns with one-shot turns before changing MCP scope or skill instructions.`,
|
|
impact,
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Ask Claude to audit the retry-heavy capability before changing config:',
|
|
text: `Investigate these retry-correlated capabilities: ${names}. Compare edit turns with retries against one-shot edit turns, identify whether the MCP server or skill actually caused rework, then propose a scoped MCP config or skill-instruction change with session evidence. Do not remove a capability solely because it appears in this report.`,
|
|
},
|
|
}
|
|
}
|
|
|
|
export function detectUnusedMcp(
|
|
calls: ToolCall[],
|
|
projects: ProjectSummary[],
|
|
projectCwds: Set<string>,
|
|
mcpCoverage = aggregateMcpCoverage(projects),
|
|
): WasteFinding | null {
|
|
const configured = loadMcpConfigs(projectCwds)
|
|
if (configured.size === 0) return null
|
|
|
|
const calledServers = new Set<string>()
|
|
for (const call of calls) {
|
|
if (!call.name.startsWith('mcp__')) continue
|
|
const seg = call.name.split('__')[1]
|
|
if (seg) calledServers.add(seg)
|
|
}
|
|
for (const p of projects) {
|
|
for (const s of p.sessions) {
|
|
for (const server of Object.keys(s.mcpBreakdown)) calledServers.add(server)
|
|
}
|
|
}
|
|
|
|
// Servers that the new coverage detector will flag fall under its
|
|
// jurisdiction (per-tool granularity, cache-aware costing) and we
|
|
// suppress them here to avoid double-flagging. Importantly, we suppress
|
|
// only the servers that actually clear the coverage detector's
|
|
// thresholds — a small, inventoried-but-uninvoked server that the
|
|
// coverage detector skips would otherwise become a blind spot.
|
|
const coverageReportedServers = new Set(
|
|
mcpCoverage
|
|
.filter(c =>
|
|
c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS
|
|
&& c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS
|
|
&& c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD,
|
|
)
|
|
.map(c => c.server),
|
|
)
|
|
|
|
const now = Date.now()
|
|
const unused: string[] = []
|
|
for (const entry of configured.values()) {
|
|
if (calledServers.has(entry.normalized)) continue
|
|
if (coverageReportedServers.has(entry.normalized)) continue
|
|
if (entry.mtime > 0 && now - entry.mtime < MCP_NEW_CONFIG_GRACE_MS) continue
|
|
unused.push(entry.original)
|
|
}
|
|
|
|
if (unused.length === 0) return null
|
|
|
|
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
|
const schemaTokensPerSession = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL
|
|
const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1)
|
|
|
|
return {
|
|
id: 'unused-mcp',
|
|
title: `${unused.length} MCP server${unused.length > 1 ? 's' : ''} configured but never used`,
|
|
explanation: `Never called in this period: ${unused.join(', ')}. Each server loads ~${TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL} tokens of tool schema into every session.`,
|
|
impact: unused.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium',
|
|
tokensSaved,
|
|
apply: { kind: 'mcp-remove', servers: unused },
|
|
fix: {
|
|
type: 'command',
|
|
label: `Remove unused server${unused.length > 1 ? 's' : ''}:`,
|
|
text: unused.map(s => `claude mcp remove ${s}`).join('\n'),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MCP deferral gap detectors — the "mcp-deferral-gaps" family (#614)
|
|
// ============================================================================
|
|
//
|
|
// Shared invariant: `session.mcpInventory` comes ONLY from
|
|
// `deferred_tools_delta` attachments, which Claude Code emits only when tool
|
|
// search / deferral is ACTIVE. Inventory presence is therefore direct
|
|
// evidence deferral was on; deferral-off sessions still show MCP invocations
|
|
// (mcpBreakdown, mcp__ tool calls) but never an inventory.
|
|
|
|
type DeferralEnvHit = {
|
|
value: string
|
|
// Human-readable config scope, e.g. "user settings" / "project local
|
|
// settings" / "shell profile". Surfaced verbatim in explanations so the
|
|
// user knows which file carries the override.
|
|
scope: string
|
|
path: string
|
|
}
|
|
|
|
// Settings scopes are searched most-specific first (project local >
|
|
// project > user local > user), matching Claude Code's effective settings
|
|
// precedence, so the hit we report is the value that actually takes
|
|
// effect. Shell profiles come last: settings "env" entries override the
|
|
// inherited environment. `homeDir` is injectable for tests (PlanContext
|
|
// style in src/act/plans.ts).
|
|
export function findDeferralEnvSetting(
|
|
name: string,
|
|
projectCwds: Iterable<string>,
|
|
homeDir = homedir(),
|
|
): DeferralEnvHit | null {
|
|
const scopes: Array<{ scope: string; path: string }> = []
|
|
for (const cwd of projectCwds) {
|
|
scopes.push({ scope: 'project local settings', path: join(cwd, '.claude', 'settings.local.json') })
|
|
scopes.push({ scope: 'project settings', path: join(cwd, '.claude', 'settings.json') })
|
|
}
|
|
scopes.push({ scope: 'user local settings', path: join(homeDir, '.claude', 'settings.local.json') })
|
|
scopes.push({ scope: 'user settings', path: join(homeDir, '.claude', 'settings.json') })
|
|
for (const { scope, path } of scopes) {
|
|
if (!existsSync(path)) continue
|
|
const config = readJsonFile(path)
|
|
const env = config?.env as Record<string, unknown> | undefined
|
|
const value = env?.[name]
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
return { value: String(value), scope, path }
|
|
}
|
|
}
|
|
// Callers only pass the three *_VAR constants today, but the function is
|
|
// exported — escape regex metacharacters so a future caller can't
|
|
// silently mis-match.
|
|
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
const linePattern = new RegExp(`^\\s*(?:export\\s+)?${escapedName}\\s*=\\s*['"]?([^'"\\s]+)['"]?`, 'm')
|
|
for (const profile of SHELL_PROFILES) {
|
|
const path = join(homeDir, profile)
|
|
if (!existsSync(path)) continue
|
|
const content = readSessionFileSync(path)
|
|
if (content === null) continue
|
|
const match = content.match(linePattern)
|
|
if (match) return { value: match[1]!, scope: 'shell profile', path }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isEnvValueFalse(value: string): boolean {
|
|
return value.toLowerCase() === 'false' || value === '0'
|
|
}
|
|
|
|
// Unset counts as first-party; only a URL whose host resolves to
|
|
// api.anthropic.com does too. Anything else — including an unparseable
|
|
// value — is an unknown proxy and we never assume its capability.
|
|
function isFirstPartyBaseUrl(value: string): boolean {
|
|
try {
|
|
return new URL(value).hostname === FIRST_PARTY_API_HOST
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Both exported: the defer-alwaysload plan builder (src/act/plans.ts) gates
|
|
// on the installed Claude Code version with the same comparison the detector
|
|
// uses on observed versions. versionPredates returns false for unparseable
|
|
// input, so gates that must fail closed check parseVersion separately.
|
|
export function parseVersion(version: string): [number, number, number] | null {
|
|
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
|
|
if (!match) return null
|
|
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
}
|
|
|
|
export function versionPredates(version: string, reference: string): boolean {
|
|
const v = parseVersion(version)
|
|
const ref = parseVersion(reference)
|
|
if (!v || !ref) return false
|
|
for (let i = 0; i < 3; i++) {
|
|
if (v[i] !== ref[i]) return v[i]! < ref[i]!
|
|
}
|
|
return false
|
|
}
|
|
|
|
function anySessionHasMcpInventory(projects: ProjectSummary[]): boolean {
|
|
return projects.some(p => p.sessions.some(s => (s.mcpInventory?.length ?? 0) > 0))
|
|
}
|
|
|
|
// Deferral is a Claude Code mechanism, and both suppression signals for this
|
|
// family (ToolSearch calls, mcpInventory) exist only in Claude Code
|
|
// transcripts. Usage evidence must therefore be Claude-scoped too: the
|
|
// Codex/Copilot/opencode/Hermes parsers also normalize MCP calls into
|
|
// mcpBreakdown, but those sessions can never carry the counter-evidence, so
|
|
// counting them would flag a "deferral gap" Claude Code never had.
|
|
function isClaudeSession(session: SessionSummary): boolean {
|
|
return session.turns.some(t => t.assistantCalls.some(c => c.provider === 'claude'))
|
|
}
|
|
|
|
/**
|
|
* mcp-deferral-off: MCP tool definitions are being loaded upfront in every
|
|
* session because tool search / deferral is inactive.
|
|
*
|
|
* Signal: MCP overhead exists (configured servers and/or observed mcp__
|
|
* invocations), yet across the whole window there is not a single ToolSearch
|
|
* invocation and not a single session with an mcpInventory — which
|
|
* deferral-active sessions always produce (see family invariant above).
|
|
*
|
|
* Cause attribution probes config in a fixed order, each producing a
|
|
* distinct explanation: ENABLE_TOOL_SEARCH=false override, non-first-party
|
|
* ANTHROPIC_BASE_URL (unknown proxy: deferral auto-disables because most
|
|
* proxies don't forward tool_reference blocks), CLAUDE_CODE_USE_VERTEX
|
|
* (tool search is disabled by default on Vertex AI, changelog v2.1.119),
|
|
* every observed Claude Code version predating v2.1.7 (when tool search
|
|
* auto mode became on by default), else a generic transcript-evidence
|
|
* message.
|
|
*/
|
|
// Cause attribution for mcp-deferral-off. Probes config in a fixed order —
|
|
// false override, unknown proxy, Vertex, stale version, explicit-but-
|
|
// ineffective override, generic — each producing a distinct explanation and
|
|
// fix. Split out so the detector stays focused on evidence gathering.
|
|
function attributeDeferralOffCause(
|
|
enableToolSearch: DeferralEnvHit | null,
|
|
projectCwds: Set<string>,
|
|
apiCalls: ApiCallMeta[],
|
|
homeDir: string,
|
|
): { cause: string; fix: WasteAction; apply?: FindingApply } {
|
|
if (enableToolSearch && isEnvValueFalse(enableToolSearch.value)) {
|
|
return {
|
|
cause: `Cause: ${ENABLE_TOOL_SEARCH_VAR}=${enableToolSearch.value} is set in ${enableToolSearch.scope} (${shortHomePath(enableToolSearch.path)}), forcing all tool definitions upfront.`,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Ask Claude to remove the stale override:',
|
|
text: `Remove the ${ENABLE_TOOL_SEARCH_VAR}=${enableToolSearch.value} setting from ${enableToolSearch.path}. Tool search is on by default on first-party endpoints, so deleting the override re-enables MCP tool deferral.`,
|
|
},
|
|
apply: { kind: 'defer-enable', cause: 'env-false', settingPath: enableToolSearch.path, settingScope: enableToolSearch.scope, value: enableToolSearch.value },
|
|
}
|
|
}
|
|
const baseUrl = findDeferralEnvSetting(ANTHROPIC_BASE_URL_VAR, projectCwds, homeDir)
|
|
if (baseUrl && !isFirstPartyBaseUrl(baseUrl.value)) {
|
|
return {
|
|
cause: `Cause: ${ANTHROPIC_BASE_URL_VAR} points at a non-first-party host in ${baseUrl.scope} (${shortHomePath(baseUrl.path)}). Tool deferral silently auto-disables behind proxies because most don't forward tool_reference blocks; whether this proxy can is unknown.`,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'shell-config',
|
|
label: `Verify your proxy forwards tool_reference blocks first (an explicit override fails on proxies that don't), then force tool search back on with:`,
|
|
text: `export ${ENABLE_TOOL_SEARCH_VAR}=true`,
|
|
},
|
|
apply: { kind: 'defer-enable', cause: 'proxy-unknown', settingPath: baseUrl.path, settingScope: baseUrl.scope, value: baseUrl.value },
|
|
}
|
|
}
|
|
const vertex = findDeferralEnvSetting(CLAUDE_CODE_USE_VERTEX_VAR, projectCwds, homeDir)
|
|
if (vertex && !isEnvValueFalse(vertex.value)) {
|
|
return {
|
|
cause: `Cause: ${CLAUDE_CODE_USE_VERTEX_VAR} is set in ${vertex.scope} (${shortHomePath(vertex.path)}), and tool search is disabled by default on Vertex AI.`,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'shell-config',
|
|
label: 'Opt in to tool search on Vertex (disabled by default there):',
|
|
text: `export ${ENABLE_TOOL_SEARCH_VAR}=true`,
|
|
},
|
|
apply: { kind: 'defer-enable', cause: 'vertex', settingPath: vertex.path, settingScope: vertex.scope, value: vertex.value },
|
|
}
|
|
}
|
|
const versions = apiCalls.map(c => c.version).filter(v => v.length > 0)
|
|
if (versions.length > 0 && versions.every(v => versionPredates(v, TOOL_SEARCH_DEFAULT_ON_VERSION))) {
|
|
return {
|
|
cause: `Cause: every observed Claude Code version in this period predates v${TOOL_SEARCH_DEFAULT_ON_VERSION}, where MCP tool search became on by default.`,
|
|
fix: {
|
|
type: 'command',
|
|
label: 'Update Claude Code to get default-on MCP tool deferral:',
|
|
text: 'claude update',
|
|
},
|
|
apply: { kind: 'defer-enable', cause: 'old-version' },
|
|
}
|
|
}
|
|
// false and auto values were handled earlier, so a hit here is an explicit
|
|
// truthy override that is already applied — re-suggesting the same export
|
|
// would be redundant.
|
|
if (enableToolSearch) {
|
|
return {
|
|
cause: `Cause: none determinable — ${ENABLE_TOOL_SEARCH_VAR}=${enableToolSearch.value} is already set in ${enableToolSearch.scope} (${shortHomePath(enableToolSearch.path)}), yet transcripts show no deferral activity.`,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'The override is already on; ask Claude to investigate why deferral is still inactive:',
|
|
text: `${ENABLE_TOOL_SEARCH_VAR}=${enableToolSearch.value} is set in ${enableToolSearch.path}, but sessions show no ToolSearch calls and no deferred-tool inventory. Check whether requests pass through a proxy that strips tool_reference blocks and whether the running Claude Code version supports tool search.`,
|
|
},
|
|
}
|
|
}
|
|
return {
|
|
cause: `Cause: none determinable from config — no disabling override, proxy, or Vertex setting found.`,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'shell-config',
|
|
label: 'Deferral is on by default on first-party endpoints; to force it explicitly, add:',
|
|
text: `export ${ENABLE_TOOL_SEARCH_VAR}=true`,
|
|
},
|
|
}
|
|
}
|
|
|
|
export function detectMcpDeferralOff(
|
|
calls: ToolCall[],
|
|
projects: ProjectSummary[],
|
|
projectCwds: Set<string>,
|
|
apiCalls: ApiCallMeta[],
|
|
homeDir = homedir(),
|
|
): WasteFinding | null {
|
|
// Deferral-active evidence anywhere in the window suppresses the finding.
|
|
if (calls.some(c => c.name === TOOL_SEARCH_TOOL_NAME)) return null
|
|
if (anySessionHasMcpInventory(projects)) return null
|
|
|
|
const configured = loadMcpConfigs(projectCwds, homeDir)
|
|
// Servers pinned with alwaysLoad load upfront BY DESIGN: they are never
|
|
// deferred (so their missing inventory is not evidence deferral is
|
|
// broken), and their schema cost is mcp-alwaysload-hygiene's jurisdiction
|
|
// — charging it here too would double-count the same tokens.
|
|
const pinnedServers = new Set(
|
|
[...configured.values()].filter(e => e.alwaysLoadPaths.length > 0).map(e => e.normalized),
|
|
)
|
|
const configuredUnpinned = [...configured.keys()].filter(s => !pinnedServers.has(s))
|
|
|
|
// `calls` comes from scanSessions, which reads Claude Code JSONL only, so
|
|
// it is already Claude-scoped (see isClaudeSession for why that matters).
|
|
const observedServers = new Set<string>()
|
|
for (const call of calls) {
|
|
if (!call.name.startsWith('mcp__')) continue
|
|
const seg = call.name.split('__')[1]
|
|
if (seg && !pinnedServers.has(seg)) observedServers.add(seg)
|
|
}
|
|
let invocations = 0
|
|
let sessionsWithMcpCalls = 0
|
|
let totalSessions = 0
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
if (!isClaudeSession(session)) continue
|
|
totalSessions++
|
|
let sessionCalls = 0
|
|
for (const [server, data] of Object.entries(session.mcpBreakdown)) {
|
|
if (pinnedServers.has(server)) continue
|
|
observedServers.add(server)
|
|
sessionCalls += data.calls
|
|
}
|
|
if (sessionCalls > 0) sessionsWithMcpCalls++
|
|
invocations += sessionCalls
|
|
}
|
|
}
|
|
|
|
const servers = new Set([...configuredUnpinned, ...observedServers])
|
|
if (servers.size === 0) return null
|
|
|
|
// Configured servers load their schemas into every session; with only
|
|
// invocation evidence we can vouch just for the sessions that called MCP.
|
|
const affectedSessions = configuredUnpinned.length > 0 ? totalSessions : sessionsWithMcpCalls
|
|
if (affectedSessions < DEFERRAL_OFF_MIN_MCP_SESSIONS) return null
|
|
|
|
const enableToolSearch = findDeferralEnvSetting(ENABLE_TOOL_SEARCH_VAR, projectCwds, homeDir)
|
|
// auto / auto:N overrides — including out-of-range N, which the threshold
|
|
// detector clamps — are the defer-threshold detector's jurisdiction (it
|
|
// can compute the tuned N); reporting them here too would double-flag the
|
|
// same schema overhead. This regex must stay identical to the one in
|
|
// detectMcpDeferThreshold or values could slip through both detectors.
|
|
if (enableToolSearch && /^auto(?::\d+)?$/.test(enableToolSearch.value)) return null
|
|
|
|
// Without deferral there is never an inventory, so per-server tool counts
|
|
// are unknown — follow detectUnusedMcp's convention of 5 tools x 400
|
|
// tokens per server.
|
|
const perServerSchemaTokens = TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL
|
|
const perSessionSchemaTokens = servers.size * perServerSchemaTokens
|
|
const tokensSaved = perSessionSchemaTokens * affectedSessions
|
|
const callRate = affectedSessions > 0 ? invocations / affectedSessions : 0
|
|
// Pluralize on the rendered value so 0.98 and 1.0 read consistently.
|
|
const callRateText = callRate.toFixed(1)
|
|
|
|
const evidence =
|
|
`~${formatTokens(perSessionSchemaTokens)} tokens of MCP tool schema ` +
|
|
`(${servers.size} server${servers.size === 1 ? '' : 's'} at ~${formatTokens(perServerSchemaTokens)} tokens/server) sit in the prompt prefix of ` +
|
|
`${affectedSessions} session${affectedSessions === 1 ? '' : 's'} at ` +
|
|
`${callRateText} MCP call${callRateText === '1.0' ? '' : 's'}/session, with zero ToolSearch calls ` +
|
|
`and no deferred-tool inventory observed — tool deferral appears inactive. ` +
|
|
`Deferral would move ~all of that schema out of the prefix.`
|
|
|
|
const { cause, fix, apply } = attributeDeferralOffCause(enableToolSearch, projectCwds, apiCalls, homeDir)
|
|
|
|
return {
|
|
id: 'mcp-deferral-off',
|
|
title: 'MCP tool deferral appears inactive',
|
|
explanation: `${evidence} ${cause}`,
|
|
impact: tokensSaved >= DEFERRAL_OFF_HIGH_IMPACT_TOKENS ? 'high' : 'medium',
|
|
tokensSaved,
|
|
fix,
|
|
...(apply ? { apply } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* mcp-alwaysload-hygiene: servers pinned with `"alwaysLoad": true` (an
|
|
* .mcp.json / settings mcpServers sibling of type/url, Claude Code
|
|
* v2.1.121+) whose observed call rate doesn't justify exempting them from
|
|
* deferral. alwaysLoad puts the server's full tool schema in every
|
|
* session's prefix even though deferral is available, and additionally
|
|
* blocks session startup on that server's connection (capped at
|
|
* ALWAYSLOAD_STARTUP_CAP_SECONDS).
|
|
*/
|
|
export function detectMcpAlwaysLoadHygiene(
|
|
projects: ProjectSummary[],
|
|
projectCwds: Set<string>,
|
|
apiCalls: ApiCallMeta[] = [],
|
|
mcpCoverage = aggregateMcpCoverage(projects),
|
|
homeDir = homedir(),
|
|
): WasteFinding | null {
|
|
const configured = loadMcpConfigs(projectCwds, homeDir)
|
|
const pinned = [...configured.values()].filter(e => e.alwaysLoadPaths.length > 0)
|
|
if (pinned.length === 0) return null
|
|
|
|
// When every observed Claude Code version predates server-level
|
|
// alwaysLoad support, the key is inert — the tools defer normally, so
|
|
// the pin costs nothing and the finding's claim would be false.
|
|
const versions = apiCalls.map(c => c.version).filter(v => v.length > 0)
|
|
if (versions.length > 0 && versions.every(v => versionPredates(v, ALWAYSLOAD_MIN_VERSION))) return null
|
|
|
|
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
|
if (totalSessions === 0) return null
|
|
|
|
const coverageByServer = new Map(mcpCoverage.map(c => [c.server, c]))
|
|
const invocationsByServer = new Map<string, number>()
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
for (const [server, data] of Object.entries(session.mcpBreakdown)) {
|
|
invocationsByServer.set(server, (invocationsByServer.get(server) ?? 0) + data.calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
const lines: string[] = []
|
|
const fixLines: string[] = []
|
|
const applyServers: Array<{ server: string; paths: string[] }> = []
|
|
let tokensSaved = 0
|
|
for (const entry of pinned) {
|
|
const invocations = invocationsByServer.get(entry.normalized) ?? 0
|
|
const callRate = invocations / totalSessions
|
|
if (callRate >= ALWAYSLOAD_MAX_CALLS_PER_SESSION) continue
|
|
// Prefer real inventory numbers when a session observed them; without
|
|
// an inventory fall back to the 5 tools x 400 tokens convention, and
|
|
// charge every session since alwaysLoad forces the load unconditionally.
|
|
const coverage = coverageByServer.get(entry.normalized)
|
|
const toolsAvailable = coverage?.toolsAvailable ?? TOOLS_PER_MCP_SERVER
|
|
const loadedSessions = coverage?.loadedSessions ?? totalSessions
|
|
tokensSaved += toolsAvailable * TOKENS_PER_MCP_TOOL * loadedSessions
|
|
lines.push(`${entry.original}: ${invocations} call${invocations === 1 ? '' : 's'} across ${totalSessions} session${totalSessions === 1 ? '' : 's'}`)
|
|
fixLines.push(`- Remove "alwaysLoad": true from ${entry.original} in ${entry.alwaysLoadPaths.map(shortHomePath).join(', ')}.`)
|
|
// Original (config-key) name plus the exact files carrying the pin, so
|
|
// the defer-alwaysload plan edits precisely what was observed.
|
|
applyServers.push({ server: entry.original, paths: entry.alwaysLoadPaths })
|
|
}
|
|
if (lines.length === 0) return null
|
|
|
|
return {
|
|
id: 'mcp-alwaysload-hygiene',
|
|
title: `${lines.length} alwaysLoad MCP server${lines.length === 1 ? '' : 's'} rarely used`,
|
|
explanation:
|
|
`These servers are pinned with alwaysLoad, so their tool schemas sit in every session's prefix ` +
|
|
`despite deferral being available, and session startup blocks on each server's connection ` +
|
|
`(up to ${ALWAYSLOAD_STARTUP_CAP_SECONDS}s). Usage doesn't justify the pin: ${lines.join('; ')}.`,
|
|
impact: tokensSaved >= ALWAYSLOAD_HIGH_IMPACT_TOKENS ? 'high' : 'medium',
|
|
tokensSaved,
|
|
apply: { kind: 'defer-alwaysload', servers: applyServers },
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Ask Claude to unpin the rarely-used servers (tool search still discovers their tools on demand):',
|
|
text: fixLines.join('\n'),
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* mcp-defer-threshold: an explicit ENABLE_TOOL_SEARCH=auto or auto:N
|
|
* override whose threshold the configured MCP definitions never reach —
|
|
* auto defers only when defs exceed N% of the context window (default 10),
|
|
* so everything still loads upfront while carrying substantial per-session
|
|
* cost. Lowest priority of the family; emitted only on a substantial gap.
|
|
*/
|
|
export function detectMcpDeferThreshold(
|
|
projects: ProjectSummary[],
|
|
projectCwds: Set<string>,
|
|
homeDir = homedir(),
|
|
): WasteFinding | null {
|
|
const setting = findDeferralEnvSetting(ENABLE_TOOL_SEARCH_VAR, projectCwds, homeDir)
|
|
if (!setting) return null
|
|
// Must accept exactly what detectMcpDeferralOff yields to this detector
|
|
// (any auto:N, N unbounded), or an out-of-range N would silently slip
|
|
// through both detectors. Oversized values clamp to 100% — a threshold
|
|
// that can never trigger, the strongest form of this finding.
|
|
const match = /^auto(?::(\d+))?$/.exec(setting.value)
|
|
if (!match) return null
|
|
const percent = match[1] !== undefined
|
|
? Math.min(DEFER_THRESHOLD_MAX_PERCENT, parseInt(match[1], 10))
|
|
: DEFER_THRESHOLD_DEFAULT_PERCENT
|
|
|
|
// Inventory anywhere in the window means the auto threshold did trigger
|
|
// and deferral is working; nothing to tune.
|
|
if (anySessionHasMcpInventory(projects)) return null
|
|
|
|
const configured = loadMcpConfigs(projectCwds, homeDir)
|
|
const servers = new Set(configured.keys())
|
|
// Claude-scoped for the same reason as detectMcpDeferralOff: other
|
|
// providers' sessions carry MCP breakdowns but not this override's cost.
|
|
let totalSessions = 0
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
if (!isClaudeSession(session)) continue
|
|
totalSessions++
|
|
for (const server of Object.keys(session.mcpBreakdown)) servers.add(server)
|
|
}
|
|
}
|
|
if (servers.size === 0) return null
|
|
if (totalSessions === 0) return null
|
|
|
|
// No inventory (see suppression above) means per-server tool counts are
|
|
// unknown — same 5 tools x 400 tokens convention as detectUnusedMcp.
|
|
const defsPerSession = servers.size * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL
|
|
const onePercent = DEFER_THRESHOLD_CONTEXT_WINDOW_TOKENS / 100
|
|
const thresholdTokens = percent * onePercent
|
|
// Deferral kicks in only when defs EXCEED the threshold; at or below it,
|
|
// everything loads upfront.
|
|
if (defsPerSession > thresholdTokens) return null
|
|
if (defsPerSession < DEFER_THRESHOLD_MIN_TOKENS_PER_SESSION) return null
|
|
|
|
const tokensSaved = defsPerSession * totalSessions
|
|
// Largest integer N such that the defs still exceed N% — i.e. the loosest
|
|
// auto:N at which deferral actually kicks in.
|
|
const recommendedPercent = Math.max(0, Math.ceil(defsPerSession / onePercent) - 1)
|
|
// If the defs already exceed the default 10% threshold, the override is
|
|
// pure downside: removing it restores default auto behavior, which defers.
|
|
const removeOverride = defsPerSession > DEFER_THRESHOLD_DEFAULT_PERCENT * onePercent
|
|
|
|
return {
|
|
id: 'mcp-defer-threshold',
|
|
title: 'MCP tool search auto threshold never triggers',
|
|
explanation:
|
|
`${ENABLE_TOOL_SEARCH_VAR}=${setting.value} is set in ${setting.scope} (${shortHomePath(setting.path)}), ` +
|
|
`deferring MCP tool definitions only when they exceed ${percent}% of the ${formatTokens(DEFER_THRESHOLD_CONTEXT_WINDOW_TOKENS)}-token context window ` +
|
|
`(~${formatTokens(thresholdTokens)} tokens). Your estimated ~${formatTokens(defsPerSession)} tokens of definitions per session fit under that, ` +
|
|
`so every tool still loads upfront in all ${totalSessions} session${totalSessions === 1 ? '' : 's'}.`,
|
|
impact: tokensSaved >= DEFER_THRESHOLD_MEDIUM_IMPACT_TOKENS ? 'medium' : 'low',
|
|
tokensSaved,
|
|
apply: {
|
|
kind: 'defer-threshold',
|
|
settingPath: setting.path,
|
|
settingScope: setting.scope,
|
|
value: setting.value,
|
|
recommendedPercent,
|
|
removeOverride,
|
|
},
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Ask Claude to tighten the auto threshold:',
|
|
text: removeOverride
|
|
? `Remove the ${ENABLE_TOOL_SEARCH_VAR}=${setting.value} override from ${setting.path}; the default auto threshold (${DEFER_THRESHOLD_DEFAULT_PERCENT}%) already defers this volume of tool definitions.`
|
|
: `In ${setting.path}, change ${ENABLE_TOOL_SEARCH_VAR}=${setting.value} to ${ENABLE_TOOL_SEARCH_VAR}=auto:${recommendedPercent} so ~${formatTokens(defsPerSession)} tokens of MCP tool definitions per session are deferred instead of loaded upfront.`,
|
|
},
|
|
}
|
|
}
|
|
|
|
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)
|
|
const content = readSessionFileSync(filePath)
|
|
if (content === null) return { totalLines: 0, importedFiles: 0 }
|
|
|
|
let totalLines = content.split('\n').length
|
|
let importedFiles = 0
|
|
const dir = join(filePath, '..')
|
|
|
|
IMPORT_PATTERN.lastIndex = 0
|
|
for (const match of content.matchAll(IMPORT_PATTERN)) {
|
|
const rawPath = match[1]
|
|
if (!rawPath) continue
|
|
const resolved = rawPath.startsWith('/') ? rawPath : join(dir, rawPath)
|
|
if (!existsSync(resolved)) continue
|
|
const nested = expandImports(resolved, seen, depth + 1)
|
|
totalLines += nested.totalLines
|
|
importedFiles += 1 + nested.importedFiles
|
|
}
|
|
|
|
return { totalLines, importedFiles }
|
|
}
|
|
|
|
export function detectBloatedClaudeMd(projectCwds: Set<string>): WasteFinding | null {
|
|
const bloated: { path: string; expandedLines: number; imports: number }[] = []
|
|
|
|
for (const cwd of projectCwds) {
|
|
for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
|
|
const fullPath = join(cwd, name)
|
|
if (!existsSync(fullPath)) continue
|
|
const { totalLines, importedFiles } = expandImports(fullPath, new Set(), 0)
|
|
if (totalLines > CLAUDEMD_HEALTHY_LINES) {
|
|
bloated.push({ path: `${shortHomePath(cwd)}/${name}`, expandedLines: totalLines, imports: importedFiles })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bloated.length === 0) return null
|
|
|
|
const sorted = bloated.sort((a, b) => b.expandedLines - a.expandedLines)
|
|
const worst = sorted[0]
|
|
const totalExtraLines = sorted.reduce((s, b) => s + (b.expandedLines - CLAUDEMD_HEALTHY_LINES), 0)
|
|
const tokensSaved = totalExtraLines * CLAUDEMD_TOKENS_PER_LINE
|
|
|
|
const list = sorted.slice(0, TOP_ITEMS_PREVIEW).map(b => {
|
|
const importNote = b.imports > 0 ? ` with ${b.imports} @-import${b.imports > 1 ? 's' : ''}` : ''
|
|
return `${b.path} (${b.expandedLines} lines${importNote})`
|
|
}).join(', ')
|
|
|
|
return {
|
|
id: 'claude-md-too-long',
|
|
title: `Your CLAUDE.md is too long`,
|
|
explanation: `${list}. CLAUDE.md plus all @-imported files load into every API call. Trimming below ${CLAUDEMD_HEALTHY_LINES} lines saves ~${formatTokens(tokensSaved)} tokens per call.`,
|
|
impact: worst.expandedLines > CLAUDEMD_HIGH_THRESHOLD_LINES ? 'high' : 'medium',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'prompt',
|
|
label: 'Ask Claude in the current session to trim it:',
|
|
text: `Review CLAUDE.md and all @-imported files. Cut total expanded content to under ${CLAUDEMD_HEALTHY_LINES} lines. Remove anything Claude can figure out from the code itself. Keep only rules, gotchas, and non-obvious conventions.`,
|
|
},
|
|
}
|
|
}
|
|
|
|
export const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
|
|
export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit'])
|
|
|
|
export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null {
|
|
let reads = 0
|
|
let edits = 0
|
|
let recentEdits = 0
|
|
let recentReads = 0
|
|
for (const call of calls) {
|
|
if (READ_TOOL_NAMES.has(call.name)) {
|
|
reads++
|
|
if (call.recent) recentReads++
|
|
} else if (EDIT_TOOL_NAMES.has(call.name)) {
|
|
edits++
|
|
if (call.recent) recentEdits++
|
|
}
|
|
}
|
|
|
|
if (edits < MIN_EDITS_FOR_RATIO) return null
|
|
const ratio = reads / edits
|
|
if (ratio >= HEALTHY_READ_EDIT_RATIO) return null
|
|
|
|
const impact: Impact = ratio < LOW_RATIO_HIGH_THRESHOLD ? 'high' : ratio < LOW_RATIO_MEDIUM_THRESHOLD ? 'medium' : 'low'
|
|
const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0)
|
|
const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ
|
|
|
|
let trend: Trend | 'resolved' = 'active'
|
|
if (recentEdits >= MIN_EDITS_FOR_RATIO) {
|
|
const recentRatio = recentReads / recentEdits
|
|
if (recentRatio >= HEALTHY_READ_EDIT_RATIO) trend = 'resolved'
|
|
else if (recentRatio > ratio * (1 / IMPROVING_THRESHOLD)) trend = 'improving'
|
|
}
|
|
if (trend === 'resolved') return null
|
|
|
|
return {
|
|
id: 'read-edit-ratio',
|
|
title: 'Claude edits more than it reads',
|
|
explanation: `Claude made ${reads} reads and ${edits} edits (ratio ${ratio.toFixed(1)}:1). A healthy ratio is ${HEALTHY_READ_EDIT_RATIO}+ reads per edit. Editing without reading leads to retries and wasted tokens.`,
|
|
impact,
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'claude-md',
|
|
label: 'Add to your CLAUDE.md:',
|
|
text: 'Before editing any file, read it first. Before modifying a function, grep for all callers. Research before you edit.',
|
|
},
|
|
trend,
|
|
}
|
|
}
|
|
|
|
const DEFAULT_CACHE_BASELINE_TOKENS = 50_000
|
|
const CACHE_BASELINE_QUANTILE = 0.25
|
|
const CACHE_BLOAT_MULTIPLIER = 1.4
|
|
const CACHE_VERSION_MIN_SAMPLES = 5
|
|
const CACHE_VERSION_DIFF_THRESHOLD = 10_000
|
|
|
|
function computeBudgetAwareCacheBaseline(projects: ProjectSummary[]): number {
|
|
const sessions = projects.flatMap(p => p.sessions)
|
|
if (sessions.length === 0) return DEFAULT_CACHE_BASELINE_TOKENS
|
|
const cacheWrites = sessions.map(s => s.totalCacheWriteTokens).filter(n => n > 0)
|
|
if (cacheWrites.length < MIN_API_CALLS_FOR_CACHE) return DEFAULT_CACHE_BASELINE_TOKENS
|
|
const sorted = cacheWrites.sort((a, b) => a - b)
|
|
return sorted[Math.floor(sorted.length * CACHE_BASELINE_QUANTILE)] || DEFAULT_CACHE_BASELINE_TOKENS
|
|
}
|
|
|
|
export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSummary[], dateRange?: DateRange): WasteFinding | null {
|
|
if (apiCalls.length < MIN_API_CALLS_FOR_CACHE) return null
|
|
|
|
const sorted = apiCalls.map(c => c.cacheCreationTokens).sort((a, b) => a - b)
|
|
const median = sorted[Math.floor(sorted.length / 2)]
|
|
const baseline = computeBudgetAwareCacheBaseline(projects)
|
|
const bloatThreshold = baseline * CACHE_BLOAT_MULTIPLIER
|
|
|
|
if (median < bloatThreshold) return null
|
|
|
|
const recentCalls = apiCalls.filter(c => c.recent)
|
|
const totalBloated = apiCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length
|
|
const recentBloated = recentCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length
|
|
const trend = sessionTrend(recentBloated, totalBloated, dateRange, recentCalls.length > 0)
|
|
if (trend === 'resolved') return null
|
|
|
|
const versionCounts = new Map<string, { total: number; count: number }>()
|
|
for (const call of apiCalls) {
|
|
if (!call.version) continue
|
|
const entry = versionCounts.get(call.version) ?? { total: 0, count: 0 }
|
|
entry.total += call.cacheCreationTokens
|
|
entry.count++
|
|
versionCounts.set(call.version, entry)
|
|
}
|
|
const versionAvgs = [...versionCounts.entries()]
|
|
.filter(([, d]) => d.count >= CACHE_VERSION_MIN_SAMPLES)
|
|
.map(([v, d]) => ({ version: v, avg: Math.round(d.total / d.count) }))
|
|
.sort((a, b) => b.avg - a.avg)
|
|
|
|
const excess = median - baseline
|
|
const tokensSaved = excess * apiCalls.length
|
|
|
|
let versionNote = ''
|
|
if (versionAvgs.length >= 2) {
|
|
const [high, ...rest] = versionAvgs
|
|
const low = rest[rest.length - 1]
|
|
if (high.avg - low.avg > CACHE_VERSION_DIFF_THRESHOLD) {
|
|
versionNote = ` Version ${high.version} averages ${formatTokens(high.avg)} vs ${low.version} at ${formatTokens(low.avg)}.`
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: 'warmup-heavy',
|
|
title: 'Session warmup is unusually large',
|
|
explanation: `Median cache_creation per call is ${formatTokens(median)} tokens, about ${formatTokens(excess)} above your baseline of ${formatTokens(baseline)}.${versionNote}`,
|
|
impact: excess > CACHE_EXCESS_HIGH_THRESHOLD ? 'high' : 'medium',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'shell-config',
|
|
label: 'Check for recent Claude Code updates or heavy MCP/skill additions. As a workaround (not officially supported), add to ~/.zshrc or ~/.bashrc:',
|
|
text: 'export ANTHROPIC_CUSTOM_HEADERS=\'User-Agent: claude-cli/2.1.98 (external, sdk-cli)\'',
|
|
},
|
|
trend,
|
|
}
|
|
}
|
|
|
|
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 [] }
|
|
}
|
|
|
|
export async function detectGhostAgents(calls: ToolCall[]): Promise<WasteFinding | null> {
|
|
const defined = await listMarkdownFiles(join(homedir(), '.claude', 'agents'))
|
|
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, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '')
|
|
|
|
return {
|
|
id: 'unused-agents',
|
|
title: `${ghosts.length} custom agent${ghosts.length > 1 ? 's' : ''} you never use`,
|
|
explanation: `Defined in ~/.claude/agents/ but never invoked in this period: ${list}. Each adds ~${TOKENS_PER_AGENT_DEF} tokens to the Task tool schema on every session.`,
|
|
impact: ghosts.length >= GHOST_AGENTS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_AGENTS_MEDIUM_THRESHOLD ? 'medium' : 'low',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'command',
|
|
label: `Archive unused agent${ghosts.length > 1 ? 's' : ''}:`,
|
|
text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/agents/${name}.md ~/.claude/agents/.archived/`).join('\n'),
|
|
},
|
|
apply: { kind: 'archive', names: ghosts },
|
|
}
|
|
}
|
|
|
|
export async function detectGhostSkills(calls: ToolCall[]): Promise<WasteFinding | null> {
|
|
const defined = await listSkillDirs(join(homedir(), '.claude', 'skills'))
|
|
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) return null
|
|
|
|
const tokensSaved = ghosts.length * TOKENS_PER_SKILL_DEF
|
|
const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '')
|
|
|
|
return {
|
|
id: 'unused-skills',
|
|
title: `${ghosts.length} skill${ghosts.length > 1 ? 's' : ''} you never use`,
|
|
explanation: `In ~/.claude/skills/ but not invoked this period: ${list}. Each adds ~${TOKENS_PER_SKILL_DEF} tokens of metadata to every session.`,
|
|
impact: ghosts.length >= GHOST_SKILLS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_SKILLS_MEDIUM_THRESHOLD ? 'medium' : 'low',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'command',
|
|
label: `Archive unused skill${ghosts.length > 1 ? 's' : ''}:`,
|
|
text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/skills/${name} ~/.claude/skills/.archived/`).join('\n'),
|
|
},
|
|
apply: { kind: 'archive', names: ghosts },
|
|
}
|
|
}
|
|
|
|
export async function detectGhostCommands(userMessages: string[]): Promise<WasteFinding | null> {
|
|
const defined = await listMarkdownFiles(join(homedir(), '.claude', 'commands'))
|
|
if (defined.length === 0) return null
|
|
|
|
const invoked = new Set<string>()
|
|
for (const msg of userMessages) {
|
|
COMMAND_PATTERN.lastIndex = 0
|
|
for (const m of msg.matchAll(COMMAND_PATTERN)) {
|
|
const name = (m[1] || m[2] || '').trim()
|
|
if (name) invoked.add(name)
|
|
}
|
|
}
|
|
|
|
const ghosts = defined.filter(name => !invoked.has(name))
|
|
if (ghosts.length === 0) return null
|
|
|
|
const tokensSaved = ghosts.length * TOKENS_PER_COMMAND_DEF
|
|
const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '')
|
|
|
|
return {
|
|
id: 'unused-commands',
|
|
title: `${ghosts.length} slash command${ghosts.length > 1 ? 's' : ''} you never use`,
|
|
explanation: `In ~/.claude/commands/ but not referenced this period: ${list}. Each adds ~${TOKENS_PER_COMMAND_DEF} tokens of definition per session.`,
|
|
impact: ghosts.length >= GHOST_COMMANDS_MEDIUM_THRESHOLD ? 'medium' : 'low',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'command',
|
|
label: `Archive unused command${ghosts.length > 1 ? 's' : ''}:`,
|
|
text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/commands/${name}.md ~/.claude/commands/.archived/`).join('\n'),
|
|
},
|
|
apply: { kind: 'archive', names: ghosts },
|
|
}
|
|
}
|
|
|
|
function readShellProfileLimit(): number | null {
|
|
for (const profile of SHELL_PROFILES) {
|
|
const path = join(homedir(), profile)
|
|
if (!existsSync(path)) continue
|
|
const content = readSessionFileSync(path)
|
|
if (content === null) continue
|
|
const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m)
|
|
if (match) return parseInt(match[1], 10)
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function detectBashBloat(): WasteFinding | null {
|
|
const profileLimit = readShellProfileLimit()
|
|
const envLimit = process.env['BASH_MAX_OUTPUT_LENGTH']
|
|
const configured = profileLimit ?? (envLimit ? parseInt(envLimit, 10) : null)
|
|
|
|
if (configured !== null && configured <= BASH_RECOMMENDED_LIMIT) return null
|
|
|
|
const limit = configured ?? BASH_DEFAULT_LIMIT
|
|
const extraChars = limit - BASH_RECOMMENDED_LIMIT
|
|
const tokensSaved = Math.round(extraChars * BASH_TOKENS_PER_CHAR)
|
|
|
|
return {
|
|
id: 'bash-output-cap',
|
|
title: 'Shrink bash output limit',
|
|
explanation: `Your bash output cap is ${(limit / 1000).toFixed(0)}K chars (${configured ? 'configured' : 'default'}). Most output fits in ${(BASH_RECOMMENDED_LIMIT / 1000).toFixed(0)}K. The extra ~${formatTokens(tokensSaved)} tokens per bash call is trailing noise.`,
|
|
impact: 'medium',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'shell-config',
|
|
label: 'Add to ~/.zshrc or ~/.bashrc:',
|
|
text: `export BASH_MAX_OUTPUT_LENGTH=${BASH_RECOMMENDED_LIMIT}`,
|
|
},
|
|
}
|
|
}
|
|
|
|
function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number {
|
|
return session.totalInputTokens
|
|
+ session.totalOutputTokens
|
|
+ session.totalCacheReadTokens
|
|
+ session.totalCacheWriteTokens
|
|
}
|
|
|
|
function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number {
|
|
return session.totalInputTokens
|
|
+ session.totalCacheReadTokens * CACHE_READ_DISCOUNT
|
|
+ session.totalCacheWriteTokens * CACHE_WRITE_MULTIPLIER
|
|
}
|
|
|
|
function formatContextRatio(ratio: number): string {
|
|
if (ratio >= CONTEXT_BLOAT_RATIO_DISPLAY_CAP) return `${CONTEXT_BLOAT_RATIO_DISPLAY_CAP}+`
|
|
return ratio.toFixed(1)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Worth-it / low-worth-session detector helpers
|
|
// ============================================================================
|
|
|
|
// Use (\s|$|--) instead of \b after commit/push so `git commit-tree` and
|
|
// `git commit-graph` are not treated as deliveries. The `--` clause keeps
|
|
// `git commit --amend` matching as a real delivery command.
|
|
const DELIVERY_COMMAND_PATTERNS = [
|
|
/(?:^|[;&|]\s*)git\s+(?:commit|push)(?=\s|$|--)(?![^;&|]*--dry-run)/,
|
|
/(?:^|[;&|]\s*)gh\s+pr\s+(?:create|merge)(?=\s|$|--)(?![^;&|]*--dry-run)/,
|
|
]
|
|
|
|
function sessionDeliveryCommand(session: ProjectSummary['sessions'][number]): string | null {
|
|
const commands = Object.keys(session.bashBreakdown)
|
|
return commands.find(command => DELIVERY_COMMAND_PATTERNS.some(pattern => pattern.test(command))) ?? null
|
|
}
|
|
|
|
function hasCategoryBreakdownData(session: ProjectSummary['sessions'][number]): boolean {
|
|
return Object.values(session.categoryBreakdown).some(category =>
|
|
category.turns > 0
|
|
|| category.costUSD > 0
|
|
|| category.retries > 0
|
|
|| category.editTurns > 0
|
|
|| category.oneShotTurns > 0
|
|
)
|
|
}
|
|
|
|
function sessionEditTurns(session: ProjectSummary['sessions'][number]): number {
|
|
if (hasCategoryBreakdownData(session)) {
|
|
return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.editTurns, 0)
|
|
}
|
|
return session.turns.filter(turn => turn.hasEdits).length
|
|
}
|
|
|
|
function sessionOneShotTurns(session: ProjectSummary['sessions'][number]): number {
|
|
if (hasCategoryBreakdownData(session)) {
|
|
return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.oneShotTurns, 0)
|
|
}
|
|
return session.turns.filter(turn => turn.hasEdits && turn.retries === 0).length
|
|
}
|
|
|
|
function sessionRetryCount(session: ProjectSummary['sessions'][number]): number {
|
|
if (hasCategoryBreakdownData(session)) {
|
|
return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.retries, 0)
|
|
}
|
|
return session.turns.reduce((sum, turn) => sum + turn.retries, 0)
|
|
}
|
|
|
|
function sessionTotalTurns(session: ProjectSummary['sessions'][number]): number {
|
|
if (hasCategoryBreakdownData(session)) {
|
|
return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.turns, 0)
|
|
}
|
|
return session.turns.length
|
|
}
|
|
|
|
// Token-savings estimate for a low-worth candidate. Two regimes:
|
|
// - No-edit sessions: a bounded fraction of full session tokens is counted
|
|
// as recoverable. Full cost is an upper bound, not a point estimate:
|
|
// read-only work may still contain useful exploration or analysis.
|
|
// - Sessions with edits but with retries / no one-shot: only the retry
|
|
// fraction is counted as recoverable. Edits may still have been useful;
|
|
// we credit the model with that and only flag the retry overhead.
|
|
// Ratio is bounded to [0, 1] so retry-heavy sessions with weird turn counts
|
|
// can't claim more than the full session token total.
|
|
function estimateLowWorthRecoverableTokens(
|
|
session: ProjectSummary['sessions'][number],
|
|
editTurns: number,
|
|
retries: number,
|
|
): number {
|
|
const tokens = sessionTokenTotal(session)
|
|
if (editTurns === 0) return Math.round(tokens * WORTH_IT_NO_EDIT_RECOVERY_FRACTION)
|
|
const totalTurns = sessionTotalTurns(session)
|
|
if (totalTurns === 0) return 0
|
|
const fraction = Math.min(1, Math.max(0, retries / totalTurns))
|
|
return Math.round(tokens * fraction)
|
|
}
|
|
|
|
// Session-opener texts, the single source of truth shared by the optimize
|
|
// findings below and by `codeburn guard` (SessionStart hook). Kept as
|
|
// constants so the two surfaces can never drift.
|
|
export const LOW_WORTH_OPENER = 'Before continuing, name the deliverable in one sentence (PR title, file changed, command output you expect). Stop and check with me if (a) you spend more than 10 minutes without an edit, or (b) the same approach fails twice. Do not retry past two attempts on any single fix.'
|
|
export const CONTEXT_HEAVY_OPENER = 'Start fresh before continuing. Use only the current goal, the relevant files, the failing command/output, and the constraints below. Restate the working context in under 10 bullets before editing.'
|
|
|
|
export type LowWorthCandidate = {
|
|
project: string
|
|
sessionId: string
|
|
date: string
|
|
cost: number
|
|
tokens: number
|
|
reasons: string[]
|
|
}
|
|
|
|
export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCandidate[] {
|
|
const candidates: LowWorthCandidate[] = []
|
|
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue
|
|
if (sessionDeliveryCommand(session)) continue
|
|
|
|
const editTurns = sessionEditTurns(session)
|
|
const oneShotTurns = sessionOneShotTurns(session)
|
|
const retries = sessionRetryCount(session)
|
|
const reasons: string[] = []
|
|
|
|
if (editTurns === 0 && session.totalCostUSD >= WORTH_IT_NO_EDIT_MIN_COST_USD) {
|
|
reasons.push('no edit turns')
|
|
}
|
|
if (retries >= WORTH_IT_MIN_RETRIES) {
|
|
reasons.push(`${retries} retries`)
|
|
}
|
|
if (
|
|
editTurns > 0
|
|
&& oneShotTurns === 0
|
|
&& retries >= WORTH_IT_RETRY_WITH_EDIT_MIN_RETRIES
|
|
) {
|
|
reasons.push('no one-shot edit turns')
|
|
}
|
|
|
|
if (reasons.length === 0) continue
|
|
|
|
candidates.push({
|
|
project: project.project,
|
|
sessionId: session.sessionId,
|
|
date: session.firstTimestamp.slice(0, 10),
|
|
cost: session.totalCostUSD,
|
|
tokens: estimateLowWorthRecoverableTokens(session, editTurns, retries),
|
|
reasons,
|
|
})
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) =>
|
|
b.cost - a.cost
|
|
|| a.date.localeCompare(b.date)
|
|
|| a.project.localeCompare(b.project)
|
|
|| a.sessionId.localeCompare(b.sessionId)
|
|
)
|
|
return candidates
|
|
}
|
|
|
|
export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding | null {
|
|
const candidates = findLowWorthCandidates(projects)
|
|
if (candidates.length === 0) return null
|
|
|
|
const preview = candidates.slice(0, WORTH_IT_PREVIEW)
|
|
const list = preview
|
|
.map(s => `${s.project}/${s.sessionId} on ${s.date}: ${formatCost(s.cost)} (${s.reasons.join(', ')})`)
|
|
.join('; ')
|
|
const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : ''
|
|
// Per-candidate `tokens` is already the recoverable estimate (bounded
|
|
// no-edit fraction, retry-fraction for edit-with-retries). Sum across candidates.
|
|
const tokensSaved = Math.round(candidates.reduce((sum, s) => sum + s.tokens, 0))
|
|
const totalCost = candidates.reduce((sum, s) => sum + s.cost, 0)
|
|
|
|
// Three tiers consistent with detectContextBloat: high at >=10 candidates
|
|
// or >=$50 total spend at risk; low at <=2 candidates AND <$10 total;
|
|
// medium in between.
|
|
let impact: Impact
|
|
if (candidates.length >= WORTH_IT_HIGH_MIN_CANDIDATES || totalCost >= WORTH_IT_HIGH_TOTAL_COST_USD) {
|
|
impact = 'high'
|
|
} else if (candidates.length <= WORTH_IT_LOW_MAX_CANDIDATES && totalCost < WORTH_IT_LOW_MAX_TOTAL_COST_USD) {
|
|
impact = 'low'
|
|
} else {
|
|
impact = 'medium'
|
|
}
|
|
|
|
return {
|
|
id: 'low-worth-sessions',
|
|
title: `${candidates.length} possibly low-worth expensive session${candidates.length === 1 ? '' : 's'}`,
|
|
explanation: `Sessions with meaningful spend but weak delivery signals: ${list}${extra}. This is a review candidate, not proof of waste: CodeBurn flags missing edit turns, repeated retries, and sessions without git delivery commands so you can decide whether the work was worth its cost before it becomes a habit.`,
|
|
impact,
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'session-opener',
|
|
label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
|
|
text: LOW_WORTH_OPENER,
|
|
},
|
|
}
|
|
}
|
|
|
|
export type ContextBloatCandidate = {
|
|
project: string
|
|
sessionId: string
|
|
date: string
|
|
effectiveInputTokens: number
|
|
outputTokens: number
|
|
ratio: number
|
|
excessInputTokens: number
|
|
growthRatio: number | null
|
|
}
|
|
|
|
export function findContextBloatCandidates(projects: ProjectSummary[]): ContextBloatCandidate[] {
|
|
const candidates: ContextBloatCandidate[] = []
|
|
|
|
for (const project of projects) {
|
|
const sessions = [...project.sessions].sort((a, b) =>
|
|
new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime()
|
|
)
|
|
let previousInputTokens: number | null = null
|
|
let previousTimestampMs: number | null = null
|
|
|
|
for (const session of sessions) {
|
|
const inputTokens = sessionEffectiveContextTokens(session)
|
|
const outputTokens = session.totalOutputTokens
|
|
const ratio = inputTokens / Math.max(outputTokens, 1)
|
|
const currentMs = new Date(session.firstTimestamp).getTime()
|
|
const gapMs = previousTimestampMs !== null ? currentMs - previousTimestampMs : null
|
|
// Suppress growth ratio when the previous session is too far back to be
|
|
// a meaningful baseline (e.g. a small test run weeks before a real
|
|
// working session would otherwise produce alarming "1000x" figures).
|
|
const growthRatio = previousInputTokens !== null
|
|
&& previousInputTokens > 0
|
|
&& gapMs !== null
|
|
&& gapMs <= CONTEXT_BLOAT_GROWTH_MAX_GAP_MS
|
|
? inputTokens / previousInputTokens
|
|
: null
|
|
|
|
// Anchor growth to the immediately previous project session, even if
|
|
// that session is below threshold and never becomes a finding.
|
|
previousInputTokens = inputTokens
|
|
previousTimestampMs = currentMs
|
|
|
|
if (inputTokens < CONTEXT_BLOAT_MIN_INPUT_TOKENS) continue
|
|
if (ratio < CONTEXT_BLOAT_MIN_RATIO) continue
|
|
|
|
candidates.push({
|
|
project: project.project,
|
|
sessionId: session.sessionId,
|
|
date: session.firstTimestamp.slice(0, 10),
|
|
effectiveInputTokens: inputTokens,
|
|
outputTokens,
|
|
ratio,
|
|
excessInputTokens: Math.max(0, inputTokens - outputTokens * CONTEXT_BLOAT_TARGET_RATIO),
|
|
growthRatio,
|
|
})
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) =>
|
|
b.excessInputTokens - a.excessInputTokens
|
|
|| a.date.localeCompare(b.date)
|
|
|| a.project.localeCompare(b.project)
|
|
|| a.sessionId.localeCompare(b.sessionId)
|
|
)
|
|
return candidates
|
|
}
|
|
|
|
export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet<string>): WasteFinding | null {
|
|
const candidates = findContextBloatCandidates(projects)
|
|
.filter(c => !excludedSessionIds?.has(c.sessionId))
|
|
if (candidates.length === 0) return null
|
|
|
|
const preview = candidates.slice(0, CONTEXT_BLOAT_PREVIEW)
|
|
const list = preview
|
|
.map(c => {
|
|
const growth = c.growthRatio !== null && c.growthRatio >= CONTEXT_BLOAT_GROWTH_RATIO
|
|
? `, ${c.growthRatio.toFixed(1)}x previous session input`
|
|
: ''
|
|
return `${c.project}/${c.sessionId} on ${c.date}: ${formatTokens(c.effectiveInputTokens)} effective input/cache vs ${formatTokens(c.outputTokens)} output (${formatContextRatio(c.ratio)}:1${growth})`
|
|
})
|
|
.join('; ')
|
|
const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : ''
|
|
// Savings estimate only counts context above a healthier 15:1 input-output ratio.
|
|
// Detection stays stricter at 25:1 so borderline sessions are not shown.
|
|
const tokensSaved = Math.round(candidates.reduce((sum, c) => sum + c.excessInputTokens, 0))
|
|
const totalInputTokens = candidates.reduce((sum, c) => sum + c.effectiveInputTokens, 0)
|
|
|
|
// Tier on candidate count first, total context size second. A single 600K
|
|
// session is "high"; 1-2 modest-sized sessions are "low"; everything in
|
|
// between is "medium".
|
|
let impact: Impact
|
|
if (candidates.length >= CONTEXT_BLOAT_HIGH_MIN_CANDIDATES || totalInputTokens >= CONTEXT_BLOAT_HIGH_INPUT_TOKENS) {
|
|
impact = 'high'
|
|
} else if (candidates.length <= CONTEXT_BLOAT_LOW_MAX_CANDIDATES && totalInputTokens < CONTEXT_BLOAT_LOW_INPUT_TOKENS) {
|
|
impact = 'low'
|
|
} else {
|
|
impact = 'medium'
|
|
}
|
|
|
|
return {
|
|
id: 'context-heavy-sessions',
|
|
title: `${candidates.length} context-heavy session${candidates.length === 1 ? '' : 's'}`,
|
|
explanation: `Effective input/cache tokens swamp output in these sessions: ${list}${extra}. This can come from stale context carryover, inherently context-heavy work, or abandoned runs that loaded too much context; starting fresh with only the current goal and relevant files can cut repeated prompt overhead.`,
|
|
impact,
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'session-opener',
|
|
label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
|
|
text: CONTEXT_HEAVY_OPENER,
|
|
},
|
|
}
|
|
}
|
|
|
|
export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet<string>): WasteFinding | null {
|
|
type Outlier = {
|
|
project: string
|
|
sessionId: string
|
|
date: string
|
|
cost: number
|
|
avgCost: number
|
|
ratio: number
|
|
tokenExcess: number
|
|
}
|
|
|
|
const outliers: Outlier[] = []
|
|
|
|
for (const project of projects) {
|
|
const sessions = project.sessions.filter(s => s.totalCostUSD > 0)
|
|
if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue
|
|
|
|
const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0)
|
|
const totalTokens = sessions.reduce((sum, s) => sum + sessionTokenTotal(s), 0)
|
|
for (const session of sessions) {
|
|
const avgCost = (totalCost - session.totalCostUSD) / (sessions.length - 1)
|
|
const avgTokens = (totalTokens - sessionTokenTotal(session)) / (sessions.length - 1)
|
|
if (avgCost <= 0) continue
|
|
|
|
const ratio = session.totalCostUSD / avgCost
|
|
if (ratio <= SESSION_OUTLIER_MULTIPLIER) continue
|
|
if (session.totalCostUSD < MIN_SESSION_OUTLIER_COST_USD) continue
|
|
// Avoid reporting the same session under both this finding and the
|
|
// context-bloat finding. Context-bloat takes priority because its
|
|
// suggested fix ("start fresh") is more concrete than the generic
|
|
// "tighter constraint" advice here.
|
|
if (excludedSessionIds?.has(session.sessionId)) continue
|
|
|
|
outliers.push({
|
|
project: project.project,
|
|
sessionId: session.sessionId,
|
|
date: session.firstTimestamp.slice(0, 10),
|
|
cost: session.totalCostUSD,
|
|
avgCost,
|
|
ratio,
|
|
tokenExcess: Math.max(0, sessionTokenTotal(session) - avgTokens),
|
|
})
|
|
}
|
|
}
|
|
|
|
if (outliers.length === 0) return null
|
|
|
|
outliers.sort((a, b) => b.cost - a.cost)
|
|
const preview = outliers.slice(0, SESSION_OUTLIER_PREVIEW)
|
|
const list = preview
|
|
.map(o => `${o.project}/${o.sessionId} on ${o.date}: ${formatCost(o.cost)} (${o.ratio.toFixed(1)}x avg)`)
|
|
.join('; ')
|
|
const extra = outliers.length > preview.length ? `; +${outliers.length - preview.length} more` : ''
|
|
const tokensSaved = Math.round(outliers.reduce((sum, o) => sum + o.tokenExcess, 0))
|
|
const totalExcessCost = outliers.reduce((sum, o) => sum + Math.max(0, o.cost - o.avgCost), 0)
|
|
|
|
return {
|
|
id: 'cost-outliers',
|
|
title: `${outliers.length} high-cost session outlier${outliers.length === 1 ? '' : 's'}`,
|
|
explanation: `Sessions costing more than ${SESSION_OUTLIER_MULTIPLIER}x their peer-session average in the same project: ${list}${extra}. These usually come from broad prompts, runaway loops, or context-heavy work that should be split into smaller sessions.`,
|
|
impact: outliers.length >= 3 || totalExcessCost >= 10 ? 'high' : 'medium',
|
|
tokensSaved,
|
|
fix: {
|
|
type: 'paste',
|
|
destination: 'session-opener',
|
|
label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
|
|
text: 'Before making changes, summarize the smallest viable plan. Keep context narrow, avoid broad searches, and stop after the first working patch so I can review before continuing.',
|
|
},
|
|
}
|
|
}
|
|
|
|
function findYoungProjectFirstSessionIds(projects: ProjectSummary[]): Set<string> {
|
|
const firstSessionIds = new Set<string>()
|
|
|
|
for (const project of projects) {
|
|
const costed = project.sessions.filter(s => s.totalCostUSD > 0)
|
|
if (costed.length >= YOUNG_PROJECT_SESSION_LIMIT) continue
|
|
|
|
let firstSession: ProjectSummary['sessions'][number] | null = null
|
|
for (const session of costed) {
|
|
if (
|
|
firstSession === null
|
|
|| new Date(session.firstTimestamp).getTime() < new Date(firstSession.firstTimestamp).getTime()
|
|
) {
|
|
firstSession = session
|
|
}
|
|
}
|
|
|
|
if (firstSession) firstSessionIds.add(firstSession.sessionId)
|
|
}
|
|
|
|
return firstSessionIds
|
|
}
|
|
|
|
// ============================================================================
|
|
// Scoring
|
|
// ============================================================================
|
|
|
|
const HEALTH_WEIGHTS: Record<Impact, number> = {
|
|
high: HEALTH_WEIGHT_HIGH,
|
|
medium: HEALTH_WEIGHT_MEDIUM,
|
|
low: HEALTH_WEIGHT_LOW,
|
|
}
|
|
|
|
export function computeHealth(findings: WasteFinding[]): { score: number; grade: HealthGrade } {
|
|
if (findings.length === 0) return { score: 100, grade: 'A' }
|
|
let penalty = 0
|
|
for (const f of findings) penalty += HEALTH_WEIGHTS[f.impact] ?? 0
|
|
const score = Math.max(0, 100 - Math.min(HEALTH_MAX_PENALTY, penalty))
|
|
const grade: HealthGrade =
|
|
score >= GRADE_A_MIN ? 'A' :
|
|
score >= GRADE_B_MIN ? 'B' :
|
|
score >= GRADE_C_MIN ? 'C' :
|
|
score >= GRADE_D_MIN ? 'D' : 'F'
|
|
return { score, grade }
|
|
}
|
|
|
|
const URGENCY_WEIGHTS: Record<Impact, number> = { high: 1, medium: 0.5, low: 0.2 }
|
|
|
|
function urgencyScore(f: WasteFinding): number {
|
|
const normalizedTokens = Math.min(1, f.tokensSaved / URGENCY_TOKEN_NORMALIZE)
|
|
return URGENCY_WEIGHTS[f.impact] * URGENCY_IMPACT_WEIGHT + normalizedTokens * URGENCY_TOKEN_WEIGHT
|
|
}
|
|
|
|
type TrendInputs = {
|
|
recentCount: number
|
|
recentWindowMs: number
|
|
baselineCount: number
|
|
baselineWindowMs: number
|
|
hasRecentActivity: boolean
|
|
}
|
|
|
|
export function computeTrend(inputs: TrendInputs): Trend | 'resolved' {
|
|
const { recentCount, recentWindowMs, baselineCount, baselineWindowMs, hasRecentActivity } = inputs
|
|
if (baselineCount === 0) return 'active'
|
|
if (recentCount === 0 && hasRecentActivity) return 'resolved'
|
|
if (!hasRecentActivity) return 'active'
|
|
const baselineRate = baselineCount / baselineWindowMs
|
|
const recentRate = recentCount / Math.max(recentWindowMs, 1)
|
|
if (recentRate < baselineRate * IMPROVING_THRESHOLD) return 'improving'
|
|
return 'active'
|
|
}
|
|
|
|
function sessionTrend(
|
|
recentItemCount: number,
|
|
totalItemCount: number,
|
|
dateRange: DateRange | undefined,
|
|
hasRecentActivity: boolean,
|
|
): Trend | 'resolved' {
|
|
const now = Date.now()
|
|
const baselineCount = totalItemCount - recentItemCount
|
|
const periodStart = dateRange ? dateRange.start.getTime() : now - DEFAULT_TREND_PERIOD_MS
|
|
const recentStart = now - RECENT_WINDOW_MS
|
|
const baselineWindowMs = Math.max(recentStart - periodStart, 1)
|
|
return computeTrend({
|
|
recentCount: recentItemCount,
|
|
recentWindowMs: RECENT_WINDOW_MS,
|
|
baselineCount,
|
|
baselineWindowMs,
|
|
hasRecentActivity,
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cost estimation
|
|
// ============================================================================
|
|
|
|
const INPUT_COST_RATIO = 0.7
|
|
const DEFAULT_COST_PER_TOKEN = 0
|
|
|
|
export function computeInputCostRate(projects: ProjectSummary[]): number {
|
|
const sessions = projects.flatMap(p => p.sessions)
|
|
const totalCost = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
|
|
const totalTokens = sessions.reduce((s, sess) =>
|
|
s + sess.totalInputTokens + sess.totalCacheReadTokens + sess.totalCacheWriteTokens, 0)
|
|
if (totalTokens === 0 || totalCost === 0) return DEFAULT_COST_PER_TOKEN
|
|
return (totalCost * INPUT_COST_RATIO) / totalTokens
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main entry points
|
|
// ============================================================================
|
|
|
|
type CacheEntry = { data: OptimizeResult; ts: number }
|
|
const resultCache = new Map<string, CacheEntry>()
|
|
|
|
function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string {
|
|
const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all'
|
|
const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
|
return `${dr}:${fingerprint}`
|
|
}
|
|
|
|
export async function scanAndDetect(
|
|
projects: ProjectSummary[],
|
|
dateRange?: DateRange,
|
|
): Promise<OptimizeResult> {
|
|
if (projects.length === 0) {
|
|
return { findings: [], costRate: 0, healthScore: 100, healthGrade: 'A', modelRecommendations: [] }
|
|
}
|
|
|
|
const key = cacheKey(projects, dateRange)
|
|
const cached = resultCache.get(key)
|
|
if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data
|
|
|
|
const costRate = computeInputCostRate(projects)
|
|
const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange)
|
|
const mcpCoverage = aggregateMcpCoverage(projects)
|
|
|
|
const findings: WasteFinding[] = []
|
|
// Priority order for the per-session findings: low-worth → context-bloat →
|
|
// outliers. Each later detector excludes sessions already named by an
|
|
// earlier one so a single session is not listed in three findings.
|
|
const lowWorthSessionIds = new Set(findLowWorthCandidates(projects).map(c => c.sessionId))
|
|
const contextBloatVisibleIds = new Set(
|
|
findContextBloatCandidates(projects)
|
|
.filter(c => !lowWorthSessionIds.has(c.sessionId))
|
|
.map(c => c.sessionId),
|
|
)
|
|
const firstSessionIds = findYoungProjectFirstSessionIds(projects)
|
|
const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds, ...firstSessionIds])
|
|
const syncDetectors: Array<() => WasteFinding | null> = [
|
|
() => detectCacheBloat(apiCalls, projects, dateRange),
|
|
() => detectLowReadEditRatio(toolCalls),
|
|
() => detectJunkReads(toolCalls, dateRange),
|
|
() => detectDuplicateReads(toolCalls, dateRange),
|
|
() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage),
|
|
() => detectMcpToolCoverage(projects, mcpCoverage),
|
|
() => detectMcpProfileAdvisor(projects, mcpCoverage),
|
|
// mcp-deferral-gaps family (#614): detection only, no apply plans yet.
|
|
() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls),
|
|
() => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage),
|
|
() => detectMcpDeferThreshold(projects, projectCwds),
|
|
() => detectCapabilityReliability(projects),
|
|
() => detectLowWorthSessions(projects),
|
|
() => detectContextBloat(projects, lowWorthSessionIds),
|
|
() => detectSessionOutliers(projects, outlierExclusions),
|
|
() => detectBloatedClaudeMd(projectCwds),
|
|
() => detectBashBloat(),
|
|
]
|
|
for (const detect of syncDetectors) {
|
|
const finding = detect()
|
|
if (finding) findings.push(finding)
|
|
}
|
|
|
|
const ghostResults = await Promise.all([
|
|
detectGhostAgents(toolCalls),
|
|
detectGhostSkills(toolCalls),
|
|
detectGhostCommands(userMessages),
|
|
])
|
|
for (const f of ghostResults) if (f) findings.push(f)
|
|
|
|
findings.sort((a, b) => urgencyScore(b) - urgencyScore(a))
|
|
const { score, grade } = computeHealth(findings)
|
|
|
|
const modelRecommendations: ModelDefaultRecommendation[] = []
|
|
for (const project of projects) {
|
|
const rec = recommendModelDefault(project, { now: dateRange?.end })
|
|
if (rec) modelRecommendations.push(rec)
|
|
}
|
|
|
|
const result: OptimizeResult = { findings, costRate, healthScore: score, healthGrade: grade, modelRecommendations }
|
|
resultCache.set(key, { data: result, ts: Date.now() })
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI rendering
|
|
// ============================================================================
|
|
|
|
const PANEL_WIDTH = 62
|
|
const SEP = '\u2500'
|
|
const IMPACT_COLORS: Record<Impact, string> = { high: RED, medium: ORANGE, low: DIM }
|
|
const GRADE_COLORS: Record<HealthGrade, string> = { A: GREEN, B: GREEN, C: GOLD, D: ORANGE, F: RED }
|
|
|
|
function wrap(text: string, width: number, indent: string): string {
|
|
const words = text.split(' ')
|
|
const lines: string[] = []
|
|
let current = ''
|
|
for (const word of words) {
|
|
if (current && current.length + word.length + 1 > width) {
|
|
lines.push(indent + current)
|
|
current = word
|
|
} else {
|
|
current = current ? current + ' ' + word : word
|
|
}
|
|
}
|
|
if (current) lines.push(indent + current)
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/// Section header for a finding's fix block, declaring its intended
|
|
/// destination. Issue #277: users were dropping one-time session openers
|
|
/// into CLAUDE.md as permanent rules because the prompts had no labeled
|
|
/// home in the output.
|
|
function renderActionHeader(action: WasteAction): string {
|
|
const headerWidth = PANEL_WIDTH - 4
|
|
const fillTo = (label: string): string => {
|
|
const inner = ` ${label} `
|
|
const trailing = Math.max(2, headerWidth - inner.length - 4)
|
|
return `--${inner}${SEP.repeat(trailing)}`.padEnd(headerWidth)
|
|
}
|
|
switch (action.type) {
|
|
case 'file-content':
|
|
return fillTo(`Suggested ${action.path} addition`)
|
|
case 'command':
|
|
return fillTo('Run this command')
|
|
case 'paste':
|
|
switch (action.destination) {
|
|
case 'claude-md': return fillTo('Suggested CLAUDE.md addition (permanent rule)')
|
|
case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)')
|
|
case 'prompt': return fillTo('Ask Claude in the current session')
|
|
case 'shell-config': return fillTo('Add to your shell config')
|
|
default: return fillTo('Suggested action')
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
|
|
const lines: string[] = []
|
|
const costSaved = f.tokensSaved * costRate
|
|
const impactLabel = f.impact.charAt(0).toUpperCase() + f.impact.slice(1)
|
|
const trendBadge = f.trend === 'improving' ? ' improving \u2193 ' : ''
|
|
const savings = `~${formatTokens(f.tokensSaved)} tokens (~${formatCost(costSaved)})`
|
|
const titlePad = PANEL_WIDTH - f.title.length - impactLabel.length - trendBadge.length - 8
|
|
const pad = titlePad > 0 ? ' ' + SEP.repeat(titlePad) + ' ' : ' '
|
|
|
|
lines.push(chalk.hex(DIM)(` ${SEP}${SEP}${SEP} `) +
|
|
chalk.bold(`${n}. ${f.title}`) +
|
|
chalk.hex(DIM)(pad) +
|
|
chalk.hex(IMPACT_COLORS[f.impact])(impactLabel) +
|
|
(trendBadge ? chalk.hex(GREEN)(trendBadge) : '') +
|
|
chalk.hex(DIM)(` ${SEP}${SEP}${SEP}`))
|
|
lines.push('')
|
|
lines.push(wrap(f.explanation, PANEL_WIDTH - 4, ' '))
|
|
lines.push('')
|
|
lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`))
|
|
lines.push('')
|
|
|
|
// Destination header — issue #277. Tells the user where each suggestion
|
|
// belongs (CLAUDE.md / session opener / current chat / shell config) so
|
|
// permanent rules and one-time prompts are no longer interchangeable in
|
|
// the output.
|
|
const a = f.fix
|
|
lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a)}`))
|
|
lines.push(chalk.hex(DIM)(` ${a.label}`))
|
|
if (a.type === 'file-content') {
|
|
for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`))
|
|
} else if (a.type === 'command') {
|
|
for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`))
|
|
} else {
|
|
for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`))
|
|
}
|
|
lines.push('')
|
|
return lines
|
|
}
|
|
|
|
function renderOptimize(
|
|
findings: WasteFinding[],
|
|
costRate: number,
|
|
periodLabel: string,
|
|
periodCost: number,
|
|
sessionCount: number,
|
|
callCount: number,
|
|
healthScore: number,
|
|
healthGrade: HealthGrade,
|
|
appliedHeader?: string,
|
|
previouslyApplied?: Record<string, string>,
|
|
modelRecommendations?: ModelDefaultRecommendation[],
|
|
): string {
|
|
const lines: string[] = []
|
|
lines.push('')
|
|
lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn config health')}${chalk.dim(' ' + periodLabel)}`)
|
|
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
|
|
|
const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : ''
|
|
lines.push(' ' + [
|
|
`${sessionCount} sessions`,
|
|
`${callCount.toLocaleString()} calls`,
|
|
chalk.hex(GOLD)(formatCost(periodCost)),
|
|
`Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`,
|
|
].join(chalk.hex(DIM)(' ')))
|
|
if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader))
|
|
lines.push('')
|
|
|
|
if (findings.length === 0) {
|
|
lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.'))
|
|
lines.push('')
|
|
lines.push(chalk.dim(' CodeBurn optimize scans your Claude Code sessions and config for'))
|
|
lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused'))
|
|
lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.'))
|
|
lines.push('')
|
|
return lines.join('\n')
|
|
}
|
|
|
|
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
|
|
const totalCost = totalTokens * costRate
|
|
const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0
|
|
const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1)
|
|
|
|
const costText = costRate > 0 ? ` (~${formatCost(totalCost)}, ~${pct}% of spend)` : ''
|
|
lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens${costText}`))
|
|
lines.push('')
|
|
|
|
for (let i = 0; i < findings.length; i++) {
|
|
const f = findings[i]!
|
|
const appliedOn = previouslyApplied?.[f.id]
|
|
const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f
|
|
lines.push(...renderFinding(i + 1, shown, costRate))
|
|
}
|
|
|
|
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
|
lines.push(chalk.dim(' Estimates only.'))
|
|
lines.push('')
|
|
|
|
if (modelRecommendations && modelRecommendations.length > 0) {
|
|
lines.push(chalk.bold.hex(ORANGE)(' Model defaults recommendation'))
|
|
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
|
for (const rec of modelRecommendations) {
|
|
lines.push(` ${rec.project}: ${chalk.bold(rec.currentModel)} -> ${chalk.bold.hex(GREEN)(rec.candidateModel)}`)
|
|
lines.push(chalk.dim(` Current: ${(rec.currentOneShotRate*100).toFixed(1)}% one-shot over ${rec.currentEditTurns} edits, ${formatCost(rec.currentCostPerEdit)}/edit`))
|
|
lines.push(chalk.dim(` Candidate: ${(rec.candidateOneShotRate*100).toFixed(1)}% one-shot over ${rec.candidateEditTurns} edits, ${formatCost(rec.candidateCostPerEdit)}/edit`))
|
|
lines.push(` To apply: ${chalk.hex(CYAN)(`codeburn act apply-model ${rec.project}`)}`)
|
|
lines.push('')
|
|
}
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export async function runOptimize(
|
|
projects: ProjectSummary[],
|
|
periodLabel: string,
|
|
dateRange?: DateRange,
|
|
opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record<string, string> } = {},
|
|
): Promise<void> {
|
|
const format = opts.format ?? 'text'
|
|
if (projects.length === 0 && format === 'text') {
|
|
console.log(chalk.dim('\n No usage data found for this period.\n'))
|
|
return
|
|
}
|
|
|
|
if (format === 'text') {
|
|
process.stderr.write(chalk.dim(' Analyzing your sessions...\n'))
|
|
}
|
|
|
|
const result = await scanAndDetect(projects, dateRange)
|
|
const { findings, costRate, healthScore, healthGrade } = result
|
|
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)
|
|
|
|
if (format === 'json') {
|
|
console.log(JSON.stringify(buildOptimizeJsonReport(projects, periodLabel, result, dateRange), null, 2))
|
|
return
|
|
}
|
|
|
|
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
|
|
console.log(output)
|
|
}
|
|
|
|
export function buildOptimizeJsonReport(
|
|
projects: ProjectSummary[],
|
|
periodLabel: string,
|
|
result: OptimizeResult,
|
|
dateRange?: DateRange,
|
|
): OptimizeJsonReport {
|
|
const sessions = projects.flatMap(p => p.sessions)
|
|
const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
|
const calls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
|
const potentialSavingsTokens = result.findings.reduce((s, f) => s + f.tokensSaved, 0)
|
|
const potentialSavingsCostUSD = potentialSavingsTokens * result.costRate
|
|
const potentialSavingsPercent = periodCostUSD > 0
|
|
? Math.round((potentialSavingsCostUSD / periodCostUSD) * 1000) / 10
|
|
: null
|
|
|
|
return {
|
|
period: {
|
|
label: periodLabel,
|
|
start: dateRange?.start.toISOString() ?? null,
|
|
end: dateRange?.end.toISOString() ?? null,
|
|
},
|
|
summary: {
|
|
healthScore: result.healthScore,
|
|
healthGrade: result.healthGrade,
|
|
findingCount: result.findings.length,
|
|
periodCostUSD,
|
|
sessions: sessions.length,
|
|
calls,
|
|
potentialSavingsTokens,
|
|
potentialSavingsCostUSD,
|
|
potentialSavingsPercent,
|
|
costRateUSD: result.costRate,
|
|
},
|
|
findings: result.findings.map(f => ({
|
|
id: f.id,
|
|
title: f.title,
|
|
explanation: f.explanation,
|
|
severity: f.impact,
|
|
trend: f.trend ?? null,
|
|
tokensSaved: f.tokensSaved,
|
|
estimatedSavingsUSD: f.tokensSaved * result.costRate,
|
|
fix: f.fix,
|
|
})),
|
|
modelRecommendations: result.modelRecommendations,
|
|
}
|
|
}
|