import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import type { AgentsMdConfig, AgentsMdConfigInput, ConfigDiagnostic, ConfigLoadOptions, LoadedAgentsMdConfig, MaintenanceConfig, MaintenanceConfigInput, MaintenanceMode, PiRunMode, } from "./types.ts"; export const CONFIG_FILE_NAME = "agents-md.json"; export const DEFAULT_MAINTENANCE_CONFIG: Readonly = Object.freeze({ mode: "prompt", maxCuesPerAgentRun: 1, maxAffectedFiles: 12, maxGeneratedBytes: 32 * 1024, allowHeadlessAuto: false, }); export const DEFAULT_CONFIG: Readonly = Object.freeze({ maintenance: DEFAULT_MAINTENANCE_CONFIG, }); const maintenanceModes = new Set([ "prompt", "settled", "review", "off", ]); export function getGlobalConfigPath(): string { return join(getAgentDir(), CONFIG_FILE_NAME); } export function getProjectConfigPath(cwd: string): string { return join(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME); } export async function loadAgentsMdConfig( options: ConfigLoadOptions, ): Promise { const diagnostics: ConfigDiagnostic[] = []; const loadedPaths: string[] = []; const global = await readConfigFile( getGlobalConfigPath(), diagnostics, loadedPaths, ); const project = options.projectTrusted ? await readConfigFile( getProjectConfigPath(options.cwd), diagnostics, loadedPaths, ) : undefined; return { config: mergeConfig(global, project), loadedPaths, diagnostics, }; } export function mergeConfig( ...sources: readonly (AgentsMdConfigInput | undefined)[] ): AgentsMdConfig { return { maintenance: { ...DEFAULT_MAINTENANCE_CONFIG, ...sources.reduce( (merged, source) => ({ ...merged, ...source?.maintenance }), {}, ), }, }; } export function isAutomaticMaintenanceEnabled( config: AgentsMdConfig, mode: PiRunMode, ): boolean { if (config.maintenance.mode === "off") return false; return ( (mode !== "print" && mode !== "json") || config.maintenance.allowHeadlessAuto ); } async function readConfigFile( path: string, diagnostics: ConfigDiagnostic[], loadedPaths: string[], ): Promise { let text: string; try { text = await readFile(path, "utf8"); } catch (error) { if (hasErrorCode(error, "ENOENT")) return undefined; diagnostics.push({ path, message: "Could not read configuration." }); return undefined; } let parsed: unknown; try { parsed = JSON.parse(text); } catch { diagnostics.push({ path, message: "Configuration is not valid JSON." }); return undefined; } loadedPaths.push(path); return parseConfig(parsed, path, diagnostics); } function parseConfig( input: unknown, path: string, diagnostics: ConfigDiagnostic[], ): AgentsMdConfigInput { if (!isRecord(input)) { diagnostics.push({ path, message: "Configuration must be a JSON object." }); return {}; } if (input.maintenance === undefined) return {}; if (!isRecord(input.maintenance)) { diagnostics.push({ path, message: "maintenance must be a JSON object." }); return {}; } return { maintenance: parseMaintenance(input.maintenance, path, diagnostics), }; } function parseMaintenance( input: Record, path: string, diagnostics: ConfigDiagnostic[], ): MaintenanceConfigInput { const config: MaintenanceConfigInput = {}; if (input.mode !== undefined) { if ( typeof input.mode === "string" && maintenanceModes.has(input.mode as MaintenanceMode) ) { config.mode = input.mode as MaintenanceMode; } else { diagnostics.push({ path, message: "maintenance.mode is invalid." }); } } setNonNegativeInteger( config, "maxCuesPerAgentRun", input.maxCuesPerAgentRun, path, diagnostics, ); setNonNegativeInteger( config, "maxAffectedFiles", input.maxAffectedFiles, path, diagnostics, ); setNonNegativeInteger( config, "maxGeneratedBytes", input.maxGeneratedBytes, path, diagnostics, ); if (input.allowHeadlessAuto !== undefined) { if (typeof input.allowHeadlessAuto === "boolean") { config.allowHeadlessAuto = input.allowHeadlessAuto; } else { diagnostics.push({ path, message: "maintenance.allowHeadlessAuto must be boolean.", }); } } return config; } function setNonNegativeInteger( config: MaintenanceConfigInput, key: "maxCuesPerAgentRun" | "maxAffectedFiles" | "maxGeneratedBytes", value: unknown, path: string, diagnostics: ConfigDiagnostic[], ): void { if (value === undefined) return; if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { config[key] = value; return; } diagnostics.push({ path, message: `maintenance.${key} must be a non-negative integer.`, }); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function hasErrorCode(error: unknown, expectedCode: string): boolean { return ( typeof error === "object" && error !== null && "code" in error && error.code === expectedCode ); }