@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +2 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
@@ -0,0 +1,2983 @@
1
+ /** Context maintenance for an active coding-agent session. */
2
+
3
+ import { scheduler } from "node:timers/promises";
4
+ import {
5
+ type Agent,
6
+ type AgentMessage,
7
+ type AgentTurnEndContext,
8
+ countTokens,
9
+ resolveTelemetry,
10
+ type StreamFn,
11
+ type ThinkingLevel,
12
+ } from "@oh-my-pi/pi-agent-core";
13
+ import {
14
+ AGGRESSIVE_SHAKE_CONFIG,
15
+ AUTO_HANDOFF_THRESHOLD_FOCUS,
16
+ applyShakeRegions,
17
+ CompactionCancelledError,
18
+ type CompactionPreparation,
19
+ type CompactionResult,
20
+ type CompactionSettings,
21
+ calculateContextTokens,
22
+ collectShakeRegions,
23
+ compact,
24
+ compactionContextTokens,
25
+ createCompactionSummaryMessage,
26
+ DEFAULT_SHAKE_CONFIG,
27
+ effectiveReserveTokens,
28
+ estimateTokens,
29
+ prepareCompaction,
30
+ resolveBudgetReserveTokens,
31
+ resolveThresholdTokens,
32
+ type ShakeConfig,
33
+ type ShakeRegion,
34
+ type SummaryOptions,
35
+ shouldCompact,
36
+ shouldUseOpenAiRemoteCompaction,
37
+ } from "@oh-my-pi/pi-agent-core/compaction";
38
+ import {
39
+ DEFAULT_PRUNE_CONFIG,
40
+ pruneSupersededToolResults,
41
+ pruneToolOutputs,
42
+ readToolSupersedeKey,
43
+ } from "@oh-my-pi/pi-agent-core/compaction/pruning";
44
+ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
45
+ import type { AssistantMessage, CodexCompactionContext, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
46
+ import * as AIError from "@oh-my-pi/pi-ai/error";
47
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
48
+ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
49
+ import { logger } from "@oh-my-pi/pi-utils";
50
+ import * as snapcompact from "@oh-my-pi/snapcompact";
51
+ import type { ModelRegistry } from "../config/model-registry";
52
+ import { MODEL_ROLE_IDS } from "../config/model-roles";
53
+ import type { Settings } from "../config/settings";
54
+ import { getDefault } from "../config/settings";
55
+ import type { ExtensionRunner, SessionBeforeCompactResult } from "../extensibility/extensions";
56
+ import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
57
+ import type { GoalModeState } from "../goals/state";
58
+ import { resolveMemoryBackend } from "../memory-backend/resolve";
59
+ import type { MemoryBackendOperationContext } from "../memory-backend/types";
60
+ import type { NonMessageTokenSource } from "../modes/utils/context-usage";
61
+ import { computeNonMessageTokens } from "../modes/utils/context-usage";
62
+ import { createPlanReadMatcher } from "../plan-mode/plan-protection";
63
+ import type { ConfiguredThinkingLevel } from "../thinking";
64
+ import type { AgentSessionEvent } from "./agent-session-events";
65
+ import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from "./agent-session-types";
66
+ import { findCompactMode } from "./compact-modes";
67
+ import { convertToLlm, stripImagesFromMessage } from "./messages";
68
+ import { isTerminalTextAssistantAnswer } from "./queued-messages";
69
+ import {
70
+ resolveCompactionConfiguredTarget,
71
+ resolveContextPromotionConfiguredTarget,
72
+ resolveRoleModelFull,
73
+ } from "./role-models";
74
+ import type { SessionContext } from "./session-context";
75
+ import { getLatestCompactionEntry } from "./session-context";
76
+ import type { CompactionEntry, SessionEntry } from "./session-entries";
77
+ import type { SessionManager } from "./session-manager";
78
+ import type { ShakeMode, ShakeResult } from "./shake-types";
79
+
80
+ export type CompactionCheckResult = Readonly<{
81
+ deferredHandoff: boolean;
82
+ continuationScheduled: boolean;
83
+ automaticContinuationBlocked?: boolean;
84
+ historyRewritten?: boolean;
85
+ }>;
86
+
87
+ /** Shared no-op result for dispatcher paths that perform no maintenance. */
88
+ export const COMPACTION_CHECK_NONE: CompactionCheckResult = {
89
+ deferredHandoff: false,
90
+ continuationScheduled: false,
91
+ };
92
+ const COMPACTION_CHECK_DEFERRED_HANDOFF: CompactionCheckResult = {
93
+ deferredHandoff: true,
94
+ continuationScheduled: false,
95
+ };
96
+ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
97
+ deferredHandoff: false,
98
+ continuationScheduled: true,
99
+ };
100
+ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
101
+ deferredHandoff: false,
102
+ continuationScheduled: false,
103
+ automaticContinuationBlocked: true,
104
+ };
105
+
106
+ /**
107
+ * User-facing notice for a compaction dead end: maintenance freed too little
108
+ * to retry safely. `remedies` names the recovery actions left on the emitting
109
+ * path — by the time the post-pass dead end fires, the tiered rescue has
110
+ * already attempted both elide and image-drop automatically.
111
+ */
112
+ function compactionDeadEndWarning(remedies: string): string {
113
+ return (
114
+ "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. " +
115
+ `The most recent turn alone is too large to reduce further; ${remedies} or switch to a larger-context model.`
116
+ );
117
+ }
118
+
119
+ /** Creates one provider-scoped compaction lifecycle descriptor. */
120
+ export function createCodexCompactionContext(options: {
121
+ trigger: CodexCompactionContext["trigger"];
122
+ reason: CodexCompactionContext["reason"];
123
+ phase: CodexCompactionContext["phase"];
124
+ }): CodexCompactionContext {
125
+ return {
126
+ operationId: crypto.randomUUID(),
127
+ trigger: options.trigger,
128
+ reason: options.reason,
129
+ phase: options.phase,
130
+ strategy: "memento",
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Per-turn prune cache window. A tool result whose all-message suffix exceeds
136
+ * this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
137
+ * cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside
138
+ * this tail (matches the supersede pass's default `suffixTokenLimit`); deeper
139
+ * stale/age victims are left to compaction/shake, which rebuild the cache anyway.
140
+ */
141
+ const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000;
142
+
143
+ /**
144
+ * Idle gap after which the supersede pass may flush the whole sent region (the
145
+ * provider cache is cold, so re-writing it is free). MUST exceed the maximum
146
+ * Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a
147
+ * still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL.
148
+ */
149
+ const PRUNE_IDLE_FLUSH_MS = 90 * 60_000;
150
+
151
+ /**
152
+ * Hysteresis band for the post-maintenance "did we actually create headroom?"
153
+ * check shared by the shake tail and the context-full / snapcompact tail. A
154
+ * pass counts as having resolved threshold pressure only when residual context
155
+ * lands at or below `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against
156
+ * the raw threshold lets a pass keep reclaiming a trickle of the previous
157
+ * turn's output and land just under the line every turn, sustaining the
158
+ * auto-continue dead loop reported in #2275; the same band stops the
159
+ * context-full / snapcompact tail from re-firing on a history whose single
160
+ * most-recent kept turn already exceeds the threshold (the snapcompact thrash).
161
+ */
162
+ const COMPACTION_RECOVERY_BAND = 0.8;
163
+
164
+ function mergeLlmCompactionPreserveData(
165
+ hookPreserveData: Record<string, unknown> | undefined,
166
+ resultPreserveData: Record<string, unknown> | undefined,
167
+ ): Record<string, unknown> | undefined {
168
+ const preserveData = { ...(hookPreserveData ?? {}), ...(resultPreserveData ?? {}) };
169
+ return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined);
170
+ }
171
+
172
+ /** Capabilities borrowed from the owning AgentSession. */
173
+ export interface SessionMaintenanceHost {
174
+ agent: Agent;
175
+ sessionManager: SessionManager;
176
+ settings: Settings;
177
+ modelRegistry: ModelRegistry;
178
+ extensionRunner: ExtensionRunner | undefined;
179
+ sideStreamFn: StreamFn;
180
+ providerSessionState: Map<string, ProviderSessionState>;
181
+ model(): Model | undefined;
182
+ thinkingLevel(): ThinkingLevel | undefined;
183
+ isDisposed(): boolean;
184
+ isStreaming(): boolean;
185
+ isGeneratingHandoff(): boolean;
186
+ promptGeneration(): number;
187
+ sessionId(): string;
188
+ messages(): AgentMessage[];
189
+ baseSystemPrompt(): string[];
190
+ goalModeState(): GoalModeState | undefined;
191
+ planReferencePath(): string;
192
+ nonMessageTokenSource(): NonMessageTokenSource;
193
+ memoryBackendSession(): MemoryBackendOperationContext["session"];
194
+ emitSessionEvent(event: AgentSessionEvent): Promise<void>;
195
+ emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
196
+ schedulePostPromptTask(
197
+ task: (signal: AbortSignal) => Promise<void>,
198
+ options?: { delayMs?: number; generation?: number; onSkip?: (reason: "aborted" | "stale-generation") => void },
199
+ ): void;
200
+ scheduleAgentContinue(options?: {
201
+ delayMs?: number;
202
+ generation?: number;
203
+ shouldContinue?: () => boolean;
204
+ onSkip?: (
205
+ reason:
206
+ | "aborted"
207
+ | "stale-generation"
208
+ | "session-unavailable"
209
+ | "should-continue-false"
210
+ | "post-restore-unavailable",
211
+ ) => void;
212
+ onError?: () => void;
213
+ }): void;
214
+ scheduleCompactionContinuation(options: {
215
+ generation: number;
216
+ autoContinue: boolean;
217
+ terminalTextAnswer: boolean;
218
+ suppressContinuation: boolean;
219
+ }): boolean;
220
+ persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean>;
221
+ findLastAssistantMessage(): AssistantMessage | undefined;
222
+ disconnectFromAgent(): void;
223
+ reconnectToAgent(): void;
224
+ drainStrandedQueuedMessages(): void;
225
+ buildDisplaySessionContext(): SessionContext;
226
+ convertToLlmForSideRequest(messages: AgentMessage[]): Message[];
227
+ obfuscateTextForProvider(text: string | undefined): string | undefined;
228
+ obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation;
229
+ closeCodexProviderSessionsForHistoryRewrite(): void;
230
+ resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void;
231
+ resetPlanReference(): void;
232
+ syncTodoPhasesFromBranch(): void;
233
+ resetAdvisorRuntimes(): void;
234
+ rebaseAfterCompaction(): void;
235
+ getContextBreakdown(options?: {
236
+ contextWindow?: number;
237
+ pendingMessages?: AgentMessage[];
238
+ }): ContextUsageBreakdown | undefined;
239
+ getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined;
240
+ shake(mode: ShakeMode, options?: { config?: ShakeConfig; signal?: AbortSignal }): Promise<ShakeResult>;
241
+ dropImages(): Promise<{ removed: number }>;
242
+ runHandoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined>;
243
+ removeAssistantMessageFromActiveContext(message: AssistantMessage): void;
244
+ dropPersistedAssistantTurn(message: AssistantMessage): Promise<void>;
245
+ runRecoveryCompactionWithRollback(
246
+ reason: "overflow" | "incomplete",
247
+ message: AssistantMessage,
248
+ allowDefer: boolean,
249
+ options: { autoContinue: boolean; triggerContextTokens?: number },
250
+ ): Promise<CompactionCheckResult>;
251
+ parseRetryAfterMsFromError(errorMessage: string): number | undefined;
252
+ setModelTemporary(
253
+ model: Model,
254
+ thinkingLevel?: ConfiguredThinkingLevel,
255
+ options?: { ephemeral?: boolean },
256
+ ): Promise<void>;
257
+ abort(options?: {
258
+ goalReason?: "interrupted" | "internal";
259
+ reason?: string;
260
+ preserveCompaction?: boolean;
261
+ }): Promise<void>;
262
+ abortHandoff(): void;
263
+ }
264
+
265
+ /** Owns compaction, pruning, shake, promotion, and automatic context maintenance. */
266
+ export class SessionMaintenance {
267
+ #compactionAbortController: AbortController | undefined;
268
+ #autoCompactionAbortController: AbortController | undefined;
269
+ #skipPostTurnMaintenanceAssistantTimestamp: number | undefined;
270
+ readonly #host: SessionMaintenanceHost;
271
+
272
+ get #model(): Model | undefined {
273
+ return this.#host.model();
274
+ }
275
+
276
+ get #goalModeState(): GoalModeState | undefined {
277
+ return this.#host.goalModeState();
278
+ }
279
+
280
+ constructor(host: SessionMaintenanceHost) {
281
+ this.#host = host;
282
+ }
283
+
284
+ /** Whether manual or automatic context maintenance is active. */
285
+ get isCompacting(): boolean {
286
+ return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
287
+ }
288
+
289
+ /** Assistant timestamp whose post-turn maintenance must be skipped once. */
290
+ get skipPostTurnMaintenanceAssistantTimestamp(): number | undefined {
291
+ return this.#skipPostTurnMaintenanceAssistantTimestamp;
292
+ }
293
+
294
+ set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) {
295
+ this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp;
296
+ }
297
+ /**
298
+ * Append plan-read protection to a prune/shake config so the active plan
299
+ * file survives compaction alongside skill reads (the config defaults
300
+ * already carry skill protection). The matcher reads the current plan
301
+ * reference path at match time, so retitled plans are covered.
302
+ */
303
+ #withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
304
+ const planMatcher = createPlanReadMatcher(() => this.#host.planReferencePath());
305
+ return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
306
+ }
307
+
308
+ async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
309
+ const branchEntries = this.#host.sessionManager.getBranch();
310
+ const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
311
+ const result = pruneToolOutputs(
312
+ branchEntries,
313
+ this.#withPlanProtection({
314
+ ...DEFAULT_PRUNE_CONFIG,
315
+ pruneUseless: this.#host.settings.getGroup("compaction").dropUseless,
316
+ // Cache-stable boundary: never re-write the warm, already-sent prefix
317
+ // (deep stale/age victims) or summarized-away entries every turn.
318
+ keepBoundaryId,
319
+ cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS,
320
+ }),
321
+ );
322
+ if (result.prunedCount === 0) {
323
+ return undefined;
324
+ }
325
+
326
+ await this.#host.sessionManager.rewriteEntries();
327
+ const sessionContext = this.#host.buildDisplaySessionContext();
328
+ this.#host.agent.replaceMessages(sessionContext.messages);
329
+ this.#host.resetAdvisorRuntimes();
330
+ this.#host.syncTodoPhasesFromBranch();
331
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
332
+ return result;
333
+ }
334
+
335
+ /**
336
+ * Per-turn stale-result pass: prune older `read` results that a newer read
337
+ * of the same file has made stale, plus results their tool flagged
338
+ * contextually useless. Cache-aware (only fires when the suffix after a
339
+ * candidate is small or the session has been idle long enough that the
340
+ * provider prompt cache is cold), so it is cheap to run every turn. Gated
341
+ * on the `compaction.supersedeReads` and `compaction.dropUseless` settings.
342
+ *
343
+ * Persists via `rewriteEntries` like every other history rewrite — the
344
+ * session file must match the live (pruned) context or file-based forks
345
+ * (`/fork`, `/tan`) and resume rebuild a divergent prefix and cold-miss the
346
+ * provider prompt cache.
347
+ */
348
+ async #pruneStaleToolResults(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
349
+ const { supersedeReads, dropUseless } = this.#host.settings.getGroup("compaction");
350
+ if (!supersedeReads && !dropUseless) return undefined;
351
+ const branchEntries = this.#host.sessionManager.getBranch();
352
+ const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
353
+ const result = pruneSupersededToolResults(
354
+ branchEntries,
355
+ this.#withPlanProtection({
356
+ supersedeKey: supersedeReads ? readToolSupersedeKey : undefined,
357
+ pruneUseless: dropUseless,
358
+ protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
359
+ // Never re-write summarized-away entries; only flush the whole sent
360
+ // region once the cache is genuinely cold (idle exceeds the 1h TTL).
361
+ keepBoundaryId,
362
+ idleFlushMs: PRUNE_IDLE_FLUSH_MS,
363
+ }),
364
+ );
365
+ if (result.prunedCount === 0) {
366
+ return undefined;
367
+ }
368
+
369
+ await this.#host.sessionManager.rewriteEntries();
370
+ const sessionContext = this.#host.buildDisplaySessionContext();
371
+ this.#host.agent.replaceMessages(sessionContext.messages);
372
+ this.#host.resetAdvisorRuntimes();
373
+ this.#host.syncTodoPhasesFromBranch();
374
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
375
+ return result;
376
+ }
377
+
378
+ /**
379
+ * Strip image content blocks from every message on the current branch and
380
+ * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
381
+ * `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
382
+ * are mutated, then `rewriteEntries` durably commits the new shape. The
383
+ * agent's runtime view is rebuilt from the freshly-mutated entries so any
384
+ * provider sessions caching message identity (Codex Responses) are torn
385
+ * down to force a clean replay on the next turn.
386
+ *
387
+ * No-op when the branch carries no images; returns `{ removed: 0 }` and
388
+ * skips the disk rewrite.
389
+ */
390
+ async dropImages(): Promise<{ removed: number }> {
391
+ const branchEntries = this.#host.sessionManager.getBranch();
392
+ let removed = 0;
393
+ for (const entry of branchEntries) {
394
+ if (entry.type === "message") {
395
+ removed += stripImagesFromMessage(entry.message);
396
+ continue;
397
+ }
398
+ if (entry.type === "custom_message" && typeof entry.content !== "string") {
399
+ const kept: typeof entry.content = [];
400
+ let dropped = 0;
401
+ for (const part of entry.content) {
402
+ if (part.type === "image") {
403
+ dropped++;
404
+ } else {
405
+ kept.push(part);
406
+ }
407
+ }
408
+ if (dropped > 0) {
409
+ if (kept.length === 0) {
410
+ kept.push({ type: "text", text: "[image removed]" });
411
+ }
412
+ entry.content = kept;
413
+ removed += dropped;
414
+ }
415
+ }
416
+ }
417
+ if (removed === 0) {
418
+ return { removed: 0 };
419
+ }
420
+ await this.#host.sessionManager.rewriteEntries();
421
+ const sessionContext = this.#host.buildDisplaySessionContext();
422
+ this.#host.agent.replaceMessages(sessionContext.messages);
423
+ this.#host.resetAdvisorRuntimes();
424
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
425
+ return { removed };
426
+ }
427
+
428
+ /**
429
+ * Surgically reduce context by dropping heavy content ("shake").
430
+ *
431
+ * - `images` delegates to {@link dropImages}.
432
+ * - `elide` replaces whole tool-call results and large fenced/XML blocks
433
+ * with short placeholders that embed an `artifact://` recovery link.
434
+ *
435
+ * Mutates the branch in place, persists via `rewriteEntries`, replays the
436
+ * rebuilt context through the agent, and tears down provider sessions that
437
+ * cache message identity — same rewrite contract as {@link dropImages}.
438
+ *
439
+ * No-op (zero counts) when nothing is eligible.
440
+ */
441
+ async shake(mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal } = {}): Promise<ShakeResult> {
442
+ if (mode === "images") {
443
+ const { removed } = await this.#host.dropImages();
444
+ return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
445
+ }
446
+
447
+ const branchEntries = this.#host.sessionManager.getBranch();
448
+ const config = this.#withPlanProtection({
449
+ ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG),
450
+ // Skip entries summarized away by the latest compaction — shaking them
451
+ // only churns persisted history with no prompt/cache effect.
452
+ keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId,
453
+ });
454
+ const regions = collectShakeRegions(branchEntries, config);
455
+ if (regions.length === 0) {
456
+ return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
457
+ }
458
+
459
+ const artifactId = await this.#saveShakeArtifact(regions);
460
+ const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId));
461
+
462
+ let toolResultsDropped = 0;
463
+ let blocksDropped = 0;
464
+ let originalTokens = 0;
465
+ let replacementTokens = 0;
466
+ const items = regions.map((region, index) => {
467
+ if (region.kind === "toolResult") toolResultsDropped++;
468
+ else blocksDropped++;
469
+ originalTokens += region.tokens;
470
+ const replacement = replacements[index];
471
+ if (replacement.length > 0) replacementTokens += countTokens(replacement);
472
+ return { region, replacement };
473
+ });
474
+
475
+ applyShakeRegions(items);
476
+
477
+ await this.#host.sessionManager.rewriteEntries();
478
+ const sessionContext = this.#host.buildDisplaySessionContext();
479
+ this.#host.agent.replaceMessages(sessionContext.messages);
480
+ this.#host.resetAdvisorRuntimes();
481
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
482
+
483
+ return {
484
+ mode,
485
+ toolResultsDropped,
486
+ blocksDropped,
487
+ tokensFreed: Math.max(0, originalTokens - replacementTokens),
488
+ artifactId,
489
+ };
490
+ }
491
+
492
+ #shakeElidePlaceholder(region: ShakeRegion, index: number, artifactId: string | undefined): string {
493
+ if (artifactId) {
494
+ return `[shaken ~${region.tokens} tokens — recover: artifact://${artifactId} (region ${index + 1})]`;
495
+ }
496
+ return `[shaken ~${region.tokens} tokens]`;
497
+ }
498
+
499
+ /**
500
+ * Concatenate the original region contents into one session artifact so the
501
+ * agent can read them back via `artifact://<id>`. Returns `undefined` when
502
+ * the session is not persisted or the write fails — callers degrade to a
503
+ * bare placeholder.
504
+ */
505
+ async #saveShakeArtifact(regions: ShakeRegion[]): Promise<string | undefined> {
506
+ const parts: string[] = [];
507
+ for (let i = 0; i < regions.length; i++) {
508
+ const region = regions[i];
509
+ parts.push(`### region ${i + 1} (${region.label}, ~${region.tokens} tok)`, "", region.originalText, "");
510
+ }
511
+ try {
512
+ return await this.#host.sessionManager.saveArtifact(parts.join("\n"), "shake");
513
+ } catch {
514
+ return undefined;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Manually compact the session context.
520
+ * Aborts current agent operation first.
521
+ * @param customInstructions Optional instructions for the compaction summary
522
+ * @param options Optional callbacks for completion/error handling
523
+ */
524
+ async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
525
+ if (this.#compactionAbortController) {
526
+ throw new Error("Compaction already in progress");
527
+ }
528
+ // Resolve the `/compact <mode>` subcommand up front so input validation
529
+ // runs before we disconnect/abort the active agent operation below.
530
+ const compactMode = options?.mode ? findCompactMode(options.mode) : undefined;
531
+ // Modes that produce no LLM summary (snapcompact) have nothing to focus.
532
+ // Reject focus text loudly so programmatic callers don't silently lose
533
+ // instructions (the slash path pre-validates via parseCompactArgs).
534
+ // `internalGuidance` counts the same way — plan-mode approval never
535
+ // combines with a rejects-focus mode, but reject early if a caller ever
536
+ // wires it up so we don't silently drop the directive on the snapcompact
537
+ // fallback (issue #4359).
538
+ if (compactMode?.rejectsFocus && (customInstructions || options?.internalGuidance)) {
539
+ throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
540
+ }
541
+ const compactionAbortController = new AbortController();
542
+ this.#compactionAbortController = compactionAbortController;
543
+
544
+ try {
545
+ this.#host.disconnectFromAgent();
546
+ await this.#host.abort({ goalReason: "internal", preserveCompaction: true });
547
+ if (!this.#model) {
548
+ throw new Error("No model selected");
549
+ }
550
+
551
+ const compactionSettings = this.#host.settings.getGroup("compaction");
552
+ // The `/compact <mode>` override (resolved above) replaces the configured
553
+ // strategy/remote flags for this one invocation. Merged before
554
+ // prepareCompaction so the remote gating (preparation.settings.
555
+ // remoteEnabled/endpoint) and the snapcompact decision below both see it.
556
+ const effectiveSettings = compactMode
557
+ ? { ...compactionSettings, ...compactMode.overrides }
558
+ : compactionSettings;
559
+ // /compact remote demands provider-native compaction. When no remote
560
+ // endpoint is configured (one would override per-model gating in
561
+ // compact()), drop fallback candidates that aren't remote-capable so the
562
+ // engine never silently runs a local summary on a configured-but-non-
563
+ // remote compactionModel. If filtering empties the chain, warn and fall
564
+ // back to the full chain so the operation still completes.
565
+ const availableModels = this.#host.modelRegistry.getAvailable();
566
+ const requireProviderRemote = Boolean(compactMode?.requiresRemote && !effectiveSettings.remoteEndpoint);
567
+ let compactionCandidates = this.#getCompactionModelCandidates(
568
+ availableModels,
569
+ requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined,
570
+ );
571
+ if (requireProviderRemote && compactionCandidates.length === 0) {
572
+ this.#host.emitNotice(
573
+ "warning",
574
+ `remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`,
575
+ "compaction",
576
+ );
577
+ compactionCandidates = this.#getCompactionModelCandidates(availableModels);
578
+ }
579
+ const pathEntries = this.#host.sessionManager.getBranch();
580
+ const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model);
581
+ if (!preparation) {
582
+ // Check why we can't compact
583
+ const lastEntry = pathEntries[pathEntries.length - 1];
584
+ if (lastEntry?.type === "compaction") {
585
+ throw new Error("Already compacted");
586
+ }
587
+ throw new Error("Nothing to compact (session too small)");
588
+ }
589
+
590
+ let hookCompaction: CompactionResult | undefined;
591
+ let fromExtension = false;
592
+ let preserveData: Record<string, unknown> | undefined;
593
+
594
+ if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) {
595
+ const result = (await this.#host.extensionRunner.emit({
596
+ type: "session_before_compact",
597
+ preparation,
598
+ branchEntries: pathEntries,
599
+ customInstructions,
600
+ signal: compactionAbortController.signal,
601
+ })) as SessionBeforeCompactResult | undefined;
602
+
603
+ if (result?.cancel) {
604
+ throw new CompactionCancelledError();
605
+ }
606
+
607
+ if (result?.compaction) {
608
+ hookCompaction = result.compaction;
609
+ fromExtension = true;
610
+ }
611
+ }
612
+
613
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
614
+
615
+ // Strategy honored on manual /compact too. Custom instructions (public
616
+ // user focus OR internal plan-mode guidance) imply a directed LLM
617
+ // summary; a text-only model cannot read snapcompact frames.
618
+ const wantsSnapcompact =
619
+ compactionPrep.kind !== "fromHook" &&
620
+ effectiveSettings.strategy === "snapcompact" &&
621
+ !customInstructions &&
622
+ !options?.internalGuidance;
623
+ // `/compact snapcompact` is an explicit no-LLM archive request: honor
624
+ // its contract by failing locally rather than silently shipping the
625
+ // transcript to a provider. The default-configured snapcompact
626
+ // strategy, in contrast, falls back to LLM compaction (mirroring the
627
+ // auto-compaction path) so a routine /compact still completes on a
628
+ // text-only model (issue #5064).
629
+ const explicitSnapcompact = compactMode?.name === "snapcompact";
630
+ let snapcompactReady = wantsSnapcompact;
631
+ const snapcompactShapeSetting = this.#host.settings.get("snapcompact.shape");
632
+ let snapcompactShape: snapcompact.Shape | undefined;
633
+ // Claude refuses inputs that reproduce its own reasoning as text
634
+ // ("reasoning_extraction"), and the snapcompact archive is replayed as
635
+ // text into every later request; drop `¶think:` sections for
636
+ // Anthropic-dialect targets (issue #6093).
637
+ const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic";
638
+ if (wantsSnapcompact && !this.#model.input.includes("image")) {
639
+ if (explicitSnapcompact) {
640
+ this.#host.emitNotice(
641
+ "warning",
642
+ `snapcompact needs a vision-capable model (${this.#model.id} is text-only)`,
643
+ "compaction",
644
+ );
645
+ throw new Error(`snapcompact cannot run locally: ${this.#model.id} is text-only.`);
646
+ }
647
+ this.#host.emitNotice(
648
+ "warning",
649
+ `snapcompact needs a vision-capable model (${this.#model.id} is text-only); falling back to LLM compaction`,
650
+ "compaction",
651
+ );
652
+ snapcompactReady = false;
653
+ } else if (snapcompactReady) {
654
+ const text = snapcompact.serializeConversation(
655
+ convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
656
+ { includeThinking: snapcompactIncludeThinking },
657
+ );
658
+ const probeText = snapcompact.renderabilityProbeText(
659
+ text,
660
+ preparation.previousPreserveData,
661
+ preparation.previousSummary,
662
+ );
663
+ snapcompactShape = snapcompact.resolveShapeForText(probeText, this.#model, snapcompactShapeSetting);
664
+ const renderScan = snapcompact.scanRenderability(probeText, { shape: snapcompactShape });
665
+ if (!renderScan.isSafe) {
666
+ const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
667
+ this.#host.emitNotice(
668
+ "warning",
669
+ `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%). No LLM fallback was attempted.`,
670
+ "compaction",
671
+ );
672
+ throw new Error(
673
+ `snapcompact cannot render this conversation locally: unsupported characters for selected snapcompact font (${percent}%).`,
674
+ );
675
+ }
676
+ }
677
+
678
+ let summary: string;
679
+ let shortSummary: string | undefined;
680
+ let firstKeptEntryId: string;
681
+ let tokensBefore: number;
682
+ let details: unknown;
683
+ let codexCompaction: CodexCompactionContext | undefined;
684
+
685
+ // Snapcompact runs locally first. The frame cap is sized from the live
686
+ // model window via #computeSnapcompactMaxFrames so the post-render context
687
+ // fits without the warning loop (issue #3247). Zero-frame budget now fails
688
+ // the snapcompact request locally rather than falling back to an LLM call.
689
+ let snapcompactResult: snapcompact.CompactionResult | undefined;
690
+ if (snapcompactReady) {
691
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
692
+ if (maxFrames < 1) {
693
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
694
+ model: this.#model?.id,
695
+ });
696
+ this.#host.emitNotice(
697
+ "warning",
698
+ "snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
699
+ "compaction",
700
+ );
701
+ throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
702
+ } else {
703
+ const shape = snapcompactShape;
704
+ if (!shape) {
705
+ throw new Error("snapcompact shape was not resolved before rendering.");
706
+ }
707
+ snapcompactResult = await snapcompact.compact(preparation, {
708
+ convertToLlm,
709
+ model: this.#model,
710
+ ...(snapcompactShapeSetting === "auto" ? {} : { shape }),
711
+ maxFrames,
712
+ includeThinking: snapcompactIncludeThinking,
713
+ });
714
+ const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
715
+ if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
716
+ logger.warn("Snapcompact exceeded the per-request frame payload budget", {
717
+ model: this.#model?.id,
718
+ framePayloadBytes,
719
+ budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
720
+ });
721
+ this.#host.emitNotice(
722
+ "warning",
723
+ "snapcompact produced too much standing image payload. No LLM fallback was attempted.",
724
+ "compaction",
725
+ );
726
+ throw new Error(
727
+ "snapcompact cannot run locally: standing image payload exceeds the per-request budget.",
728
+ );
729
+ }
730
+ const ctxWindow = this.#model?.contextWindow ?? 0;
731
+ const budget =
732
+ ctxWindow > 0
733
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
734
+ : Number.POSITIVE_INFINITY;
735
+ if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
736
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
737
+ model: this.#model?.id,
738
+ });
739
+ this.#host.emitNotice(
740
+ "warning",
741
+ "snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
742
+ "compaction",
743
+ );
744
+ throw new Error("snapcompact could not bring the context under the limit locally.");
745
+ }
746
+ }
747
+ }
748
+
749
+ if (compactionPrep.kind === "fromHook") {
750
+ summary = compactionPrep.summary;
751
+ shortSummary = compactionPrep.shortSummary;
752
+ firstKeptEntryId = compactionPrep.firstKeptEntryId;
753
+ tokensBefore = compactionPrep.tokensBefore;
754
+ details = compactionPrep.details;
755
+ preserveData = compactionPrep.preserveData;
756
+ } else if (snapcompactResult) {
757
+ summary = snapcompactResult.summary;
758
+ shortSummary = snapcompactResult.shortSummary;
759
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
760
+ tokensBefore = snapcompactResult.tokensBefore;
761
+ details = snapcompactResult.details;
762
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
763
+ } else {
764
+ codexCompaction = createCodexCompactionContext({
765
+ trigger: "manual",
766
+ reason: "user_requested",
767
+ phase: "standalone_turn",
768
+ });
769
+ // Generate compaction result. Only convert known abort-shaped
770
+ // rejections (AbortError raised while the abort signal is set,
771
+ // or an already-typed sentinel) into `CompactionCancelledError`
772
+ // so downstream callers can discriminate cancel from generic
773
+ // failure via `instanceof` without inspecting message strings.
774
+ // Real compaction bugs (network, server, parsing, etc.) keep
775
+ // their original shape — they must not be silently relabeled
776
+ // as cancellations even if the signal happens to be aborted
777
+ // for an unrelated reason. Assignments live inside the try
778
+ // block because every catch path throws — the post-try reads
779
+ // of the result-derived locals are reachable only on success.
780
+ try {
781
+ const result = await this.#compactWithFallbackModel(
782
+ preparation,
783
+ options?.internalGuidance ?? customInstructions,
784
+ compactionAbortController.signal,
785
+ {
786
+ promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt),
787
+ extraContext: compactionPrep.hookContext,
788
+ remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"),
789
+ convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
790
+ codexCompaction,
791
+ },
792
+ compactionCandidates,
793
+ );
794
+ summary = result.summary;
795
+ shortSummary = result.shortSummary;
796
+ firstKeptEntryId = result.firstKeptEntryId;
797
+ tokensBefore = result.tokensBefore;
798
+ details = result.details;
799
+ preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData);
800
+ } catch (err) {
801
+ if (err instanceof CompactionCancelledError) {
802
+ throw err;
803
+ }
804
+ if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
805
+ throw new CompactionCancelledError();
806
+ }
807
+ throw err;
808
+ }
809
+ }
810
+
811
+ if (compactionAbortController.signal.aborted) {
812
+ throw new CompactionCancelledError();
813
+ }
814
+
815
+ this.#host.sessionManager.appendCompaction(
816
+ summary,
817
+ shortSummary,
818
+ firstKeptEntryId,
819
+ tokensBefore,
820
+ details,
821
+ fromExtension,
822
+ preserveData,
823
+ );
824
+ const newEntries = this.#host.sessionManager.getEntries();
825
+ const sessionContext = this.#host.buildDisplaySessionContext();
826
+ this.#host.agent.replaceMessages(sessionContext.messages);
827
+ this.#host.rebaseAfterCompaction();
828
+ // Compaction discarded the conversation history that carried the approved
829
+ // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
830
+ // the plan from disk and re-injects it on the next turn (issue #1246).
831
+ this.#host.resetPlanReference();
832
+ this.#host.resetAdvisorRuntimes();
833
+ this.#host.syncTodoPhasesFromBranch();
834
+ if (codexCompaction) {
835
+ this.#host.resetCodexProviderAfterCompaction(codexCompaction);
836
+ } else {
837
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
838
+ }
839
+
840
+ // Get the saved compaction entry for the hook
841
+ const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
842
+ | CompactionEntry
843
+ | undefined;
844
+
845
+ if (this.#host.extensionRunner && savedCompactionEntry) {
846
+ await this.#host.extensionRunner.emit({
847
+ type: "session_compact",
848
+ compactionEntry: savedCompactionEntry,
849
+ fromExtension,
850
+ });
851
+ }
852
+
853
+ const compactionResult: CompactionResult = {
854
+ summary,
855
+ shortSummary,
856
+ firstKeptEntryId,
857
+ tokensBefore,
858
+ details,
859
+ preserveData,
860
+ };
861
+ options?.onComplete?.(compactionResult);
862
+ return compactionResult;
863
+ } catch (error) {
864
+ const err = error instanceof Error ? error : new Error(String(error));
865
+ options?.onError?.(err);
866
+ throw error;
867
+ } finally {
868
+ if (this.#compactionAbortController === compactionAbortController) {
869
+ this.#compactionAbortController = undefined;
870
+ }
871
+ this.#host.reconnectToAgent();
872
+ // Compaction disconnected before `await abort()`, so abort's finally drain
873
+
874
+ // (and any steer/follow-up that arrived mid-compaction — async IRC, an
875
+ // `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
876
+ // (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
877
+ // queues, so nothing else resumes them: re-drain now that the listener is back
878
+ // and `isCompacting` is false, or the queued turn hangs until the next prompt.
879
+ this.#host.drainStrandedQueuedMessages();
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Ask the active memory backend for an extra-context block to splice into
885
+ * the compaction summary prompt. Both the manual and auto compaction paths
886
+ * funnel through this helper so the behaviour stays identical.
887
+ *
888
+ * Failures are swallowed: a memory backend going sideways MUST NOT block
889
+ * compaction (which is itself the recovery path for context overflow).
890
+ */
891
+ async #collectMemoryBackendContext(preparation: {
892
+ messagesToSummarize: AgentMessage[];
893
+ turnPrefixMessages: AgentMessage[];
894
+ }): Promise<string | undefined> {
895
+ const backend = await resolveMemoryBackend(this.#host.settings);
896
+ if (!backend.preCompactionContext) return undefined;
897
+ const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
898
+ try {
899
+ return await backend.preCompactionContext(messages, this.#host.settings, this.#host.memoryBackendSession());
900
+ } catch (err) {
901
+ logger.debug("Memory backend preCompactionContext failed", {
902
+ backend: backend.id,
903
+ error: String(err),
904
+ });
905
+ return undefined;
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff).
911
+ */
912
+ abortCompaction(): void {
913
+ this.#compactionAbortController?.abort();
914
+ this.#autoCompactionAbortController?.abort();
915
+ this.#host.abortHandoff();
916
+ }
917
+
918
+ /** Cancel only automatic maintenance while preserving a manual compaction. */
919
+ abortAutomaticCompaction(): void {
920
+ this.#autoCompactionAbortController?.abort();
921
+ }
922
+
923
+ /** Trigger idle compaction through the auto-compaction flow (with UI events). */
924
+ async runIdleCompaction(): Promise<void> {
925
+ if (this.#host.isStreaming() || this.isCompacting) return;
926
+ await this.runAutoCompaction("idle", false, true);
927
+ }
928
+
929
+ /**
930
+ * Local token estimate of the stored conversation (plus any pending messages),
931
+ * independent of provider-reported usage. A `before_provider_request` hook
932
+ * (e.g. a compression extension such as Headroom) or other on-wire payload
933
+ * transform can shrink the request below the real stored conversation; the
934
+ * provider then reports deflated prompt tokens, so anchoring the compaction
935
+ * decision purely on that usage lets the real history grow unbounded until it
936
+ * overflows and native compaction can no longer run. This estimate is the
937
+ * floor the compaction decision respects so on-wire compression can never
938
+ * suppress it.
939
+ */
940
+ #estimateStoredContextTokens(pendingMessages: AgentMessage[] = []): number {
941
+ // Exclude encrypted reasoning (thinkingSignature / redactedThinking): its
942
+ // local byte size diverges from what the provider bills, so counting it here
943
+ // would let a thinking-heavy turn falsely trip the floor. The provider usage
944
+ // (the other arm of compactionContextTokens) already accounts for it.
945
+ const opts = { excludeEncryptedReasoning: true } as const;
946
+ return (
947
+ computeNonMessageTokens(this.#host.nonMessageTokenSource()) +
948
+ this.#host.messages().reduce((sum, msg) => sum + estimateTokens(msg, opts), 0) +
949
+ pendingMessages.reduce((sum, msg) => sum + estimateTokens(msg, opts), 0)
950
+ );
951
+ }
952
+
953
+ #estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number {
954
+ const breakdown = this.#host.getContextBreakdown({ contextWindow, pendingMessages: messages });
955
+ const localEstimate = this.#estimateStoredContextTokens(messages);
956
+ // Floor by the local estimate: a payload-shrinking before_provider_request
957
+ // hook deflates the provider-anchored breakdown, which must not suppress
958
+ // pre-prompt compaction (see #estimateStoredContextTokens).
959
+ return compactionContextTokens(breakdown?.usedTokens ?? 0, localEstimate);
960
+ }
961
+
962
+ async runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
963
+ const model = this.#model;
964
+ if (!model) return;
965
+ const contextWindow = model.contextWindow ?? 0;
966
+ if (contextWindow <= 0) return;
967
+ const compactionSettings = this.#host.settings.getGroup("compaction");
968
+ const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
969
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
970
+
971
+ // Auto-promote first: switching to a larger-context model avoids compacting
972
+ // the history at all. The post-turn threshold path already promotes before
973
+ // compacting; without this, the pre-prompt path would pre-empt promotion and
974
+ // compact (snapcompact/summary) a session that should have just been promoted.
975
+ if (await this.#promoteContextModel()) {
976
+ logger.debug("Pre-prompt context promotion avoided compaction", {
977
+ contextTokens,
978
+ contextWindow,
979
+ model: `${model.provider}/${model.id}`,
980
+ });
981
+ return;
982
+ }
983
+
984
+ logger.debug("Pre-prompt context maintenance triggered by pending prompt size", {
985
+ contextTokens,
986
+ contextWindow,
987
+ model: `${model.provider}/${model.id}`,
988
+ });
989
+ await this.runAutoCompaction("threshold", false, false, false, {
990
+ autoContinue: false,
991
+ triggerContextTokens: contextTokens,
992
+ phase: "pre_turn",
993
+ });
994
+ }
995
+
996
+ /**
997
+ * Compact continuing tool-loop runs before the next provider request.
998
+ *
999
+ * `onTurnEnd` is the safe boundary: tool results for the just-finished turn
1000
+ * are already paired in `activeMessages`, the live array the agent loop reads
1001
+ * before its next model call. Before compacting, the just-finished turn is
1002
+ * synchronously persisted if async message hooks have not reached the normal
1003
+ * append path yet. Mid-run handoff is suppressed because resetting the session
1004
+ * while the loop owns `activeMessages` would race the next request; handoff
1005
+ * strategy falls back to in-place context-full compaction here.
1006
+ */
1007
+ async maintainContextMidRun(
1008
+ activeMessages: AgentMessage[],
1009
+ signal: AbortSignal | undefined,
1010
+ context: AgentTurnEndContext | undefined,
1011
+ ): Promise<void> {
1012
+ if (
1013
+ signal?.aborted ||
1014
+ this.#host.isDisposed() ||
1015
+ this.isCompacting ||
1016
+ this.#host.isGeneratingHandoff() ||
1017
+ !context?.willContinue
1018
+ )
1019
+ return;
1020
+
1021
+ const model = this.#model;
1022
+ const contextWindow = model?.contextWindow ?? 0;
1023
+ if (contextWindow <= 0) return;
1024
+
1025
+ const compactionSettings = this.#host.settings.getGroup("compaction");
1026
+ if (
1027
+ !compactionSettings.enabled ||
1028
+ compactionSettings.strategy === "off" ||
1029
+ compactionSettings.midTurnEnabled === false
1030
+ ) {
1031
+ return;
1032
+ }
1033
+
1034
+ const lastAssistant = [...activeMessages]
1035
+ .reverse()
1036
+ .find((message): message is AssistantMessage => message.role === "assistant");
1037
+ if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
1038
+
1039
+ if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return;
1040
+
1041
+ const billedContextTokens = calculateContextTokens(lastAssistant.usage);
1042
+ const storedContextTokens = this.#estimateStoredContextTokens();
1043
+ const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
1044
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
1045
+
1046
+ // Promote to a larger-context sibling before compacting, mirroring the
1047
+ // pre-prompt (runPrePromptCompactionIfNeeded) and post-turn threshold
1048
+ // (checkCompaction) paths. Without this, a long mid-turn tool loop that
1049
+ // crosses the threshold compacts the history (and can hit the no-progress
1050
+ // dead-end on a single oversized turn) on a model that should have just
1051
+ // been promoted to a larger window instead.
1052
+ if (await this.#promoteContextModel()) {
1053
+ logger.debug("Mid-run context promotion avoided compaction", {
1054
+ contextTokens,
1055
+ contextWindow,
1056
+ from: `${model?.provider}/${model?.id}`,
1057
+ });
1058
+ return;
1059
+ }
1060
+
1061
+ const messagesBefore = activeMessages.length;
1062
+ await this.runAutoCompaction("threshold", false, false, false, {
1063
+ autoContinue: false,
1064
+ suppressContinuation: true,
1065
+ suppressHandoff: true,
1066
+ triggerContextTokens: contextTokens,
1067
+ phase: "mid_turn",
1068
+ });
1069
+
1070
+ if (signal?.aborted) return;
1071
+ const compactedMessages = this.#host.agent.state.messages;
1072
+ if (compactedMessages !== activeMessages) {
1073
+ activeMessages.splice(0, activeMessages.length, ...compactedMessages);
1074
+ }
1075
+ logger.debug("Mid-run compaction ran between provider calls", {
1076
+ contextTokens,
1077
+ contextWindow,
1078
+ strategy: compactionSettings.strategy,
1079
+ goalActive: this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active",
1080
+ messagesBefore,
1081
+ messagesAfter: activeMessages.length,
1082
+ });
1083
+ }
1084
+ /**
1085
+ * Check if context maintenance or promotion is needed and run it.
1086
+ * Called after agent_end and before prompt submission.
1087
+ *
1088
+ * Four cases (in order):
1089
+ * 1. Input overflow + promotion: promote to larger model, retry without maintenance.
1090
+ * 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
1091
+ * 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
1092
+ * model burned its output budget without producing an actionable deliverable
1093
+ * (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
1094
+ * run compaction/handoff and retry.
1095
+ * 4. Threshold: context over threshold, run context maintenance (no auto-retry).
1096
+ *
1097
+ * @param assistantMessage The assistant message to check
1098
+ * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1099
+ * @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a
1100
+ * deferred post-prompt task instead of running inline. Callers running inside the
1101
+ * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
1102
+ * on the pre-prompt path (where the next agent turn is about to start) set it to false
1103
+ * to avoid racing the deferred handoff against the new turn.
1104
+ * @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
1105
+ * @returns whether compaction/recovery scheduled a handoff, retry, auto-continue, or
1106
+ * queued-message drain that already owns the next turn. Callers MUST skip
1107
+ * `session_stop` and other agent continuations when `continuationScheduled`
1108
+ * is true.
1109
+ */
1110
+ async checkCompaction(
1111
+ assistantMessage: AssistantMessage,
1112
+ skipAbortedCheck = true,
1113
+ allowDefer = true,
1114
+ autoContinue = true,
1115
+ ): Promise<CompactionCheckResult> {
1116
+ // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
1117
+ if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return COMPACTION_CHECK_NONE;
1118
+ const contextWindow = this.#model?.contextWindow ?? 0;
1119
+ const generation = this.#host.promptGeneration();
1120
+ // Skip overflow check if the message came from a different model.
1121
+ // This handles the case where user switched from a smaller-context model (e.g. opus)
1122
+ // to a larger-context model (e.g. codex) - the overflow error from the old model
1123
+ // shouldn't trigger compaction for the new model.
1124
+ const sameModel =
1125
+ this.#model && assistantMessage.provider === this.#model.provider && assistantMessage.model === this.#model.id;
1126
+ // This handles the case where an error was kept after compaction (in the "kept" region).
1127
+ // The error shouldn't trigger another compaction since we already compacted.
1128
+ // Example: opus fails -> switch to codex -> compact -> switch back to opus -> opus error
1129
+ // is still in context but shouldn't trigger compaction again.
1130
+ const compactionEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch());
1131
+ const errorIsFromBeforeCompaction =
1132
+ compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
1133
+ if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
1134
+ // Clear the failed turn from active context so the retry (or the next
1135
+ // user prompt) does not replay it. The persisted branch entry stays
1136
+ // for now: when no recovery path runs, the user-facing transcript
1137
+ // MUST keep the only assistant message explaining why the turn
1138
+ // stopped. The branch entry is dropped further down, but only on the
1139
+ // paths that actually schedule a retry/compaction.
1140
+ this.#host.removeAssistantMessageFromActiveContext(assistantMessage);
1141
+
1142
+ // Try context promotion first - switch to a larger model and retry without compacting
1143
+ const promoted = await this.#tryContextPromotion(assistantMessage);
1144
+ if (promoted) {
1145
+ await this.#host.dropPersistedAssistantTurn(assistantMessage);
1146
+ // Retry on the promoted (larger) model without compacting
1147
+ this.#host.scheduleAgentContinue({ delayMs: 100, generation });
1148
+ return COMPACTION_CHECK_CONTINUATION;
1149
+ }
1150
+
1151
+ // No promotion target available fall through to compaction
1152
+ const compactionSettings = this.#host.settings.getGroup("compaction");
1153
+ if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
1154
+ return await this.#host.runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, {
1155
+ autoContinue,
1156
+ });
1157
+ }
1158
+ return COMPACTION_CHECK_NONE;
1159
+ }
1160
+ // A context promotion can land while the failing call is already in
1161
+ // flight (or on a run whose loop predates the switch): the overflow
1162
+ // error then arrives stamped with the pre-promotion model while
1163
+ // `this.#host.model()` is already the promoted target. The sameModel guard
1164
+ // above deliberately ignores stale foreign-model errors, but this
1165
+ // state is not stale — recover exactly like the promotion path:
1166
+ // drop the dead turn and retry on the already-promoted model. Gated
1167
+ // narrowly on "current model IS the failed model's promotion target
1168
+ // with a strictly larger window" so genuinely stale errors from
1169
+ // old user-switched models keep surfacing untouched.
1170
+ if (
1171
+ !sameModel &&
1172
+ autoContinue &&
1173
+ !errorIsFromBeforeCompaction &&
1174
+ assistantMessage.stopReason === "error" &&
1175
+ this.#model &&
1176
+ contextWindow > 0 &&
1177
+ this.#host.settings.getGroup("contextPromotion").enabled
1178
+ ) {
1179
+ const failedModel = this.#host.modelRegistry.find(assistantMessage.provider, assistantMessage.model);
1180
+ const failedWindow = failedModel?.contextWindow ?? 0;
1181
+ const promotionTarget = failedModel
1182
+ ? resolveContextPromotionConfiguredTarget(failedModel, this.#host.modelRegistry.getAvailable())
1183
+ : undefined;
1184
+ if (
1185
+ failedModel &&
1186
+ failedWindow > 0 &&
1187
+ contextWindow > failedWindow &&
1188
+ promotionTarget &&
1189
+ modelsAreEqual(promotionTarget, this.#model) &&
1190
+ AIError.isContextOverflow(assistantMessage, failedWindow)
1191
+ ) {
1192
+ this.#host.removeAssistantMessageFromActiveContext(assistantMessage);
1193
+ await this.#host.dropPersistedAssistantTurn(assistantMessage);
1194
+ logger.debug("Overflow on pre-promotion model; retrying on promoted model", {
1195
+ failed: `${assistantMessage.provider}/${assistantMessage.model}`,
1196
+ current: `${this.#model.provider}/${this.#model.id}`,
1197
+ });
1198
+ this.#host.scheduleAgentContinue({ delayMs: 100, generation });
1199
+ return COMPACTION_CHECK_CONTINUATION;
1200
+ }
1201
+ }
1202
+
1203
+ // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
1204
+ // (and Codex) maps to stopReason === "length". The model burned its
1205
+ // `max_output_tokens` budget on reasoning/text and emitted no actionable
1206
+ // deliverable. Same recovery class as overflow: promotion if available,
1207
+ // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
1208
+ // allow the handoff strategy to actually run.
1209
+ if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
1210
+ // Same active-context vs persisted-history split as the overflow path
1211
+ // above: clear the dead turn from agent state so it cannot be replayed,
1212
+ // but keep it on the branch unless promotion or compaction actually runs.
1213
+ this.#host.removeAssistantMessageFromActiveContext(assistantMessage);
1214
+
1215
+ const promoted = await this.#tryContextPromotion(assistantMessage);
1216
+ if (promoted) {
1217
+ await this.#host.dropPersistedAssistantTurn(assistantMessage);
1218
+ logger.debug("Context promotion triggered by response.incomplete (length stop)", {
1219
+ from: `${assistantMessage.provider}/${assistantMessage.model}`,
1220
+ });
1221
+ this.#host.scheduleAgentContinue({ delayMs: 100, generation });
1222
+ return COMPACTION_CHECK_CONTINUATION;
1223
+ }
1224
+
1225
+ const incompleteCompactionSettings = this.#host.settings.getGroup("compaction");
1226
+ if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
1227
+ logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
1228
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
1229
+ strategy: incompleteCompactionSettings.strategy,
1230
+ });
1231
+ return await this.#host.runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, {
1232
+ autoContinue,
1233
+ triggerContextTokens: calculateContextTokens(assistantMessage.usage),
1234
+ });
1235
+ }
1236
+ // Neither promotion nor compaction is available — surface the dead-end so
1237
+ // the user understands why the turn yielded with nothing.
1238
+ logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
1239
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
1240
+ });
1241
+ return COMPACTION_CHECK_NONE;
1242
+ }
1243
+
1244
+ // Stale-result pass runs every turn, before any threshold gating: it is
1245
+ // cheap (bails when no candidate) and independent of the compaction
1246
+ // setting.
1247
+ const supersedeResult = await this.#pruneStaleToolResults();
1248
+
1249
+ const compactionSettings = this.#host.settings.getGroup("compaction");
1250
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
1251
+
1252
+ // Case 4: Threshold - turn succeeded but context is getting large
1253
+ // Skip if this was an error (non-overflow errors don't have usage data)
1254
+ if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
1255
+ const pruneResult = await this.#pruneToolOutputs();
1256
+ const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
1257
+ // `errorIsFromBeforeCompaction` (computed above) is the general
1258
+ // "this assistant message predates the latest compaction" predicate here,
1259
+ // not just an error-specific one; alias it locally so the threshold intent
1260
+ // reads clearly (#3412 review).
1261
+ const assistantPredatesCompaction = errorIsFromBeforeCompaction;
1262
+ // An assistant that predates the latest compaction carries stale, pre-rewrite
1263
+ // `usage`: the scheduled auto-continue re-enters this check with the kept
1264
+ // assistant (#promptWithMessage → checkCompaction), and its old high prompt
1265
+ // count would re-trip the threshold on a freshly compacted history. Drop the
1266
+ // stale provider number for those messages and let the live stored estimate
1267
+ // (the floor applied below) drive the decision instead.
1268
+ const assistantUsageContextTokens = assistantPredatesCompaction
1269
+ ? 0
1270
+ : calculateContextTokens(assistantMessage.usage);
1271
+ const storedContextTokens = this.#estimateStoredContextTokens();
1272
+ // Pruning frees bytes for the NEXT prompt; it does not change the size of
1273
+ // the prompt the LLM just billed for. Earlier revisions subtracted the
1274
+ // per-turn supersede/prune `tokensSaved` from the threshold input, which
1275
+ // let a long-running `/goal` session sit above `compaction.thresholdTokens`
1276
+ // indefinitely whenever per-turn pruning saved enough to drop the
1277
+ // post-prune estimate below the user-configured trigger — the visible
1278
+ // context (anchored to the same provider billing) still showed >threshold,
1279
+ // but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
1280
+ // last turn's billed context tokens, floored by the post-prune
1281
+ // stored-conversation estimate so a payload-compression hook still can't
1282
+ // deflate the trigger.
1283
+ const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
1284
+ const postMaintenanceContextTokens = compactionContextTokens(
1285
+ Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
1286
+ storedContextTokens,
1287
+ );
1288
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
1289
+ const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
1290
+ logger.debug("Auto-compaction threshold decision", {
1291
+ phase: "post-agent-end",
1292
+ goalModeEnabled: this.#goalModeState?.enabled === true,
1293
+ goalStatus: this.#goalModeState?.goal.status,
1294
+ stopReason: assistantMessage.stopReason,
1295
+ sameModel: sameModel === true,
1296
+ contextWindow,
1297
+ strategy: compactionSettings.strategy,
1298
+ thresholdTokens,
1299
+ assistantUsageContextTokens,
1300
+ storedContextTokens,
1301
+ resolvedContextTokens: contextTokens,
1302
+ postMaintenanceContextTokens,
1303
+ maintenanceTokensFreed,
1304
+ shouldCompact: shouldThresholdCompact,
1305
+ contextPromotionEnabled: this.#host.settings.get("contextPromotion.enabled") === true,
1306
+ });
1307
+ if (shouldThresholdCompact) {
1308
+ // Try promotion first — if a larger model is available, switch instead of compacting
1309
+ const promoted = await this.#tryContextPromotion(assistantMessage);
1310
+ if (!promoted) {
1311
+ return await this.runAutoCompaction("threshold", false, false, allowDefer, {
1312
+ autoContinue,
1313
+ triggerContextTokens: postMaintenanceContextTokens,
1314
+ phase: "pre_turn",
1315
+ terminalTextAnswer: isTerminalTextAssistantAnswer(assistantMessage),
1316
+ });
1317
+ }
1318
+ logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
1319
+ contextTokens,
1320
+ contextWindow,
1321
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
1322
+ });
1323
+ }
1324
+ return COMPACTION_CHECK_NONE;
1325
+ }
1326
+
1327
+ /**
1328
+ * Attempt context promotion to a larger model.
1329
+ * Returns true if promotion succeeded (caller should retry without compacting).
1330
+ */
1331
+ async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
1332
+ const currentModel = this.#model;
1333
+ if (!currentModel) return false;
1334
+ // The overflow/length error may have come from a model the user already
1335
+ // switched away from; only promote when the failing turn was this model.
1336
+ if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
1337
+ return false;
1338
+ return this.#promoteContextModel();
1339
+ }
1340
+
1341
+ /**
1342
+ * Switch to a larger-context sibling when context promotion is enabled and a
1343
+ * target with a strictly larger window (and a usable key) exists. Returns true
1344
+ * when the model was switched, so the caller can retry without compacting.
1345
+ * Message-independent core shared by the post-turn overflow path
1346
+ * ({@link #tryContextPromotion}) and the pre-prompt threshold path
1347
+ * ({@link runPrePromptCompactionIfNeeded}).
1348
+ */
1349
+ async #promoteContextModel(): Promise<boolean> {
1350
+ const promotionSettings = this.#host.settings.getGroup("contextPromotion");
1351
+ if (!promotionSettings.enabled) return false;
1352
+ const currentModel = this.#model;
1353
+ if (!currentModel) return false;
1354
+ const contextWindow = currentModel.contextWindow ?? 0;
1355
+ if (contextWindow <= 0) return false;
1356
+ const targetModel = await this.resolveContextPromotionTarget(currentModel, contextWindow);
1357
+ if (!targetModel) return false;
1358
+
1359
+ try {
1360
+ await this.#host.setModelTemporary(targetModel, undefined, { ephemeral: true });
1361
+ logger.debug("Context promotion switched model on overflow", {
1362
+ from: `${currentModel.provider}/${currentModel.id}`,
1363
+ to: `${targetModel.provider}/${targetModel.id}`,
1364
+ });
1365
+ return true;
1366
+ } catch (error) {
1367
+ logger.warn("Context promotion failed", {
1368
+ from: `${currentModel.provider}/${currentModel.id}`,
1369
+ to: `${targetModel.provider}/${targetModel.id}`,
1370
+ error: String(error),
1371
+ });
1372
+ return false;
1373
+ }
1374
+ }
1375
+
1376
+ async resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
1377
+ const availableModels = this.#host.modelRegistry.getAvailable();
1378
+ if (availableModels.length === 0) return undefined;
1379
+
1380
+ const candidate = resolveContextPromotionConfiguredTarget(currentModel, availableModels);
1381
+ if (!candidate) return undefined;
1382
+ if (modelsAreEqual(candidate, currentModel)) return undefined;
1383
+ if (candidate.contextWindow == null || candidate.contextWindow <= contextWindow) return undefined;
1384
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
1385
+ if (!apiKey) return undefined;
1386
+ return candidate;
1387
+ }
1388
+
1389
+ #getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] {
1390
+ return this.resolveCompactionModelCandidates(this.#model, availableModels, filter);
1391
+ }
1392
+
1393
+ resolveCompactionModelCandidates(
1394
+ preferredModel: Model | null | undefined,
1395
+ availableModels: Model[],
1396
+ filter?: (model: Model) => boolean,
1397
+ ): Model[] {
1398
+ const candidates: Model[] = [];
1399
+ const seen = new Set<string>();
1400
+
1401
+ const addCandidate = (model: Model | undefined): void => {
1402
+ if (!model) return;
1403
+ const key = `${model.provider}/${model.id}`;
1404
+ if (seen.has(key)) return;
1405
+ seen.add(key);
1406
+ // `seen` still tracks rejected models so the largest-context fallback
1407
+ // scan below doesn't reintroduce them; the filter just suppresses
1408
+ // inclusion in this caller's candidate chain.
1409
+ if (filter && !filter(model)) return;
1410
+ candidates.push(model);
1411
+ };
1412
+
1413
+ if (preferredModel) {
1414
+ addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels));
1415
+ }
1416
+ addCandidate(preferredModel ?? undefined);
1417
+ for (const role of MODEL_ROLE_IDS) {
1418
+ addCandidate(
1419
+ resolveRoleModelFull(this.#host.settings, role, availableModels, preferredModel ?? undefined).model,
1420
+ );
1421
+ }
1422
+
1423
+ const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
1424
+ for (const model of sortedByContext) {
1425
+ if (!seen.has(`${model.provider}/${model.id}`)) {
1426
+ addCandidate(model);
1427
+ break;
1428
+ }
1429
+ }
1430
+
1431
+ return candidates;
1432
+ }
1433
+
1434
+ #buildCompactionAuthError(): Error {
1435
+ const currentModel = this.#model;
1436
+ if (!currentModel) {
1437
+ return new Error(
1438
+ "Compaction requires a model with usable credentials, but no authenticated compaction model is available.",
1439
+ );
1440
+ }
1441
+ return new Error(
1442
+ `Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` +
1443
+ `Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`,
1444
+ );
1445
+ }
1446
+
1447
+ async #compactWithFallbackModel(
1448
+ preparation: CompactionPreparation,
1449
+ customInstructions: string | undefined,
1450
+ signal: AbortSignal,
1451
+ options?: SummaryOptions,
1452
+ precomputedCandidates?: Model[],
1453
+ ): Promise<CompactionResult> {
1454
+ const candidates =
1455
+ precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable());
1456
+ const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
1457
+
1458
+ for (const candidate of candidates) {
1459
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
1460
+ if (!apiKey) continue;
1461
+
1462
+ try {
1463
+ return await compact(
1464
+ this.#host.obfuscatePreparationForProvider(preparation),
1465
+ candidate,
1466
+ this.#host.modelRegistry.resolver(candidate, this.#host.sessionId()),
1467
+ this.#host.obfuscateTextForProvider(customInstructions),
1468
+ signal,
1469
+ {
1470
+ ...options,
1471
+ metadata: this.#host.agent.metadataForProvider(candidate.provider),
1472
+ convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
1473
+ telemetry,
1474
+ // Honor the user's /model thinking selection (incl. `off`) on
1475
+ // the manual `/compact` path. Clamped per-model inside compact()
1476
+ // via resolveCompactionEffort so unsupported-effort models
1477
+ // (xai-oauth/grok-build) don't trip requireSupportedEffort.
1478
+ thinkingLevel: this.#host.thinkingLevel(),
1479
+ tools: this.#host.agent.state.tools,
1480
+ sessionId: this.#host.sessionId(),
1481
+ promptCacheKey: this.#host.sessionId(),
1482
+ providerSessionState: this.#host.providerSessionState,
1483
+ // Route every summarization HTTP request through the
1484
+ // session's side-stream transport so the provider
1485
+ // concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
1486
+ // brackets compaction the same way it brackets the live
1487
+ // agent turn — without this, multiple ollama-cloud
1488
+ // subagents auto/manually compacting issued uncapped
1489
+ // summary requests in parallel (chatgpt-codex review on
1490
+ // #3751).
1491
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
1492
+ const stream = await this.#host.sideStreamFn(requestModel, requestContext, requestOptions);
1493
+ return stream.result();
1494
+ },
1495
+ },
1496
+ );
1497
+ } catch (error) {
1498
+ if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) {
1499
+ throw error;
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ throw this.#buildCompactionAuthError();
1505
+ }
1506
+
1507
+ async #prepareCompactionFromHooks(
1508
+ preparation: CompactionPreparation,
1509
+ hookCompaction: CompactionResult | undefined,
1510
+ ): Promise<
1511
+ | {
1512
+ kind: "fromHook";
1513
+ summary: string;
1514
+ shortSummary: string | undefined;
1515
+ firstKeptEntryId: string;
1516
+ tokensBefore: number;
1517
+ details: unknown;
1518
+ preserveData: Record<string, unknown> | undefined;
1519
+ }
1520
+ | {
1521
+ kind: "needsLlm";
1522
+ hookContext: string[] | undefined;
1523
+ hookPrompt: string | undefined;
1524
+ preserveData: Record<string, unknown> | undefined;
1525
+ }
1526
+ > {
1527
+ let hookContext: string[] | undefined;
1528
+ let hookPrompt: string | undefined;
1529
+ let preserveData: Record<string, unknown> | undefined;
1530
+
1531
+ if (!hookCompaction && this.#host.extensionRunner?.hasHandlers("session.compacting")) {
1532
+ const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
1533
+ const result = (await this.#host.extensionRunner.emit({
1534
+ type: "session.compacting",
1535
+ sessionId: this.#host.sessionId(),
1536
+ messages: compactMessages,
1537
+ })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
1538
+
1539
+ hookContext = result?.context;
1540
+ hookPrompt = result?.prompt;
1541
+ preserveData = result?.preserveData;
1542
+ }
1543
+
1544
+ const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
1545
+ if (memoryBackendContext) {
1546
+ hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
1547
+ }
1548
+
1549
+ if (hookCompaction) {
1550
+ preserveData ??= hookCompaction.preserveData;
1551
+ return {
1552
+ kind: "fromHook",
1553
+ summary: hookCompaction.summary,
1554
+ shortSummary: hookCompaction.shortSummary,
1555
+ firstKeptEntryId: hookCompaction.firstKeptEntryId,
1556
+ tokensBefore: hookCompaction.tokensBefore,
1557
+ details: hookCompaction.details,
1558
+ preserveData,
1559
+ };
1560
+ }
1561
+
1562
+ return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
1563
+ }
1564
+
1565
+ /**
1566
+ * Cap on snapcompact frames the post-compaction context can carry without
1567
+ * busting the model window. Mirrors the per-frame token charge used by the
1568
+ * projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative
1569
+ * high-res Anthropic ceiling), so picking `maxFrames` from this helper makes
1570
+ * {@link #projectSnapcompactContextTokens} succeed by construction.
1571
+ *
1572
+ * Skip vs. cap use different reserves on purpose. The **skip** decision
1573
+ * (return `0`) trips only when kept-recent plus non-message tokens already
1574
+ * eat the entire `ctxWindow − reserve` envelope: at that point no archive
1575
+ * shape — frame-bearing or text-only — can fit, and the caller MUST
1576
+ * shortcut to the LLM summarizer instead of re-running snapcompact to
1577
+ * re-emit the "could not bring the context under the limit" warning every
1578
+ * threshold tick. The **cap** calculation subtracts a shape-aware reserve
1579
+ * (`2 × geometry(shape).capacity` chars worth of text edges, billed at the
1580
+ * tiktoken cl100k baseline, plus a 2k summary-template allowance) sized
1581
+ * from the same `shape` snapcompact will use, so the projection still
1582
+ * passes once frames land — but it MUST NOT gate the skip decision, since
1583
+ * a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in
1584
+ * `planArchive`) typically costs only a few hundred tokens of summary
1585
+ * lead and would fit under residual headroom far smaller than the cap
1586
+ * reserve (chatgpt-codex reviews on #3249).
1587
+ *
1588
+ * Returns `1` when the frame charge would overflow but the text-only path
1589
+ * still has room: snapcompact's planner picks the frame-less layout
1590
+ * automatically when the discarded text fits in the edges, so giving it
1591
+ * the minimum cap lets it succeed instead of being skipped outright.
1592
+ *
1593
+ * Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens =
1594
+ * ~402k frame-token projection always overflows any sub-1M-token window
1595
+ * (issue #3247).
1596
+ */
1597
+ #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
1598
+ const ctxWindow = this.#model?.contextWindow ?? 0;
1599
+ if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
1600
+ const reserve = effectiveReserveTokens(ctxWindow, settings);
1601
+ let baseTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource());
1602
+ for (const message of preparation.recentMessages) {
1603
+ baseTokens += estimateTokens(message);
1604
+ }
1605
+ const totalBudget = ctxWindow - reserve;
1606
+ // Skip iff there is no headroom whatsoever; a text-only archive costs
1607
+ // far less than the cap reserve below, so any positive residual is
1608
+ // worth attempting and the projection guard catches actual overflow.
1609
+ if (baseTokens >= totalBudget) return 0;
1610
+ // Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge
1611
+ // when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)`
1612
+ // plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this
1613
+ // snapcompact pass will actually use (matches the `shape` argument
1614
+ // passed to `snapcompact.compact` in the auto and manual paths) so the
1615
+ // text-edge cost reflects the live frame geometry rather than a fixed
1616
+ // approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve
1617
+ // undersized the ~7k text-edge cost on the default Anthropic
1618
+ // 11on16-bw shape, so the projection then rejected the `maxFrames`
1619
+ // the cap had picked and the warning loop reappeared.
1620
+ //
1621
+ // - `textHead` and `textTail` each consume up to `geometry.capacity`
1622
+ // chars when frames > 0 (one HQ-capacity page per edge: see
1623
+ // `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars
1624
+ // total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus
1625
+ // 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k.
1626
+ // - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically
1627
+ // for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer
1628
+ // drift on denser content (e.g. dense JSON / tool-result blobs).
1629
+ // - Summary template (intro + FILES section + grid notes) bills
1630
+ // ~2k tokens for typical sessions.
1631
+ const shape = snapcompact.resolveShape(this.#model, this.#host.settings.get("snapcompact.shape"));
1632
+ const edgeCap = snapcompact.geometry(shape).capacity;
1633
+ const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
1634
+ const SUMMARY_TEMPLATE_TOKENS = 2000;
1635
+ const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
1636
+ const frameBudget = totalBudget - baseTokens - capReserve;
1637
+ if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
1638
+ return Math.min(
1639
+ Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
1640
+ snapcompact.MAX_FRAMES_DEFAULT,
1641
+ snapcompact.maxFramesForDataBudget(),
1642
+ );
1643
+ }
1644
+
1645
+ #snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number {
1646
+ const archive = snapcompact.getPreservedArchive(result.preserveData);
1647
+ return archive ? snapcompact.frameDataBytes(archive.frames) : 0;
1648
+ }
1649
+
1650
+ /**
1651
+ * Project the post-compaction context size of a snapcompact result: kept
1652
+ * recent messages + the summary message with its re-attached frames + the
1653
+ * fixed non-message overhead (system prompt + tools). Mirrors how the
1654
+ * compacted context is rebuilt, so the estimate matches the wire shape, and
1655
+ * lets the caller decide whether snapcompact brought the context under the
1656
+ * window or should fall back to an LLM summary.
1657
+ */
1658
+ #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
1659
+ const archive = snapcompact.getPreservedArchive(result.preserveData);
1660
+ const blocks = archive
1661
+ ? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET })
1662
+ : undefined;
1663
+ const summaryMessage = createCompactionSummaryMessage(
1664
+ result.summary,
1665
+ result.tokensBefore,
1666
+ new Date().toISOString(),
1667
+ result.shortSummary,
1668
+ undefined,
1669
+ undefined,
1670
+ blocks,
1671
+ );
1672
+ let tokens = computeNonMessageTokens(this.#host.nonMessageTokenSource()) + estimateTokens(summaryMessage);
1673
+ for (const message of preparation.recentMessages) {
1674
+ tokens += estimateTokens(message);
1675
+ }
1676
+ return tokens;
1677
+ }
1678
+
1679
+ /**
1680
+ * Post-maintenance progress check for the context-full / snapcompact tail.
1681
+ *
1682
+ * After `appendCompaction` rewrote history and `replaceMessages` swapped in the
1683
+ * compacted context, measure the residual context off the live message set and
1684
+ * decide whether maintenance actually created headroom. Mirrors the shake
1685
+ * recovery-band logic (#2275): a session whose single most-recent turn already
1686
+ * blows the threshold cannot be reduced by compaction (findCutPoint keeps that
1687
+ * turn verbatim), so re-firing on the next agent_end just thrashes. We only
1688
+ * report progress when residual context lands at or below
1689
+ * `COMPACTION_RECOVERY_BAND × threshold` — a band that sits strictly under the
1690
+ * compaction threshold, so reaching it guarantees the next turn cannot
1691
+ * re-trip threshold compaction.
1692
+ *
1693
+ * When the model/window is unknown we cannot evaluate the band, so we
1694
+ * optimistically allow the continuation (preserving prior behavior).
1695
+ */
1696
+ #compactionCreatedHeadroom(): boolean {
1697
+ const contextWindow = this.#model?.contextWindow ?? 0;
1698
+ if (contextWindow <= 0) return true;
1699
+ const compactionSettings = this.#host.settings.getGroup("compaction");
1700
+ const residualTokens = compactionContextTokens(
1701
+ this.#host.getContextUsage({ contextWindow })?.tokens ?? 0,
1702
+ this.#estimateStoredContextTokens(),
1703
+ );
1704
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
1705
+ const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
1706
+ // Residual at/below the band is authoritative headroom: the band sits
1707
+ // strictly under the compaction threshold, so the next turn cannot
1708
+ // re-trip threshold compaction regardless of how little this pass shaved.
1709
+ // Don't add a secondary "smaller than the trigger" guard — when stale/
1710
+ // tool-output pruning already dropped context under the band before this
1711
+ // pass, the trigger is itself sub-band, and requiring a strict reduction
1712
+ // would suppress a valid continuation and emit a false no-progress warning
1713
+ // even though compaction left the session safe.
1714
+ return residualTokens <= recoveryBand;
1715
+ }
1716
+
1717
+ /**
1718
+ * Retry-side counterpart to {@link #compactionCreatedHeadroom}. An
1719
+ * overflow/incomplete recovery only needs the rebuilt prompt to *fit* the
1720
+ * window again — it does not have to land under the compaction threshold, let
1721
+ * alone the stricter `COMPACTION_RECOVERY_BAND × threshold` hysteresis the
1722
+ * auto-continue thrash guard uses. Reusing the band here turned recoverable
1723
+ * overflows into manual dead-ends: a 200k-window prompt compacted from
1724
+ * overflow down to ~150k is comfortably retryable, but sits above
1725
+ * `0.8 × 170k = 136k` and was wrongly refused (PR #3412 review).
1726
+ *
1727
+ * Measures residual context against the usable budget (`contextWindow - reserve`).
1728
+ * The default absolute reserve can exceed bundled small-context windows, or
1729
+ * nearly consume a 16k-class window; those known-impossible defaults fall
1730
+ * back to the proportional 15% reserve. Explicit valid reserves still define
1731
+ * the usable prompt budget so retries do not enter headroom the user
1732
+ * intentionally reserved. Callers MUST
1733
+ * invoke this AFTER dropping the failed assistant from `this.#host.messages()`, so
1734
+ * the just-failed turn (which the retry prompt will not include) is excluded
1735
+ * from the estimate.
1736
+ *
1737
+ * When the model/window is unknown we cannot evaluate the budget, so we
1738
+ * optimistically allow the retry (preserving prior behavior).
1739
+ */
1740
+ #compactionCreatedRetryFit(): boolean {
1741
+ const contextWindow = this.#model?.contextWindow ?? 0;
1742
+ if (contextWindow <= 0) return true;
1743
+ const compactionSettings = this.#host.settings.getGroup("compaction");
1744
+ const residualTokens = compactionContextTokens(
1745
+ this.#host.getContextUsage({ contextWindow })?.tokens ?? 0,
1746
+ this.#estimateStoredContextTokens(),
1747
+ );
1748
+ const fitBudget = Math.max(0, contextWindow - resolveBudgetReserveTokens(contextWindow, compactionSettings));
1749
+ return residualTokens <= fitBudget;
1750
+ }
1751
+
1752
+ /**
1753
+ * Last-resort tiered reducer when {@link runAutoCompaction} would otherwise
1754
+ * dead-end. The summarizer cut at the only available turn boundary, but the
1755
+ * kept tail is still over the recovery band because a single recent turn (a
1756
+ * large tool-result, a heavy fenced/XML block, attached images) is itself
1757
+ * bigger than the band and `findCutPoint` cannot cut inside one message.
1758
+ *
1759
+ * Tier 1 — `shake("elide")` reaches INSIDE that tail: heavy tool-result /
1760
+ * block content is offloaded to one `artifact://` blob behind a recoverable
1761
+ * placeholder. Skipped when this pass already ran a shake (`skipElide`).
1762
+ * Tier 2 — `dropImages()`: the manual `/shake images` remedy, automated.
1763
+ * Image blocks are stripped from the branch; unlike elided text they are NOT
1764
+ * artifact-recoverable, so this tier only runs once elide has failed the
1765
+ * progress re-test.
1766
+ *
1767
+ * Each tier that rewrote history re-anchors the in-flight context snapshot,
1768
+ * then the caller's progress predicate is re-tested; the first tier that
1769
+ * restores progress emits one info notice describing everything freed and
1770
+ * stops. Returns whether progress was restored — `false` falls through to
1771
+ * the dead-end warning.
1772
+ */
1773
+ async #rescueCompactionDeadEnd(
1774
+ signal: AbortSignal,
1775
+ options: { skipElide: boolean; hasProgress: () => boolean },
1776
+ ): Promise<boolean> {
1777
+ if (signal.aborted) return false;
1778
+ // Tier 0 — a snapcompact pass whose just-written frame archive is itself
1779
+ // the over-budget cost (each pass re-renders the carried-forward text
1780
+ // into MORE frames, so the archive grows past the recovery band and the
1781
+ // elide/image tiers below can never shrink it): rebuild the archive at
1782
+ // a threshold-derived frame budget.
1783
+ const frameRescue = await this.#rescueSnapcompactFrameOverflow(
1784
+ this.#host.sessionManager.getBranch(),
1785
+ this.#host.settings.getGroup("compaction"),
1786
+ signal,
1787
+ );
1788
+ if (frameRescue !== undefined && options.hasProgress()) return true;
1789
+ let elided = 0;
1790
+ let elidedTokens = 0;
1791
+ let elideSink = "placeholders";
1792
+ if (!options.skipElide) {
1793
+ try {
1794
+ const result = await this.#host.shake("elide", { signal });
1795
+ elided = result.toolResultsDropped + result.blocksDropped;
1796
+ elidedTokens = result.tokensFreed;
1797
+ if (result.artifactId) elideSink = "an artifact";
1798
+ if (elided > 0) {
1799
+ // The elide pass rewrote history; re-anchor the in-flight snapshot
1800
+ // so the caller's headroom/retry-fit re-test measures the shaken
1801
+ // context.
1802
+ this.#host.rebaseAfterCompaction();
1803
+ }
1804
+ } catch (error) {
1805
+ logger.warn("Dead-end shake rescue failed", {
1806
+ error: error instanceof Error ? error.message : String(error),
1807
+ });
1808
+ }
1809
+ if (elided > 0 && options.hasProgress()) {
1810
+ this.#host.emitNotice(
1811
+ "info",
1812
+ `Compaction dead-end recovery: ${this.#describeElideRescue(elided, elidedTokens, elideSink)} so maintenance could make progress.`,
1813
+ "compaction",
1814
+ );
1815
+ return true;
1816
+ }
1817
+ }
1818
+ if (signal.aborted) return false;
1819
+ let imagesDropped = 0;
1820
+ try {
1821
+ imagesDropped = (await this.#host.dropImages()).removed;
1822
+ if (imagesDropped > 0) this.#host.rebaseAfterCompaction();
1823
+ } catch (error) {
1824
+ logger.warn("Dead-end image-drop rescue failed", {
1825
+ error: error instanceof Error ? error.message : String(error),
1826
+ });
1827
+ }
1828
+ if (imagesDropped > 0 && options.hasProgress()) {
1829
+ const elidedPart = elided > 0 ? `${this.#describeElideRescue(elided, elidedTokens, elideSink)} and ` : "";
1830
+ this.#host.emitNotice(
1831
+ "info",
1832
+ `Compaction dead-end recovery: ${elidedPart}dropped ${imagesDropped} attached image${imagesDropped === 1 ? "" : "s"} so maintenance could make progress.`,
1833
+ "compaction",
1834
+ );
1835
+ return true;
1836
+ }
1837
+ return false;
1838
+ }
1839
+
1840
+ /** Notice fragment for a dead-end elide tier: what was freed and where it went. */
1841
+ #describeElideRescue(elided: number, tokensFreed: number, sink: string): string {
1842
+ return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`;
1843
+ }
1844
+
1845
+ /**
1846
+ * Frame budget for {@link #rescueSnapcompactFrameOverflow}: targets
1847
+ * `COMPACTION_RECOVERY_BAND × threshold` (the same band
1848
+ * {@link #compactionCreatedHeadroom} re-tests), not the window-fit budget
1849
+ * {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive
1850
+ * must land back under the maintenance trigger, or the next settle
1851
+ * re-enters the same dead-end. Cap reserve mirrors
1852
+ * #computeSnapcompactMaxFrames (text edges + summary template), and
1853
+ * `keptTailTokens` charges the kept entries AFTER the archive so the
1854
+ * budget mirrors what #compactionCreatedHeadroom will actually measure.
1855
+ * Returns 0 when not even one frame fits that budget — the rebuild could
1856
+ * never create headroom, so the caller must not append it.
1857
+ */
1858
+ #computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number {
1859
+ const ctxWindow = this.#model?.contextWindow ?? 0;
1860
+ if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
1861
+ const thresholdTokens = resolveThresholdTokens(ctxWindow, settings);
1862
+ const recoveryBandTokens = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
1863
+ const baseTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource());
1864
+ const shape = snapcompact.resolveShape(this.#model, this.#host.settings.get("snapcompact.shape"));
1865
+ const edgeCap = snapcompact.geometry(shape).capacity;
1866
+ const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
1867
+ const SUMMARY_TEMPLATE_TOKENS = 2000;
1868
+ const frameBudget = recoveryBandTokens - baseTokens - keptTailTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS;
1869
+ if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0;
1870
+ // Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived
1871
+ // count above the per-request payload budget would "shrink" a huge
1872
+ // archive to a frame count the rebuilt prompt can never attach anyway.
1873
+ return Math.min(
1874
+ Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
1875
+ snapcompact.MAX_FRAMES_DEFAULT,
1876
+ snapcompact.maxFramesForDataBudget(),
1877
+ );
1878
+ }
1879
+
1880
+ /**
1881
+ * Dead-end rescue for a branch whose latest snapcompact CompactionEntry is
1882
+ * itself billed past the maintenance threshold
1883
+ * (`FRAME_TOKEN_ESTIMATE × frames`). Reaching the `!preparation` dead-end
1884
+ * proves everything after that entry is already kept-recent (nothing to
1885
+ * summarize), so the archive is the irreducible cost — and the elide/image
1886
+ * tiers can never touch it: `collectShakeRegions` and `dropImages()` only
1887
+ * inspect "message"/"custom_message" entries, so a `type: "compaction"`
1888
+ * entry falls through both and the session re-warns on every resume (the
1889
+ * shape issue #4786's rescue does not cover).
1890
+ *
1891
+ * Rebuilds the SAME archive locally — no LLM, no network — by re-running
1892
+ * `snapcompact.compact()` over the entry's carried-forward source text at
1893
+ * a maxFrames derived from the trigger threshold instead of the window:
1894
+ * `planArchive` truncates the oldest chars to fit, so the rebuilt entry
1895
+ * genuinely shrinks. The rebuilt entry keeps the stale entry's
1896
+ * `firstKeptEntryId`, so the kept tail is untouched, and persisting
1897
+ * through `appendCompaction()` lets the write-time superseded-compaction
1898
+ * elision drop the stale frame payload from the JSONL automatically.
1899
+ */
1900
+ async #rescueSnapcompactFrameOverflow(
1901
+ branchEntries: SessionEntry[],
1902
+ settings: CompactionSettings,
1903
+ signal: AbortSignal,
1904
+ ): Promise<snapcompact.CompactionResult | undefined> {
1905
+ if (signal.aborted) return undefined;
1906
+ // Re-rendering frames needs a vision-capable model, same gate as the
1907
+ // snapcompact strategy path.
1908
+ if (!this.#model?.input.includes("image")) return undefined;
1909
+ const staleEntry = getLatestCompactionEntry(branchEntries);
1910
+ if (!staleEntry) return undefined;
1911
+ // Only rescue when the archive is the actual source of the overflow.
1912
+ // The frame budget below charges every kept entry the rebuilt context
1913
+ // will still carry — the kept-recent region from `firstKeptEntryId`
1914
+ // (re-emitted before the archive by buildSessionContext) plus the
1915
+ // entries after the archive — on top of the fixed context, mirroring
1916
+ // what #compactionCreatedHeadroom will measure. When not even one
1917
+ // frame fits (e.g. a huge kept tool result dominates), rebuilding
1918
+ // would append the replacement compaction at the leaf — turning the
1919
+ // branch tail into a compaction entry, which prepareCompaction's
1920
+ // last-entry guard can never summarize past even after an elide
1921
+ // shrinks the real culprit. Bail and let the elide/image tiers handle
1922
+ // that tail instead.
1923
+ let keptTailTokens = 0;
1924
+ let inKeptRegion = false;
1925
+ for (const entry of branchEntries) {
1926
+ if (entry.id === staleEntry.firstKeptEntryId) inKeptRegion = true;
1927
+ if (entry.id === staleEntry.id) {
1928
+ // Everything after the archive is always kept.
1929
+ inKeptRegion = true;
1930
+ continue;
1931
+ }
1932
+ if (!inKeptRegion) continue;
1933
+ const message = (entry as { message?: AgentMessage }).message;
1934
+ if (message) keptTailTokens += estimateTokens(message);
1935
+ }
1936
+ const archive = snapcompact.getPreservedArchive(staleEntry.preserveData);
1937
+ if (!archive || archive.frames.length <= 1) return undefined;
1938
+ const archiveText = snapcompact.archiveSourceText(archive);
1939
+ if (!archiveText) return undefined;
1940
+ const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings, keptTailTokens);
1941
+ if (maxFrames < 1 || maxFrames >= archive.frames.length) return undefined;
1942
+
1943
+ const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined;
1944
+ const fileOps = snapcompact.createFileOps();
1945
+ for (const file of staleDetails?.readFiles ?? []) fileOps.read.add(file);
1946
+ for (const file of staleDetails?.modifiedFiles ?? []) fileOps.edited.add(file);
1947
+ const shapeSetting = this.#host.settings.get("snapcompact.shape");
1948
+ const shape = snapcompact.resolveShapeForText(archiveText, this.#model, shapeSetting);
1949
+ let result: snapcompact.CompactionResult;
1950
+ try {
1951
+ result = await snapcompact.compact(
1952
+ {
1953
+ firstKeptEntryId: staleEntry.firstKeptEntryId,
1954
+ messagesToSummarize: [],
1955
+ turnPrefixMessages: [],
1956
+ tokensBefore: staleEntry.tokensBefore,
1957
+ previousSummary: staleEntry.summary,
1958
+ previousPreserveData: staleEntry.preserveData,
1959
+ fileOps,
1960
+ },
1961
+ {
1962
+ convertToLlm,
1963
+ model: this.#model,
1964
+ ...(shapeSetting === "auto" ? {} : { shape }),
1965
+ maxFrames,
1966
+ },
1967
+ );
1968
+ } catch (error) {
1969
+ logger.warn("Dead-end snapcompact frame rescue failed", {
1970
+ error: error instanceof Error ? error.message : String(error),
1971
+ });
1972
+ return undefined;
1973
+ }
1974
+ if (signal.aborted) return undefined;
1975
+ const rebuilt = snapcompact.getPreservedArchive(result.preserveData);
1976
+ if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return undefined;
1977
+
1978
+ const rebuiltEntryId = this.#host.sessionManager.appendCompaction(
1979
+ result.summary,
1980
+ result.shortSummary,
1981
+ result.firstKeptEntryId,
1982
+ result.tokensBefore,
1983
+ result.details,
1984
+ false,
1985
+ result.preserveData,
1986
+ );
1987
+ const sessionContext = this.#host.buildDisplaySessionContext();
1988
+ this.#host.agent.replaceMessages(sessionContext.messages);
1989
+ this.#host.rebaseAfterCompaction();
1990
+ // Same post-rewrite bookkeeping as the regular compaction append: the
1991
+ // rebuilt context no longer carries the transient plan reference (#1246),
1992
+ // and advisor cursors / todo phases were derived from the replaced
1993
+ // history.
1994
+ this.#host.resetPlanReference();
1995
+ this.#host.resetAdvisorRuntimes();
1996
+ this.#host.syncTodoPhasesFromBranch();
1997
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
1998
+ // Extensions must see the entry that is now active, not (only) the one
1999
+ // this rebuild just superseded — mirror the regular append path's hook.
2000
+ const rebuiltEntry = this.#host.sessionManager.getEntries().find(e => e.id === rebuiltEntryId) as
2001
+ | CompactionEntry
2002
+ | undefined;
2003
+ if (this.#host.extensionRunner && rebuiltEntry) {
2004
+ await this.#host.extensionRunner.emit({
2005
+ type: "session_compact",
2006
+ compactionEntry: rebuiltEntry,
2007
+ fromExtension: false,
2008
+ });
2009
+ }
2010
+ this.#host.emitNotice(
2011
+ "info",
2012
+ `Compaction dead-end recovery: rebuilt the trailing snapcompact archive at a smaller frame budget (${archive.frames.length} → ${rebuilt.frames.length} frames) so maintenance could make progress.`,
2013
+ "compaction",
2014
+ );
2015
+ return result;
2016
+ }
2017
+
2018
+ /**
2019
+ * Internal: Run auto-compaction with events.
2020
+ *
2021
+ * @param allowDefer If true (default), threshold-driven handoff strategy is allowed to
2022
+ * schedule itself as a deferred post-prompt task and return a deferred-handoff result
2023
+ * immediately. The caller MUST treat that as "compaction will happen async — do not
2024
+ * also schedule `agent.continue()` for this turn", otherwise the deferred handoff
2025
+ * races a fresh streaming turn (the symptom: "Auto-handoff" loader + assistant
2026
+ * message still streaming). Callers on a path that is about to start a new agent
2027
+ * turn (e.g. the pre-prompt check in `#promptWithMessage`) pass `false` to force
2028
+ * inline execution so the handoff completes before the new turn begins.
2029
+ * @returns whether auto-compaction scheduled a follow-up turn.
2030
+ */
2031
+ async runAutoCompaction(
2032
+ reason: "overflow" | "threshold" | "idle" | "incomplete",
2033
+ willRetry: boolean,
2034
+ deferred = false,
2035
+ allowDefer = true,
2036
+ options: {
2037
+ autoContinue?: boolean;
2038
+ triggerContextTokens?: number;
2039
+ suppressContinuation?: boolean;
2040
+ suppressHandoff?: boolean;
2041
+ phase?: CodexCompactionContext["phase"];
2042
+ terminalTextAnswer?: boolean;
2043
+ } = {},
2044
+ ): Promise<CompactionCheckResult> {
2045
+ const compactionSettings = this.#host.settings.getGroup("compaction");
2046
+ if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
2047
+ if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
2048
+ const generation = this.#host.promptGeneration();
2049
+ const terminalTextAnswer =
2050
+ options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#host.findLastAssistantMessage());
2051
+ const suppressContinuation = options.suppressContinuation === true;
2052
+ const shouldAutoContinue =
2053
+ !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
2054
+ const suppressHandoff = options.suppressHandoff === true;
2055
+ let fallbackFromShake = false;
2056
+ // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
2057
+ // reclaims nothing we fall through to the summary-compaction body below so
2058
+ // the oversized input still gets resolved.
2059
+ if (compactionSettings.strategy === "shake") {
2060
+ const outcome = await this.#runAutoShake(
2061
+ reason,
2062
+ willRetry,
2063
+ generation,
2064
+ shouldAutoContinue,
2065
+ terminalTextAnswer,
2066
+ options.triggerContextTokens,
2067
+ suppressContinuation,
2068
+ );
2069
+ if (outcome !== "fallback") return outcome;
2070
+ fallbackFromShake = true;
2071
+ }
2072
+ // "overflow" and "incomplete" force inline execution because they are recovery
2073
+ // paths the caller wants resolved before scheduling the next turn. "idle" is
2074
+ // triggered by the idle loop and does its own scheduling.
2075
+ if (
2076
+ !suppressHandoff &&
2077
+ !deferred &&
2078
+ allowDefer &&
2079
+ reason !== "overflow" &&
2080
+ reason !== "incomplete" &&
2081
+ reason !== "idle" &&
2082
+ compactionSettings.strategy === "handoff"
2083
+ ) {
2084
+ this.#host.schedulePostPromptTask(
2085
+ async signal => {
2086
+ await Promise.resolve();
2087
+ if (signal.aborted) return;
2088
+ await this.runAutoCompaction(reason, willRetry, true, true, {
2089
+ ...options,
2090
+ terminalTextAnswer,
2091
+ });
2092
+ },
2093
+ { generation },
2094
+ );
2095
+ return {
2096
+ ...COMPACTION_CHECK_DEFERRED_HANDOFF,
2097
+ continuationScheduled: shouldAutoContinue,
2098
+ };
2099
+ }
2100
+
2101
+ // "overflow" forces context-full because the input itself is broken — a handoff
2102
+ // LLM call would hit the same overflow. "incomplete" is an output-side problem,
2103
+ // so a handoff request on the existing context is still viable.
2104
+ let action: "context-full" | "handoff" | "snapcompact" =
2105
+ compactionSettings.strategy === "snapcompact"
2106
+ ? "snapcompact"
2107
+ : compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
2108
+ ? "handoff"
2109
+ : "context-full";
2110
+ if (action === "snapcompact" && this.#model && !this.#model.input.includes("image")) {
2111
+ this.#host.emitNotice(
2112
+ "warning",
2113
+ `snapcompact needs a vision-capable active model (${this.#model.id} is text-only); using context-full auto-compaction instead.`,
2114
+ "compaction",
2115
+ );
2116
+ action = "context-full";
2117
+ }
2118
+ // Abort any older auto-compaction before installing this run's controller.
2119
+ this.#autoCompactionAbortController?.abort();
2120
+ const autoCompactionAbortController = new AbortController();
2121
+ this.#autoCompactionAbortController = autoCompactionAbortController;
2122
+ const autoCompactionSignal = autoCompactionAbortController.signal;
2123
+
2124
+ try {
2125
+ // Emit start AFTER the controller is installed so isCompacting is already true
2126
+ // for any listener — and for input routed during this emit's event-loop yield:
2127
+ // a message typed as the compaction loader appears must land in the compaction
2128
+ // queue, not the core steering queue (which handoff's agent.reset() would wipe).
2129
+ await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action });
2130
+ if (action === "handoff") {
2131
+ let handoffSwitchCancelled = false;
2132
+ const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
2133
+ const handoffResult = await this.#host.runHandoff(handoffFocus, {
2134
+ autoTriggered: true,
2135
+ signal: autoCompactionSignal,
2136
+ onSwitchCancelled: () => {
2137
+ handoffSwitchCancelled = true;
2138
+ },
2139
+ });
2140
+ if (!handoffResult) {
2141
+ const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled;
2142
+ if (aborted) {
2143
+ await this.#host.emitSessionEvent({
2144
+ type: "auto_compaction_end",
2145
+ action,
2146
+ result: undefined,
2147
+ aborted: true,
2148
+ willRetry: false,
2149
+ });
2150
+ return COMPACTION_CHECK_NONE;
2151
+ }
2152
+ logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
2153
+ reason,
2154
+ });
2155
+ action = "context-full";
2156
+ }
2157
+ if (handoffResult) {
2158
+ await this.#host.emitSessionEvent({
2159
+ type: "auto_compaction_end",
2160
+ action,
2161
+ result: undefined,
2162
+ aborted: false,
2163
+ willRetry: false,
2164
+ });
2165
+ const continuationScheduled =
2166
+ !autoCompactionSignal.aborted &&
2167
+ this.#host.scheduleCompactionContinuation({
2168
+ generation,
2169
+ autoContinue: reason !== "idle" && shouldAutoContinue,
2170
+ terminalTextAnswer,
2171
+ suppressContinuation,
2172
+ });
2173
+ return {
2174
+ ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
2175
+ historyRewritten: true,
2176
+ };
2177
+ }
2178
+ }
2179
+
2180
+ if (!this.#model) {
2181
+ await this.#host.emitSessionEvent({
2182
+ type: "auto_compaction_end",
2183
+ action,
2184
+ result: undefined,
2185
+ aborted: false,
2186
+ willRetry: false,
2187
+ skipped: true,
2188
+ });
2189
+ return COMPACTION_CHECK_NONE;
2190
+ }
2191
+
2192
+ const availableModels = this.#host.modelRegistry.getAvailable();
2193
+ if (availableModels.length === 0) {
2194
+ await this.#host.emitSessionEvent({
2195
+ type: "auto_compaction_end",
2196
+ action,
2197
+ result: undefined,
2198
+ aborted: false,
2199
+ willRetry: false,
2200
+ skipped: true,
2201
+ });
2202
+ return COMPACTION_CHECK_NONE;
2203
+ }
2204
+
2205
+ const pathEntries = this.#host.sessionManager.getBranch();
2206
+
2207
+ let pathEntriesForCompaction = pathEntries;
2208
+ let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.#model);
2209
+ if (!preparation) {
2210
+ // prepareCompaction found nothing to summarize because the kept region
2211
+ // is a single oversized recent turn — findCutPoint never cuts inside a
2212
+ // tool result, so a huge tool-result / fenced block tail leaves nothing
2213
+ // on the summarizable side and summary compaction cannot even start.
2214
+ // That is exactly the dead-end the elide shake rescues: it reaches
2215
+ // INSIDE the tail and offloads heavy content to an artifact placeholder,
2216
+ // shrinking the tail so findCutPoint can then move the cut and leave
2217
+ // older turns to summarize. Run the same tiered rescue the
2218
+ // post-maintenance guard uses (elide, then image drop), with progress
2219
+ // defined as "prepareCompaction now succeeds on the rewritten branch",
2220
+ // and fall through to the normal compaction body when it does (writing
2221
+ // a compaction entry anchors the stale billed usage so the
2222
+ // auto-continue re-check cannot re-trip and loop the warning — issue
2223
+ // #4786). `skipElide` when we already fell through from a shake
2224
+ // strategy pass (it tried and found nothing); skip entirely on the
2225
+ // idle timer (it re-checks usage on its own cadence).
2226
+ let rescueRewroteHistory = false;
2227
+ // A snapcompact CompactionEntry is invisible to both rescue tiers
2228
+ // below (they only inspect message entries) and to prepareCompaction
2229
+ // itself (last-entry-is-compaction guard), so a frame archive billed
2230
+ // past the threshold dead-ends here on every resume. Rebuild it at a
2231
+ // threshold-derived frame budget first — but treat that as complete
2232
+ // only when it actually created headroom: the latest archive may not
2233
+ // be the oversized tail (e.g. a huge kept tool result after it), and
2234
+ // declaring victory on a mere frame-count shrink would skip the
2235
+ // elide/image tiers that can still reach that tail and suppress a
2236
+ // warning the user should see.
2237
+ let frameRescueResult: snapcompact.CompactionResult | undefined;
2238
+ let frameRescueCreatedHeadroom = false;
2239
+ if (reason !== "idle") {
2240
+ frameRescueResult = await this.#rescueSnapcompactFrameOverflow(
2241
+ pathEntriesForCompaction,
2242
+ compactionSettings,
2243
+ autoCompactionSignal,
2244
+ );
2245
+ if (frameRescueResult) {
2246
+ rescueRewroteHistory = true;
2247
+ pathEntriesForCompaction = this.#host.sessionManager.getBranch();
2248
+ frameRescueCreatedHeadroom = this.#compactionCreatedHeadroom();
2249
+ }
2250
+ if (!frameRescueCreatedHeadroom) {
2251
+ await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
2252
+ skipElide: fallbackFromShake,
2253
+ hasProgress: () => {
2254
+ // Only reached when a tier actually freed something, so the
2255
+ // branch has been rewritten either way.
2256
+ rescueRewroteHistory = true;
2257
+ pathEntriesForCompaction = this.#host.sessionManager.getBranch();
2258
+ preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.#model);
2259
+ return preparation !== undefined;
2260
+ },
2261
+ });
2262
+ }
2263
+ }
2264
+ if (!preparation) {
2265
+ const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom;
2266
+ const deadEndWarning = noProgressDeadEnd
2267
+ ? compactionDeadEndWarning("shrink it (e.g. clear large tool output)")
2268
+ : undefined;
2269
+ // A rescue that appended a rebuilt archive without creating
2270
+ // headroom must carry the dead-end badge on the entry the
2271
+ // transcript actually shows (the rebuilt one), or the pause
2272
+ // loses its explanation once the notice scrolls away. Stamp it
2273
+ // BEFORE the auto_compaction_end event: a result-carrying event
2274
+ // makes the TUI rebuild the chat from the current entries
2275
+ // immediately, so a later stamp would not appear until some
2276
+ // unrelated rebuild.
2277
+ if (deadEndWarning && frameRescueResult) {
2278
+ const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch());
2279
+ if (stampEntry) {
2280
+ stampEntry.warning = deadEndWarning;
2281
+ await this.#host.sessionManager.rewriteEntries();
2282
+ }
2283
+ }
2284
+ // A successful frame rescue rewrote history and activated a new
2285
+ // compaction entry — surface it as a real (non-skipped) result so
2286
+ // the TUI rebuilds the transcript instead of treating the pass as
2287
+ // a benign no-op.
2288
+ await this.#host.emitSessionEvent({
2289
+ type: "auto_compaction_end",
2290
+ action,
2291
+ result: frameRescueResult,
2292
+ aborted: false,
2293
+ willRetry: false,
2294
+ skipped: frameRescueResult === undefined,
2295
+ });
2296
+ let continuationScheduled = false;
2297
+ if (frameRescueCreatedHeadroom) {
2298
+ continuationScheduled = this.#host.scheduleCompactionContinuation({
2299
+ generation,
2300
+ autoContinue: shouldAutoContinue,
2301
+ terminalTextAnswer,
2302
+ suppressContinuation,
2303
+ });
2304
+ } else if (!suppressContinuation && this.#host.agent.hasQueuedMessages()) {
2305
+ this.#host.scheduleAgentContinue({
2306
+ delayMs: 100,
2307
+ generation,
2308
+ shouldContinue: () => this.#host.agent.hasQueuedMessages(),
2309
+ });
2310
+ continuationScheduled = true;
2311
+ }
2312
+ if (deadEndWarning) {
2313
+ this.#host.emitNotice("warning", deadEndWarning, "compaction");
2314
+ }
2315
+ // A rescue that offloaded content but still could not produce a
2316
+ // preparation rewrote the branch; flag it so the overflow-recovery
2317
+ // rollback does not re-restore the just-failed assistant turn on top
2318
+ // of the elided tail.
2319
+ const base = continuationScheduled
2320
+ ? COMPACTION_CHECK_CONTINUATION
2321
+ : noProgressDeadEnd
2322
+ ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION
2323
+ : COMPACTION_CHECK_NONE;
2324
+ return rescueRewroteHistory ? { ...base, historyRewritten: true } : base;
2325
+ }
2326
+ }
2327
+
2328
+ let hookCompaction: CompactionResult | undefined;
2329
+ let fromExtension = false;
2330
+ let preserveData: Record<string, unknown> | undefined;
2331
+ let codexCompaction: CodexCompactionContext | undefined;
2332
+
2333
+ if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) {
2334
+ const hookResult = (await this.#host.extensionRunner.emit({
2335
+ type: "session_before_compact",
2336
+ preparation,
2337
+ branchEntries: pathEntriesForCompaction,
2338
+ customInstructions: undefined,
2339
+ signal: autoCompactionSignal,
2340
+ })) as SessionBeforeCompactResult | undefined;
2341
+
2342
+ if (hookResult?.cancel) {
2343
+ await this.#host.emitSessionEvent({
2344
+ type: "auto_compaction_end",
2345
+ action,
2346
+ result: undefined,
2347
+ aborted: true,
2348
+ willRetry: false,
2349
+ });
2350
+ return COMPACTION_CHECK_NONE;
2351
+ }
2352
+
2353
+ if (hookResult?.compaction) {
2354
+ hookCompaction = hookResult.compaction;
2355
+ fromExtension = true;
2356
+ }
2357
+ }
2358
+
2359
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
2360
+
2361
+ let summary: string;
2362
+ let shortSummary: string | undefined;
2363
+ let firstKeptEntryId: string;
2364
+ let tokensBefore: number;
2365
+ let details: unknown;
2366
+
2367
+ // Snapcompact runs locally first. The post-compaction context = kept-recent
2368
+ // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
2369
+ // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
2370
+ // live window so we don't run snapcompact just to overflow every threshold
2371
+ // tick. Any local blocker (unsupported snapcompact glyphs, kept-history too large,
2372
+ // post-render overflow) downgrades auto maintenance to a context-full LLM
2373
+ // summary instead of wedging the session (#3659) — auto runs the default
2374
+ // strategy on the user's behalf, so a fallback that lets the session keep
2375
+ // running is the right behavior. Manual `/compact snapcompact` keeps the
2376
+ // local-only contract (#3599): the user explicitly picked it.
2377
+ let snapcompactResult: snapcompact.CompactionResult | undefined;
2378
+ let snapcompactBlocker: string | undefined;
2379
+ if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
2380
+ // Drop `¶think:` sections for Anthropic-dialect targets: the archive
2381
+ // is replayed as text and Claude refuses reproduced reasoning
2382
+ // ("reasoning_extraction", issue #6093).
2383
+ const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic";
2384
+ const text = snapcompact.serializeConversation(
2385
+ convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
2386
+ { includeThinking: snapcompactIncludeThinking },
2387
+ );
2388
+ const probeText = snapcompact.renderabilityProbeText(
2389
+ text,
2390
+ preparation.previousPreserveData,
2391
+ preparation.previousSummary,
2392
+ );
2393
+ const shapeSetting = this.#host.settings.get("snapcompact.shape");
2394
+ const shape = snapcompact.resolveShapeForText(probeText, this.#model, shapeSetting);
2395
+ const renderScan = snapcompact.scanRenderability(probeText, { shape });
2396
+ if (!renderScan.isSafe) {
2397
+ const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
2398
+ logger.warn("Snapcompact disabled: unsupported characters for selected snapcompact font", {
2399
+ model: this.#model?.id,
2400
+ unrenderableRatio: renderScan.unrenderableRatio,
2401
+ });
2402
+ snapcompactBlocker = `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%); using context-full auto-compaction instead.`;
2403
+ } else {
2404
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
2405
+ if (maxFrames < 1) {
2406
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
2407
+ model: this.#model?.id,
2408
+ });
2409
+ snapcompactBlocker =
2410
+ "snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead.";
2411
+ } else {
2412
+ snapcompactResult = await snapcompact.compact(preparation, {
2413
+ convertToLlm,
2414
+ model: this.#model,
2415
+ ...(shapeSetting === "auto" ? {} : { shape }),
2416
+ maxFrames,
2417
+ includeThinking: snapcompactIncludeThinking,
2418
+ });
2419
+ const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
2420
+ if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
2421
+ logger.warn("Snapcompact exceeded the per-request frame payload budget", {
2422
+ model: this.#model?.id,
2423
+ framePayloadBytes,
2424
+ budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
2425
+ });
2426
+ snapcompactBlocker =
2427
+ "snapcompact produced too much standing image payload; using context-full auto-compaction instead.";
2428
+ snapcompactResult = undefined;
2429
+ }
2430
+ if (snapcompactResult) {
2431
+ const ctxWindow = this.#model?.contextWindow ?? 0;
2432
+ const budget =
2433
+ ctxWindow > 0
2434
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
2435
+ : Number.POSITIVE_INFINITY;
2436
+ const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
2437
+ if (projected > budget) {
2438
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
2439
+ model: this.#model?.id,
2440
+ projected,
2441
+ budget,
2442
+ });
2443
+ snapcompactBlocker =
2444
+ "snapcompact could not bring the context under the limit; using context-full auto-compaction instead.";
2445
+ snapcompactResult = undefined;
2446
+ }
2447
+ }
2448
+ }
2449
+ }
2450
+ if (snapcompactBlocker) {
2451
+ this.#host.emitNotice("warning", snapcompactBlocker, "compaction");
2452
+ action = "context-full";
2453
+ }
2454
+ }
2455
+
2456
+ if (compactionPrep.kind === "fromHook") {
2457
+ summary = compactionPrep.summary;
2458
+ shortSummary = compactionPrep.shortSummary;
2459
+ firstKeptEntryId = compactionPrep.firstKeptEntryId;
2460
+ tokensBefore = compactionPrep.tokensBefore;
2461
+ details = compactionPrep.details;
2462
+ preserveData = compactionPrep.preserveData;
2463
+ } else if (snapcompactResult) {
2464
+ summary = snapcompactResult.summary;
2465
+ shortSummary = snapcompactResult.shortSummary;
2466
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
2467
+ tokensBefore = snapcompactResult.tokensBefore;
2468
+ details = snapcompactResult.details;
2469
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
2470
+ } else {
2471
+ const candidates = this.#getCompactionModelCandidates(availableModels);
2472
+ const retrySettings = this.#host.settings.getGroup("retry");
2473
+ const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
2474
+ let compactResult: CompactionResult | undefined;
2475
+ let lastError: unknown;
2476
+ codexCompaction = createCodexCompactionContext({
2477
+ trigger: "auto",
2478
+ reason: "context_limit",
2479
+ phase:
2480
+ options.phase ??
2481
+ (reason === "threshold" ? "pre_turn" : reason === "idle" ? "standalone_turn" : "mid_turn"),
2482
+ });
2483
+
2484
+ for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
2485
+ const candidate = candidates[candidateIndex];
2486
+ const hasMoreCandidates = candidateIndex < candidates.length - 1;
2487
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
2488
+ if (!apiKey) continue;
2489
+
2490
+ let attempt = 0;
2491
+ while (true) {
2492
+ try {
2493
+ compactResult = await compact(
2494
+ this.#host.obfuscatePreparationForProvider(preparation),
2495
+ candidate,
2496
+ this.#host.modelRegistry.resolver(candidate, this.#host.sessionId()),
2497
+ undefined,
2498
+ autoCompactionSignal,
2499
+ {
2500
+ promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt),
2501
+ extraContext: compactionPrep.hookContext,
2502
+ remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"),
2503
+ metadata: this.#host.agent.metadataForProvider(candidate.provider),
2504
+ initiatorOverride: "agent",
2505
+ convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
2506
+ telemetry,
2507
+ // Honor the user's /model thinking selection on the
2508
+ // auto-compaction path — the most-fired compaction
2509
+ // site. Clamped per-model inside compact() via
2510
+ // resolveCompactionEffort.
2511
+ thinkingLevel: this.#host.thinkingLevel(),
2512
+ tools: this.#host.agent.state.tools,
2513
+ sessionId: this.#host.sessionId(),
2514
+ promptCacheKey: this.#host.sessionId(),
2515
+ providerSessionState: this.#host.providerSessionState,
2516
+ codexCompaction,
2517
+ },
2518
+ );
2519
+ break;
2520
+ } catch (error) {
2521
+ if (autoCompactionSignal.aborted) {
2522
+ throw error;
2523
+ }
2524
+
2525
+ const message = error instanceof Error ? error.message : String(error);
2526
+ const id = AIError.classify(error, candidate.api);
2527
+ if (AIError.is(id, AIError.Flag.AuthFailed)) {
2528
+ lastError = this.#buildCompactionAuthError();
2529
+ break;
2530
+ }
2531
+ if (AIError.is(id, AIError.Flag.Timeout)) {
2532
+ logger.warn(
2533
+ hasMoreCandidates
2534
+ ? "Auto-compaction summarization timed out, trying next model"
2535
+ : "Auto-compaction summarization timed out, not retrying same model",
2536
+ {
2537
+ error: message,
2538
+ model: `${candidate.provider}/${candidate.id}`,
2539
+ },
2540
+ );
2541
+ lastError = error;
2542
+ break;
2543
+ }
2544
+
2545
+ const retryAfterMs = this.#host.parseRetryAfterMsFromError(message);
2546
+ const shouldRetry =
2547
+ retrySettings.enabled &&
2548
+ attempt < retrySettings.maxRetries &&
2549
+ (retryAfterMs !== undefined ||
2550
+ AIError.is(id, AIError.Flag.Transient) ||
2551
+ AIError.is(id, AIError.Flag.UsageLimit));
2552
+ if (!shouldRetry) {
2553
+ lastError = error;
2554
+ break;
2555
+ }
2556
+
2557
+ const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
2558
+ const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
2559
+
2560
+ // If retry delay is too long (>30s), try next candidate instead of waiting
2561
+ const maxAcceptableDelayMs = 30_000;
2562
+ if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) {
2563
+ logger.warn("Auto-compaction retry delay too long, trying next model", {
2564
+ delayMs,
2565
+ retryAfterMs,
2566
+ error: message,
2567
+ model: `${candidate.provider}/${candidate.id}`,
2568
+ });
2569
+ lastError = error;
2570
+ break; // Exit retry loop, continue to next candidate
2571
+ }
2572
+
2573
+ attempt++;
2574
+ logger.warn("Auto-compaction failed, retrying", {
2575
+ attempt,
2576
+ maxRetries: retrySettings.maxRetries,
2577
+ delayMs,
2578
+ retryAfterMs,
2579
+ error: message,
2580
+ model: `${candidate.provider}/${candidate.id}`,
2581
+ });
2582
+ await scheduler.wait(delayMs, { signal: autoCompactionSignal });
2583
+ }
2584
+ }
2585
+
2586
+ if (compactResult) {
2587
+ break;
2588
+ }
2589
+ }
2590
+
2591
+ if (!compactResult) {
2592
+ if (lastError) {
2593
+ throw lastError;
2594
+ }
2595
+ throw new Error("Compaction failed: no available model");
2596
+ }
2597
+
2598
+ summary = compactResult.summary;
2599
+ shortSummary = compactResult.shortSummary;
2600
+ firstKeptEntryId = compactResult.firstKeptEntryId;
2601
+ tokensBefore = compactResult.tokensBefore;
2602
+ details = compactResult.details;
2603
+ preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, compactResult.preserveData);
2604
+ }
2605
+
2606
+ if (autoCompactionSignal.aborted) {
2607
+ await this.#host.emitSessionEvent({
2608
+ type: "auto_compaction_end",
2609
+ action,
2610
+ result: undefined,
2611
+ aborted: true,
2612
+ willRetry: false,
2613
+ });
2614
+ return COMPACTION_CHECK_NONE;
2615
+ }
2616
+
2617
+ this.#host.sessionManager.appendCompaction(
2618
+ summary,
2619
+ shortSummary,
2620
+ firstKeptEntryId,
2621
+ tokensBefore,
2622
+ details,
2623
+ fromExtension,
2624
+ preserveData,
2625
+ );
2626
+ const newEntries = this.#host.sessionManager.getEntries();
2627
+ const sessionContext = this.#host.buildDisplaySessionContext();
2628
+ this.#host.agent.replaceMessages(sessionContext.messages);
2629
+ this.#host.rebaseAfterCompaction();
2630
+ // Compaction discarded the conversation history that carried the approved
2631
+ // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
2632
+ // the plan from disk and re-injects it on the next turn (issue #1246).
2633
+ this.#host.resetPlanReference();
2634
+ this.#host.resetAdvisorRuntimes();
2635
+ this.#host.syncTodoPhasesFromBranch();
2636
+ if (codexCompaction) {
2637
+ this.#host.resetCodexProviderAfterCompaction(codexCompaction);
2638
+ } else {
2639
+ this.#host.closeCodexProviderSessionsForHistoryRewrite();
2640
+ }
2641
+
2642
+ // Get the saved compaction entry for the hook
2643
+ const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
2644
+ | CompactionEntry
2645
+ | undefined;
2646
+
2647
+ if (this.#host.extensionRunner && savedCompactionEntry) {
2648
+ await this.#host.extensionRunner.emit({
2649
+ type: "session_compact",
2650
+ compactionEntry: savedCompactionEntry,
2651
+ fromExtension,
2652
+ });
2653
+ }
2654
+
2655
+ const result: CompactionResult = {
2656
+ summary,
2657
+ shortSummary,
2658
+ firstKeptEntryId,
2659
+ tokensBefore,
2660
+ details,
2661
+ preserveData,
2662
+ };
2663
+ // Post-maintenance progress guard — evaluated BEFORE emitting
2664
+ // auto_compaction_end so the TUI rebuild triggered by that event
2665
+ // already reflects any rescue rewrite (elide / image-drop) and the
2666
+ // dead-end warning stamped on the compaction entry. Snapcompact can
2667
+ // project over budget and fall back to a context-full summary; the
2668
+ // summarizer keeps `keepRecentTokens` of recent history verbatim and
2669
+ // findCutPoint can only cut at turn boundaries (never tool results),
2670
+ // so a single oversized recent turn (e.g. a huge tool result) leaves
2671
+ // the rewritten context still above threshold. Scheduling the
2672
+ // continuation regardless means the next agent_end re-enters
2673
+ // checkCompaction over the same oversized tail and re-fires forever.
2674
+ // The retry and the threshold auto-continue use different progress
2675
+ // tests (a recoverable overflow only has to fit; the auto-continue
2676
+ // thrash needs the stricter recovery band), so each branch evaluates
2677
+ // its own below.
2678
+ let continuationScheduled = false;
2679
+ // A non-idle pass that wanted to continue (retry or auto-continue) but freed
2680
+ // too little for that path to proceed is a dead-end: warn once so the user
2681
+ // understands why maintenance paused instead of silently looping.
2682
+ let noProgressDeadEnd = false;
2683
+ let retryFits = false;
2684
+ let hasHeadroom = false;
2685
+
2686
+ if (willRetry) {
2687
+ const messages = this.#host.agent.state.messages;
2688
+ const lastMsg = messages[messages.length - 1];
2689
+ if (lastMsg?.role === "assistant") {
2690
+ const lastAssistant = lastMsg as AssistantMessage;
2691
+ // Drop the prior turn before retry when it carries no actionable deliverable:
2692
+ // - "error": failure was kept in history but must not re-enter the next turn's prompt.
2693
+ // - reason === "incomplete" && stopReason === "length": truncated output (typically
2694
+ // reasoning-only) — re-running it produces the same dead-end.
2695
+ const shouldDrop =
2696
+ lastAssistant.stopReason === "error" ||
2697
+ (reason === "incomplete" && lastAssistant.stopReason === "length");
2698
+ if (shouldDrop) {
2699
+ this.#host.agent.replaceMessages(messages.slice(0, -1));
2700
+ this.#host.rebaseAfterCompaction();
2701
+ }
2702
+ }
2703
+
2704
+ // Retry only needs the rebuilt prompt to fit the window again — measured
2705
+ // AFTER the drop above so the just-failed turn (which the retry prompt
2706
+ // won't include) is excluded. Reusing the auto-continue recovery band
2707
+ // here turned recoverable overflows into manual dead-ends (#3412 review),
2708
+ // so use the looser fit budget.
2709
+ retryFits = this.#compactionCreatedRetryFit();
2710
+ if (!retryFits) {
2711
+ retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
2712
+ skipElide: fallbackFromShake,
2713
+ hasProgress: () => this.#compactionCreatedRetryFit(),
2714
+ });
2715
+ }
2716
+ if (!retryFits) {
2717
+ noProgressDeadEnd = true;
2718
+ }
2719
+ } else if (reason !== "idle") {
2720
+ // Mirror the shake recovery-band check: only auto-continue when compaction
2721
+ // landed residual context under `COMPACTION_RECOVERY_BAND × threshold`.
2722
+ // Re-firing on a history that still sits just over the line is the
2723
+ // snapcompact thrash, so require genuine headroom, not a bare fit. Even
2724
+ // when auto-continue is disabled, a no-headroom threshold pass must still
2725
+ // block later automatic continuations (todo reminders/session_stop hooks)
2726
+ // from re-entering the same oversized context.
2727
+ hasHeadroom = this.#compactionCreatedHeadroom();
2728
+ if (!hasHeadroom) {
2729
+ hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
2730
+ skipElide: fallbackFromShake,
2731
+ hasProgress: () => this.#compactionCreatedHeadroom(),
2732
+ });
2733
+ }
2734
+ if (!hasHeadroom) {
2735
+ noProgressDeadEnd = true;
2736
+ }
2737
+ }
2738
+
2739
+ const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined;
2740
+ if (deadEndWarning) {
2741
+ // Stamp the divider: the compaction bar badges the dead-end and
2742
+ // carries the full warning in its ctrl+o detail, so the pause
2743
+ // stays explained even after the notice row scrolls away. Stamp
2744
+ // the branch's LATEST compaction entry — a frame rescue may have
2745
+ // superseded `savedCompactionEntry` with a rebuilt one, and the
2746
+ // collapsed transcript badges only the active entry.
2747
+ const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()) ?? savedCompactionEntry;
2748
+ if (stampEntry) {
2749
+ stampEntry.warning = deadEndWarning;
2750
+ await this.#host.sessionManager.rewriteEntries();
2751
+ }
2752
+ }
2753
+
2754
+ await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
2755
+
2756
+ if (retryFits) {
2757
+ this.#host.scheduleAgentContinue({ delayMs: 100, generation });
2758
+ continuationScheduled = true;
2759
+ } else {
2760
+ continuationScheduled = this.#host.scheduleCompactionContinuation({
2761
+ generation,
2762
+ autoContinue: hasHeadroom && shouldAutoContinue,
2763
+ terminalTextAnswer,
2764
+ suppressContinuation,
2765
+ });
2766
+ }
2767
+
2768
+ if (deadEndWarning) {
2769
+ this.#host.emitNotice("warning", deadEndWarning, "compaction");
2770
+ }
2771
+ if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
2772
+ return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
2773
+ } catch (error) {
2774
+ if (autoCompactionSignal.aborted) {
2775
+ await this.#host.emitSessionEvent({
2776
+ type: "auto_compaction_end",
2777
+ action,
2778
+ result: undefined,
2779
+ aborted: true,
2780
+ willRetry: false,
2781
+ });
2782
+ return COMPACTION_CHECK_NONE;
2783
+ }
2784
+ const errorMessage = error instanceof Error ? error.message : "compaction failed";
2785
+ await this.#host.emitSessionEvent({
2786
+ type: "auto_compaction_end",
2787
+ action,
2788
+ result: undefined,
2789
+ aborted: false,
2790
+ willRetry: false,
2791
+ errorMessage:
2792
+ reason === "overflow"
2793
+ ? `Context overflow recovery failed: ${errorMessage}`
2794
+ : reason === "incomplete"
2795
+ ? `Incomplete response recovery failed: ${errorMessage}`
2796
+ : `Auto-compaction failed: ${errorMessage}`,
2797
+ });
2798
+ } finally {
2799
+ if (this.#autoCompactionAbortController === autoCompactionAbortController) {
2800
+ this.#autoCompactionAbortController = undefined;
2801
+ }
2802
+ }
2803
+ return COMPACTION_CHECK_NONE;
2804
+ }
2805
+
2806
+ /**
2807
+ * Run a shake-strategy auto-maintenance pass. Emits the
2808
+ * `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`,
2809
+ * runs {@link shake} inline against the protect-window config, and schedules
2810
+ * continuation exactly like the context-full tail.
2811
+ *
2812
+ * Returns `"fallback"` only for an overflow recovery where shake reclaimed
2813
+ * nothing (or threw) — the caller then runs the summary-compaction body so
2814
+ * the oversized input still gets resolved. Returns `"handled"` otherwise.
2815
+ */
2816
+ async #runAutoShake(
2817
+ reason: "overflow" | "threshold" | "idle" | "incomplete",
2818
+ willRetry: boolean,
2819
+ generation: number,
2820
+ autoContinue: boolean,
2821
+ terminalTextAnswer: boolean,
2822
+ triggerContextTokens?: number,
2823
+ suppressContinuation = false,
2824
+ ): Promise<CompactionCheckResult | "fallback"> {
2825
+ const action = "shake";
2826
+ this.#autoCompactionAbortController?.abort();
2827
+ const controller = new AbortController();
2828
+ this.#autoCompactionAbortController = controller;
2829
+ const signal = controller.signal;
2830
+ try {
2831
+ await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action });
2832
+ const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
2833
+ if (signal.aborted) {
2834
+ await this.#host.emitSessionEvent({
2835
+ type: "auto_compaction_end",
2836
+ action,
2837
+ result: undefined,
2838
+ aborted: true,
2839
+ willRetry: false,
2840
+ });
2841
+ return COMPACTION_CHECK_NONE;
2842
+ }
2843
+ const reclaimed = result.toolResultsDropped + result.blocksDropped > 0;
2844
+ // Detect the dead-loop reported in issues #2119/#2275: the threshold check
2845
+ // fires, shake runs, but residual context is still above the configured
2846
+ // threshold. The next agent_end would re-trigger shake, which has nothing
2847
+ // new to drop on the second pass, so the loop spins until the user kills it.
2848
+ // Same hazard for "incomplete" (the retry would re-hit the length cap) and
2849
+ // for the existing "overflow + nothing reclaimed" case. In every recovery
2850
+ // reason we hand off to the summarization-driven context-full path so the
2851
+ // situation actually resolves; "idle" is exempt because its 60s+ timer
2852
+ // re-checks usage before re-firing and cannot dead-loop on its own.
2853
+ //
2854
+ // #2275: the post-shake check MUST stay provider-anchored when caller
2855
+ // usage and local estimates diverge. The local estimator undercounts
2856
+ // thinking-signature payloads, so thinking-heavy sessions can read well
2857
+ // below the provider usage that fired the threshold. Prefer the caller's
2858
+ // context figure when supplied, then subtract shake's own savings and add
2859
+ // hysteresis (80% recovery band) so we don't oscillate at the boundary.
2860
+ // Threshold callers pass the provider-billed trigger after accounting for
2861
+ // any supersede/drop-useless pruning that already rewrote the next prompt;
2862
+ // without that pre-shake savings, shake can fall through to context-full
2863
+ // even though the post-prune history is already inside the recovery band.
2864
+ const contextWindow = this.#model?.contextWindow ?? 0;
2865
+ const compactionSettings = this.#host.settings.getGroup("compaction");
2866
+ let stillOverThreshold = false;
2867
+ if (contextWindow > 0) {
2868
+ if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
2869
+ const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
2870
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
2871
+ const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
2872
+ stillOverThreshold = correctedTokens > recoveryBand;
2873
+ } else {
2874
+ const postShakeTokens = this.#host.getContextUsage({ contextWindow })?.tokens ?? 0;
2875
+ stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
2876
+ }
2877
+ }
2878
+ const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
2879
+ if (shouldFallBack) {
2880
+ const errorMessage = reclaimed
2881
+ ? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.`
2882
+ : "Auto-shake found nothing eligible to drop; falling back to context-full compaction.";
2883
+ await this.#host.emitSessionEvent({
2884
+ type: "auto_compaction_end",
2885
+ action,
2886
+ result: undefined,
2887
+ aborted: false,
2888
+ willRetry: false,
2889
+ skipped: !reclaimed,
2890
+ errorMessage,
2891
+ });
2892
+ return "fallback";
2893
+ }
2894
+ await this.#host.emitSessionEvent({
2895
+ type: "auto_compaction_end",
2896
+ action,
2897
+ result: undefined,
2898
+ aborted: false,
2899
+ willRetry,
2900
+ skipped: !reclaimed,
2901
+ });
2902
+
2903
+ let continuationScheduled = false;
2904
+ if (willRetry) {
2905
+ // The shake rebuild replays every entry, so a trailing error/length
2906
+ // assistant from the failed turn re-enters agent state — drop it before
2907
+ // retrying, same as the context-full tail.
2908
+ const messages = this.#host.agent.state.messages;
2909
+ const lastMsg = messages[messages.length - 1];
2910
+ if (lastMsg?.role === "assistant") {
2911
+ const lastAssistant = lastMsg as AssistantMessage;
2912
+ const shouldDrop =
2913
+ lastAssistant.stopReason === "error" ||
2914
+ (reason === "incomplete" && lastAssistant.stopReason === "length");
2915
+ if (shouldDrop) this.#host.agent.replaceMessages(messages.slice(0, -1));
2916
+ }
2917
+ this.#host.scheduleAgentContinue({ delayMs: 100, generation });
2918
+ continuationScheduled = true;
2919
+ } else {
2920
+ continuationScheduled = this.#host.scheduleCompactionContinuation({
2921
+ generation,
2922
+ autoContinue: reason !== "idle" && autoContinue,
2923
+ terminalTextAnswer,
2924
+ suppressContinuation,
2925
+ });
2926
+ }
2927
+ if (!reclaimed) {
2928
+ return willRetry && continuationScheduled
2929
+ ? { ...COMPACTION_CHECK_CONTINUATION, historyRewritten: true }
2930
+ : continuationScheduled
2931
+ ? COMPACTION_CHECK_CONTINUATION
2932
+ : COMPACTION_CHECK_NONE;
2933
+ }
2934
+ return {
2935
+ ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
2936
+ historyRewritten: true,
2937
+ };
2938
+ } catch (error) {
2939
+ if (signal.aborted) {
2940
+ await this.#host.emitSessionEvent({
2941
+ type: "auto_compaction_end",
2942
+ action,
2943
+ result: undefined,
2944
+ aborted: true,
2945
+ willRetry: false,
2946
+ });
2947
+ return COMPACTION_CHECK_NONE;
2948
+ }
2949
+ const message = error instanceof Error ? error.message : "shake failed";
2950
+ await this.#host.emitSessionEvent({
2951
+ type: "auto_compaction_end",
2952
+ action,
2953
+ result: undefined,
2954
+ aborted: false,
2955
+ willRetry: false,
2956
+ errorMessage: message,
2957
+ skipped: false,
2958
+ });
2959
+ // Overflow still needs recovery even if shake threw.
2960
+ return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE;
2961
+ } finally {
2962
+ if (this.#autoCompactionAbortController === controller) {
2963
+ this.#autoCompactionAbortController = undefined;
2964
+ }
2965
+ }
2966
+ }
2967
+
2968
+ /**
2969
+ * Toggle auto-compaction setting.
2970
+ */
2971
+ setAutoCompactionEnabled(enabled: boolean): void {
2972
+ this.#host.settings.set("compaction.enabled", enabled);
2973
+ if (enabled && this.#host.settings.get("compaction.strategy") === "off") {
2974
+ const defaultStrategy = getDefault("compaction.strategy");
2975
+ this.#host.settings.set("compaction.strategy", defaultStrategy === "off" ? "context-full" : defaultStrategy);
2976
+ }
2977
+ }
2978
+
2979
+ /** Whether auto-compaction is enabled */
2980
+ get autoCompactionEnabled(): boolean {
2981
+ return this.#host.settings.get("compaction.enabled") && this.#host.settings.get("compaction.strategy") !== "off";
2982
+ }
2983
+ }