import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; export interface ConfiguredModel { provider: string; id: string; thinking?: ModelThinkingLevel; } /** * How `compactAfterTokens` is interpreted. * * - `"calibrated"` (default): use the static `compactAfterTokens` value directly. * Backwards-compatible with all existing V3 configs. * * - `"ratio"`: compute the effective threshold as * `floor(model.contextWindow * compactAfterTokensRatio)`. This auto-scales the * proactive compaction trigger to the active model's context window, so a 1M * context model is not preempted at the same 81K threshold as a 128K model. * * Some models advertise a large context window but lose attention at long * range; users can lower `compactAfterTokensRatio` to compact earlier on such * models without giving up the window on models that stay sharp. * * When the active model's `contextWindow` is unavailable (undefined, 0, or * negative), ratio mode falls back to the calibrated `compactAfterTokens` * value so compaction still triggers safely. */ export type CompactAfterTokensMode = "calibrated" | "ratio"; export interface Config { observeAfterTokens: number; reflectAfterTokens: number; compactAfterTokens: number; compactAfterTokensMode: CompactAfterTokensMode; compactAfterTokensRatio: number; observationsPoolMaxTokens: number; observationsPoolTargetTokens: number; agentMaxTurns: number; model?: ConfiguredModel; passive: boolean; debugLog: boolean; } export const DEFAULTS: Config = { observeAfterTokens: 10_000, reflectAfterTokens: 20_000, compactAfterTokens: 81_000, compactAfterTokensMode: "calibrated", compactAfterTokensRatio: 0.68, observationsPoolMaxTokens: 20_000, observationsPoolTargetTokens: 10_000, agentMaxTurns: 16, passive: false, debugLog: false, }; export const COMPACT_AFTER_TOKENS_MODE_VALUES: readonly CompactAfterTokensMode[] = ["calibrated", "ratio"] as const; /** * Resolve the effective proactive-compaction token threshold for the given * config and active model context window. * * In `"calibrated"` mode this is always `config.compactAfterTokens`. * * In `"ratio"` mode this is `floor(contextWindow * compactAfterTokensRatio)` * (clamped to a minimum of 1) when `contextWindow` is a positive number, and * falls back to `config.compactAfterTokens` otherwise. */ export function resolveCompactAfterTokens(config: Config, contextWindow: number | undefined): number { if (config.compactAfterTokensMode === "ratio" && typeof contextWindow === "number" && contextWindow > 0) { return Math.max(1, Math.floor(contextWindow * config.compactAfterTokensRatio)); } return config.compactAfterTokens; } export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; const SETTINGS_KEY = "observational-memory"; const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE"; function positiveIntegerOrUndefined(value: unknown): number | undefined { return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined; } function validTargetOrUndefined(value: unknown, maxTokens: number): number | undefined { const target = positiveIntegerOrUndefined(value); return target !== undefined && target < maxTokens ? target : undefined; } function derivedObservationPoolTarget(maxTokens: number): number { return Math.floor(maxTokens / 2); } function isThinkingLevel(value: unknown): value is ModelThinkingLevel { return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value); } function isCompactAfterTokensMode(value: unknown): value is CompactAfterTokensMode { return typeof value === "string" && (COMPACT_AFTER_TOKENS_MODE_VALUES as readonly string[]).includes(value); } /** * A valid ratio is a finite number strictly between 0 and 1. * 0 would never trigger; >= 1 would compact at/after the full window with no * room left for the response. */ function validRatioOrUndefined(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 && value < 1 ? value : undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function normalizeModel(value: unknown): ConfiguredModel | undefined { if (!isRecord(value)) return undefined; const provider = nonEmptyString(value.provider); const id = nonEmptyString(value.id); if (!provider || !id) return undefined; const model: ConfiguredModel = { provider, id }; if (isThinkingLevel(value.thinking)) model.thinking = value.thinking; return model; } function normalizeSettingsConfig(value: Record): Partial { const normalized: Partial = {}; const numberKeys = [ "observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "observationsPoolTargetTokens", "agentMaxTurns", ] as const; for (const key of numberKeys) { const normalizedValue = positiveIntegerOrUndefined(value[key]); if (normalizedValue !== undefined) normalized[key] = normalizedValue; } if (isCompactAfterTokensMode(value.compactAfterTokensMode)) { normalized.compactAfterTokensMode = value.compactAfterTokensMode; } const ratio = validRatioOrUndefined(value.compactAfterTokensRatio); if (ratio !== undefined) normalized.compactAfterTokensRatio = ratio; if (typeof value.passive === "boolean") normalized.passive = value.passive; if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog; const model = normalizeModel(value.model); if (model) normalized.model = model; return normalized; } export function readEnvConfig(env: NodeJS.ProcessEnv = process.env): Partial { const rawPassive = env[PASSIVE_ENV]; if (rawPassive === undefined) return {}; const passive = rawPassive.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(passive)) return { passive: true }; if (["0", "false", "no", "off"].includes(passive)) return { passive: false }; return {}; } function readNamespacedConfig(path: string): Partial { if (!existsSync(path)) return {}; try { const raw = JSON.parse(readFileSync(path, "utf-8")) as Record; const nested = raw[SETTINGS_KEY]; return isRecord(nested) ? normalizeSettingsConfig(nested) : {}; } catch { return {}; } } export function loadConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): Config { const globalPath = join(getAgentDir(), "settings.json"); const projectPath = join(cwd, ".pi", "settings.json"); const globalConfig = readNamespacedConfig(globalPath); const projectConfig = readNamespacedConfig(projectPath); const envConfig = readEnvConfig(env); const merged = { ...DEFAULTS, observationsPoolTargetTokens: undefined, ...globalConfig, ...projectConfig, ...envConfig, }; const target = validTargetOrUndefined( merged.observationsPoolTargetTokens, merged.observationsPoolMaxTokens, ) ?? derivedObservationPoolTarget(merged.observationsPoolMaxTokens); return { ...merged, observationsPoolTargetTokens: target, }; }