import { parseAdditionalArgs } from "./cli-args.ts"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import { mergeChildEnv, validateChildEnv } from "./env.ts"; import { PROFILES, THINKING_LEVELS, type ProfileDefaults, type ThinkingLevel } from "./types.ts"; const CONFIG_FILE_NAME = "pi-dede.json"; const MAX_CONFIG_BYTES = 64 * 1024; export interface DedeConfig { profiles: ProfileDefaults; additionalArgs: string[]; context: { forkMinTokens: number; forkMaxContextRatio: number; }; } interface DedeConfigFile { profiles?: ProfileDefaults; additionalArgs?: string[]; context?: { forkMinTokens?: number; forkMaxContextRatio?: number; }; } const DEFAULT_CONTEXT_CONFIG = { forkMinTokens: 4_000, forkMaxContextRatio: 0.7, }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function assertKnownKeys(value: Record, allowed: readonly string[], label: string): void { const allowedKeys = new Set(allowed); const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key)); if (unknown.length) throw new Error(`${label} contains unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`); } /** Convert trusted JSON flag/value overrides to the child argv representation. */ function parseAdditionalArgsConfig(value: unknown, label: string): string[] { if (!isRecord(value)) throw new Error(`${label} must be an object mapping CLI flags to boolean or string values`); const args: string[] = []; for (const [flag, override] of Object.entries(value)) { // Validate keys even when false omits that flag from the final argv. parseAdditionalArgs([flag], `${label}.${flag}`); if (typeof override === "boolean") { if (override) args.push(flag); continue; } if (typeof override !== "string" || !override || override.startsWith("-") || override.includes("\0")) { throw new Error(`${label}.${flag} must be true, false, or a non-empty value that does not begin with "-"`); } args.push(flag, override); } parseAdditionalArgs(args, label); return args; } function parseConfig(content: string, path: string): DedeConfigFile { if (Buffer.byteLength(content, "utf8") > MAX_CONFIG_BYTES) { throw new Error(`${path} exceeds ${MAX_CONFIG_BYTES} UTF-8 bytes`); } let parsed: unknown; try { parsed = JSON.parse(content); } catch { throw new Error(`Could not parse ${path}: invalid JSON`); } if (!isRecord(parsed)) throw new Error(`${path} must contain a JSON object`); assertKnownKeys(parsed, ["profiles", "additionalArgs", "context"], path); const additionalArgs = parsed.additionalArgs === undefined ? undefined : parseAdditionalArgsConfig(parsed.additionalArgs, `${path}.additionalArgs`); if (parsed.context !== undefined) { if (!isRecord(parsed.context)) throw new Error(`${path}.context must be an object`); assertKnownKeys(parsed.context, ["forkMinTokens", "forkMaxContextRatio"], `${path}.context`); const forkMinTokens = parsed.context.forkMinTokens; if (forkMinTokens !== undefined && (typeof forkMinTokens !== "number" || !Number.isInteger(forkMinTokens) || forkMinTokens < 0)) { throw new Error(`${path}.context.forkMinTokens must be a non-negative integer`); } if (parsed.context.forkMaxContextRatio !== undefined && ( typeof parsed.context.forkMaxContextRatio !== "number" || parsed.context.forkMaxContextRatio <= 0 || parsed.context.forkMaxContextRatio > 1 )) { throw new Error(`${path}.context.forkMaxContextRatio must be greater than 0 and at most 1`); } } if (parsed.profiles === undefined) { return { ...(additionalArgs !== undefined ? { additionalArgs } : {}), ...(parsed.context !== undefined ? { context: { ...parsed.context } } : {}), }; } if (!isRecord(parsed.profiles)) throw new Error(`${path}.profiles must be an object`); assertKnownKeys(parsed.profiles, PROFILES, `${path}.profiles`); const profiles: ProfileDefaults = {}; for (const profile of PROFILES) { const value = parsed.profiles[profile]; if (value === undefined) continue; if (!isRecord(value)) throw new Error(`${path}.profiles.${profile} must be an object`); assertKnownKeys(value, ["model", "thinking", "env", "additionalArgs"], `${path}.profiles.${profile}`); if (value.model !== undefined && (typeof value.model !== "string" || value.model.trim().length === 0)) { throw new Error(`${path}.profiles.${profile}.model must be a non-empty string`); } if (value.thinking !== undefined && !(THINKING_LEVELS as readonly unknown[]).includes(value.thinking)) { throw new Error(`${path}.profiles.${profile}.thinking must be one of: ${THINKING_LEVELS.join(", ")}`); } const env = value.env === undefined ? undefined : validateChildEnv(value.env, `${path}.profiles.${profile}.env`); const additionalArgs = value.additionalArgs === undefined ? undefined : parseAdditionalArgsConfig(value.additionalArgs, `${path}.profiles.${profile}.additionalArgs`); profiles[profile] = { ...(typeof value.model === "string" ? { model: value.model.trim() } : {}), ...(value.thinking !== undefined ? { thinking: value.thinking as ThinkingLevel } : {}), ...(env !== undefined ? { env } : {}), ...(additionalArgs !== undefined ? { additionalArgs } : {}), }; } return { profiles, ...(additionalArgs !== undefined ? { additionalArgs } : {}), ...(parsed.context !== undefined ? { context: { ...parsed.context } } : {}), }; } async function readConfig(path: string): Promise { try { return parseConfig(await readFile(path, "utf8"), path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; } } function mergeProfileDefaults(globalDefaults: ProfileDefaults = {}, projectDefaults: ProfileDefaults = {}): ProfileDefaults { const merged: ProfileDefaults = {}; for (const profile of PROFILES) { const globalValue = globalDefaults[profile]; const projectValue = projectDefaults[profile]; const env = mergeChildEnv([globalValue?.env, projectValue?.env]); const value = { ...globalValue, ...projectValue, ...(Object.keys(env).length > 0 ? { env } : {}), }; if (value.model !== undefined || value.thinking !== undefined || value.env !== undefined || value.additionalArgs !== undefined) merged[profile] = value; } return merged; } export function getDedeConfigPaths(cwd: string): { global: string; project: string } { return { global: join(getAgentDir(), CONFIG_FILE_NAME), project: join(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME), }; } /** Load global configuration and, for trusted projects only, field-level project overrides. */ export async function loadDedeConfig(cwd: string, projectTrusted: boolean): Promise { const paths = getDedeConfigPaths(cwd); const globalConfig = await readConfig(paths.global); const projectConfig = projectTrusted ? await readConfig(paths.project) : {}; return { profiles: mergeProfileDefaults(globalConfig.profiles, projectConfig.profiles), additionalArgs: [...(projectConfig.additionalArgs ?? globalConfig.additionalArgs ?? [])], context: { ...DEFAULT_CONTEXT_CONFIG, ...globalConfig.context, ...projectConfig.context, }, }; } export async function loadProfileDefaults(cwd: string, projectTrusted: boolean): Promise { return (await loadDedeConfig(cwd, projectTrusted)).profiles; }