export interface SessionCompactionPolicy { enabled: boolean; maxSessionRuns: number; maxRawInputTokens: number; maxSessionAgeHours: number; } export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; export interface AdapterSessionManagement { supportsSessionResume: boolean; nativeContextManagement: NativeContextManagement; defaultSessionCompaction: SessionCompactionPolicy; } export interface ResolvedSessionCompactionPolicy { policy: SessionCompactionPolicy; adapterSessionManagement: AdapterSessionManagement | null; explicitOverride: Partial; source: "adapter_default" | "agent_override" | "legacy_fallback"; } const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { enabled: true, maxSessionRuns: 200, maxRawInputTokens: 2_000_000, maxSessionAgeHours: 72, }; // Adapters with native context management still participate in session resume, // but Paperclip should not rotate them using threshold-based compaction. const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { enabled: true, maxSessionRuns: 0, maxRawInputTokens: 0, maxSessionAgeHours: 0, }; export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ "claude_local", "codex_local", "cursor", "gemini_local", "opencode_local", "pi_local", ]); export const ADAPTER_SESSION_MANAGEMENT: Record = { claude_local: { supportsSessionResume: true, nativeContextManagement: "confirmed", defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, }, codex_local: { supportsSessionResume: true, nativeContextManagement: "confirmed", defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, }, cursor: { supportsSessionResume: true, nativeContextManagement: "unknown", defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, }, gemini_local: { supportsSessionResume: true, nativeContextManagement: "unknown", defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, }, opencode_local: { supportsSessionResume: true, nativeContextManagement: "unknown", defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, }, pi_local: { supportsSessionResume: true, nativeContextManagement: "unknown", defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, }, }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function readBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "number") { if (value === 1) return true; if (value === 0) return false; return undefined; } if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { return true; } if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { return false; } return undefined; } function readNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } if (typeof value !== "string") return undefined; const parsed = Number(value.trim()); return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined; } export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null { if (!adapterType) return null; return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null; } export function readSessionCompactionOverride(runtimeConfig: unknown): Partial { const runtime = isRecord(runtimeConfig) ? runtimeConfig : {}; const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {}; const compaction = isRecord( heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction, ) ? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record : {}; const explicit: Partial = {}; const enabled = readBoolean(compaction.enabled); const maxSessionRuns = readNumber(compaction.maxSessionRuns); const maxRawInputTokens = readNumber(compaction.maxRawInputTokens); const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours); if (enabled !== undefined) explicit.enabled = enabled; if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns; if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens; if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours; return explicit; } export function resolveSessionCompactionPolicy( adapterType: string | null | undefined, runtimeConfig: unknown, ): ResolvedSessionCompactionPolicy { const adapterSessionManagement = getAdapterSessionManagement(adapterType); const explicitOverride = readSessionCompactionOverride(runtimeConfig); const hasExplicitOverride = Object.keys(explicitOverride).length > 0; const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType)); const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? { ...DEFAULT_SESSION_COMPACTION_POLICY, enabled: fallbackEnabled, }; return { policy: { enabled: explicitOverride.enabled ?? basePolicy.enabled, maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns, maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens, maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours, }, adapterSessionManagement, explicitOverride, source: hasExplicitOverride ? "agent_override" : adapterSessionManagement ? "adapter_default" : "legacy_fallback", }; } export function hasSessionCompactionThresholds(policy: Pick< SessionCompactionPolicy, "maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours" >) { return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; }