/** * rr-config.ts — read-only roundrobin group config IO. * * Reads ~/.pi/agent/roundrobin/config.json (the "default" group) and every * *.json file under presets/ (each preset = one roundrobin/ virtual model, * where is the file name with its .json suffix stripped). Only the * scalar fields (timeoutMs/cooldownMs/strategy/log) are normalized here; * virtualModel defaults + candidate inheritance live in resolveVirtualModel. * * Parsing is cached by file mtime: a hot reload that doesn't touch the config * files reuses the parsed object and skips JSON.parse. ENOENT / bad JSON is * caught from readFileSync directly (no separate existsSync stat). */ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; const RR_DIR = join(getAgentDir(), "roundrobin"); const RR_CONFIG_PATH = join(RR_DIR, "config.json"); const PRESETS_DIR = join(RR_DIR, "presets"); export interface RrCandidate { provider: string; model: string; } export interface RrVirtualModel { id?: string; name?: string; reasoning?: boolean; input?: string[]; contextWindow?: number; maxTokens?: number; thinkingLevelMap?: Record; compat?: Record; } export interface RrConfig { virtualModel?: RrVirtualModel; candidates?: RrCandidate[]; log?: boolean; timeoutMs?: number; cooldownMs?: number; strategy?: "sticky" | "round-robin" | "primary"; } export interface GroupConfig { name: string; config: RrConfig; } /** Normalize scalar fields only; virtualModel passes through untouched. */ function normalizeConfig(parsed: RrConfig | null): RrConfig { if (!parsed) return { candidates: [], log: true, timeoutMs: 30000, cooldownMs: 60000, strategy: "sticky" }; return { virtualModel: parsed.virtualModel, // resolveVirtualModel owns defaults + inheritance candidates: Array.isArray(parsed.candidates) ? parsed.candidates : [], log: parsed.log !== false, timeoutMs: Math.max(1000, typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : 30000), cooldownMs: Math.max(1000, typeof parsed.cooldownMs === "number" ? parsed.cooldownMs : 60000), strategy: parsed.strategy === "round-robin" || parsed.strategy === "primary" ? parsed.strategy : "sticky", }; } const parseCache = new Map(); /** Read + parse a JSON config file, cached by mtime. Returns null if missing or * unparseable (never throws). */ function readJsonIfExists(path: string): RrConfig | null { let mtime: number; try { mtime = statSync(path).mtimeMs; } catch { parseCache.delete(path); return null; } const cached = parseCache.get(path); if (cached && cached.mtime === mtime) return cached.config; let config: RrConfig | null; try { config = JSON.parse(readFileSync(path, "utf-8")) as RrConfig; } catch { config = null; } parseCache.set(path, { mtime, config }); return config; } /** * All groups: "default" (config.json) + one per presets/ file. A corrupt preset * returns null and is normalized to an empty group (skipped, never throws). */ export function loadAllGroupsConfig(): GroupConfig[] { const out: GroupConfig[] = [{ name: "default", config: normalizeConfig(readJsonIfExists(RR_CONFIG_PATH)) }]; let files: string[] = []; try { files = readdirSync(PRESETS_DIR) .filter((n) => n.endsWith(".json") && !n.startsWith("bak.")) .sort(); } catch { // presets dir missing — only the "default" group is loaded } // Group name (→ virtual model id) = file stem, so `roundrobin/strong` maps to // `presets/strong.json` exactly. Without this the id would be `strong.json` and // `roundrobin/strong` would only resolve via pi's fuzzy `includes` fallback, // which breaks the moment two preset stems share a substring prefix. for (const file of files) out.push({ name: file.slice(0, -5), config: normalizeConfig(readJsonIfExists(join(PRESETS_DIR, file))) }); return out; }