diff --git a/src/tui/components/BudgetAlertPanel.ts b/src/tui/components/BudgetAlertPanel.ts new file mode 100644 index 0000000..e88ef05 --- /dev/null +++ b/src/tui/components/BudgetAlertPanel.ts @@ -0,0 +1,397 @@ +/** + * Budget Alert Panel Component + * + * Displays budget alerts, burn rate, and cost projections. + * Shows warnings at 80% and critical alerts at 95% budget consumed. + */ + +import blessed from 'blessed'; +import { + CostSummary, + BudgetStatus, + BurnRate, + BudgetAlert, + TopConsumer, + formatCost, + formatBurnRate, + formatTimeToExhaustion, + getBudgetBadge, +} from '../utils/costTracking.js'; +import { colors } from '../utils/colors.js'; + +export interface BudgetAlertPanelOptions { + /** Parent screen */ + parent: blessed.Widgets.Screen; + + /** Position options */ + top: number | string; + left: number | string; + width: number | string; + height: number | string; + + /** Callback when alert is acknowledged */ + onAcknowledge?: (alertId: string) => void; + + /** Callback when budget settings are opened */ + onOpenSettings?: () => void; +} + +export class BudgetAlertPanel { + private container: blessed.Widgets.BoxElement; + private headerBox: blessed.Widgets.BoxElement; + private contentBox: blessed.Widgets.BoxElement; + private footerBox: blessed.Widgets.BoxElement; + private costSummary: CostSummary | null = null; + private alerts: BudgetAlert[] = []; + private onAcknowledge?: (alertId: string) => void; + private onOpenSettings?: () => void; + + constructor(options: BudgetAlertPanelOptions) { + this.onAcknowledge = options.onAcknowledge; + this.onOpenSettings = options.onOpenSettings; + + // Main container + this.container = blessed.box({ + parent: options.parent, + top: options.top, + left: options.left, + width: options.width, + height: options.height, + label: ' Budget Dashboard ', + border: { type: 'line' }, + style: { + border: { fg: colors.border }, + label: { fg: colors.header }, + }, + hidden: true, + }); + + // Header with current cost and budget + this.headerBox = blessed.box({ + parent: this.container, + top: 0, + left: 0, + right: 0, + height: 3, + content: '{gray-fg}No budget data{/}', + tags: true, + }); + + // Content area with alerts and details + this.contentBox = blessed.box({ + parent: this.container, + top: 3, + left: 0, + right: 0, + bottom: 1, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + tags: true, + style: { + fg: colors.text, + }, + }); + + // Footer with controls + this.footerBox = blessed.box({ + parent: this.container, + bottom: 0, + left: 0, + right: 0, + height: 1, + content: ' [a] Acknowledge [r] Refresh [s] Settings [Esc] Close', + style: { + fg: colors.muted, + }, + }); + + // Bind keyboard events + this.bindKeys(); + } + + /** + * Bind keyboard shortcuts + */ + private bindKeys(): void { + this.contentBox.key(['a'], () => this.acknowledgeCurrentAlert()); + this.contentBox.key(['r'], () => this.refresh()); + this.contentBox.key(['s'], () => { + if (this.onOpenSettings) { + this.onOpenSettings(); + } + }); + } + + /** + * Update cost summary data + */ + setCostSummary(summary: CostSummary): void { + this.costSummary = summary; + this.render(); + } + + /** + * Update alerts + */ + setAlerts(alerts: BudgetAlert[]): void { + this.alerts = alerts; + this.render(); + } + + /** + * Refresh display + */ + refresh(): void { + this.render(); + this.container.screen.render(); + } + + /** + * Acknowledge current/most recent alert + */ + private acknowledgeCurrentAlert(): void { + const unacknowledged = this.alerts.filter(a => !a.acknowledged); + if (unacknowledged.length > 0 && this.onAcknowledge) { + this.onAcknowledge(unacknowledged[0].id); + } + } + + /** + * Render the panel + */ + private render(): void { + this.renderHeader(); + this.renderContent(); + this.container.screen.render(); + } + + /** + * Render header with budget status + */ + private renderHeader(): void { + if (!this.costSummary) { + this.headerBox.setContent('{gray-fg}No budget data loaded{/}'); + return; + } + + const { budget, totalCostUsd, burnRate } = this.costSummary; + const lines: string[] = []; + + // Budget progress bar + if (budget.limit > 0) { + const percent = Math.min(100, budget.percentUsed); + const filled = Math.floor(percent / 5); // 20 segments + const empty = 20 - filled; + + let barColor = 'green'; + if (budget.warningLevel === 'critical') barColor = 'red'; + else if (budget.warningLevel === 'warning') barColor = 'yellow'; + + const bar = `{${barColor}-fg}${'█'.repeat(filled)}{/}{gray-fg}${'░'.repeat(empty)}{/}`; + + lines.push(` ${formatCost(totalCostUsd)} / ${formatCost(budget.limit)} ${bar} ${Math.round(percent)}%`); + } else { + lines.push(` Session Cost: {green-fg}${formatCost(totalCostUsd)}{/} {gray-fg}(no budget set){/}`); + } + + // Burn rate line + if (burnRate.costPerMinute > 0) { + const burnRateColor = burnRate.isHighBurnRate ? 'yellow' : 'green'; + lines.push(` Rate: {${burnRateColor}-fg}${formatBurnRate(burnRate.costPerMinute)}{/}`); + + if (burnRate.timeToExhaustion && budget.limit > 0) { + lines[1] += ` Time to exhaustion: ${burnRate.timeToExhaustion}`; + } + } + + this.headerBox.setContent(lines.join('\n')); + } + + /** + * Render content area + */ + private renderContent(): void { + const lines: string[] = []; + + // Show active alerts first + const activeAlerts = this.alerts.filter(a => !a.acknowledged); + if (activeAlerts.length > 0) { + lines.push(this.renderAlertsSection(activeAlerts)); + lines.push(''); + } + + // Show top consumers + if (this.costSummary) { + lines.push(this.renderTopConsumersSection(this.costSummary)); + } + + // Show burn rate details + if (this.costSummary?.burnRate) { + lines.push(''); + lines.push(this.renderBurnRateSection(this.costSummary.burnRate)); + } + + this.contentBox.setContent(lines.join('\n')); + this.contentBox.setScrollPerc(0); + } + + /** + * Render alerts section + */ + private renderAlertsSection(alerts: BudgetAlert[]): string { + const lines: string[] = []; + + lines.push('{bold}{red-fg}══════════════════════════════════════════════════{/}'); + lines.push('{bold}{red-fg} ACTIVE ALERTS ({/}' + alerts.length + '{bold}{red-fg}){/}'); + lines.push('{bold}{red-fg}══════════════════════════════════════════════════{/}'); + lines.push(''); + + for (const alert of alerts) { + const icon = alert.type === 'exhausted' ? '🚨' : + alert.type === 'critical' ? '⚠️' : '⚡'; + const color = alert.type === 'exhausted' || alert.type === 'critical' ? 'red' : 'yellow'; + + lines.push(`{${color}-fg}${icon} ${alert.type.toUpperCase()}{/} {gray-fg}${new Date(alert.timestamp).toLocaleTimeString()}{/}`); + lines.push(` Spent: ${formatCost(alert.spent)} / ${formatCost(alert.limit)}`); + lines.push(` Burn rate: ${formatBurnRate(alert.burnRate)}`); + lines.push(''); + + if (alert.topConsumers.length > 0) { + lines.push(' {bold}Top consumers:{/}'); + for (const consumer of alert.topConsumers) { + const beadInfo = consumer.currentBead ? ` {cyan-fg}(${consumer.currentBead}){/}` : ''; + const insightInfo = consumer.insight ? ` {gray-fg}- ${consumer.insight}{/}` : ''; + lines.push(` ${consumer.workerId}${beadInfo}: {yellow-fg}${formatCost(consumer.costUsd)}{/}${insightInfo}`); + } + } + lines.push(''); + } + + return lines.join('\n'); + } + + /** + * Render top consumers section + */ + private renderTopConsumersSection(summary: CostSummary): string { + const lines: string[] = []; + const workers = Array.from(summary.byWorker.values()) + .sort((a, b) => b.costUsd - a.costUsd) + .slice(0, 5); + + if (workers.length === 0) { + return ''; + } + + lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}'); + lines.push('{bold}{cyan-fg} TOP CONSUMERS{/}'); + lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}'); + lines.push(''); + + const totalCost = summary.totalCostUsd; + + for (const worker of workers) { + const percent = totalCost > 0 ? Math.round((worker.costUsd / totalCost) * 100) : 0; + const beadInfo = worker.currentBead ? ` {cyan-fg}(${worker.currentBead}){/}` : ''; + + // Create mini progress bar + const barWidth = 10; + const filled = Math.floor((percent / 100) * barWidth); + const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); + + lines.push(`{bold}${worker.workerId}{/}${beadInfo}`); + lines.push(` {yellow-fg}${formatCost(worker.costUsd)}{/} {gray-fg}[${bar}] ${percent}%{/}`); + lines.push(` {gray-fg}~${Math.round(worker.total / 1000)}k tokens, ${worker.apiCalls} calls{/}`); + lines.push(''); + } + + return lines.join('\n'); + } + + /** + * Render burn rate section + */ + private renderBurnRateSection(burnRate: BurnRate): string { + const lines: string[] = []; + + lines.push('{bold}{magenta-fg}══════════════════════════════════════════════════{/}'); + lines.push('{bold}{magenta-fg} BURN RATE ANALYSIS{/}'); + lines.push('{bold}{magenta-fg}══════════════════════════════════════════════════{/}'); + lines.push(''); + + const rateColor = burnRate.isHighBurnRate ? 'yellow' : 'green'; + lines.push(`Current rate: {${rateColor}-fg}${formatBurnRate(burnRate.costPerMinute)}{/}`); + lines.push(`Window: ${burnRate.windowMinutes} minutes`); + + if (burnRate.timeToExhaustion) { + lines.push(`Time to exhaustion: {cyan-fg}${burnRate.timeToExhaustion}{/}`); + } + + lines.push(`Projected session total: {yellow-fg}${formatCost(burnRate.projectedTotalCost)}{/}`); + + if (burnRate.isHighBurnRate) { + lines.push(''); + lines.push('{yellow-fg}⚠ High burn rate detected!{/}'); + } + + return lines.join('\n'); + } + + /** + * Show the panel + */ + show(): void { + this.container.show(); + this.contentBox.focus(); + this.container.screen.render(); + } + + /** + * Hide the panel + */ + hide(): void { + this.container.hide(); + this.container.screen.render(); + } + + /** + * Toggle visibility + */ + toggle(): void { + if (this.container.hidden) { + this.show(); + } else { + this.hide(); + } + } + + /** + * Check if visible + */ + isVisible(): boolean { + return !this.container.hidden; + } + + /** + * Focus this component + */ + focus(): void { + this.contentBox.focus(); + } + + /** + * Get the underlying blessed element + */ + getElement(): blessed.Widgets.BoxElement { + return this.container; + } +} + +/** + * Create a budget alert panel + */ +export function createBudgetAlertPanel(options: BudgetAlertPanelOptions): BudgetAlertPanel { + return new BudgetAlertPanel(options); +} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 58ad337..f208563 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -51,3 +51,9 @@ export type { SemanticNarrativePanelOptions } from './SemanticNarrativePanel.js' export { WorkerAnalyticsPanel } from './WorkerAnalyticsPanel.js'; export type { WorkerAnalyticsPanelOptions } from './WorkerAnalyticsPanel.js'; + +export { FileContextPanel } from './FileContextPanel.js'; +export type { FileContextPanelOptions, FileContext, FileOperation } from './FileContextPanel.js'; + +export { BudgetAlertPanel, createBudgetAlertPanel } from './BudgetAlertPanel.js'; +export type { BudgetAlertPanelOptions } from './BudgetAlertPanel.js'; diff --git a/src/tui/utils/costTracking.ts b/src/tui/utils/costTracking.ts index cfe2974..ef0dcbf 100644 --- a/src/tui/utils/costTracking.ts +++ b/src/tui/utils/costTracking.ts @@ -3,6 +3,7 @@ * * Tracks token usage from log events and calculates estimated costs. * Displays total tokens, estimated cost, and per-worker breakdown. + * Includes burn rate calculations, budget alerts, and projections. */ import { LogEvent } from '../../types.js'; @@ -27,6 +28,12 @@ export interface WorkerCost extends TokenUsage { /** Number of API calls */ apiCalls: number; + + /** Current bead being worked on (if any) */ + currentBead?: string; + + /** Last activity timestamp */ + lastActivityTs?: number; } export interface CostSummary { @@ -42,6 +49,9 @@ export interface CostSummary { /** Budget status */ budget: BudgetStatus; + /** Burn rate information */ + burnRate: BurnRate; + /** Time range of data */ timeRange: { start: number; @@ -64,16 +74,85 @@ export interface BudgetStatus { /** Warning level (none, warning, critical) */ warningLevel: 'none' | 'warning' | 'critical'; + + /** Remaining budget in USD */ + remaining: number; +} + +export interface BurnRate { + /** Cost per minute in USD */ + costPerMinute: number; + + /** Time in minutes until budget exhausted (null if no budget or zero burn rate) */ + minutesToExhaustion: number | null; + + /** Formatted time to exhaustion (e.g., "2h 30m", "45m", "< 1m") */ + timeToExhaustion: string | null; + + /** Projected total cost at current burn rate (if session continues) */ + projectedTotalCost: number; + + /** Window size used for burn rate calculation in minutes */ + windowMinutes: number; + + /** Whether burn rate is high (> 0.50/min) */ + isHighBurnRate: boolean; +} + +export interface BudgetAlert { + /** Unique alert ID */ + id: string; + + /** Alert type */ + type: 'warning' | 'critical' | 'exhausted'; + + /** Alert message */ + message: string; + + /** Timestamp when alert was generated */ + timestamp: number; + + /** Current spend at time of alert */ + spent: number; + + /** Budget limit */ + limit: number; + + /** Burn rate at time of alert */ + burnRate: number; + + /** Top consumers at time of alert */ + topConsumers: TopConsumer[]; + + /** Whether alert has been acknowledged */ + acknowledged: boolean; +} + +export interface TopConsumer { + /** Worker ID */ + workerId: string; + + /** Cost in USD */ + costUsd: number; + + /** Percentage of total cost */ + percentOfTotal: number; + + /** Current bead (if known) */ + currentBead?: string; + + /** Reason for high consumption (optional insight) */ + insight?: string; } export interface CostTrackingOptions { /** Budget limit in USD (0 = no limit) */ budgetLimit?: number; - /** Warning threshold (percent, default 75) */ + /** Warning threshold (percent, default 80 per plan.md) */ warningThreshold?: number; - /** Critical threshold (percent, default 90) */ + /** Critical threshold (percent, default 95 per plan.md) */ criticalThreshold?: number; /** Input cost per 1M tokens (default: $3 for Claude) */ @@ -81,14 +160,22 @@ export interface CostTrackingOptions { /** Output cost per 1M tokens (default: $15 for Claude) */ outputCostPerMillion?: number; + + /** Burn rate window in minutes (default: 5) */ + burnRateWindowMinutes?: number; + + /** High burn rate threshold in USD/min (default: 0.50) */ + highBurnRateThreshold?: number; } const DEFAULT_OPTIONS: Required = { budgetLimit: 0, - warningThreshold: 75, - criticalThreshold: 90, + warningThreshold: 80, // Per plan.md: warning at 80% + criticalThreshold: 95, // Per plan.md: critical at 95% inputCostPerMillion: 3.00, // Claude Sonnet input outputCostPerMillion: 15.00, // Claude Sonnet output + burnRateWindowMinutes: 5, // Calculate burn rate over last 5 minutes + highBurnRateThreshold: 0.50, // High burn rate if > $0.50/min }; // Model pricing (per 1M tokens) @@ -114,6 +201,13 @@ export class CostTracker { private firstEventTs: number | null = null; private lastEventTs: number | null = null; + // Burn rate tracking + private costHistory: Array<{ ts: number; cost: number; worker: string }> = []; + + // Alert tracking + private alerts: BudgetAlert[] = []; + private lastWarningLevel: 'none' | 'warning' | 'critical' = 'none'; + constructor(options: CostTrackingOptions = {}) { this.options = { ...DEFAULT_OPTIONS, ...options }; } @@ -144,6 +238,8 @@ export class CostTracker { total: 0, costUsd: 0, apiCalls: 0, + currentBead: event.bead, + lastActivityTs: event.ts, }; this.workerCosts.set(event.worker, workerCost); } @@ -153,13 +249,33 @@ export class CostTracker { workerCost.output += tokens.output; workerCost.total += tokens.input + tokens.output; workerCost.apiCalls += 1; + workerCost.lastActivityTs = event.ts; + if (event.bead) { + workerCost.currentBead = event.bead; + } // Calculate cost based on model const model = (event.model as string) || 'claude-sonnet-4-6'; const pricing = MODEL_PRICING[model] || MODEL_PRICING['claude-sonnet-4-6']; - workerCost.costUsd = - (workerCost.input * pricing.input / 1_000_000) + - (workerCost.output * pricing.output / 1_000_000); + const incrementalCost = + (tokens.input * pricing.input / 1_000_000) + + (tokens.output * pricing.output / 1_000_000); + + workerCost.costUsd += incrementalCost; + + // Track cost history for burn rate calculation + this.costHistory.push({ + ts: event.ts, + cost: incrementalCost, + worker: event.worker, + }); + + // Trim old history (keep last 30 minutes) + const cutoffTs = event.ts - (30 * 60 * 1000); + this.costHistory = this.costHistory.filter(h => h.ts > cutoffTs); + + // Check for budget alerts + this.checkBudgetAlerts(); } /** @@ -216,6 +332,7 @@ export class CostTracker { (totalOutput * totalPrice.output / 1_000_000); const budget = this.calculateBudgetStatus(totalCostUsd); + const burnRate = this.calculateBurnRate(); return { total: { @@ -226,6 +343,7 @@ export class CostTracker { totalCostUsd, byWorker: new Map(this.workerCosts), budget, + burnRate, timeRange: { start: this.firstEventTs || Date.now(), end: this.lastEventTs || Date.now(), @@ -233,6 +351,227 @@ export class CostTracker { }; } + /** + * Calculate burn rate (cost per minute) + */ + private calculateBurnRate(): BurnRate { + const now = this.lastEventTs || Date.now(); + const windowMs = this.options.burnRateWindowMinutes * 60 * 1000; + const windowStart = now - windowMs; + + // Get costs within the burn rate window + const recentCosts = this.costHistory.filter(h => h.ts >= windowStart); + const totalCostInWindow = recentCosts.reduce((sum, h) => sum + h.cost, 0); + + // Calculate actual window duration (may be less if we don't have enough history) + const oldestInWindow = recentCosts.length > 0 + ? Math.min(...recentCosts.map(h => h.ts)) + : windowStart; + const actualWindowMs = now - oldestInWindow; + const actualWindowMinutes = actualWindowMs / 60000; + + // Cost per minute + const costPerMinute = actualWindowMinutes > 0 + ? totalCostInWindow / actualWindowMinutes + : 0; + + // Calculate total cost directly (avoid recursion with getSummary) + let totalInput = 0; + let totalOutput = 0; + for (const worker of this.workerCosts.values()) { + totalInput += worker.input; + totalOutput += worker.output; + } + const totalPrice = MODEL_PRICING['claude-sonnet-4-6']; + const currentTotalCost = + (totalInput * totalPrice.input / 1_000_000) + + (totalOutput * totalPrice.output / 1_000_000); + + // Calculate time to exhaustion + let minutesToExhaustion: number | null = null; + let timeToExhaustion: string | null = null; + + if (this.options.budgetLimit > 0 && costPerMinute > 0) { + const remaining = this.options.budgetLimit - currentTotalCost; + if (remaining > 0) { + minutesToExhaustion = remaining / costPerMinute; + timeToExhaustion = formatTimeToExhaustion(minutesToExhaustion); + } else { + minutesToExhaustion = 0; + timeToExhaustion = 'exhausted'; + } + } + + // Projected total cost at current burn rate for remainder of session + // Assume 60-minute session by default if we don't have enough data + const sessionDurationMs = (this.lastEventTs || now) - (this.firstEventTs || now); + const sessionMinutes = sessionDurationMs / 60000; + const projectedTotalCost = sessionMinutes > 0 + ? currentTotalCost + (costPerMinute * Math.max(0, 60 - sessionMinutes)) + : costPerMinute * 60; + + return { + costPerMinute, + minutesToExhaustion, + timeToExhaustion, + projectedTotalCost, + windowMinutes: this.options.burnRateWindowMinutes, + isHighBurnRate: costPerMinute > this.options.highBurnRateThreshold, + }; + } + + /** + * Get top consumers by cost + */ + getTopConsumers(limit: number = 5): TopConsumer[] { + const totalCost = Array.from(this.workerCosts.values()) + .reduce((sum, w) => sum + w.costUsd, 0); + + if (totalCost === 0) return []; + + const consumers = Array.from(this.workerCosts.values()) + .sort((a, b) => b.costUsd - a.costUsd) + .slice(0, limit) + .map(w => ({ + workerId: w.workerId, + costUsd: w.costUsd, + percentOfTotal: (w.costUsd / totalCost) * 100, + currentBead: w.currentBead, + insight: this.getWorkerInsight(w), + })); + + return consumers; + } + + /** + * Get insight about worker's cost pattern + */ + private getWorkerInsight(worker: WorkerCost): string | undefined { + // Check for high API call count + if (worker.apiCalls > 100) { + return 'high API call volume'; + } + + // Check for high output ratio (expensive) + const outputRatio = worker.total > 0 ? worker.output / worker.total : 0; + if (outputRatio > 0.4) { + return 'high output token ratio'; + } + + // Check for rapid recent activity + const recentCosts = this.costHistory.filter(h => h.worker === worker.workerId); + if (recentCosts.length > 20) { + return 'high activity rate'; + } + + return undefined; + } + + /** + * Check budget thresholds and generate alerts + */ + private checkBudgetAlerts(): void { + if (this.options.budgetLimit === 0) return; + + const summary = this.getSummary(); + const { warningLevel, spent, limit } = summary.budget; + + // Only generate alert if warning level changed + if (warningLevel !== this.lastWarningLevel && warningLevel !== 'none') { + const alert: BudgetAlert = { + id: `alert-${Date.now()}`, + type: warningLevel === 'critical' ? 'critical' : 'warning', + message: this.generateAlertMessage(warningLevel, spent, limit, summary.burnRate), + timestamp: Date.now(), + spent, + limit, + burnRate: summary.burnRate.costPerMinute, + topConsumers: this.getTopConsumers(3), + acknowledged: false, + }; + + this.alerts.push(alert); + this.lastWarningLevel = warningLevel; + } + + // Check for budget exhaustion + if (summary.budget.isOverBudget && this.lastWarningLevel !== 'critical') { + const alert: BudgetAlert = { + id: `alert-${Date.now()}`, + type: 'exhausted', + message: `Budget exhausted! Spent $${spent.toFixed(2)} of $${limit.toFixed(2)} budget.`, + timestamp: Date.now(), + spent, + limit, + burnRate: summary.burnRate.costPerMinute, + topConsumers: this.getTopConsumers(3), + acknowledged: false, + }; + + this.alerts.push(alert); + this.lastWarningLevel = 'critical'; + } + } + + /** + * Generate alert message + */ + private generateAlertMessage( + level: 'warning' | 'critical', + spent: number, + limit: number, + burnRate: BurnRate + ): string { + const percent = Math.round((spent / limit) * 100); + const icon = level === 'critical' ? '🚨' : '⚠️'; + const label = level === 'critical' ? 'CRITICAL' : 'WARNING'; + + let message = `${icon} BUDGET ${label}\n\n`; + message += `Daily budget ${percent}% consumed ($${spent.toFixed(2)} / $${limit.toFixed(2)})\n`; + + if (burnRate.costPerMinute > 0) { + message += `Current burn rate: $${burnRate.costPerMinute.toFixed(2)}/min\n`; + + if (burnRate.timeToExhaustion) { + message += `Time until budget exhausted: ${burnRate.timeToExhaustion}\n`; + } + } + + return message.trim(); + } + + /** + * Get all active alerts + */ + getAlerts(): BudgetAlert[] { + return this.alerts.filter(a => !a.acknowledged); + } + + /** + * Get all alerts including acknowledged + */ + getAllAlerts(): BudgetAlert[] { + return [...this.alerts]; + } + + /** + * Acknowledge an alert + */ + acknowledgeAlert(alertId: string): void { + const alert = this.alerts.find(a => a.id === alertId); + if (alert) { + alert.acknowledged = true; + } + } + + /** + * Clear all alerts + */ + clearAlerts(): void { + this.alerts = []; + this.lastWarningLevel = 'none'; + } + /** * Calculate budget status */ @@ -246,11 +585,13 @@ export class CostTracker { percentUsed: 0, isOverBudget: false, warningLevel: 'none', + remaining: 0, }; } const percentUsed = (spent / limit) * 100; const isOverBudget = spent > limit; + const remaining = Math.max(0, limit - spent); let warningLevel: 'none' | 'warning' | 'critical' = 'none'; if (percentUsed >= this.options.criticalThreshold || isOverBudget) { @@ -265,6 +606,7 @@ export class CostTracker { percentUsed, isOverBudget, warningLevel, + remaining, }; } @@ -273,8 +615,11 @@ export class CostTracker { */ reset(): void { this.workerCosts.clear(); + this.costHistory = []; + this.alerts = []; this.firstEventTs = null; this.lastEventTs = null; + this.lastWarningLevel = 'none'; } /** @@ -282,6 +627,16 @@ export class CostTracker { */ setBudgetLimit(limit: number): void { this.options.budgetLimit = limit; + // Re-check alerts with new limit + this.checkBudgetAlerts(); + } + + /** + * Get cost history for the specified time window + */ + getCostHistory(sinceMinutes: number = 30): Array<{ ts: number; cost: number; worker: string }> { + const cutoffTs = (this.lastEventTs || Date.now()) - (sinceMinutes * 60 * 1000); + return this.costHistory.filter(h => h.ts >= cutoffTs); } } @@ -314,6 +669,34 @@ export function formatTokens(count: number): string { return `${(count / 1_000_000).toFixed(2)}M`; } +/** + * Format time to exhaustion + */ +export function formatTimeToExhaustion(minutes: number): string { + if (minutes < 1) { + return '< 1m'; + } + if (minutes < 60) { + return `~${Math.round(minutes)}m`; + } + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + if (mins === 0) { + return `~${hours}h`; + } + return `~${hours}h ${mins}m`; +} + +/** + * Format burn rate for display + */ +export function formatBurnRate(costPerMinute: number): string { + if (costPerMinute < 0.01) { + return `$${(costPerMinute * 100).toFixed(2)}c/min`; + } + return `$${costPerMinute.toFixed(2)}/min`; +} + /** * Get budget indicator character */ @@ -329,6 +712,59 @@ export function getBudgetIndicator(status: BudgetStatus): string { } } +/** + * Get budget status badge text + */ +export function getBudgetBadge(status: BudgetStatus): string { + if (status.limit === 0) { + return ''; + } + + const percent = Math.round(status.percentUsed); + const icon = getBudgetIndicator(status); + + if (status.isOverBudget) { + return `${icon} OVER BUDGET`; + } + + if (status.warningLevel === 'critical') { + return `${icon} ${percent}% CRITICAL`; + } + + if (status.warningLevel === 'warning') { + return `${icon} ${percent}%`; + } + + return `${percent}%`; +} + +/** + * Format budget alert for display + */ +export function formatBudgetAlert(alert: BudgetAlert): string { + const lines: string[] = []; + + const icon = alert.type === 'exhausted' ? '🚨' : + alert.type === 'critical' ? '⚠️' : '⚡'; + + lines.push(`${icon} BUDGET ${alert.type.toUpperCase()}`); + lines.push(''); + lines.push(`Spent: $${alert.spent.toFixed(2)} / $${alert.limit.toFixed(2)}`); + lines.push(`Burn rate: ${formatBurnRate(alert.burnRate)}`); + lines.push(''); + + if (alert.topConsumers.length > 0) { + lines.push('Top consumers:'); + for (const consumer of alert.topConsumers) { + const beadInfo = consumer.currentBead ? ` (${consumer.currentBead})` : ''; + const insightInfo = consumer.insight ? ` - ${consumer.insight}` : ''; + lines.push(` ${consumer.workerId}${beadInfo}: ${formatCost(consumer.costUsd)}${insightInfo}`); + } + } + + return lines.join('\n'); +} + /** * Create a global cost tracker instance */