Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ export type SpendFlow = {
// ————— src/optimize.ts —————

export type WasteAction =
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' }
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' | 'manual' }
| { type: 'command'; label: string; text: string }
| { type: 'file-content'; label: string; path: string; content: string }

Expand Down
23 changes: 23 additions & 0 deletions app/renderer/sections/Optimize.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,29 @@ describe('Optimize', () => {
expect(screen.getByText('{"batch":true}')).toBeInTheDocument()
})

it('renders and copies connector guidance as a manual action', async () => {
const report = makeOptimizeReport()
report.findings.push({
id: 'mcp-low-coverage', title: 'Underused claude.ai connector',
explanation: 'The connector loads unused tools.', severity: 'medium',
trend: null, tokensSaved: 2_000, estimatedSavingsUSD: 1,
fix: {
type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:',
text: 'Open /mcp and disable claude.ai Google Calendar.',
},
})
getOptimizeReport.mockResolvedValue(report)
render(<Optimize period="30days" provider="all" />)
const row = await screen.findByRole('button', { name: /Underused claude.ai connector/ })
fireEvent.click(row)

expect(screen.getByText('Manage the connector where it loads:')).toBeInTheDocument()
expect(screen.getByText('Open /mcp and disable claude.ai Google Calendar.')).toBeInTheDocument()
expect(row.parentElement?.querySelector('.opt-fix')).toHaveClass('opt-fix-paste')
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('Open /mcp and disable claude.ai Google Calendar.'))
})

it('switches to Reverts and Abandoned and shows only the matching yield details', async () => {
render(<Optimize period="30days" provider="all" />)
await screen.findByText('Opus is doing your small talk')
Expand Down
40 changes: 39 additions & 1 deletion src/act/optimize-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,55 @@ function changeLines(fp: FindingPlan): string[] {
})
}

function planTokensSaved(fp: FindingPlan): number {
if (fp.plan?.mcpSavingsUncertain) return Number.NaN
const byServer = fp.finding.applyTokensSavedByServer
const affected = fp.plan?.affectedMcpServers
if (byServer && affected) return affected.reduce((sum, server) => sum + (byServer[server] ?? 0), 0)
return fp.finding.applyTokensSaved ?? fp.finding.tokensSaved
}

function manualActionLines(fp: FindingPlan): string[] {
if (fp.finding.manualFollowUp) {
return [fp.finding.manualFollowUp.label, fp.finding.manualFollowUp.text]
}
const action = fp.finding.fix
if (action.type === 'paste' && action.destination === 'manual') {
return [action.label, action.text]
}
return []
}

export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], costRate: number): string {
const lines: string[] = ['']
lines.push(chalk.bold(' Appliable config-class fixes:'))
appliable.forEach((fp, i) => {
const f = fp.finding
const savings = `~${formatTokens(f.tokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(f.tokensSaved * costRate)}` : ''}`
const actionTokensSaved = planTokensSaved(fp)
const savings = Number.isFinite(actionTokensSaved)
? `~${formatTokens(actionTokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(actionTokensSaved * costRate)}` : ''}`
: 'Savings not estimated'
lines.push('')
lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`)
if (fp.plan?.affectedMcpServers?.length) {
const servers = fp.plan.affectedMcpServers.join(', ')
lines.push(chalk.yellow(` Removes local MCP server${fp.plan.affectedMcpServers.length === 1 ? '' : 's'}: ${servers}`))
}
for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`))
for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`))
const manualLines = manualActionLines(fp)
if (manualLines.length > 0) {
lines.push(chalk.cyan(' Manual follow-up (not applied):'))
for (const line of manualLines) lines.push(chalk.cyan(` ${line}`))
}
})
if (manual.length > 0) {
lines.push('')
lines.push(chalk.dim(' Not auto-appliable (apply by hand):'))
for (const fp of manual) {
lines.push(chalk.dim(` - ${fp.finding.title} [${fp.finding.id}] manual`))
for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`))
for (const line of manualActionLines(fp)) lines.push(chalk.cyan(` ${line}`))
}
}
lines.push('')
Expand Down Expand Up @@ -129,6 +161,7 @@ export async function runOptimizeApply(
print(chalk.dim('\n No appliable config-class fixes for this period.'))
for (const fp of manual) {
for (const note of fp.notes) print(chalk.yellow(` ! ${fp.finding.id}: ${note}`))
for (const line of manualActionLines(fp)) print(chalk.cyan(` ${line}`))
}
print()
return
Expand Down Expand Up @@ -177,6 +210,11 @@ export async function runOptimizeApply(
const record = await runAction(fp.plan!, opts.actionsDir)
print(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`)
print(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`))
const manualLines = manualActionLines(fp)
if (manualLines.length > 0) {
print(chalk.cyan(' Still requires manual action:'))
for (const line of manualLines) print(chalk.cyan(` ${line}`))
}
} catch (e) {
errout.write(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`) + '\n')
process.exitCode = 1
Expand Down
23 changes: 18 additions & 5 deletions src/act/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,12 +275,15 @@ function pathNoteAdder(pathNotes: Record<string, string>): (path: string, note:
}

function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : []
const servers = finding.apply?.kind === 'mcp-remove'
? [...new Set(finding.apply.servers)]
: []
const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson]
const docs = new ConfigDocs(r.homeDir)
const skips: string[] = []
const pathNotes: Record<string, string> = {}
const addPathNote = pathNoteAdder(pathNotes)
const affectedServers: string[] = []

for (const server of servers) {
let removed = false
Expand All @@ -291,14 +294,24 @@ function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
if (res.removed) removed = true
if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir))
}
if (!removed) skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`)
if (removed) affectedServers.push(server)
else skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`)
}

const changes = docs.changes()
const notes = [...docs.errorNotes(), ...skips]
const attribution = finding.applyTokensSavedByServer
const partialWithoutAttribution = affectedServers.length < servers.length && !attribution
const affectedMissingAttribution = attribution !== undefined
&& affectedServers.some(server => !Object.hasOwn(attribution, server))
const savingsUncertain = docs.errorNotes().length > 0
|| partialWithoutAttribution
|| affectedMissingAttribution
if (changes.length === 0) return { plan: null, notes }
const plan = mcpPlan('mcp-remove', finding.id, `Remove ${affectedServers.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes, affectedServers)
if (savingsUncertain) plan.mcpSavingsUncertain = true
return {
plan: mcpPlan('mcp-remove', finding.id, `Remove ${changes.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes),
plan,
notes,
...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}),
}
Expand Down Expand Up @@ -371,8 +384,8 @@ function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPla
}
}

function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[]): ActionPlan {
return { kind, findingId, description, changes }
function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[], affectedMcpServers?: string[]): ActionPlan {
return { kind, findingId, description, changes, ...(affectedMcpServers ? { affectedMcpServers } : {}) }
}

// ---------------------------------------------------------------------------
Expand Down
29 changes: 22 additions & 7 deletions src/act/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,8 @@ type CaptureCtx = {
now: Date
}

function mcpServersFromApply(finding: WasteFinding): string[] {
function mcpServersFromApply(finding: WasteFinding, affectedMcpServers?: string[]): string[] {
if (affectedMcpServers) return affectedMcpServers
if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers
if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server)
return []
Expand All @@ -684,24 +685,37 @@ function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] {
return observedMcpServers(ctx.projects)
}

export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
export function captureBaseline(
finding: WasteFinding,
kind: ActionKind,
ctx: CaptureCtx,
affectedMcpServers?: string[],
): ActionBaseline | undefined {
const common = {
windowDays: ctx.windowDays,
capturedAt: ctx.now.toISOString(),
estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)),
estimatedTokens: Math.max(0, Math.round(finding.applyTokensSaved ?? finding.tokensSaved)),
}

if (MCP_KINDS.has(kind)) {
const servers = mcpServersFromApply(finding)
const servers = mcpServersFromApply(finding, affectedMcpServers)
if (servers.length === 0) return undefined
const covByServer = new Map(ctx.coverage.map(c => [c.server, c]))
const metrics: Record<string, number> = {}
for (const server of servers) {
const cov = covByServer.get(server)
const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
// Removal realizes only the unused schema that the low-coverage
// detector estimated. If coverage is unavailable, omit the numeric
// claim instead of inventing a five-tool baseline.
const tools = finding.id === 'mcp-low-coverage'
? cov?.unusedTools.length ?? 0
: cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
metrics[server] = tools * TOKENS_PER_MCP_TOOL
}
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
const estimatedTokens = finding.applyTokensSavedByServer
? Math.round(servers.reduce((sum, server) => sum + (finding.applyTokensSavedByServer?.[server] ?? 0), 0))
: common.estimatedTokens
return { ...common, estimatedTokens, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}

if (DEFER_KINDS.has(kind)) {
Expand Down Expand Up @@ -750,7 +764,8 @@ export async function captureBaselinesForPlans(
const projects = await loadProjects({ start, end: now })
const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now }
for (const fp of applicable) {
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx)
if (fp.plan!.mcpSavingsUncertain) continue
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx, fp.plan!.affectedMcpServers)
if (baseline) fp.plan!.baseline = baseline
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/act/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,10 @@ export type ActionPlan = {
findingId?: string | null
changes: PlannedChange[]
baseline?: ActionBaseline
// MCP plans only: exact server identities the generated file mutations own.
// Preview and baseline capture must not claim skipped/managed targets.
affectedMcpServers?: string[]
// Relevant config scopes could not all be read, so removal may proceed
// with warnings but savings/baseline claims must be suppressed.
mcpSavingsUncertain?: boolean
}
2 changes: 2 additions & 0 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,8 @@ function actionDestinationHeader(action: WasteAction): string {
return '── Ask Claude in the current session '.padEnd(64, '─')
case 'shell-config':
return '── Add to your shell config '.padEnd(64, '─')
case 'manual':
return '── Manual action '.padEnd(64, '─')
default:
return '── Suggested action '.padEnd(64, '─')
}
Expand Down
Loading
Loading