SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
3.2 KB · 109 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/daemon/budget.ts4 * Description: Budget Guard — hard daily/per-run USD ceilings with a persisted per-day ledger (v2 design §7.6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdir, readFile, writeFile } from "node:fs/promises";11import { dirname, join } from "node:path";1213export interface BudgetConfig {14  maxUsdPerDay: number;15  maxUsdPerRun: number;16  hardStop: boolean;17}1819export const DEFAULT_BUDGET: Readonly<BudgetConfig> = Object.freeze({20  maxUsdPerDay: 20,21  maxUsdPerRun: 3,22  hardStop: true,23});2425interface Ledger {26  /** YYYY-MM-DD the ledger accumulates for; a new day resets it. */27  date: string;28  spentUsd: number;29  perGoal: Record<string, number>;30}3132function today(now: Date): string {33  return now.toISOString().slice(0, 10);34}3536/**37 * Persisted at .khaelor/daemon/ledger.json. All numbers come from real API38 * usage reported by the run (Absolute Rule #4) — the guard only adds and39 * compares, never estimates.40 */41export class BudgetGuard {42  readonly #path: string;43  readonly #config: BudgetConfig;44  #ledger: Ledger;4546  constructor(daemonDir: string, config: BudgetConfig = { ...DEFAULT_BUDGET }) {47    this.#path = join(daemonDir, "ledger.json");48    this.#config = config;49    this.#ledger = { date: today(new Date()), spentUsd: 0, perGoal: {} };50  }5152  get config(): BudgetConfig {53    return this.#config;54  }5556  async load(): Promise<void> {57    try {58      const raw = JSON.parse(await readFile(this.#path, "utf8")) as Ledger;59      if (typeof raw.date === "string" && typeof raw.spentUsd === "number") {60        this.#ledger = { date: raw.date, spentUsd: raw.spentUsd, perGoal: raw.perGoal ?? {} };61      }62    } catch {63      // fresh ledger64    }65  }6667  #roll(now: Date): void {68    const day = today(now);69    if (this.#ledger.date !== day) {70      this.#ledger = { date: day, spentUsd: 0, perGoal: {} };71    }72  }7374  spentToday(now = new Date()): number {75    this.#roll(now);76    return this.#ledger.spentUsd;77  }7879  spentTodayForGoal(goalId: string, now = new Date()): number {80    this.#roll(now);81    return this.#ledger.perGoal[goalId] ?? 0;82  }8384  /**85   * May a run start? Enforces the daemon-wide daily ceiling and the goal's86   * own daily ceiling. With hardStop, the answer is binding (v2 §7.6).87   */88  canStart(goalId: string, goalMaxUsdPerDay: number, now = new Date()): { ok: boolean; reason?: string } {89    this.#roll(now);90    if (this.#ledger.spentUsd >= this.#config.maxUsdPerDay) {91      return { ok: false, reason: `daemon daily budget exhausted ($${this.#config.maxUsdPerDay})` };92    }93    const goalSpent = this.#ledger.perGoal[goalId] ?? 0;94    if (goalSpent >= goalMaxUsdPerDay) {95      return { ok: false, reason: `goal daily budget exhausted ($${goalMaxUsdPerDay})` };96    }97    return { ok: true };98  }99100  /** Record a run's real cost. */101  async record(goalId: string, usd: number, now = new Date()): Promise<void> {102    this.#roll(now);103    this.#ledger.spentUsd += usd;104    this.#ledger.perGoal[goalId] = (this.#ledger.perGoal[goalId] ?? 0) + usd;105    await mkdir(dirname(this.#path), { recursive: true });106    await writeFile(this.#path, `${JSON.stringify(this.#ledger, null, 2)}\n`, "utf8");107  }108}109