import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import type { PiWebConfigFile, PiWebProvider, ResolvedPiWebConfig, SearchContextSize, WebSearchLocation, WebSearchMode, } from "./types.ts"; export const CONFIG_FILENAME = "pi-web.json"; export const DEFAULT_SEARCH_MODE: WebSearchMode = "cached"; export const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; export const DEFAULT_TIMEOUT_MS = 300_000; const CONFIG_KEYS = new Set([ "provider", "model", "summary_provider", "summary_model", "exa_api_key", "mode", "search_context_size", "allowed_domains", "user_location", "max_output_tokens", "timeout_ms", ]); const LOCATION_KEYS = new Set(["country", "region", "city", "timezone"]); const PROVIDERS = new Set(["openai-codex", "openai", "exa"]); const MODES = new Set(["disabled", "cached", "indexed", "live"]); const CONTEXT_SIZES = new Set(["low", "medium", "high"]); const DOMAIN_RE = /^(?:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/i; export type ConfigScope = "global" | "project"; export interface ConfigPaths { global: string; project: string; } export interface LoadConfigOptions { cwd: string; projectTrusted: boolean; agentDir?: string; env?: NodeJS.ProcessEnv; } export class PiWebConfigError extends Error { constructor( message: string, readonly kind: "missing" | "invalid" = "invalid", ) { super(message); this.name = "PiWebConfigError"; } } export function getPiWebConfigPaths(cwd: string, agentDir = getAgentDir()): ConfigPaths { return { global: join(agentDir, CONFIG_FILENAME), project: join(cwd, CONFIG_DIR_NAME, CONFIG_FILENAME), }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } async function readOptionalConfig(path: string): Promise { let text: string; try { text = await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new PiWebConfigError(`Cannot read pi-web config ${path}: ${error instanceof Error ? error.message : String(error)}`); } let value: unknown; try { value = JSON.parse(text); } catch (error) { throw new PiWebConfigError(`Invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`); } if (!isRecord(value)) throw new PiWebConfigError(`Config ${path} must contain a JSON object`); const unknownKeys = Object.keys(value).filter((key) => !CONFIG_KEYS.has(key)); if (unknownKeys.length > 0) { throw new PiWebConfigError(`Unknown ${path} field(s): ${unknownKeys.join(", ")}`); } return value as PiWebConfigFile; } function environmentConfig(env: NodeJS.ProcessEnv): PiWebConfigFile | undefined { const config: PiWebConfigFile = {}; if (env.PI_WEB_PROVIDER) config.provider = env.PI_WEB_PROVIDER as PiWebProvider; if (env.PI_WEB_MODEL) config.model = env.PI_WEB_MODEL; if (env.PI_WEB_SUMMARY_PROVIDER) config.summary_provider = env.PI_WEB_SUMMARY_PROVIDER; if (env.PI_WEB_SUMMARY_MODEL) config.summary_model = env.PI_WEB_SUMMARY_MODEL; if (env.EXA_API_KEY) config.exa_api_key = env.EXA_API_KEY; if (env.PI_WEB_SEARCH_MODE) config.mode = env.PI_WEB_SEARCH_MODE as WebSearchMode; if (env.PI_WEB_SEARCH_CONTEXT_SIZE) { config.search_context_size = env.PI_WEB_SEARCH_CONTEXT_SIZE as SearchContextSize; } if (env.PI_WEB_ALLOWED_DOMAINS) { config.allowed_domains = env.PI_WEB_ALLOWED_DOMAINS.split(",").map((domain) => domain.trim()).filter(Boolean); } const location: WebSearchLocation = {}; if (env.PI_WEB_LOCATION_COUNTRY) location.country = env.PI_WEB_LOCATION_COUNTRY; if (env.PI_WEB_LOCATION_REGION) location.region = env.PI_WEB_LOCATION_REGION; if (env.PI_WEB_LOCATION_CITY) location.city = env.PI_WEB_LOCATION_CITY; if (env.PI_WEB_LOCATION_TIMEZONE) location.timezone = env.PI_WEB_LOCATION_TIMEZONE; if (Object.keys(location).length > 0) config.user_location = location; if (env.PI_WEB_MAX_OUTPUT_TOKENS) config.max_output_tokens = Number(env.PI_WEB_MAX_OUTPUT_TOKENS); if (env.PI_WEB_TIMEOUT_MS) config.timeout_ms = Number(env.PI_WEB_TIMEOUT_MS); return Object.keys(config).length > 0 ? config : undefined; } function mergeConfigs(base: PiWebConfigFile, override: PiWebConfigFile): PiWebConfigFile { return { ...base, ...override, user_location: base.user_location || override.user_location ? { ...base.user_location, ...override.user_location } : undefined, }; } function validateLocation(value: unknown): WebSearchLocation | undefined { if (value === undefined) return undefined; if (!isRecord(value)) throw new PiWebConfigError("user_location must be an object"); const unknownKeys = Object.keys(value).filter((key) => !LOCATION_KEYS.has(key)); if (unknownKeys.length > 0) throw new PiWebConfigError(`Unknown user_location field(s): ${unknownKeys.join(", ")}`); const result: WebSearchLocation = {}; for (const key of LOCATION_KEYS) { const item = value[key]; if (item === undefined) continue; if (typeof item !== "string" || item.trim().length === 0) { throw new PiWebConfigError(`user_location.${key} must be a non-empty string`); } result[key as keyof WebSearchLocation] = item.trim(); } return Object.keys(result).length > 0 ? result : undefined; } function validatePositiveInteger(value: unknown, field: string, min: number, max: number): number { if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { throw new PiWebConfigError(`${field} must be an integer from ${min} to ${max}`); } return value as number; } function validateResolvedConfig(config: PiWebConfigFile, source: string): ResolvedPiWebConfig { if (!config.provider) { throw new PiWebConfigError( `pi-web has no configured search provider. Run /web-config or set provider in ${CONFIG_FILENAME}.`, "missing", ); } if (!PROVIDERS.has(config.provider)) { throw new PiWebConfigError(`provider must be one of: ${[...PROVIDERS].join(", ")}`); } const summaryProvider = typeof config.summary_provider === "string" && config.summary_provider.trim() ? config.summary_provider.trim() : undefined; const summaryModel = typeof config.summary_model === "string" && config.summary_model.trim() ? config.summary_model.trim() : undefined; if ((summaryProvider && !summaryModel) || (!summaryProvider && summaryModel)) { throw new PiWebConfigError("summary_provider and summary_model must either both be configured or both be omitted"); } let pipeline: ResolvedPiWebConfig["pipeline"]; let model: string; let exaApiKey: string | undefined; if (config.provider === "exa") { if (typeof config.exa_api_key !== "string" || config.exa_api_key.trim().length === 0) { throw new PiWebConfigError( `pi-web has no Exa API key. Run /web-config or set exa_api_key in ${CONFIG_FILENAME}.`, "missing", ); } pipeline = "exa"; model = "exa-search"; exaApiKey = config.exa_api_key.trim(); } else { if (typeof config.model !== "string" || config.model.trim().length === 0) { throw new PiWebConfigError( `pi-web has no configured standalone search model. Run /web-config or set model in ${CONFIG_FILENAME}.`, "missing", ); } pipeline = summaryProvider && summaryModel ? "openai-summary" : "openai"; model = config.model.trim(); } const mode = config.mode ?? (config.provider === "exa" ? "live" : DEFAULT_SEARCH_MODE); if (!MODES.has(mode)) throw new PiWebConfigError(`mode must be one of: ${[...MODES].join(", ")}`); if (config.search_context_size !== undefined && !CONTEXT_SIZES.has(config.search_context_size)) { throw new PiWebConfigError(`search_context_size must be one of: ${[...CONTEXT_SIZES].join(", ")}`); } let allowedDomains: string[] | undefined; if (config.allowed_domains !== undefined) { if (!Array.isArray(config.allowed_domains)) throw new PiWebConfigError("allowed_domains must be an array"); allowedDomains = [...new Set(config.allowed_domains.map((domain) => { if (typeof domain !== "string" || !DOMAIN_RE.test(domain.trim())) { throw new PiWebConfigError(`Invalid allowed domain: ${String(domain)}`); } return domain.trim().toLowerCase(); }))]; if (allowedDomains.length === 0) allowedDomains = undefined; if (allowedDomains && allowedDomains.length > 100) { throw new PiWebConfigError("allowed_domains accepts at most 100 domains"); } } return { provider: config.provider, pipeline, model, ...(pipeline === "openai-summary" ? { summaryProvider, summaryModel } : {}), ...(exaApiKey ? { exaApiKey } : {}), mode, searchContextSize: config.search_context_size, allowedDomains, userLocation: validateLocation(config.user_location), maxOutputTokens: config.max_output_tokens === undefined ? DEFAULT_MAX_OUTPUT_TOKENS : validatePositiveInteger(config.max_output_tokens, "max_output_tokens", 1, 50_000), timeoutMs: config.timeout_ms === undefined ? DEFAULT_TIMEOUT_MS : validatePositiveInteger(config.timeout_ms, "timeout_ms", 1_000, 600_000), source, }; } export async function loadPiWebConfig(options: LoadConfigOptions): Promise { const paths = getPiWebConfigPaths(options.cwd, options.agentDir); const globalConfig = await readOptionalConfig(paths.global); const projectConfig = options.projectTrusted ? await readOptionalConfig(paths.project) : undefined; const envConfig = environmentConfig(options.env ?? process.env); let merged: PiWebConfigFile = {}; const sources: string[] = []; if (globalConfig) { merged = mergeConfigs(merged, globalConfig); sources.push(paths.global); } if (projectConfig) { merged = mergeConfigs(merged, projectConfig); sources.push(paths.project); } if (envConfig) { merged = mergeConfigs(merged, envConfig); sources.push("environment"); } if (sources.length === 0) { throw new PiWebConfigError( `pi-web is not configured. Run /web-config or create ${paths.global} with an explicit provider and its required fields.`, "missing", ); } return validateResolvedConfig(merged, sources.join(" + ")); } export async function savePiWebConfig( scope: ConfigScope, cwd: string, config: PiWebConfigFile, agentDir = getAgentDir(), ): Promise { const path = getPiWebConfigPaths(cwd, agentDir)[scope]; validateResolvedConfig(config, path); await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); await chmod(path, 0o600); return path; }