import * as fs from "node:fs"; import { Value } from "typebox/value"; import { DcpConfigSchema, type DcpConfig, type CompressConfig, type DeduplicationConfig, type PurgeErrorsConfig, type ManualModeConfig, type ExperimentalConfig, type StrategiesConfig, } from "./config-schema.ts"; // Re-export types so existing imports from config.ts continue to work export type { DcpConfig, CompressConfig, DeduplicationConfig, PurgeErrorsConfig, ManualModeConfig, ExperimentalConfig, StrategiesConfig, }; /** * Tool names always protected from pruning strategies. * Pi's core tools that should never have their outputs removed. */ export const BASE_PROTECTED_TOOLS = [ "compress", "bash", "read", "write", "edit", "grep", "find", "ls", "subagent", ]; // Value.Create fills all schema defaults, but Optional fields without // defaults resolve to undefined. Override the context limits that need // concrete defaults for threshold calculations. export const DEFAULT_CONFIG: DcpConfig = (() => { const config = Value.Create(DcpConfigSchema) as DcpConfig; // Protect compress tool outputs from being pruned to prevent recursive compression config.compress.protectedTools = ["compress"]; // Optional fields without schema defaults — set concrete values for threshold calculations config.compress.maxContextLimit = 200000; config.compress.minContextLimit = 100000; return config; })(); /** * Load DCP configuration from a single JSON file. * Falls back to defaults on missing file, parse error, or invalid content. * Returns warnings for validation errors and out-of-range values. * Invalid-typed values are reset to their defaults. * * @param configFilePath - Absolute path to dcp.json (typically resolved via getAgentDir()) */ export function loadConfig( configFilePath: string, ): { config: DcpConfig; warnings: string[] } { const warnings: string[] = []; const parsed = parseConfigFile(configFilePath); if (!parsed) return { config: structuredClone(DEFAULT_CONFIG), warnings }; // Deep merge raw user config over defaults so partial nested objects // (e.g. { compress: { mode: "message" } }) don't wipe sibling defaults. const merged = deepMerge( structuredClone(DEFAULT_CONFIG) as Record, parsed, ); // Clean unknown properties first Value.Clean(DcpConfigSchema, merged); // Validate and reset invalid values to defaults. // Union types produce multiple errors for the same path (one per branch); // deduplicate so each property emits at most one warning. if (!Value.Check(DcpConfigSchema, merged)) { const seenPaths = new Set(); for (const error of Value.Errors(DcpConfigSchema, merged)) { if (!error.instancePath) continue; // skip empty-path container errors if (seenPaths.has(error.instancePath)) continue; // deduplicate Union branches seenPaths.add(error.instancePath); warnings.push(`Config error at ${error.instancePath}: ${error.message}`); const defaultValue = getByPath( DEFAULT_CONFIG as unknown as Record, error.instancePath, ); if (defaultValue !== undefined) { setByPath(merged, error.instancePath, structuredClone(defaultValue)); } } } const config = merged as unknown as DcpConfig; // Post-validation range fixes (semantic constraints TypeBox can't express) if (config.compress.maxContextPercent > 100) { warnings.push( `maxContextPercent (${config.compress.maxContextPercent}) exceeds 100, reset to default`, ); config.compress.maxContextPercent = DEFAULT_CONFIG.compress.maxContextPercent; } if (config.compress.minContextPercent > 100) { warnings.push( `minContextPercent (${config.compress.minContextPercent}) exceeds 100, reset to default`, ); config.compress.minContextPercent = DEFAULT_CONFIG.compress.minContextPercent; } if (config.compress.maxContextPercent <= config.compress.minContextPercent) { warnings.push( `maxContextPercent (${config.compress.maxContextPercent}) must be greater than minContextPercent (${config.compress.minContextPercent}), reset to defaults`, ); config.compress.maxContextPercent = DEFAULT_CONFIG.compress.maxContextPercent; config.compress.minContextPercent = DEFAULT_CONFIG.compress.minContextPercent; } return { config, warnings }; } function parseConfigFile( filePath: string, ): Record | undefined { try { const content = fs.readFileSync(filePath, "utf-8"); const parsed = JSON.parse(content); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as Record; } return undefined; } catch { return undefined; } } /** * Recursively merge source into target. * Objects merge recursively. Primitives and arrays in source overwrite target. */ function deepMerge( target: Record, source: Record, ): Record { for (const key of Object.keys(source)) { const srcVal = source[key]; const tgtVal = target[key]; if ( srcVal !== null && typeof srcVal === "object" && !Array.isArray(srcVal) && tgtVal !== null && typeof tgtVal === "object" && !Array.isArray(tgtVal) ) { target[key] = deepMerge( tgtVal as Record, srcVal as Record, ); } else { target[key] = srcVal; } } return target; } /** * Get a value from a nested object using a JSON Pointer path (e.g. "/compress/mode"). */ function getByPath(obj: Record, path: string): unknown { const parts = path.split("/").filter(Boolean); let current: unknown = obj; for (const part of parts) { if (current === null || typeof current !== "object") return undefined; current = (current as Record)[part]; } return current; } /** * Set a value in a nested object using a JSON Pointer path (e.g. "/compress/mode"). */ function setByPath( obj: Record, path: string, value: unknown, ): void { const parts = path.split("/").filter(Boolean); if (parts.length === 0) return; let current: Record = obj; for (let i = 0; i < parts.length - 1; i++) { const next = current[parts[i]]; if (next === null || typeof next !== "object") return; current = next as Record; } current[parts[parts.length - 1]] = value; }