import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { DEFAULT_CONFIG, type EpochConfig } from "./constants.ts"; export interface ConfigLoadResult { config: EpochConfig; /** Human-readable problems found in the config file (already saturated to defaults). */ warnings: string[]; path: string | undefined; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function clampNumber(value: unknown, min: number, max: number): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; return Math.min(max, Math.max(min, value)); } function readBool(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; return undefined; } /** * Locate the config file. Global config only: * ~/.pi/agent/context-epochs.json (CONFIG_DIR_NAME aware). */ export function defaultConfigPath(): string { return join(homedir(), ".pi", "agent", "context-epochs.json"); } /** * Load and strictly validate the extension config. * Invalid values never crash pi: they produce a warning and fall back to * the safe default (or the nearest in-range value for ratios). */ export function loadConfig(path = defaultConfigPath()): ConfigLoadResult { const warnings: string[] = []; const config: EpochConfig = { ...DEFAULT_CONFIG }; if (!existsSync(path)) { return { config, warnings, path: undefined }; } let raw: unknown; try { raw = JSON.parse(readFileSync(path, "utf8")); } catch (error) { warnings.push( `context-epochs: config file ${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults`, ); return { config, warnings, path }; } if (!isPlainObject(raw)) { warnings.push(`context-epochs: config file ${path} must contain a JSON object; using defaults`); return { config, warnings, path }; } const enabled = readBool(raw["enabled"]); if (raw["enabled"] !== undefined && enabled === undefined) { warnings.push("context-epochs: config 'enabled' must be a boolean; using default true"); } else if (enabled !== undefined) { config.enabled = enabled; } const autoRollover = readBool(raw["autoRollover"]); if (raw["autoRollover"] !== undefined && autoRollover === undefined) { warnings.push("context-epochs: config 'autoRollover' must be a boolean; using default true"); } else if (autoRollover !== undefined) { config.autoRollover = autoRollover; } // Ratios: clamp into range instead of rejecting, but warn. const soft = clampNumber(raw["softRemainingRatio"], 0.02, 0.9); if (raw["softRemainingRatio"] !== undefined && soft === undefined) { warnings.push("context-epochs: config 'softRemainingRatio' must be a number; using default 0.25"); } else if (soft !== undefined) { config.softRemainingRatio = soft; } const critical = clampNumber(raw["criticalRemainingRatio"], 0.01, 0.9); if (raw["criticalRemainingRatio"] !== undefined && critical === undefined) { warnings.push("context-epochs: config 'criticalRemainingRatio' must be a number; using default 0.10"); } else if (critical !== undefined) { config.criticalRemainingRatio = critical; } if (config.criticalRemainingRatio >= config.softRemainingRatio) { warnings.push( "context-epochs: 'criticalRemainingRatio' must be smaller than 'softRemainingRatio'; resetting both to defaults", ); config.softRemainingRatio = DEFAULT_CONFIG.softRemainingRatio; config.criticalRemainingRatio = DEFAULT_CONFIG.criticalRemainingRatio; } const intFields: Array<[string, number, number, keyof EpochConfig]> = [ ["historySearchLimit", 1, 50, "historySearchLimit"], ["historyReadAround", 0, 10, "historyReadAround"], ["historyMaxChars", 1_000, 200_000, "historyMaxChars"], ["noteMaxChars", 500, 100_000, "noteMaxChars"], ["continuationAnchorMaxChars", 100, 20_000, "continuationAnchorMaxChars"], ]; for (const [key, min, max, target] of intFields) { const value = raw[key]; if (value === undefined) continue; const num = clampNumber(value, min, max); if (num === undefined || !Number.isInteger(num)) { warnings.push(`context-epochs: config '${key}' must be an integer in [${min}, ${max}]; using default`); continue; } (config as unknown as Record)[target] = num; } const knownKeys = new Set([ "enabled", "autoRollover", "softRemainingRatio", "criticalRemainingRatio", "historySearchLimit", "historyReadAround", "historyMaxChars", "noteMaxChars", "continuationAnchorMaxChars", ]); for (const key of Object.keys(raw)) { if (!knownKeys.has(key)) { warnings.push(`context-epochs: ignoring unknown config key '${key}'`); } } return { config, warnings, path }; }