import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; export interface TodoObserverConfig { provider: string; model: string; thinkingLevel: ThinkingLevel; observer: { maxTodos: number; bootstrapOnStart: boolean; publishRetries: number; apiRetries: number; compactionEnabled: boolean; additionalInstructions: string; }; sidebar: { width: number; minTerminalWidth: number; maxHeight: number | `${number}%`; showOnStart: boolean; }; context: { maxTurnChars: number; maxBootstrapChars: number; maxContentChars: number; maxToolArgsChars: number; includeToolResults: boolean; includeExtensionMessages: boolean; }; } export interface LoadedTodoObserverConfig { config: TodoObserverConfig; warnings: string[]; globalPath: string; projectPath?: string; } export const DEFAULT_CONFIG: TodoObserverConfig = { provider: "openai-codex", model: "gpt-5.6-luna", thinkingLevel: "medium", observer: { maxTodos: 100, bootstrapOnStart: true, publishRetries: 1, apiRetries: 2, compactionEnabled: true, additionalInstructions: "", }, sidebar: { width: 46, minTerminalWidth: 90, maxHeight: "100%", showOnStart: true, }, context: { maxTurnChars: 60_000, maxBootstrapChars: 100_000, maxContentChars: 16_000, maxToolArgsChars: 4_000, includeToolResults: true, includeExtensionMessages: false, }, }; const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function mergeObjects(base: Record, override: Record): Record { const result: Record = { ...base }; for (const [key, value] of Object.entries(override)) { if (isObject(value) && isObject(result[key])) result[key] = mergeObjects(result[key], value); else result[key] = value; } return result; } function readJson(filePath: string, warnings: string[]): Record | undefined { if (!fs.existsSync(filePath)) return undefined; try { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; if (!isObject(parsed)) { warnings.push(`${filePath}: root value must be a JSON object; ignoring it`); return undefined; } return parsed; } catch (error) { const message = error instanceof Error ? error.message : String(error); warnings.push(`${filePath}: ${message}`); return undefined; } } function stringValue(value: unknown, fallback: string, field: string, warnings: string[]): string { if (typeof value === "string" && value.trim()) return value.trim(); warnings.push(`${field} must be a non-empty string; using ${JSON.stringify(fallback)}`); return fallback; } function booleanValue(value: unknown, fallback: boolean, field: string, warnings: string[]): boolean { if (typeof value === "boolean") return value; warnings.push(`${field} must be a boolean; using ${fallback}`); return fallback; } function integerValue( value: unknown, fallback: number, min: number, max: number, field: string, warnings: string[], ): number { if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max) return value; warnings.push(`${field} must be an integer from ${min} to ${max}; using ${fallback}`); return fallback; } function maxHeightValue(value: unknown, warnings: string[]): number | `${number}%` { if (typeof value === "number" && Number.isInteger(value) && value >= 3 && value <= 1000) return value; if (typeof value === "string") { const match = value.match(/^(\d+(?:\.\d+)?)%$/); if (match) { const percent = Number(match[1]); if (percent >= 1 && percent <= 100) return value as `${number}%`; } } warnings.push(`sidebar.maxHeight must be 3-1000 rows or a percentage from 1%-100%; using "100%"`); return DEFAULT_CONFIG.sidebar.maxHeight; } function normalizeConfig(raw: Record, warnings: string[]): TodoObserverConfig { const observer = isObject(raw.observer) ? raw.observer : {}; const sidebar = isObject(raw.sidebar) ? raw.sidebar : {}; const context = isObject(raw.context) ? raw.context : {}; const thinkingLevel = THINKING_LEVELS.has(raw.thinkingLevel as ThinkingLevel) ? (raw.thinkingLevel as ThinkingLevel) : DEFAULT_CONFIG.thinkingLevel; if (!THINKING_LEVELS.has(raw.thinkingLevel as ThinkingLevel)) { warnings.push(`thinkingLevel must be off, minimal, low, medium, high, xhigh, or max; using "medium"`); } return { provider: stringValue(raw.provider, DEFAULT_CONFIG.provider, "provider", warnings), model: stringValue(raw.model, DEFAULT_CONFIG.model, "model", warnings), thinkingLevel, observer: { maxTodos: integerValue(observer.maxTodos, DEFAULT_CONFIG.observer.maxTodos, 1, 500, "observer.maxTodos", warnings), bootstrapOnStart: booleanValue( observer.bootstrapOnStart, DEFAULT_CONFIG.observer.bootstrapOnStart, "observer.bootstrapOnStart", warnings, ), publishRetries: integerValue( observer.publishRetries, DEFAULT_CONFIG.observer.publishRetries, 0, 3, "observer.publishRetries", warnings, ), apiRetries: integerValue(observer.apiRetries, DEFAULT_CONFIG.observer.apiRetries, 0, 10, "observer.apiRetries", warnings), compactionEnabled: booleanValue( observer.compactionEnabled, DEFAULT_CONFIG.observer.compactionEnabled, "observer.compactionEnabled", warnings, ), additionalInstructions: typeof observer.additionalInstructions === "string" ? observer.additionalInstructions.trim() : DEFAULT_CONFIG.observer.additionalInstructions, }, sidebar: { width: integerValue(sidebar.width, DEFAULT_CONFIG.sidebar.width, 24, 120, "sidebar.width", warnings), minTerminalWidth: integerValue( sidebar.minTerminalWidth, DEFAULT_CONFIG.sidebar.minTerminalWidth, 40, 500, "sidebar.minTerminalWidth", warnings, ), maxHeight: maxHeightValue(sidebar.maxHeight, warnings), showOnStart: booleanValue(sidebar.showOnStart, DEFAULT_CONFIG.sidebar.showOnStart, "sidebar.showOnStart", warnings), }, context: { maxTurnChars: integerValue( context.maxTurnChars, DEFAULT_CONFIG.context.maxTurnChars, 1000, 500_000, "context.maxTurnChars", warnings, ), maxBootstrapChars: integerValue( context.maxBootstrapChars, DEFAULT_CONFIG.context.maxBootstrapChars, 1000, 1_000_000, "context.maxBootstrapChars", warnings, ), maxContentChars: integerValue( context.maxContentChars, DEFAULT_CONFIG.context.maxContentChars, 500, 200_000, "context.maxContentChars", warnings, ), maxToolArgsChars: integerValue( context.maxToolArgsChars, DEFAULT_CONFIG.context.maxToolArgsChars, 200, 50_000, "context.maxToolArgsChars", warnings, ), includeToolResults: booleanValue( context.includeToolResults, DEFAULT_CONFIG.context.includeToolResults, "context.includeToolResults", warnings, ), includeExtensionMessages: booleanValue( context.includeExtensionMessages, DEFAULT_CONFIG.context.includeExtensionMessages, "context.includeExtensionMessages", warnings, ), }, }; } export function loadTodoObserverConfig(cwd: string, projectTrusted: boolean): LoadedTodoObserverConfig { const warnings: string[] = []; const globalPath = path.join(getAgentDir(), "todo-observer.json"); const projectPath = projectTrusted ? path.join(cwd, CONFIG_DIR_NAME, "todo-observer.json") : undefined; let merged = DEFAULT_CONFIG as unknown as Record; const globalConfig = readJson(globalPath, warnings); if (globalConfig) merged = mergeObjects(merged, globalConfig); if (projectPath) { const projectConfig = readJson(projectPath, warnings); if (projectConfig) merged = mergeObjects(merged, projectConfig); } return { config: normalizeConfig(merged, warnings), warnings, globalPath, projectPath }; }