import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path"; import { getAgentDir, SettingsManager, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { DEFAULT_ARTIFACT_DIRECTORY, DEFAULT_SYNTHESIS_EFFORT, DEFAULT_SYNTHESIS_MODEL, } from "./constants.js"; export type SynthesisEffort = | "inherit" | "minimal" | "low" | "medium" | "high" | "xhigh"; export interface ContinuityConfig { enabled: boolean; synthesisModel: string; synthesisEffort: SynthesisEffort; artifactDirectory: string; } export interface ResolvedContinuityConfig extends ContinuityConfig { configPath: string; artifactDirectoryPath: string; trusted: boolean; valid: boolean; disabledReason?: string; errors: string[]; } export interface NativeCompactionSettings { enabled: boolean; reserveTokens: number; keepRecentTokens: number; } export interface NativeCompactionStatus extends NativeCompactionSettings { settingsPath: string; errors: string[]; } export interface NativeCompactionView extends NativeCompactionSettings { contextWindow: number; triggerTokens: number; effectiveTriggerPercent: number; effectiveKeepRecentPercent: number; valid: boolean; errors: string[]; } export interface NativeCompactionSetup { triggerPercent: number; keepRecentPercent: number; reserveTokens: number; keepRecentTokens: number; triggerTokens: number; contextWindow: number; } export const DEFAULT_CONFIG: ContinuityConfig = { enabled: true, synthesisModel: DEFAULT_SYNTHESIS_MODEL, synthesisEffort: DEFAULT_SYNTHESIS_EFFORT as SynthesisEffort, artifactDirectory: DEFAULT_ARTIFACT_DIRECTORY, }; export function publicConfigFromResolved( config: ResolvedContinuityConfig, ): ContinuityConfig { return { enabled: config.enabled, synthesisModel: config.synthesisModel, synthesisEffort: config.synthesisEffort, artifactDirectory: config.artifactDirectory, }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } const SYNTHESIS_EFFORTS = new Set([ "inherit", "minimal", "low", "medium", "high", "xhigh", ]); export function isSynthesisEffort(value: string): value is SynthesisEffort { return SYNTHESIS_EFFORTS.has(value as SynthesisEffort); } export function validateConfig( raw: unknown, configPath: string, ): { config?: ContinuityConfig; errors: string[] } { const errors: string[] = []; if (!isRecord(raw)) { return { errors: [`${configPath}: configuration must be a JSON object`] }; } const config: ContinuityConfig = { ...DEFAULT_CONFIG }; if (raw.enabled !== undefined) { if (typeof raw.enabled !== "boolean") errors.push("enabled must be a boolean"); else config.enabled = raw.enabled; } if (raw.triggerAtPercent !== undefined) { errors.push( "triggerAtPercent is no longer a runtime setting; use /continuity settings to configure native reserveTokens", ); } if (raw.keepRecentPercent !== undefined) { errors.push( "keepRecentPercent is no longer a runtime setting; use /continuity settings to configure native keepRecentTokens", ); } if (raw.synthesisModel !== undefined) { if ( typeof raw.synthesisModel !== "string" || raw.synthesisModel.trim() === "" ) { errors.push( 'synthesisModel must be "inherit" or a provider/model string', ); } else { config.synthesisModel = raw.synthesisModel.trim(); } } if (raw.synthesisEffort !== undefined) { const effort = typeof raw.synthesisEffort === "string" ? raw.synthesisEffort.trim() : ""; if (!isSynthesisEffort(effort)) { errors.push( 'synthesisEffort must be one of "inherit", "minimal", "low", "medium", "high", or "xhigh"', ); } else { config.synthesisEffort = effort; } } if (raw.artifactDirectory !== undefined) { if ( typeof raw.artifactDirectory !== "string" || raw.artifactDirectory.trim() === "" ) { errors.push("artifactDirectory must be a non-empty string"); } else { config.artifactDirectory = raw.artifactDirectory.trim(); } } if ( config.synthesisModel !== "inherit" && !config.synthesisModel.includes("/") ) { errors.push( 'synthesisModel must be "inherit" or a concrete provider/model id', ); } return errors.length > 0 ? { errors } : { config, errors: [] }; } export function resolveArtifactDirectoryPath( configRoot: string, artifactDirectory: string, configPath: string, ): { artifactDirectoryPath?: string; errors: string[] } { if (isAbsolute(artifactDirectory)) { return { artifactDirectoryPath: artifactDirectory, errors: [] }; } const artifactDirectoryPath = resolve(configRoot, artifactDirectory); const relativeArtifactPath = relative(configRoot, artifactDirectoryPath); if ( relativeArtifactPath === ".." || relativeArtifactPath.startsWith("../") || isAbsolute(relativeArtifactPath) ) { return { errors: [ `${configPath}: artifactDirectory must resolve under ${configRoot} unless absolute`, ], }; } return { artifactDirectoryPath, errors: [] }; } export function validateConfigDraftForPath( config: ContinuityConfig, configPath: string, ): string[] { const validation = validateConfig(config, configPath); if (!validation.config) return validation.errors; return resolveArtifactDirectoryPath( dirname(configPath), validation.config.artifactDirectory, configPath, ).errors; } async function syncDirectory(path: string): Promise { const handle = await open(path, "r"); try { await handle.sync(); } finally { await handle.close(); } } async function writeJsonAtomically( path: string, value: Record, ): Promise { const directory = dirname(path); await mkdir(directory, { recursive: true }); const temporaryPath = join( directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, ); let handle; try { handle = await open(temporaryPath, "wx", 0o600); await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8"); await handle.sync(); await handle.close(); handle = undefined; await rename(temporaryPath, path); await syncDirectory(directory); } finally { if (handle) await handle.close().catch(() => undefined); await rm(temporaryPath, { force: true }).catch(() => undefined); } } export async function writeConfigToDisk( configPath: string, config: ContinuityConfig, ): Promise { const errors = validateConfigDraftForPath(config, configPath); if (errors.length > 0) throw new Error(errors.join("; ")); await writeJsonAtomically( configPath, config as unknown as Record, ); } export async function loadConfigFromDisk( cwd: string, configDirName: string, trusted: boolean, ): Promise { const configPath = join(cwd, configDirName, "session-continuity.json"); const defaultArtifactPath = resolve( cwd, configDirName, DEFAULT_ARTIFACT_DIRECTORY, ); if (!trusted) { return { ...DEFAULT_CONFIG, configPath, artifactDirectoryPath: defaultArtifactPath, trusted, valid: false, enabled: false, disabledReason: "project is not trusted", errors: [ `Project-local config is ignored until the project is trusted: ${configPath}`, ], }; } let raw: unknown = DEFAULT_CONFIG; if (existsSync(configPath)) { try { raw = JSON.parse(await readFile(configPath, "utf8")); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ...DEFAULT_CONFIG, configPath, artifactDirectoryPath: defaultArtifactPath, trusted, valid: false, enabled: false, disabledReason: `invalid configuration in ${configPath}`, errors: [`${configPath}: ${message}`], }; } } const validation = validateConfig(raw, configPath); if (!validation.config) { return { ...DEFAULT_CONFIG, configPath, artifactDirectoryPath: defaultArtifactPath, trusted, valid: false, enabled: false, disabledReason: `invalid configuration in ${configPath}`, errors: validation.errors, }; } const resolvedArtifactDirectory = resolveArtifactDirectoryPath( resolve(cwd, configDirName), validation.config.artifactDirectory, configPath, ); if (!resolvedArtifactDirectory.artifactDirectoryPath) { return { ...validation.config, configPath, artifactDirectoryPath: defaultArtifactPath, trusted, valid: false, enabled: false, disabledReason: `invalid configuration in ${configPath}`, errors: resolvedArtifactDirectory.errors, }; } return { ...validation.config, configPath, artifactDirectoryPath: resolvedArtifactDirectory.artifactDirectoryPath, trusted, valid: true, errors: [], }; } export async function loadConfig( ctx: ExtensionContext, configDirName: string, ): Promise { return loadConfigFromDisk(ctx.cwd, configDirName, ctx.isProjectTrusted()); } export function detectNativeCompaction( cwd: string, configDirName: string, projectTrusted: boolean, agentDir = getAgentDir(), ): NativeCompactionStatus { const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted, }); const settings = settingsManager.getCompactionSettings(); return { ...settings, settingsPath: join(cwd, configDirName, "settings.json"), errors: settingsManager .drainErrors() .map(({ scope, error }) => `${scope}: ${error.message}`), }; } export function deriveNativeCompactionSetup( contextWindow: number, triggerPercent: number, keepRecentPercent: number, ): NativeCompactionSetup { if (!Number.isFinite(contextWindow) || contextWindow <= 0) throw new Error("active model context window is unavailable"); if ( !Number.isFinite(triggerPercent) || triggerPercent <= 0 || triggerPercent >= 100 ) { throw new Error("trigger percentage must be positive and below 100"); } if ( !Number.isFinite(keepRecentPercent) || keepRecentPercent <= 0 || keepRecentPercent >= triggerPercent ) { throw new Error( "keep-recent percentage must be positive and lower than trigger percentage", ); } const triggerTokens = Math.round((contextWindow * triggerPercent) / 100); const reserveTokens = contextWindow - triggerTokens; const keepRecentTokens = Math.round( (contextWindow * keepRecentPercent) / 100, ); return { triggerPercent, keepRecentPercent, reserveTokens, keepRecentTokens, triggerTokens, contextWindow, }; } export function describeNativeCompaction( settings: NativeCompactionSettings, contextWindow: number, ): NativeCompactionView { const errors: string[] = []; if (!settings.enabled) errors.push("native Pi auto-compaction is disabled"); if (!Number.isFinite(contextWindow) || contextWindow <= 0) errors.push("active model context window is unavailable"); if (!Number.isFinite(settings.reserveTokens) || settings.reserveTokens <= 0) errors.push("reserveTokens must be a positive finite number"); if ( !Number.isFinite(settings.keepRecentTokens) || settings.keepRecentTokens <= 0 ) errors.push("keepRecentTokens must be a positive finite number"); if (contextWindow > 0 && settings.reserveTokens >= contextWindow) errors.push("reserveTokens must be lower than the active context window"); const triggerTokens = contextWindow - settings.reserveTokens; if (triggerTokens > 0 && settings.keepRecentTokens >= triggerTokens) { errors.push( "keepRecentTokens must be lower than contextWindow - reserveTokens", ); } return { ...settings, contextWindow, triggerTokens, effectiveTriggerPercent: contextWindow > 0 ? (triggerTokens / contextWindow) * 100 : 0, effectiveKeepRecentPercent: contextWindow > 0 ? (settings.keepRecentTokens / contextWindow) * 100 : 0, valid: errors.length === 0, errors, }; } export async function writeProjectNativeCompactionSettings( cwd: string, configDirName: string, settings: Pick< NativeCompactionSettings, "reserveTokens" | "keepRecentTokens" >, ): Promise { const settingsPath = join(cwd, configDirName, "settings.json"); let existing: Record = {}; if (existsSync(settingsPath)) { const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error(`${settingsPath}: Pi settings must be a JSON object`); existing = parsed; } const previousCompaction = existing.compaction; if (previousCompaction !== undefined && !isRecord(previousCompaction)) { throw new Error(`${settingsPath}: compaction must be a JSON object`); } await writeJsonAtomically(settingsPath, { ...existing, compaction: { ...(isRecord(previousCompaction) ? previousCompaction : {}), enabled: true, reserveTokens: settings.reserveTokens, keepRecentTokens: settings.keepRecentTokens, }, }); return settingsPath; }