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.6 KB · 101 lines typescript
Raw Blame History
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