/** * KHAELOR * File: src/daemon/config.ts * Description: Daemon configuration — .khaelor/daemon/config.json: budget, active hours, models, channels, pricing (v2 design §7.6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { DEFAULT_BUDGET } from "./budget.js"; import type { BudgetConfig } from "./budget.js"; export interface DaemonPricing { inputPerMTok: number; outputPerMTok: number; cacheReadPerMTok: number; cacheWritePerMTok: number; } export interface DaemonConfig { budget: BudgetConfig; /** "07:00-23:00" — outside this window the daemon stays quiet (v2 §7.6). */ activeHours?: string; /** Heartbeat tick in minutes (default 30). */ heartbeatMinutes: number; model: { /** Cheap triage tier (goal checks / future LLM triage). */ heartbeat?: string; /** The model real runs use; falls back to the session config model. */ runs?: string; }; channels: { webhook?: string; command?: string }; /** * USD prices per million tokens for the runs model. Absent → run costs are * recorded as $0 and budget enforcement relies on maxRunsPerDay — costs are * never invented (Absolute Rule #4). */ pricing?: DaemonPricing; } export const DEFAULT_DAEMON_CONFIG: Readonly = Object.freeze({ budget: { ...DEFAULT_BUDGET }, heartbeatMinutes: 30, model: Object.freeze({}), channels: Object.freeze({}), }); export function daemonDirFor(projectRoot: string): string { return join(projectRoot, ".khaelor", "daemon"); } function num(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } /** Load .khaelor/daemon/config.json; every field optional, defaults applied. */ export async function loadDaemonConfig(projectRoot: string): Promise { let raw: Record = {}; try { raw = JSON.parse(await readFile(join(daemonDirFor(projectRoot), "config.json"), "utf8")) as Record< string, unknown >; } catch { return { ...DEFAULT_DAEMON_CONFIG, budget: { ...DEFAULT_BUDGET }, model: {}, channels: {} }; } const budgetRaw = (raw["budget"] ?? {}) as Record; const modelRaw = (raw["model"] ?? {}) as Record; const channelsRaw = (raw["channels"] ?? {}) as Record; const pricingRaw = raw["pricing"] as Record | undefined; return { budget: { maxUsdPerDay: num(budgetRaw["maxUsdPerDay"], DEFAULT_BUDGET.maxUsdPerDay), maxUsdPerRun: num(budgetRaw["maxUsdPerRun"], DEFAULT_BUDGET.maxUsdPerRun), hardStop: budgetRaw["hardStop"] !== false, }, ...(typeof raw["activeHours"] === "string" ? { activeHours: raw["activeHours"] } : {}), heartbeatMinutes: num(raw["heartbeatMinutes"], 30), model: { ...(typeof modelRaw["heartbeat"] === "string" ? { heartbeat: modelRaw["heartbeat"] } : {}), ...(typeof modelRaw["runs"] === "string" ? { runs: modelRaw["runs"] } : {}), }, channels: { ...(typeof channelsRaw["webhook"] === "string" ? { webhook: channelsRaw["webhook"] } : {}), ...(typeof channelsRaw["command"] === "string" ? { command: channelsRaw["command"] } : {}), }, ...(pricingRaw !== undefined ? { pricing: { inputPerMTok: num(pricingRaw["inputPerMTok"], 0), outputPerMTok: num(pricingRaw["outputPerMTok"], 0), cacheReadPerMTok: num(pricingRaw["cacheReadPerMTok"], 0), cacheWritePerMTok: num(pricingRaw["cacheWritePerMTok"], 0), }, } : {}), }; }