import { existsSync, readFileSync } from "node:fs"; import { dirname, join, parse, resolve } from "node:path"; import { parse as parseJsonc } from "jsonc-parser"; import { getPiToolsSuiteUserConfigPath } from "../config.js"; import type { ContextGatewayAccountingLogConfig, ContextGatewayBudgets, ContextGatewayMode, ContextGatewayResolvedConfig, ContextGatewayToolClass, } from "./types.js"; export const DEFAULT_CONTEXT_GATEWAY_BUDGETS: Readonly = Object.freeze({ maxInlineBytes: 8_192, maxResultBytes: 8_192, maxExactReadBytes: 32_768, maxSearchBytes: 8_192, maxSearchMatches: 12, }); export const DEFAULT_CONTEXT_GATEWAY_ACCOUNTING_LOG: Readonly = Object.freeze({ enabled: true, maxBytes: 5 * 1024 * 1024, maxBackups: 3, }); export function contextGatewayBudgetForClass( toolClass: ContextGatewayToolClass, budgets: ContextGatewayBudgets, ): number { if (toolClass === "code-read") return budgets.maxExactReadBytes; if (["repo-search", "repo-ast", "repo-structure", "ast-grep"].includes(toolClass)) { return budgets.maxSearchBytes; } return budgets.maxResultBytes; } const MODES = new Set(["off", "observe", "enforce"]); const MAX_BYTE_BUDGET = 64 * 1024 * 1024; const MAX_SEARCH_MATCHES = 1_000; function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function readJsonc(filePath: string): Record { if (!existsSync(filePath)) return {}; try { const parsed = parseJsonc(readFileSync(filePath, "utf8")); return isRecord(parsed) ? parsed : {}; } catch { return {}; } } function findProjectConfig(startDir: string): string | undefined { let dir = resolve(startDir); const root = parse(dir).root; while (true) { const candidate = join(dir, ".pi", "pi-tools-suite.jsonc"); if (existsSync(candidate)) return candidate; if (dir === root) return undefined; const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } function parseMode(value: unknown): ContextGatewayMode | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase() as ContextGatewayMode; return MODES.has(normalized) ? normalized : undefined; } function parseBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } function parseBooleanEnv(value: string): boolean | undefined { const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) return true; if (["0", "false", "no", "off"].includes(normalized)) return false; return undefined; } function parsePositiveIntegerEnv(value: string): number | undefined { const normalized = value.trim(); if (!/^\d+$/.test(normalized)) return undefined; const parsed = Number(normalized); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; } function mergeBudget( budgets: ContextGatewayBudgets, issues: string[], key: keyof ContextGatewayBudgets, value: unknown, ): void { if (value === undefined) return; const maximum = key === "maxSearchMatches" ? MAX_SEARCH_MATCHES : MAX_BYTE_BUDGET; if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > maximum) { issues.push(`contextGateway.budgets.${key} must be an integer in 1..${maximum}; keeping ${budgets[key]}.`); return; } budgets[key] = value; } export function loadContextGatewayConfig( cwd: string = process.cwd(), env: NodeJS.ProcessEnv = process.env, homeDir: string = env.HOME ?? process.env.HOME ?? "", ): ContextGatewayResolvedConfig { let mode: ContextGatewayMode = "off"; const budgets: ContextGatewayBudgets = { ...DEFAULT_CONTEXT_GATEWAY_BUDGETS }; const accountingLog: ContextGatewayAccountingLogConfig = { ...DEFAULT_CONTEXT_GATEWAY_ACCOUNTING_LOG }; const issues: string[] = []; const layers = [getPiToolsSuiteUserConfigPath(homeDir)]; if (env.PI_CONFIG_DIR) layers.push(join(env.PI_CONFIG_DIR, "pi-tools-suite.jsonc")); const projectConfig = findProjectConfig(cwd); if (projectConfig) layers.push(projectConfig); for (const filePath of layers) { const section = readJsonc(filePath).contextGateway; if (!isRecord(section)) continue; if (Object.prototype.hasOwnProperty.call(section, "mode")) { const nextMode = parseMode(section.mode); if (nextMode) mode = nextMode; else issues.push(`contextGateway.mode must be off, observe, or enforce; keeping ${mode}.`); } if (isRecord(section.budgets)) { for (const key of Object.keys(DEFAULT_CONTEXT_GATEWAY_BUDGETS) as Array) { mergeBudget(budgets, issues, key, section.budgets[key]); } } if (isRecord(section.accountingLog)) { if (Object.prototype.hasOwnProperty.call(section.accountingLog, "enabled")) { const enabled = parseBoolean(section.accountingLog.enabled); if (enabled !== undefined) accountingLog.enabled = enabled; else issues.push(`contextGateway.accountingLog.enabled must be boolean; keeping ${accountingLog.enabled}.`); } const maxBytes = section.accountingLog.maxBytes; if (maxBytes !== undefined) { if (typeof maxBytes === "number" && Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= MAX_BYTE_BUDGET) { accountingLog.maxBytes = maxBytes; } else { issues.push(`contextGateway.accountingLog.maxBytes must be an integer in 1..${MAX_BYTE_BUDGET}; keeping ${accountingLog.maxBytes}.`); } } const maxBackups = section.accountingLog.maxBackups; if (maxBackups !== undefined) { if (typeof maxBackups === "number" && Number.isSafeInteger(maxBackups) && maxBackups >= 1 && maxBackups <= 100) { accountingLog.maxBackups = maxBackups; } else { issues.push(`contextGateway.accountingLog.maxBackups must be an integer in 1..100; keeping ${accountingLog.maxBackups}.`); } } } } if (env.PI_CONTEXT_GATEWAY_MODE !== undefined) { const envMode = parseMode(env.PI_CONTEXT_GATEWAY_MODE); if (envMode) mode = envMode; else issues.push("PI_CONTEXT_GATEWAY_MODE must be off, observe, or enforce; ignoring it."); } if (env.PI_CONTEXT_GATEWAY_ACCOUNTING_LOG_ENABLED !== undefined) { const enabled = parseBooleanEnv(env.PI_CONTEXT_GATEWAY_ACCOUNTING_LOG_ENABLED); if (enabled !== undefined) accountingLog.enabled = enabled; else issues.push("PI_CONTEXT_GATEWAY_ACCOUNTING_LOG_ENABLED must be true or false; ignoring it."); } if (env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BYTES !== undefined) { const maxBytes = parsePositiveIntegerEnv(env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BYTES); if (maxBytes !== undefined && maxBytes <= MAX_BYTE_BUDGET) accountingLog.maxBytes = maxBytes; else issues.push(`PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BYTES must be an integer in 1..${MAX_BYTE_BUDGET}; ignoring it.`); } if (env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BACKUPS !== undefined) { const maxBackups = parsePositiveIntegerEnv(env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BACKUPS); if (maxBackups !== undefined && maxBackups <= 100) accountingLog.maxBackups = maxBackups; else issues.push("PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BACKUPS must be an integer in 1..100; ignoring it."); } return { mode, budgets, accountingLog, issues }; }