/** * KHAELOR * File: src/daemon/budget.ts * Description: Budget Guard — hard daily/per-run USD ceilings with a persisted per-day ledger (v2 design §7.6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; export interface BudgetConfig { maxUsdPerDay: number; maxUsdPerRun: number; hardStop: boolean; } export const DEFAULT_BUDGET: Readonly = Object.freeze({ maxUsdPerDay: 20, maxUsdPerRun: 3, hardStop: true, }); interface Ledger { /** YYYY-MM-DD the ledger accumulates for; a new day resets it. */ date: string; spentUsd: number; perGoal: Record; } function today(now: Date): string { return now.toISOString().slice(0, 10); } /** * Persisted at .khaelor/daemon/ledger.json. All numbers come from real API * usage reported by the run (Absolute Rule #4) — the guard only adds and * compares, never estimates. */ export class BudgetGuard { readonly #path: string; readonly #config: BudgetConfig; #ledger: Ledger; constructor(daemonDir: string, config: BudgetConfig = { ...DEFAULT_BUDGET }) { this.#path = join(daemonDir, "ledger.json"); this.#config = config; this.#ledger = { date: today(new Date()), spentUsd: 0, perGoal: {} }; } get config(): BudgetConfig { return this.#config; } async load(): Promise { try { const raw = JSON.parse(await readFile(this.#path, "utf8")) as Ledger; if (typeof raw.date === "string" && typeof raw.spentUsd === "number") { this.#ledger = { date: raw.date, spentUsd: raw.spentUsd, perGoal: raw.perGoal ?? {} }; } } catch { // fresh ledger } } #roll(now: Date): void { const day = today(now); if (this.#ledger.date !== day) { this.#ledger = { date: day, spentUsd: 0, perGoal: {} }; } } spentToday(now = new Date()): number { this.#roll(now); return this.#ledger.spentUsd; } spentTodayForGoal(goalId: string, now = new Date()): number { this.#roll(now); return this.#ledger.perGoal[goalId] ?? 0; } /** * May a run start? Enforces the daemon-wide daily ceiling and the goal's * own daily ceiling. With hardStop, the answer is binding (v2 §7.6). */ canStart(goalId: string, goalMaxUsdPerDay: number, now = new Date()): { ok: boolean; reason?: string } { this.#roll(now); if (this.#ledger.spentUsd >= this.#config.maxUsdPerDay) { return { ok: false, reason: `daemon daily budget exhausted ($${this.#config.maxUsdPerDay})` }; } const goalSpent = this.#ledger.perGoal[goalId] ?? 0; if (goalSpent >= goalMaxUsdPerDay) { return { ok: false, reason: `goal daily budget exhausted ($${goalMaxUsdPerDay})` }; } return { ok: true }; } /** Record a run's real cost. */ async record(goalId: string, usd: number, now = new Date()): Promise { this.#roll(now); this.#ledger.spentUsd += usd; this.#ledger.perGoal[goalId] = (this.#ledger.perGoal[goalId] ?? 0) + usd; await mkdir(dirname(this.#path), { recursive: true }); await writeFile(this.#path, `${JSON.stringify(this.#ledger, null, 2)}\n`, "utf8"); } }