feat(bd-hn5): Budget Alerts and Projections

Add comprehensive budget monitoring and alert system:

- Burn rate calculation (cost per minute over configurable window)
- Time-to-budget-exhaustion projection with formatted output
- Top consumer identification with cost breakdown and insights
- Real-time alert generation at 80% (warning) and 95% (critical) thresholds
- BudgetAlertPanel TUI component for displaying alerts and projections
- New interfaces: BurnRate, BudgetAlert, TopConsumer
- Formatting utilities: formatBurnRate, formatTimeToExhaustion, getBudgetBadge, formatBudgetAlert

Thresholds updated per plan.md specification (80%/95% instead of 75%/90%).

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-07 04:41:34 +00:00
parent c59e016b67
commit e1f81e5bed
3 changed files with 846 additions and 7 deletions

View file

@ -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);
}

View file

@ -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';

View file

@ -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<CostTrackingOptions> = {
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
*/