{"version":3,"file":"agent-session-compaction.d.ts","sourceRoot":"","sources":["../../src/core/agent-session-compaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EACX,YAAY,EAEZ,gBAAgB,EAChB,aAAa,EACb,MAAM,iCAAiC,CAAC;AAQzC,OAAO,KAAK,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAEvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,eAAe,EAA8B,MAAM,uBAAuB,CAAC;AACzF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAiC,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE1F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D,6EAA6E;AAC7E,MAAM,WAAW,wBAAwB;IACxC,cAAc,EAAE,cAAc,CAAC;IAC/B,eAAe,EAAE,eAAe,CAAC;IACjC,aAAa,EAAE,aAAa,CAAC;IAC7B,QAAQ,IAAI,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACnC,gBAAgB,IAAI,aAAa,CAAC;IAClC,oEAAoE;IACpE,kBAAkB,IAAI,eAAe,CAAC;IACtC,gBAAgB,IAAI,YAAY,EAAE,CAAC;IACnC,gBAAgB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,IAAI,CAAC;IACjD,sBAAsB,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC,CAAC;IACzG,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACrC,mBAAmB,IAAI,IAAI,CAAC;IAC5B,gBAAgB,IAAI,IAAI,CAAC;IACzB,gFAAgF;IAChF,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,+CAA+C;IAC/C,aAAa,IAAI,IAAI,CAAC;IACtB,iBAAiB,IAAI,OAAO,CAAC;CAC7B;AAED,qBAAa,oBAAoB;IAKpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAJjC,OAAO,CAAC,0BAA0B,CAA0C;IAC5E,OAAO,CAAC,8BAA8B,CAA0C;IAChF,OAAO,CAAC,0BAA0B,CAAS;IAE3C,YAA6B,IAAI,EAAE,wBAAwB,EAAI;IAE/D,6DAA6D;IAC7D,IAAI,YAAY,IAAI,OAAO,CAE1B;IAED,yCAAyC;IACzC,IAAI,qBAAqB,IAAI,OAAO,CAEnC;IAED,sCAAsC;IACtC,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE/C;IAED,4FAA4F;IAC5F,qBAAqB,IAAI,IAAI,CAE5B;IAED,sDAAsD;IACtD,eAAe,IAAI,IAAI,CAGtB;YAWa,gBAAgB;IAuE9B;;;;OAIG;IACG,OAAO,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAkEpE;IAED;;;;;;;;;;OAUG;IACG,eAAe,CAAC,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,UAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA8EhG;YAKa,kBAAkB;CAwGhC","sourcesContent":["/**\n * Compaction controller for AgentSession.\n *\n * Owns manual compaction (the /compact flow) and automatic compaction (overflow\n * recovery and threshold-triggered). It runs the shared apply pipeline — the\n * `session_before_compact` extension hook, summary generation, persistence,\n * agent-context refresh, and the `session_compact` event — and manages the\n * abort controllers and one-shot overflow-recovery guard. Extracted from\n * agent-session.ts behind a narrow CompactionControllerDeps interface.\n */\n\nimport type {\n\tAgentMessage,\n\tCompactionPreparation,\n\tCompactionResult,\n\tThinkingLevel,\n} from \"@kolisachint/hoocode-agent-core\";\nimport {\n\tcalculateContextTokens,\n\tcompact,\n\testimateContextTokens,\n\tprepareCompaction,\n\tshouldCompact,\n} from \"@kolisachint/hoocode-agent-core\";\nimport type { AssistantMessage, Model } from \"@kolisachint/hoocode-ai\";\nimport { isContextOverflow } from \"@kolisachint/hoocode-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.js\";\nimport { formatNoModelSelectedMessage } from \"./auth-guidance.js\";\nimport type { ExtensionRunner, SessionBeforeCompactResult } from \"./extensions/index.js\";\nimport type { ModelRegistry } from \"./model-registry.js\";\nimport type { CompactionEntry, SessionEntry, SessionManager } from \"./session-manager.js\";\nimport { getLatestCompactionEntry } from \"./session-manager.js\";\nimport type { SettingsManager } from \"./settings-manager.js\";\n\n/** Narrow dependencies the compaction controller needs from AgentSession. */\nexport interface CompactionControllerDeps {\n\tsessionManager: SessionManager;\n\tsettingsManager: SettingsManager;\n\tmodelRegistry: ModelRegistry;\n\tgetModel(): Model<any> | undefined;\n\tgetThinkingLevel(): ThinkingLevel;\n\t/** Read at call time; the extension runner is swapped on reload. */\n\tgetExtensionRunner(): ExtensionRunner;\n\tgetAgentMessages(): AgentMessage[];\n\tsetAgentMessages(messages: AgentMessage[]): void;\n\tgetRequiredRequestAuth(model: Model<any>): Promise<{ apiKey: string; headers?: Record<string, string> }>;\n\temit(event: AgentSessionEvent): void;\n\tdisconnectFromAgent(): void;\n\treconnectToAgent(): void;\n\t/** Abort the current agent operation and wait for idle (AgentSession.abort). */\n\tabortSession(): Promise<void>;\n\t/** Fire-and-forget continue() on the agent. */\n\tcontinueAgent(): void;\n\thasQueuedMessages(): boolean;\n}\n\nexport class CompactionController {\n\tprivate _compactionAbortController: AbortController | undefined = undefined;\n\tprivate _autoCompactionAbortController: AbortController | undefined = undefined;\n\tprivate _overflowRecoveryAttempted = false;\n\n\tconstructor(private readonly deps: CompactionControllerDeps) {}\n\n\t/** Whether manual or auto compaction is currently running */\n\tget isCompacting(): boolean {\n\t\treturn this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;\n\t}\n\n\t/** Whether auto-compaction is enabled */\n\tget autoCompactionEnabled(): boolean {\n\t\treturn this.deps.settingsManager.getCompactionEnabled();\n\t}\n\n\t/** Toggle auto-compaction setting. */\n\tsetAutoCompactionEnabled(enabled: boolean): void {\n\t\tthis.deps.settingsManager.setCompactionEnabled(enabled);\n\t}\n\n\t/** Clear the one-shot overflow-recovery guard (on new user input / successful response). */\n\tresetOverflowRecovery(): void {\n\t\tthis._overflowRecoveryAttempted = false;\n\t}\n\n\t/** Cancel in-progress compaction (manual or auto). */\n\tabortCompaction(): void {\n\t\tthis._compactionAbortController?.abort();\n\t\tthis._autoCompactionAbortController?.abort();\n\t}\n\n\t/**\n\t * Shared core for manual and auto compaction.\n\t *\n\t * Runs the `session_before_compact` extension hook, produces the compaction\n\t * (from an extension or by summarizing), persists it, updates agent context,\n\t * and emits `session_compact`. Returns `{ status: \"cancelled\" }` if an\n\t * extension cancels or the signal aborts; callers map that to their own\n\t * cancel handling (manual throws, auto emits).\n\t */\n\tprivate async _applyCompaction(params: {\n\t\tpreparation: CompactionPreparation;\n\t\tbranchEntries: SessionEntry[];\n\t\tmodel: Model<any>;\n\t\tapiKey: string;\n\t\theaders?: Record<string, string>;\n\t\tcustomInstructions?: string;\n\t\tsignal: AbortSignal;\n\t}): Promise<{ status: \"ok\"; result: CompactionResult } | { status: \"cancelled\" }> {\n\t\tconst { preparation, branchEntries, model, apiKey, headers, customInstructions, signal } = params;\n\t\tconst extensionRunner = this.deps.getExtensionRunner();\n\n\t\tlet extensionCompaction: CompactionResult | undefined;\n\t\tlet fromExtension = false;\n\n\t\tif (extensionRunner.hasHandlers(\"session_before_compact\")) {\n\t\t\tconst result = (await extensionRunner.emit({\n\t\t\t\ttype: \"session_before_compact\",\n\t\t\t\tpreparation,\n\t\t\t\tbranchEntries,\n\t\t\t\tcustomInstructions,\n\t\t\t\tsignal,\n\t\t\t})) as SessionBeforeCompactResult | undefined;\n\n\t\t\tif (result?.cancel) {\n\t\t\t\treturn { status: \"cancelled\" };\n\t\t\t}\n\t\t\tif (result?.compaction) {\n\t\t\t\textensionCompaction = result.compaction;\n\t\t\t\tfromExtension = true;\n\t\t\t}\n\t\t}\n\n\t\tconst generated =\n\t\t\textensionCompaction ??\n\t\t\t(await compact(preparation, model, apiKey, headers, customInstructions, signal, this.deps.getThinkingLevel()));\n\n\t\tif (signal.aborted) {\n\t\t\treturn { status: \"cancelled\" };\n\t\t}\n\n\t\tconst { summary, firstKeptEntryId, tokensBefore, tokensAfter, details } = generated;\n\n\t\tthis.deps.sessionManager.appendCompaction(\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tfromExtension,\n\t\t\ttokensAfter,\n\t\t);\n\t\tconst newEntries = this.deps.sessionManager.getEntries();\n\t\tthis.deps.setAgentMessages(this.deps.sessionManager.buildSessionContext().messages);\n\n\t\tconst savedCompactionEntry = newEntries.find((e) => e.type === \"compaction\" && e.summary === summary) as\n\t\t\t| CompactionEntry\n\t\t\t| undefined;\n\t\tif (savedCompactionEntry) {\n\t\t\tawait extensionRunner.emit({\n\t\t\t\ttype: \"session_compact\",\n\t\t\t\tcompactionEntry: savedCompactionEntry,\n\t\t\t\tfromExtension,\n\t\t\t});\n\t\t}\n\n\t\treturn {\n\t\t\tstatus: \"ok\",\n\t\t\tresult: { summary, firstKeptEntryId, tokensBefore, tokensAfter: tokensAfter ?? tokensBefore, details },\n\t\t};\n\t}\n\n\t/**\n\t * Manually compact the session context.\n\t * Aborts current agent operation first.\n\t * @param customInstructions Optional instructions for the compaction summary\n\t */\n\tasync compact(customInstructions?: string): Promise<CompactionResult> {\n\t\tthis.deps.disconnectFromAgent();\n\t\tawait this.deps.abortSession();\n\t\tthis._compactionAbortController = new AbortController();\n\t\tthis.deps.emit({ type: \"compaction_start\", reason: \"manual\" });\n\n\t\ttry {\n\t\t\tconst model = this.deps.getModel();\n\t\t\tif (!model) {\n\t\t\t\tthrow new Error(formatNoModelSelectedMessage());\n\t\t\t}\n\n\t\t\tconst { apiKey, headers } = await this.deps.getRequiredRequestAuth(model);\n\n\t\t\tconst pathEntries = this.deps.sessionManager.getBranch();\n\t\t\tconst settings = this.deps.settingsManager.getCompactionSettings();\n\n\t\t\tconst preparation = prepareCompaction(pathEntries, settings);\n\t\t\tif (!preparation) {\n\t\t\t\t// Check why we can't compact\n\t\t\t\tconst lastEntry = pathEntries[pathEntries.length - 1];\n\t\t\t\tif (lastEntry?.type === \"compaction\") {\n\t\t\t\t\tthrow new Error(\"Already compacted\");\n\t\t\t\t}\n\t\t\t\tthrow new Error(\"Nothing to compact (session too small)\");\n\t\t\t}\n\n\t\t\tconst applied = await this._applyCompaction({\n\t\t\t\tpreparation,\n\t\t\t\tbranchEntries: pathEntries,\n\t\t\t\tmodel,\n\t\t\t\tapiKey,\n\t\t\t\theaders,\n\t\t\t\tcustomInstructions,\n\t\t\t\tsignal: this._compactionAbortController.signal,\n\t\t\t});\n\n\t\t\tif (applied.status === \"cancelled\") {\n\t\t\t\tthrow new Error(\"Compaction cancelled\");\n\t\t\t}\n\n\t\t\tconst compactionResult = applied.result;\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"compaction_end\",\n\t\t\t\treason: \"manual\",\n\t\t\t\tresult: compactionResult,\n\t\t\t\taborted: false,\n\t\t\t\twillRetry: false,\n\t\t\t});\n\t\t\treturn compactionResult;\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tconst aborted = message === \"Compaction cancelled\" || (error instanceof Error && error.name === \"AbortError\");\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"compaction_end\",\n\t\t\t\treason: \"manual\",\n\t\t\t\tresult: undefined,\n\t\t\t\taborted,\n\t\t\t\twillRetry: false,\n\t\t\t\terrorMessage: aborted ? undefined : `Compaction failed: ${message}`,\n\t\t\t});\n\t\t\tthrow error;\n\t\t} finally {\n\t\t\tthis._compactionAbortController = undefined;\n\t\t\tthis.deps.reconnectToAgent();\n\t\t}\n\t}\n\n\t/**\n\t * Check if compaction is needed and run it.\n\t * Called after agent_end and before prompt submission.\n\t *\n\t * Two cases:\n\t * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry\n\t * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)\n\t *\n\t * @param assistantMessage The assistant message to check\n\t * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true\n\t */\n\tasync checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {\n\t\tconst settings = this.deps.settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false\n\t\tif (skipAbortedCheck && assistantMessage.stopReason === \"aborted\") return;\n\n\t\tconst model = this.deps.getModel();\n\t\tconst contextWindow = model?.contextWindow ?? 0;\n\n\t\t// Skip overflow check if the message came from a different model.\n\t\t// This handles the case where user switched from a smaller-context model (e.g. opus)\n\t\t// to a larger-context model (e.g. codex) - the overflow error from the old model\n\t\t// shouldn't trigger compaction for the new model.\n\t\tconst sameModel = model && assistantMessage.provider === model.provider && assistantMessage.model === model.id;\n\n\t\t// Skip compaction checks if this assistant message is older than the latest\n\t\t// compaction boundary. This prevents a stale pre-compaction usage/error\n\t\t// from retriggering compaction on the first prompt after compaction.\n\t\tconst compactionEntry = getLatestCompactionEntry(this.deps.sessionManager.getBranch());\n\t\tconst assistantIsFromBeforeCompaction =\n\t\t\tcompactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();\n\t\tif (assistantIsFromBeforeCompaction) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Case 1: Overflow - LLM returned context overflow error\n\t\tif (sameModel && isContextOverflow(assistantMessage, contextWindow)) {\n\t\t\tif (this._overflowRecoveryAttempted) {\n\t\t\t\tthis.deps.emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason: \"overflow\",\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t\terrorMessage:\n\t\t\t\t\t\t\"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.\",\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis._overflowRecoveryAttempted = true;\n\t\t\t// Remove the error message from agent state (it IS saved to session for history,\n\t\t\t// but we don't want it in context for the retry)\n\t\t\tconst messages = this.deps.getAgentMessages();\n\t\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\t\tthis.deps.setAgentMessages(messages.slice(0, -1));\n\t\t\t}\n\t\t\tawait this._runAutoCompaction(\"overflow\", true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Case 2: Threshold - context is getting large\n\t\t// For error messages (no usage data), estimate from last successful response.\n\t\t// This ensures sessions that hit persistent API errors (e.g. 529) can still compact.\n\t\tlet contextTokens: number;\n\t\tif (assistantMessage.stopReason === \"error\") {\n\t\t\tconst messages = this.deps.getAgentMessages();\n\t\t\tconst estimate = estimateContextTokens(messages);\n\t\t\tif (estimate.lastUsageIndex === null) return; // No usage data at all\n\t\t\t// Verify the usage source is post-compaction. Kept pre-compaction messages\n\t\t\t// have stale usage reflecting the old (larger) context and would falsely\n\t\t\t// trigger compaction right after one just finished.\n\t\t\tconst usageMsg = messages[estimate.lastUsageIndex];\n\t\t\tif (\n\t\t\t\tcompactionEntry &&\n\t\t\t\tusageMsg.role === \"assistant\" &&\n\t\t\t\t(usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcontextTokens = estimate.tokens;\n\t\t} else {\n\t\t\tcontextTokens = calculateContextTokens(assistantMessage.usage);\n\t\t}\n\t\tif (shouldCompact(contextTokens, contextWindow, settings)) {\n\t\t\tawait this._runAutoCompaction(\"threshold\", false);\n\t\t}\n\t}\n\n\t/**\n\t * Internal: Run auto-compaction with events.\n\t */\n\tprivate async _runAutoCompaction(reason: \"overflow\" | \"threshold\", willRetry: boolean): Promise<void> {\n\t\tconst settings = this.deps.settingsManager.getCompactionSettings();\n\n\t\tthis.deps.emit({ type: \"compaction_start\", reason });\n\t\tthis._autoCompactionAbortController = new AbortController();\n\n\t\ttry {\n\t\t\tconst model = this.deps.getModel();\n\t\t\tif (!model) {\n\t\t\t\tthis.deps.emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst authResult = await this.deps.modelRegistry.getApiKeyAndHeaders(model);\n\t\t\tif (!authResult.ok || !authResult.apiKey) {\n\t\t\t\tthis.deps.emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst { apiKey, headers } = authResult;\n\n\t\t\tconst pathEntries = this.deps.sessionManager.getBranch();\n\n\t\t\tconst preparation = prepareCompaction(pathEntries, settings);\n\t\t\tif (!preparation) {\n\t\t\t\tthis.deps.emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst applied = await this._applyCompaction({\n\t\t\t\tpreparation,\n\t\t\t\tbranchEntries: pathEntries,\n\t\t\t\tmodel,\n\t\t\t\tapiKey,\n\t\t\t\theaders,\n\t\t\t\tcustomInstructions: undefined,\n\t\t\t\tsignal: this._autoCompactionAbortController.signal,\n\t\t\t});\n\n\t\t\tif (applied.status === \"cancelled\") {\n\t\t\t\tthis.deps.emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: true,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst result = applied.result;\n\t\t\tthis.deps.emit({ type: \"compaction_end\", reason, result, aborted: false, willRetry });\n\n\t\t\tif (willRetry) {\n\t\t\t\tconst messages = this.deps.getAgentMessages();\n\t\t\t\tconst lastMsg = messages[messages.length - 1];\n\t\t\t\tif (lastMsg?.role === \"assistant\" && (lastMsg as AssistantMessage).stopReason === \"error\") {\n\t\t\t\t\tthis.deps.setAgentMessages(messages.slice(0, -1));\n\t\t\t\t}\n\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tthis.deps.continueAgent();\n\t\t\t\t}, 100);\n\t\t\t} else if (this.deps.hasQueuedMessages()) {\n\t\t\t\t// Auto-compaction can complete while follow-up/steering/custom messages are waiting.\n\t\t\t\t// Kick the loop so queued messages are actually delivered.\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tthis.deps.continueAgent();\n\t\t\t\t}, 100);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"compaction failed\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"compaction_end\",\n\t\t\t\treason,\n\t\t\t\tresult: undefined,\n\t\t\t\taborted: false,\n\t\t\t\twillRetry: false,\n\t\t\t\terrorMessage:\n\t\t\t\t\treason === \"overflow\"\n\t\t\t\t\t\t? `Context overflow recovery failed: ${errorMessage}`\n\t\t\t\t\t\t: `Auto-compaction failed: ${errorMessage}`,\n\t\t\t});\n\t\t} finally {\n\t\t\tthis._autoCompactionAbortController = undefined;\n\t\t}\n\t}\n}\n"]}