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%
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