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/config.ts4 * Description: Daemon configuration — .khaelor/daemon/config.json: budget, active hours, models, channels, pricing (v2 design §7.6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { readFile } from "node:fs/promises";11import { join } from "node:path";12import { DEFAULT_BUDGET } from "./budget.js";13import type { BudgetConfig } from "./budget.js";1415export interface DaemonPricing {16 inputPerMTok: number;17 outputPerMTok: number;18 cacheReadPerMTok: number;19 cacheWritePerMTok: number;20}2122export interface DaemonConfig {23 budget: BudgetConfig;24 /** "07:00-23:00" — outside this window the daemon stays quiet (v2 §7.6). */25 activeHours?: string;26 /** Heartbeat tick in minutes (default 30). */27 heartbeatMinutes: number;28 model: {29 /** Cheap triage tier (goal checks / future LLM triage). */30 heartbeat?: string;31 /** The model real runs use; falls back to the session config model. */32 runs?: string;33 };34 channels: { webhook?: string; command?: string };35 /**36 * USD prices per million tokens for the runs model. Absent → run costs are37 * recorded as $0 and budget enforcement relies on maxRunsPerDay — costs are38 * never invented (Absolute Rule #4).39 */40 pricing?: DaemonPricing;41}4243export const DEFAULT_DAEMON_CONFIG: Readonly<DaemonConfig> = Object.freeze({44 budget: { ...DEFAULT_BUDGET },45 heartbeatMinutes: 30,46 model: Object.freeze({}),47 channels: Object.freeze({}),48});4950export function daemonDirFor(projectRoot: string): string {51 return join(projectRoot, ".khaelor", "daemon");52}5354function num(value: unknown, fallback: number): number {55 return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;56}5758/** Load .khaelor/daemon/config.json; every field optional, defaults applied. */59export async function loadDaemonConfig(projectRoot: string): Promise<DaemonConfig> {60 let raw: Record<string, unknown> = {};61 try {62 raw = JSON.parse(await readFile(join(daemonDirFor(projectRoot), "config.json"), "utf8")) as Record<63 string,64 unknown65 >;66 } catch {67 return { ...DEFAULT_DAEMON_CONFIG, budget: { ...DEFAULT_BUDGET }, model: {}, channels: {} };68 }69 const budgetRaw = (raw["budget"] ?? {}) as Record<string, unknown>;70 const modelRaw = (raw["model"] ?? {}) as Record<string, unknown>;71 const channelsRaw = (raw["channels"] ?? {}) as Record<string, unknown>;72 const pricingRaw = raw["pricing"] as Record<string, unknown> | undefined;73 return {74 budget: {75 maxUsdPerDay: num(budgetRaw["maxUsdPerDay"], DEFAULT_BUDGET.maxUsdPerDay),76 maxUsdPerRun: num(budgetRaw["maxUsdPerRun"], DEFAULT_BUDGET.maxUsdPerRun),77 hardStop: budgetRaw["hardStop"] !== false,78 },79 ...(typeof raw["activeHours"] === "string" ? { activeHours: raw["activeHours"] } : {}),80 heartbeatMinutes: num(raw["heartbeatMinutes"], 30),81 model: {82 ...(typeof modelRaw["heartbeat"] === "string" ? { heartbeat: modelRaw["heartbeat"] } : {}),83 ...(typeof modelRaw["runs"] === "string" ? { runs: modelRaw["runs"] } : {}),84 },85 channels: {86 ...(typeof channelsRaw["webhook"] === "string" ? { webhook: channelsRaw["webhook"] } : {}),87 ...(typeof channelsRaw["command"] === "string" ? { command: channelsRaw["command"] } : {}),88 },89 ...(pricingRaw !== undefined90 ? {91 pricing: {92 inputPerMTok: num(pricingRaw["inputPerMTok"], 0),93 outputPerMTok: num(pricingRaw["outputPerMTok"], 0),94 cacheReadPerMTok: num(pricingRaw["cacheReadPerMTok"], 0),95 cacheWritePerMTok: num(pricingRaw["cacheWritePerMTok"], 0),96 },97 }98 : {}),99 };100}101