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; /** * Maximum estimated source tokens serialized into a single observer chunk. * Unset (default) derives the cap from the resolved memory model's context * window; see {@link resolveObserverChunkMaxTokens}. */ observerChunkMaxTokens?: number; compactAfterTokens: number; compactAfterTokensMode: CompactAfterTokensMode; compactAfterTokensRatio: number; /** Token budget for the protected newest-memory suffix; newest record always fits whole. */ newMemoryPoolMaxTokens: number; /** Advisory token target for older summarizer-eligible memory. */ oldMemoryPoolTargetTokens: number; agentMaxTurns: number; /** Legacy shared fallback for observer/summarizer model selection. */ model?: ConfiguredModel; /** Optional model override used only by the observer. */ observerModel?: ConfiguredModel; /** Optional model override used only by the summarizer. */ summarizerModel?: ConfiguredModel; showWorkerNotifications: boolean; passive: boolean; /** Run the asynchronous observer when a compaction begins. */ compactionObserverEnabled: boolean; contemplatorEnabled: boolean; contemplatorModel?: ConfiguredModel; /** Show contemplator probes and review notices in the chat transcript. */ showContemplatorMessages: boolean; /** Allow the contemplator to commission scoped structural reviewers. */ reviewerEnabled: boolean; /** Optional model override used only by short-lived structural reviewers. */ reviewerModel?: ConfiguredModel; contemplatorMinNewObservations: number; /** Minimum primary-model responses after contemplator completion, or after delivery of its probe, before the next run. */ contemplatorMinTurns: number; /** Stateless loss-aware summarizer for the old memory pool. */ summarizerEnabled: boolean; /** Additional old-pool tokens required before retrying an above-target pool. */ summarizerRetriggerTokens: number; /** Rendered old-memory tokens available before pressure-valve sampling. */ summarizerSamplingThresholdTokens: number; debugLog: boolean; } export const DEFAULTS: Config = { observeAfterTokens: 10_000, compactAfterTokens: 81_000, compactAfterTokensMode: "calibrated", compactAfterTokensRatio: 0.68, newMemoryPoolMaxTokens: 40_000, oldMemoryPoolTargetTokens: 40_000, agentMaxTurns: 16, showWorkerNotifications: true, passive: false, compactionObserverEnabled: true, contemplatorEnabled: true, showContemplatorMessages: true, reviewerEnabled: true, contemplatorMinNewObservations: 8, contemplatorMinTurns: 10, summarizerEnabled: true, summarizerRetriggerTokens: 2_000, summarizerSamplingThresholdTokens: 60_000, 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; /** Observer chunk cap used when no config is set and the model's context window is unknown. */ export const OBSERVER_CHUNK_FALLBACK_MAX_TOKENS = 60_000; /** Smallest useful observer chunk: enough for labels, omission markers, and source context. */ export const OBSERVER_CHUNK_MIN_TOKENS = 256; /** * Fraction of the memory model's context window used for the derived observer * chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount * real tokens substantially on non-ASCII content. The estimator remains * approximate; the remaining 75% of the advertised window accommodates injected memory, the * system prompt, and model output. */ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.25; /** * Resolve the maximum estimated tokens the observer serializes into one chunk. * * An explicit `observerChunkMaxTokens` config value always wins. Otherwise the * cap is `floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO)` for the resolved * memory model, falling back to {@link OBSERVER_CHUNK_FALLBACK_MAX_TOKENS} when * the context window is unavailable. * * Without a cap, a backlog that outgrows the model's context window (e.g. * after repeated observer failures, or when the extension is enabled mid-way * into a long session) makes every observer call fail, so coverage never * advances and the session can never recover. With the cap, oversized backlogs * are drained oldest-first across successive bounded passes. */ export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number { if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) { return Math.max(OBSERVER_CHUNK_MIN_TOKENS, config.observerChunkMaxTokens); } if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { return Math.max( OBSERVER_CHUNK_MIN_TOKENS, Math.floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO), ); } return OBSERVER_CHUNK_FALLBACK_MAX_TOKENS; } 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 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", "observerChunkMaxTokens", "compactAfterTokens", "newMemoryPoolMaxTokens", "oldMemoryPoolTargetTokens", "agentMaxTurns", "contemplatorMinNewObservations", "contemplatorMinTurns", "summarizerRetriggerTokens", "summarizerSamplingThresholdTokens", ] 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.showWorkerNotifications === "boolean") normalized.showWorkerNotifications = value.showWorkerNotifications; if (typeof value.passive === "boolean") normalized.passive = value.passive; if (typeof value.compactionObserverEnabled === "boolean") normalized.compactionObserverEnabled = value.compactionObserverEnabled; if (typeof value.contemplatorEnabled === "boolean") normalized.contemplatorEnabled = value.contemplatorEnabled; if (typeof value.showContemplatorMessages === "boolean") normalized.showContemplatorMessages = value.showContemplatorMessages; if (typeof value.reviewerEnabled === "boolean") normalized.reviewerEnabled = value.reviewerEnabled; if (typeof value.summarizerEnabled === "boolean") normalized.summarizerEnabled = value.summarizerEnabled; if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog; const model = normalizeModel(value.model); if (model) normalized.model = model; const observerModel = normalizeModel(value.observerModel); if (observerModel) normalized.observerModel = observerModel; const summarizerModel = normalizeModel(value.summarizerModel); if (summarizerModel) normalized.summarizerModel = summarizerModel; const contemplatorModel = normalizeModel(value.contemplatorModel); if (contemplatorModel) normalized.contemplatorModel = contemplatorModel; const reviewerModel = normalizeModel(value.reviewerModel); if (reviewerModel) normalized.reviewerModel = reviewerModel; return normalized; } export function readEnvConfig(env: NodeJS.ProcessEnv = process.env): Partial { const result: Partial = {}; const rawPassive = env[PASSIVE_ENV]; if (rawPassive !== undefined) { const passive = rawPassive.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(passive)) result.passive = true; if (["0", "false", "no", "off"].includes(passive)) result.passive = false; } const compactionObserver = env.PI_OBSERVATIONAL_MEMORY_COMPACTION_OBSERVER?.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(compactionObserver ?? "")) result.compactionObserverEnabled = true; if (["0", "false", "no", "off"].includes(compactionObserver ?? "")) result.compactionObserverEnabled = false; return result; } 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); return { ...DEFAULTS, ...globalConfig, ...projectConfig, ...envConfig, }; }