alysis-code 0.13.0__py3-none-any.whl

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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,2932 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ import tempfile
7
+ from collections.abc import Callable, Mapping
8
+ from copy import deepcopy
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+
13
+ from alysis_code.llm.base import ChatClient, count_input_tokens_if_supported
14
+ from alysis_code.llm.cache_control_blocks import cacheable_prefix_message_count
15
+ from alysis_code.llm.metadata import (
16
+ PROVIDER_METADATA_KEY,
17
+ strip_provider_metadata_from_message,
18
+ )
19
+ from alysis_code.llm.types import (
20
+ InputTokenCount,
21
+ LLMError,
22
+ UsageConfidence,
23
+ UsageSource,
24
+ )
25
+ from alysis_code.model_registry import ModelRegistry
26
+ from alysis_code.request_estimation import (
27
+ RequestTokenBreakdown,
28
+ estimate_message_tokens,
29
+ estimate_request_token_breakdown,
30
+ estimate_request_tokens,
31
+ estimate_tool_schema_tokens,
32
+ request_contains_media,
33
+ sanitize_messages_for_estimation,
34
+ )
35
+ from alysis_code.session_artifacts import SessionArtifactLayout
36
+ from alysis_code.session_store import SessionStore
37
+ from alysis_code.token_budget import (
38
+ compute_input_budget,
39
+ estimate_tokens,
40
+ trim_text_to_budget,
41
+ )
42
+ from alysis_code.usage_tracker import (
43
+ UsageSummary,
44
+ build_usage_record,
45
+ usage_context_from_client_response,
46
+ )
47
+
48
+ from .importance import ScoredTurn, estimate_turn_tokens, extract_text, score_turn
49
+ from .settings import CompactionSettings
50
+
51
+ MEMORY_MARKER = "<<<ALYSIS_CONVERSATION_MEMORY_JSON>>>"
52
+ PINS_MARKER = "<<<ALYSIS_CONVERSATION_PINS_JSON>>>"
53
+ CompactionProfileName = Literal["chat", "execution"]
54
+ RequestMessagesBuilder = Callable[
55
+ [list[dict[str, Any]]],
56
+ list[dict[str, Any]],
57
+ ]
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class _ChunkPlan:
62
+ start: int
63
+ end: int
64
+ scored_turns: list[ScoredTurn]
65
+ strategy: str
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class _ExecutionBundle:
70
+ start: int
71
+ end: int
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class _ExecutionCompactionPreview:
76
+ updated_messages: list[dict[str, Any]]
77
+ summary: dict[str, Any]
78
+ pins: list[dict[str, Any]]
79
+ memory_message_index: int | None
80
+ pins_message_index: int | None
81
+ predicted_used_tokens: int
82
+ dropped_without_summary: bool
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class _CacheAwareCompactionDecision:
87
+ adjusted_trigger_ratio: float
88
+ calibrated_used_tokens: int
89
+ reasons: tuple[str, ...]
90
+ prompt_estimate_error_ratio_p90: float | None
91
+ cache_hit_ratio: float | None
92
+ tool_schema_share: float
93
+ inline_tool_transcript_share: float
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _CachePrefixCompactionShape:
98
+ stable_prefix_message_count: int
99
+ protected_prefix_message_count: int
100
+ pinned_prefix_message_count: int
101
+ cacheable_prefix_estimated_tokens: int
102
+ cacheable_surface_estimated_tokens: int
103
+ dynamic_suffix_estimated_tokens: int
104
+ dynamic_suffix_share: float
105
+ min_cacheable_tokens: int
106
+ cacheable_prefix_preserved: bool
107
+ reasons: tuple[str, ...]
108
+
109
+ def to_payload(self) -> dict[str, Any]:
110
+ return {
111
+ "stable_prefix_message_count": self.stable_prefix_message_count,
112
+ "protected_prefix_message_count": self.protected_prefix_message_count,
113
+ "pinned_prefix_message_count": self.pinned_prefix_message_count,
114
+ "cacheable_prefix_estimated_tokens": self.cacheable_prefix_estimated_tokens,
115
+ "cacheable_surface_estimated_tokens": self.cacheable_surface_estimated_tokens,
116
+ "dynamic_suffix_estimated_tokens": self.dynamic_suffix_estimated_tokens,
117
+ "dynamic_suffix_share": self.dynamic_suffix_share,
118
+ "min_cacheable_tokens": self.min_cacheable_tokens,
119
+ "cacheable_prefix_preserved": self.cacheable_prefix_preserved,
120
+ "reasons": list(self.reasons),
121
+ }
122
+
123
+
124
+ def _ratio(numerator: int, denominator: int) -> float:
125
+ if denominator <= 0:
126
+ return 0.0
127
+ return round(max(0, numerator) / denominator, 4)
128
+
129
+
130
+ def _cache_prefix_compaction_shape(
131
+ *,
132
+ settings: CompactionSettings,
133
+ messages: list[dict[str, Any]],
134
+ tool_list: list[dict[str, Any]] | None,
135
+ request_breakdown: RequestTokenBreakdown,
136
+ pinned_prefix_len: int,
137
+ cache_policy: Mapping[str, Any] | None = None,
138
+ ) -> _CachePrefixCompactionShape:
139
+ clean_messages = [message for message in messages if isinstance(message, dict)]
140
+ stable_prefix_len = min(cacheable_prefix_message_count(clean_messages), len(clean_messages))
141
+ pinned_prefix = min(max(0, int(pinned_prefix_len)), len(clean_messages))
142
+ prefix_messages = clean_messages[:stable_prefix_len]
143
+ prefix_tokens = estimate_message_tokens(prefix_messages)
144
+ tool_schema_tokens = estimate_tool_schema_tokens(tool_list or [])
145
+ surface_tokens = prefix_tokens + tool_schema_tokens
146
+ min_tokens = _cache_policy_min_tokens(cache_policy)
147
+ cache_enabled = _cache_policy_enabled(cache_policy)
148
+ dynamic_suffix_tokens = max(0, request_breakdown.total_tokens - surface_tokens)
149
+ dynamic_suffix_share = _ratio(dynamic_suffix_tokens, request_breakdown.total_tokens)
150
+ protect_prefix = (
151
+ bool(settings.cache_aware_compaction)
152
+ and cache_enabled
153
+ and stable_prefix_len > pinned_prefix
154
+ and stable_prefix_len > 0
155
+ and prefix_tokens >= min_tokens
156
+ and dynamic_suffix_share >= 0.25
157
+ )
158
+ protected_prefix = stable_prefix_len if protect_prefix else pinned_prefix
159
+ reasons: list[str] = []
160
+ if not cache_enabled:
161
+ reasons.append("cache_disabled_or_unavailable")
162
+ if stable_prefix_len <= 0:
163
+ reasons.append("no_cacheable_prefix")
164
+ if stable_prefix_len > pinned_prefix:
165
+ if protect_prefix:
166
+ reasons.append("cacheable_prefix_protected")
167
+ else:
168
+ reasons.append("cacheable_prefix_compaction_tradeoff")
169
+ if stable_prefix_len > 0 and prefix_tokens < min_tokens:
170
+ reasons.append("cacheable_prefix_below_min_tokens")
171
+ if dynamic_suffix_share >= 0.25:
172
+ reasons.append("large_dynamic_suffix")
173
+ return _CachePrefixCompactionShape(
174
+ stable_prefix_message_count=stable_prefix_len,
175
+ protected_prefix_message_count=protected_prefix,
176
+ pinned_prefix_message_count=pinned_prefix,
177
+ cacheable_prefix_estimated_tokens=prefix_tokens,
178
+ cacheable_surface_estimated_tokens=surface_tokens,
179
+ dynamic_suffix_estimated_tokens=dynamic_suffix_tokens,
180
+ dynamic_suffix_share=dynamic_suffix_share,
181
+ min_cacheable_tokens=min_tokens,
182
+ cacheable_prefix_preserved=protected_prefix >= stable_prefix_len
183
+ if stable_prefix_len
184
+ else True,
185
+ reasons=tuple(dict.fromkeys(reasons)),
186
+ )
187
+
188
+
189
+ def _cache_policy_min_tokens(cache_policy: Mapping[str, Any] | None) -> int:
190
+ if not isinstance(cache_policy, Mapping):
191
+ return 0
192
+ for key in ("min_tokens", "min_cacheable_tokens"):
193
+ try:
194
+ value = int(cache_policy.get(key))
195
+ except (TypeError, ValueError):
196
+ continue
197
+ if value >= 0:
198
+ return value
199
+ return 0
200
+
201
+
202
+ def _cache_policy_enabled(cache_policy: Mapping[str, Any] | None) -> bool:
203
+ if not isinstance(cache_policy, Mapping):
204
+ return False
205
+ status = str(cache_policy.get("status") or "").strip().lower()
206
+ if status in {"disabled", "unsupported", "off"}:
207
+ return False
208
+ if cache_policy.get("enabled") is True:
209
+ return True
210
+ return status == "enabled"
211
+
212
+
213
+ def _cache_aware_compaction_decision(
214
+ *,
215
+ settings: CompactionSettings,
216
+ request_breakdown: RequestTokenBreakdown,
217
+ calibration: dict[str, Any],
218
+ prefix_shape: _CachePrefixCompactionShape | None = None,
219
+ ) -> _CacheAwareCompactionDecision:
220
+ used_tokens = request_breakdown.total_tokens
221
+ adjusted_trigger_ratio = settings.trigger_ratio
222
+ reasons: list[str] = []
223
+ estimate_ratio_raw = calibration.get("prompt_estimate_error_ratio_p90")
224
+ estimate_ratio = (
225
+ float(estimate_ratio_raw) if isinstance(estimate_ratio_raw, int | float) else None
226
+ )
227
+ estimate_multiplier = 1.0
228
+ if settings.cache_aware_compaction and estimate_ratio is not None:
229
+ # The snapshot is scoped to this provider/protocol/operation and uses a
230
+ # recent p90. Preserve the observed tokenizer gap instead of truncating
231
+ # every provider to one universal correction ceiling.
232
+ estimate_multiplier = max(1.0, estimate_ratio)
233
+ if estimate_multiplier >= 1.15:
234
+ adjusted_trigger_ratio -= min(0.10, (estimate_multiplier - 1.0) * 0.35)
235
+ reasons.append("provider_estimate_undercount")
236
+ calibrated_used_tokens = math.ceil(used_tokens * estimate_multiplier)
237
+ total_for_share = max(1, used_tokens)
238
+ tool_schema_share = request_breakdown.tool_schema_tokens / total_for_share
239
+ inline_tool_share = request_breakdown.inline_tool_transcript_tokens / total_for_share
240
+ cache_hit_raw = calibration.get("cache_hit_ratio")
241
+ cache_hit_ratio = float(cache_hit_raw) if isinstance(cache_hit_raw, int | float) else None
242
+ if settings.cache_aware_compaction:
243
+ if (
244
+ cache_hit_ratio is not None
245
+ and int(calibration.get("records") or 0) >= 3
246
+ and cache_hit_ratio < 0.05
247
+ ):
248
+ adjusted_trigger_ratio -= 0.04
249
+ reasons.append("low_recent_cache_hit_ratio")
250
+ if inline_tool_share >= 0.25:
251
+ adjusted_trigger_ratio -= 0.05
252
+ reasons.append("large_inline_tool_transcript")
253
+ if tool_schema_share >= 0.20:
254
+ adjusted_trigger_ratio -= 0.03
255
+ reasons.append("large_tool_schema_share")
256
+ if prefix_shape is not None:
257
+ if prefix_shape.dynamic_suffix_share >= 0.25:
258
+ adjusted_trigger_ratio -= 0.03
259
+ reasons.append("large_dynamic_suffix")
260
+ # The cache-aware floor must never raise the trigger above the
261
+ # user-configured trigger_ratio.
262
+ effective_floor = min(settings.cache_aware_min_trigger_ratio, settings.trigger_ratio)
263
+ adjusted_trigger_ratio = max(
264
+ effective_floor,
265
+ min(settings.trigger_ratio, adjusted_trigger_ratio),
266
+ )
267
+ return _CacheAwareCompactionDecision(
268
+ adjusted_trigger_ratio=adjusted_trigger_ratio,
269
+ calibrated_used_tokens=calibrated_used_tokens,
270
+ reasons=tuple(reasons),
271
+ prompt_estimate_error_ratio_p90=estimate_ratio,
272
+ cache_hit_ratio=cache_hit_ratio,
273
+ tool_schema_share=tool_schema_share,
274
+ inline_tool_transcript_share=inline_tool_share,
275
+ )
276
+
277
+
278
+ def _with_request_total(
279
+ breakdown: RequestTokenBreakdown,
280
+ *,
281
+ total_tokens: int,
282
+ ) -> RequestTokenBreakdown:
283
+ normalized_total = max(0, int(total_tokens))
284
+ delta = normalized_total - breakdown.total_tokens
285
+ if delta == 0:
286
+ return breakdown
287
+
288
+ fields = {
289
+ "bootstrap_prompt_tokens": breakdown.bootstrap_prompt_tokens,
290
+ "tool_schema_tokens": breakdown.tool_schema_tokens,
291
+ "live_conversation_history_tokens": breakdown.live_conversation_history_tokens,
292
+ "inline_tool_transcript_tokens": breakdown.inline_tool_transcript_tokens,
293
+ "memory_summary_tokens": breakdown.memory_summary_tokens,
294
+ "pins_tokens": breakdown.pins_tokens,
295
+ }
296
+ if delta > 0:
297
+ fields["live_conversation_history_tokens"] += delta
298
+ else:
299
+ remaining = -delta
300
+ for key in (
301
+ "live_conversation_history_tokens",
302
+ "inline_tool_transcript_tokens",
303
+ "memory_summary_tokens",
304
+ "pins_tokens",
305
+ "bootstrap_prompt_tokens",
306
+ "tool_schema_tokens",
307
+ ):
308
+ removable = min(fields[key], remaining)
309
+ fields[key] -= removable
310
+ remaining -= removable
311
+ if remaining <= 0:
312
+ break
313
+
314
+ return RequestTokenBreakdown(
315
+ bootstrap_prompt_tokens=fields["bootstrap_prompt_tokens"],
316
+ tool_schema_tokens=fields["tool_schema_tokens"],
317
+ live_conversation_history_tokens=fields["live_conversation_history_tokens"],
318
+ inline_tool_transcript_tokens=fields["inline_tool_transcript_tokens"],
319
+ memory_summary_tokens=fields["memory_summary_tokens"],
320
+ pins_tokens=fields["pins_tokens"],
321
+ tool_schema_budget=breakdown.tool_schema_budget,
322
+ )
323
+
324
+
325
+ def _estimate_compaction_request_breakdown(
326
+ *,
327
+ messages: list[dict[str, Any]],
328
+ tool_list: list[dict[str, Any]] | None,
329
+ pinned_prefix_len: int,
330
+ ) -> RequestTokenBreakdown:
331
+ breakdown = estimate_request_token_breakdown(
332
+ messages=messages,
333
+ tool_list=tool_list,
334
+ pinned_prefix_len=pinned_prefix_len,
335
+ )
336
+ legacy_total = estimate_request_tokens(messages, tool_list)
337
+ return _with_request_total(breakdown, total_tokens=legacy_total)
338
+
339
+
340
+ def _conservative_input_measurement(
341
+ *,
342
+ baseline_tokens: int,
343
+ measurement: InputTokenCount | None,
344
+ estimate_multiplier: float = 1.0,
345
+ ) -> tuple[int, str, str]:
346
+ """Merge optional preflight data without downgrading a safer estimate."""
347
+
348
+ baseline = max(0, int(baseline_tokens))
349
+ if measurement is None:
350
+ return (
351
+ baseline,
352
+ UsageSource.LOCAL_ESTIMATE.value,
353
+ UsageConfidence.ESTIMATED.value,
354
+ )
355
+ if measurement.confidence == UsageConfidence.AUTHORITATIVE:
356
+ return (
357
+ measurement.input_tokens,
358
+ measurement.source.value,
359
+ measurement.confidence.value,
360
+ )
361
+ multiplier = (
362
+ max(1.0, float(estimate_multiplier)) if math.isfinite(float(estimate_multiplier)) else 1.0
363
+ )
364
+ adjusted_measurement_tokens = math.ceil(measurement.input_tokens * multiplier)
365
+ if adjusted_measurement_tokens >= baseline:
366
+ return (
367
+ adjusted_measurement_tokens,
368
+ (measurement.source.value if multiplier == 1.0 else UsageSource.MIXED.value),
369
+ (
370
+ measurement.confidence.value
371
+ if multiplier == 1.0
372
+ else UsageConfidence.ESTIMATED.value
373
+ ),
374
+ )
375
+ return (
376
+ baseline,
377
+ UsageSource.MIXED.value,
378
+ UsageConfidence.ESTIMATED.value,
379
+ )
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class _ExecutionArtifactCommit:
384
+ history_path: Path
385
+ history_chunk_index: int
386
+
387
+
388
+ @dataclass(frozen=True)
389
+ class _CompactionProfile:
390
+ name: CompactionProfileName
391
+ selection_mode: str
392
+ preserve_first_user_turn: bool = False
393
+ recent_raw_tail_messages: int = 0
394
+
395
+
396
+ def _resolve_compaction_profile(
397
+ *,
398
+ profile: CompactionProfileName,
399
+ settings: CompactionSettings,
400
+ ) -> _CompactionProfile:
401
+ if profile == "execution":
402
+ recent_tail = min(max(8, settings.max_chunk_messages // 3), 24)
403
+ return _CompactionProfile(
404
+ name="execution",
405
+ selection_mode="execution_activity",
406
+ preserve_first_user_turn=True,
407
+ recent_raw_tail_messages=recent_tail,
408
+ )
409
+ return _CompactionProfile(
410
+ name="chat",
411
+ selection_mode="user_turns",
412
+ preserve_first_user_turn=False,
413
+ recent_raw_tail_messages=0,
414
+ )
415
+
416
+
417
+ def _extract_first_json_object(text: str) -> str | None:
418
+ start = text.find("{")
419
+ if start < 0:
420
+ return None
421
+
422
+ depth = 0
423
+ in_string = False
424
+ escaped = False
425
+ for idx, ch in enumerate(text[start:], start=start):
426
+ if in_string:
427
+ if escaped:
428
+ escaped = False
429
+ elif ch == "\\":
430
+ escaped = True
431
+ elif ch == '"':
432
+ in_string = False
433
+ continue
434
+ if ch == '"':
435
+ in_string = True
436
+ continue
437
+ if ch == "{":
438
+ depth += 1
439
+ continue
440
+ if ch == "}":
441
+ depth -= 1
442
+ if depth == 0:
443
+ return text[start : idx + 1]
444
+ return None
445
+
446
+
447
+ def _normalize_string_list(value: Any, *, max_items: int = 10) -> list[str]:
448
+ if not isinstance(value, list):
449
+ return []
450
+ out: list[str] = []
451
+ seen: set[str] = set()
452
+ for item in value:
453
+ text = str(item).strip()
454
+ if not text:
455
+ continue
456
+ key = text.casefold()
457
+ if key in seen:
458
+ continue
459
+ seen.add(key)
460
+ out.append(text)
461
+ if len(out) >= max_items:
462
+ break
463
+ return out
464
+
465
+
466
+ def _normalize_work_done(value: Any, *, max_items: int = 10) -> list[dict[str, Any]]:
467
+ if not isinstance(value, list):
468
+ return []
469
+ out: list[dict[str, Any]] = []
470
+ for item in value:
471
+ if isinstance(item, dict):
472
+ entry = {
473
+ "summary": str(item.get("summary") or "").strip(),
474
+ "files": _normalize_string_list(item.get("files"), max_items=10),
475
+ "commands": _normalize_string_list(item.get("commands"), max_items=10),
476
+ "results": str(item.get("results") or "").strip(),
477
+ }
478
+ else:
479
+ summary = str(item).strip()
480
+ if not summary:
481
+ continue
482
+ entry = {"summary": summary, "files": [], "commands": [], "results": ""}
483
+ if not entry["summary"]:
484
+ continue
485
+ out.append(entry)
486
+ if len(out) >= max_items:
487
+ break
488
+ return out
489
+
490
+
491
+ def _merge_string_lists(
492
+ new_value: Any,
493
+ prev_value: Any,
494
+ *,
495
+ max_items: int = 10,
496
+ ) -> list[str]:
497
+ merged: list[str] = []
498
+ seen: set[str] = set()
499
+ candidates = [
500
+ *_normalize_string_list(new_value, max_items=max_items),
501
+ *_normalize_string_list(prev_value, max_items=max_items),
502
+ ]
503
+ for item in candidates:
504
+ key = item.casefold()
505
+ if key in seen:
506
+ continue
507
+ seen.add(key)
508
+ merged.append(item)
509
+ if len(merged) >= max_items:
510
+ break
511
+ return merged
512
+
513
+
514
+ def _merge_work_done(
515
+ new_value: Any,
516
+ prev_value: Any,
517
+ *,
518
+ max_items: int = 10,
519
+ ) -> list[dict[str, Any]]:
520
+ merged: list[dict[str, Any]] = []
521
+ seen: set[str] = set()
522
+ candidates = [
523
+ *_normalize_work_done(new_value, max_items=max_items),
524
+ *_normalize_work_done(prev_value, max_items=max_items),
525
+ ]
526
+ for item in candidates:
527
+ summary = str(item.get("summary") or "").strip()
528
+ if not summary:
529
+ continue
530
+ key = summary.casefold()
531
+ if key in seen:
532
+ continue
533
+ seen.add(key)
534
+ merged.append(item)
535
+ if len(merged) >= max_items:
536
+ break
537
+ return merged
538
+
539
+
540
+ def _normalize_summary(summary: Any, previous: dict[str, Any]) -> dict[str, Any] | None:
541
+ if not isinstance(summary, dict):
542
+ return None
543
+
544
+ goal = str(summary.get("goal") or "").strip()
545
+ if not goal:
546
+ goal = str(previous.get("goal") or "").strip()
547
+
548
+ normalized = {
549
+ "goal": goal,
550
+ "constraints": _merge_string_lists(
551
+ summary.get("constraints"),
552
+ previous.get("constraints"),
553
+ max_items=10,
554
+ ),
555
+ "decisions": _merge_string_lists(
556
+ summary.get("decisions"),
557
+ previous.get("decisions"),
558
+ max_items=10,
559
+ ),
560
+ "work_done": _merge_work_done(
561
+ summary.get("work_done"),
562
+ previous.get("work_done"),
563
+ max_items=10,
564
+ ),
565
+ "open_threads": _merge_string_lists(
566
+ summary.get("open_threads"),
567
+ previous.get("open_threads"),
568
+ max_items=10,
569
+ ),
570
+ "next_steps": _merge_string_lists(
571
+ summary.get("next_steps"),
572
+ previous.get("next_steps"),
573
+ max_items=10,
574
+ ),
575
+ }
576
+ return normalized
577
+
578
+
579
+ def _normalize_pin_text(text: str) -> str:
580
+ return re.sub(r"\s+", " ", text.strip()).casefold()
581
+
582
+
583
+ def _trim_snippet(text: str, limit: int) -> str:
584
+ compact = re.sub(r"\s+", " ", text.strip())
585
+ if limit <= 0:
586
+ return ""
587
+ if len(compact) <= limit:
588
+ return compact
589
+ return compact[:limit].rstrip() + "..."
590
+
591
+
592
+ @dataclass
593
+ class CompactionState:
594
+ summary: dict[str, Any]
595
+ history_chunk_index: int
596
+ memory_message_index: int | None
597
+ pinned_prefix_len: int
598
+ pins: list[dict[str, Any]]
599
+ pins_message_index: int | None
600
+
601
+
602
+ class ConversationCompactor:
603
+ def __init__(
604
+ self,
605
+ *,
606
+ root: Path,
607
+ artifact_layout: SessionArtifactLayout,
608
+ store: SessionStore,
609
+ settings: CompactionSettings,
610
+ compactor_client: ChatClient,
611
+ model_registry: ModelRegistry,
612
+ usage_summary: UsageSummary,
613
+ usage_role: str,
614
+ pinned_prefix_len: int,
615
+ profile: CompactionProfileName = "chat",
616
+ input_token_counter: Callable[
617
+ [list[dict[str, Any]], list[dict[str, Any]] | None],
618
+ InputTokenCount | None,
619
+ ]
620
+ | None = None,
621
+ calibration_filters: Mapping[str, Any] | None = None,
622
+ ) -> None:
623
+ self._root = root.resolve()
624
+ self._store = store
625
+ self._settings = settings
626
+ self.compactor_client = compactor_client
627
+ self._model_registry = model_registry
628
+ self._usage_summary = usage_summary
629
+ self._usage_role = usage_role
630
+ self._input_token_counter = input_token_counter
631
+ self._calibration_filters: dict[str, str] = {}
632
+ self.update_calibration_filters(calibration_filters)
633
+ self._artifact_layout = artifact_layout
634
+ self._profile = _resolve_compaction_profile(profile=profile, settings=settings)
635
+ self._history_dir = self._artifact_layout.artifact_fs_path("history")
636
+ self._memory_dir = self._artifact_layout.artifact_fs_path("memory")
637
+ self._summary_path = self._memory_dir / "summary.json"
638
+ self._pins_path = self._memory_dir / "pins.json"
639
+ self.state = CompactionState(
640
+ summary={},
641
+ history_chunk_index=0,
642
+ memory_message_index=None,
643
+ pinned_prefix_len=max(0, int(pinned_prefix_len)),
644
+ pins=[],
645
+ pins_message_index=None,
646
+ )
647
+ self._restore_state_from_artifacts()
648
+
649
+ def update_calibration_filters(
650
+ self,
651
+ calibration_filters: Mapping[str, Any] | None,
652
+ ) -> None:
653
+ """Refresh route identity after an in-session provider reconfiguration."""
654
+
655
+ self._calibration_filters = {
656
+ key: str(value or "").strip()
657
+ for key, value in (calibration_filters or {}).items()
658
+ if key
659
+ in {
660
+ "provider_key",
661
+ "protocol",
662
+ "base_url_host",
663
+ "operation",
664
+ "request_mode",
665
+ "cache_strategy",
666
+ }
667
+ and str(value or "").strip()
668
+ }
669
+
670
+ def _restore_state_from_artifacts(self) -> None:
671
+ """Restore durable compaction metadata for resumed sessions."""
672
+
673
+ restored_summary: dict[str, Any] = {}
674
+ restored_pins: list[dict[str, Any]] = []
675
+ try:
676
+ if self._summary_path.exists():
677
+ raw_summary = json.loads(self._summary_path.read_text(encoding="utf-8"))
678
+ normalized = _normalize_summary(raw_summary, {})
679
+ if normalized is not None:
680
+ restored_summary = normalized
681
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
682
+ self._store.append(
683
+ "compaction_warning",
684
+ {"warning": "summary_restore_failed", "error": str(exc)},
685
+ )
686
+ try:
687
+ if self._pins_path.exists():
688
+ raw_pins = json.loads(self._pins_path.read_text(encoding="utf-8"))
689
+ candidates = raw_pins.get("pins") if isinstance(raw_pins, dict) else None
690
+ if isinstance(candidates, list):
691
+ restored_pins = self._bounded_pins(
692
+ [dict(pin) for pin in candidates if isinstance(pin, dict)]
693
+ )
694
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
695
+ self._store.append(
696
+ "compaction_warning",
697
+ {"warning": "pins_restore_failed", "error": str(exc)},
698
+ )
699
+
700
+ history_chunk_index = 0
701
+ try:
702
+ for path in self._history_dir.glob("chunk_*.jsonl"):
703
+ match = re.fullmatch(r"chunk_(\d+)\.jsonl", path.name)
704
+ if match is not None:
705
+ history_chunk_index = max(history_chunk_index, int(match.group(1)))
706
+ except OSError as exc:
707
+ self._store.append(
708
+ "compaction_warning",
709
+ {"warning": "history_restore_failed", "error": str(exc)},
710
+ )
711
+
712
+ self.state.summary = restored_summary
713
+ self.state.pins = restored_pins
714
+ self.state.history_chunk_index = history_chunk_index
715
+ if restored_summary or restored_pins or history_chunk_index:
716
+ self._store.append(
717
+ "compaction_state_restored",
718
+ {
719
+ "history_chunk_index": history_chunk_index,
720
+ "summary_restored": bool(restored_summary),
721
+ "pins_count": len(restored_pins),
722
+ },
723
+ )
724
+
725
+ def reinject_context_messages(
726
+ self,
727
+ messages: list[dict[str, Any]],
728
+ ) -> list[dict[str, Any]]:
729
+ """Make restored memory/pins model-visible again after a resume.
730
+
731
+ ``_restore_state_from_artifacts`` runs in ``__init__``, before the
732
+ session has any messages, so restored context stays invisible until
733
+ the next compaction fires. The resume path calls this once after
734
+ replaying history. The emptiness guard matters:
735
+ ``_upsert_context_messages`` inserts a memory message unconditionally,
736
+ which would add an empty marker for sessions with nothing restored.
737
+ """
738
+
739
+ if not self.state.summary and not self.state.pins:
740
+ return messages
741
+ updated = self._upsert_context_messages(
742
+ messages,
743
+ summary=self.state.summary,
744
+ pins=self.state.pins,
745
+ )
746
+ self._store.append(
747
+ "compaction_context_reinjected",
748
+ {
749
+ "summary_restored": bool(self.state.summary),
750
+ "pins_count": len(self.state.pins),
751
+ "memory_message_index": self.state.memory_message_index,
752
+ "pins_message_index": self.state.pins_message_index,
753
+ },
754
+ )
755
+ return updated
756
+
757
+ def _record_compactor_usage(
758
+ self,
759
+ *,
760
+ response: Any,
761
+ messages: list[dict[str, Any]],
762
+ operation: str,
763
+ ) -> None:
764
+ usage = getattr(response, "usage", None)
765
+ prompt_tokens = getattr(usage, "prompt_tokens", None) if usage else None
766
+ usage_context = usage_context_from_client_response(
767
+ client=self.compactor_client,
768
+ response=response,
769
+ operation=operation,
770
+ )
771
+ if prompt_tokens is None:
772
+ try:
773
+ counted_input = count_input_tokens_if_supported(
774
+ client=self.compactor_client,
775
+ messages=messages,
776
+ tools=None,
777
+ )
778
+ except Exception as exc: # noqa: BLE001 -- accounting fallback is optional
779
+ self._store.append(
780
+ "compaction_warning",
781
+ {
782
+ "warning": "provider_input_token_count_failed",
783
+ "operation": operation,
784
+ "error": str(exc),
785
+ },
786
+ )
787
+ else:
788
+ if counted_input is not None:
789
+ prompt_tokens = counted_input.input_tokens
790
+ usage_context["api_usage_source_detail"] = counted_input.source.value
791
+ usage_context["api_usage_confidence"] = counted_input.confidence.value
792
+ usage_context["api_prompt_tokens_authoritative"] = (
793
+ counted_input.confidence.value == "authoritative"
794
+ )
795
+ usage_record = build_usage_record(
796
+ role=f"{self._usage_role}:compactor",
797
+ requested_model=self.compactor_client.model,
798
+ response_model=getattr(response, "response_model", None),
799
+ messages=messages,
800
+ response_content=str(getattr(response, "content", "") or ""),
801
+ response_tool_calls=[],
802
+ api_prompt_tokens=prompt_tokens,
803
+ api_completion_tokens=(getattr(usage, "completion_tokens", None) if usage else None),
804
+ api_total_tokens=getattr(usage, "total_tokens", None) if usage else None,
805
+ api_usage=usage,
806
+ api_cached_prompt_tokens=(
807
+ getattr(usage, "cached_prompt_tokens", None) if usage else None
808
+ ),
809
+ registry=self._model_registry,
810
+ **usage_context,
811
+ )
812
+ self._usage_summary.add_record(usage_record)
813
+ self._store.append("llm_usage", usage_record.to_payload())
814
+
815
+ def _is_memory_message(self, msg: dict[str, Any]) -> bool:
816
+ if str(msg.get("role") or "") != "user":
817
+ return False
818
+ content = msg.get("content")
819
+ if not isinstance(content, str):
820
+ return False
821
+ return content.startswith(MEMORY_MARKER)
822
+
823
+ def _is_pins_message(self, msg: dict[str, Any]) -> bool:
824
+ if str(msg.get("role") or "") != "user":
825
+ return False
826
+ content = msg.get("content")
827
+ if not isinstance(content, str):
828
+ return False
829
+ return content.startswith(PINS_MARKER)
830
+
831
+ def _history_path(self, chunk_idx: int) -> Path:
832
+ candidate = (self._history_dir / f"chunk_{chunk_idx:04d}.jsonl").resolve()
833
+ candidate.relative_to(self._artifact_layout.filesystem_root.resolve())
834
+ return candidate
835
+
836
+ @property
837
+ def profile_name(self) -> CompactionProfileName:
838
+ return self._profile.name
839
+
840
+ @property
841
+ def history_dir(self) -> Path:
842
+ return self._history_dir
843
+
844
+ @property
845
+ def memory_dir(self) -> Path:
846
+ return self._memory_dir
847
+
848
+ @property
849
+ def summary_path(self) -> Path:
850
+ return self._summary_path
851
+
852
+ @property
853
+ def pins_path(self) -> Path:
854
+ return self._pins_path
855
+
856
+ def artifact_display_reference(self, artifact_path: Path) -> str:
857
+ return self._artifact_layout.display_reference_for_path(
858
+ artifact_path=artifact_path,
859
+ workspace_root=self._root,
860
+ )
861
+
862
+ @staticmethod
863
+ def _provider_request_messages(
864
+ messages: list[dict[str, Any]],
865
+ *,
866
+ request_messages_builder: RequestMessagesBuilder | None,
867
+ ) -> list[dict[str, Any]]:
868
+ if request_messages_builder is None:
869
+ return list(messages)
870
+ built = request_messages_builder(list(messages))
871
+ if not isinstance(built, list) or not all(isinstance(item, dict) for item in built):
872
+ raise TypeError("request_messages_builder must return a list of message objects")
873
+ return list(built)
874
+
875
+ @staticmethod
876
+ def _history_chunk_payload(*, idx: int, message: dict[str, Any]) -> dict[str, Any]:
877
+ payload = {
878
+ "idx": idx,
879
+ "message": strip_provider_metadata_from_message(message),
880
+ }
881
+ if PROVIDER_METADATA_KEY in message:
882
+ payload["internal_message"] = deepcopy(message)
883
+ return payload
884
+
885
+ def _write_history_chunk(
886
+ self,
887
+ *,
888
+ chunk_messages: list[dict[str, Any]],
889
+ first_idx: int,
890
+ ) -> Path | None:
891
+ chunk_idx = self.state.history_chunk_index + 1
892
+ history_path = self._history_path(chunk_idx)
893
+ try:
894
+ history_path.parent.mkdir(parents=True, exist_ok=True)
895
+ with history_path.open("w", encoding="utf-8") as fh:
896
+ for offset, msg in enumerate(chunk_messages):
897
+ payload = self._history_chunk_payload(idx=first_idx + offset, message=msg)
898
+ fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
899
+ except OSError as exc:
900
+ self._store.append(
901
+ "compaction_warning",
902
+ {
903
+ "warning": "history_chunk_write_failed",
904
+ "error": str(exc),
905
+ "first_idx": first_idx,
906
+ "count": len(chunk_messages),
907
+ },
908
+ )
909
+ return None
910
+
911
+ self.state.history_chunk_index = chunk_idx
912
+ self._store.append(
913
+ "history_chunk_written",
914
+ {
915
+ "path": self.artifact_display_reference(history_path),
916
+ "count": len(chunk_messages),
917
+ "first_idx": first_idx,
918
+ "last_idx": first_idx + max(0, len(chunk_messages) - 1),
919
+ "history_chunk_index": chunk_idx,
920
+ },
921
+ )
922
+ return history_path
923
+
924
+ def _serialize_history_chunk(
925
+ self,
926
+ *,
927
+ chunk_messages: list[dict[str, Any]],
928
+ first_idx: int,
929
+ ) -> str:
930
+ rows: list[str] = []
931
+ for offset, msg in enumerate(chunk_messages):
932
+ payload = self._history_chunk_payload(idx=first_idx + offset, message=msg)
933
+ rows.append(json.dumps(payload, ensure_ascii=False))
934
+ return "\n".join(rows) + ("\n" if rows else "")
935
+
936
+ def _stage_artifact_text(
937
+ self,
938
+ *,
939
+ artifact_path: Path,
940
+ contents: str,
941
+ warning: str,
942
+ warning_payload: dict[str, Any] | None = None,
943
+ ) -> Path | None:
944
+ extra_payload = dict(warning_payload or {})
945
+ try:
946
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
947
+ with tempfile.NamedTemporaryFile(
948
+ "w",
949
+ encoding="utf-8",
950
+ dir=artifact_path.parent,
951
+ delete=False,
952
+ prefix=f".{artifact_path.name}.",
953
+ suffix=".tmp",
954
+ ) as fh:
955
+ fh.write(contents)
956
+ staged_path = Path(fh.name)
957
+ except OSError as exc:
958
+ payload = {
959
+ "warning": warning,
960
+ "error": str(exc),
961
+ "path": self.artifact_display_reference(artifact_path),
962
+ }
963
+ payload.update(extra_payload)
964
+ self._store.append("compaction_warning", payload)
965
+ return None
966
+ return staged_path
967
+
968
+ def _publish_staged_artifact(
969
+ self,
970
+ *,
971
+ staged_path: Path,
972
+ artifact_path: Path,
973
+ warning: str,
974
+ ) -> bool:
975
+ try:
976
+ staged_path.replace(artifact_path)
977
+ except OSError as exc:
978
+ self._store.append(
979
+ "compaction_warning",
980
+ {
981
+ "warning": warning,
982
+ "error": str(exc),
983
+ "path": self.artifact_display_reference(artifact_path),
984
+ },
985
+ )
986
+ return False
987
+ return True
988
+
989
+ def _restore_execution_artifact(
990
+ self,
991
+ *,
992
+ artifact_path: Path,
993
+ previous_bytes: bytes | None,
994
+ ) -> None:
995
+ try:
996
+ if previous_bytes is None:
997
+ artifact_path.unlink(missing_ok=True)
998
+ return
999
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
1000
+ artifact_path.write_bytes(previous_bytes)
1001
+ except OSError as exc:
1002
+ self._store.append(
1003
+ "compaction_warning",
1004
+ {
1005
+ "warning": "execution_compaction_artifact_rollback_failed",
1006
+ "error": str(exc),
1007
+ "path": self.artifact_display_reference(artifact_path),
1008
+ },
1009
+ )
1010
+
1011
+ def _cleanup_staged_artifact(self, staged_path: Path | None) -> None:
1012
+ if staged_path is None:
1013
+ return
1014
+ try:
1015
+ staged_path.unlink(missing_ok=True)
1016
+ except OSError as exc:
1017
+ self._store.append(
1018
+ "compaction_warning",
1019
+ {
1020
+ "warning": "execution_compaction_temp_cleanup_failed",
1021
+ "error": str(exc),
1022
+ "path": self.artifact_display_reference(staged_path),
1023
+ },
1024
+ )
1025
+
1026
+ def _read_existing_artifact_bytes(
1027
+ self,
1028
+ *,
1029
+ artifact_path: Path,
1030
+ warning: str,
1031
+ ) -> tuple[bool, bytes | None]:
1032
+ try:
1033
+ if not artifact_path.exists():
1034
+ return True, None
1035
+ return True, artifact_path.read_bytes()
1036
+ except OSError as exc:
1037
+ self._store.append(
1038
+ "compaction_warning",
1039
+ {
1040
+ "warning": warning,
1041
+ "error": str(exc),
1042
+ "path": self.artifact_display_reference(artifact_path),
1043
+ },
1044
+ )
1045
+ return False, None
1046
+
1047
+ def _commit_execution_artifacts(
1048
+ self,
1049
+ *,
1050
+ chunk_messages: list[dict[str, Any]],
1051
+ first_idx: int,
1052
+ summary: dict[str, Any],
1053
+ pins: list[dict[str, Any]],
1054
+ ) -> _ExecutionArtifactCommit | None:
1055
+ next_chunk_index = self.state.history_chunk_index + 1
1056
+ history_path = self._history_path(next_chunk_index)
1057
+ history_text = self._serialize_history_chunk(
1058
+ chunk_messages=chunk_messages,
1059
+ first_idx=first_idx,
1060
+ )
1061
+ summary_text = json.dumps(summary, ensure_ascii=False, indent=2) + "\n"
1062
+ pins_text = None
1063
+ if pins:
1064
+ payload = {"pins": [self._public_pin(pin) for pin in pins]}
1065
+ pins_text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
1066
+
1067
+ staged_history = self._stage_artifact_text(
1068
+ artifact_path=history_path,
1069
+ contents=history_text,
1070
+ warning="history_chunk_write_failed",
1071
+ warning_payload={
1072
+ "first_idx": first_idx,
1073
+ "count": len(chunk_messages),
1074
+ },
1075
+ )
1076
+ if staged_history is None:
1077
+ return None
1078
+ staged_summary = self._stage_artifact_text(
1079
+ artifact_path=self._summary_path,
1080
+ contents=summary_text,
1081
+ warning="summary_write_failed",
1082
+ )
1083
+ if staged_summary is None:
1084
+ self._cleanup_staged_artifact(staged_history)
1085
+ return None
1086
+
1087
+ staged_pins: Path | None = None
1088
+ if pins_text is not None:
1089
+ staged_pins = self._stage_artifact_text(
1090
+ artifact_path=self._pins_path,
1091
+ contents=pins_text,
1092
+ warning="pins_write_failed",
1093
+ )
1094
+ if staged_pins is None:
1095
+ self._cleanup_staged_artifact(staged_history)
1096
+ self._cleanup_staged_artifact(staged_summary)
1097
+ return None
1098
+
1099
+ summary_ok, previous_summary = self._read_existing_artifact_bytes(
1100
+ artifact_path=self._summary_path,
1101
+ warning="summary_write_failed",
1102
+ )
1103
+ if not summary_ok:
1104
+ self._cleanup_staged_artifact(staged_history)
1105
+ self._cleanup_staged_artifact(staged_summary)
1106
+ self._cleanup_staged_artifact(staged_pins)
1107
+ return None
1108
+ pins_ok, previous_pins = self._read_existing_artifact_bytes(
1109
+ artifact_path=self._pins_path,
1110
+ warning="pins_write_failed",
1111
+ )
1112
+ if not pins_ok:
1113
+ self._cleanup_staged_artifact(staged_history)
1114
+ self._cleanup_staged_artifact(staged_summary)
1115
+ self._cleanup_staged_artifact(staged_pins)
1116
+ return None
1117
+ published_history = False
1118
+ published_summary = False
1119
+ published_pins = False
1120
+
1121
+ # Execution compaction publishes artifacts as a single transaction.
1122
+ # Safety-critical: if any publish step fails, restore prior files and
1123
+ # discard the new history chunk so the committed artifacts stay aligned
1124
+ # with the active in-memory conversation state.
1125
+ try:
1126
+ if not self._publish_staged_artifact(
1127
+ staged_path=staged_history,
1128
+ artifact_path=history_path,
1129
+ warning="history_chunk_write_failed",
1130
+ ):
1131
+ return None
1132
+ published_history = True
1133
+ staged_history = None
1134
+
1135
+ if not self._publish_staged_artifact(
1136
+ staged_path=staged_summary,
1137
+ artifact_path=self._summary_path,
1138
+ warning="summary_write_failed",
1139
+ ):
1140
+ self._restore_execution_artifact(
1141
+ artifact_path=history_path,
1142
+ previous_bytes=None,
1143
+ )
1144
+ return None
1145
+ published_summary = True
1146
+ staged_summary = None
1147
+
1148
+ if staged_pins is not None:
1149
+ if not self._publish_staged_artifact(
1150
+ staged_path=staged_pins,
1151
+ artifact_path=self._pins_path,
1152
+ warning="pins_write_failed",
1153
+ ):
1154
+ self._restore_execution_artifact(
1155
+ artifact_path=history_path,
1156
+ previous_bytes=None,
1157
+ )
1158
+ self._restore_execution_artifact(
1159
+ artifact_path=self._summary_path,
1160
+ previous_bytes=previous_summary,
1161
+ )
1162
+ return None
1163
+ published_pins = True
1164
+ staged_pins = None
1165
+ finally:
1166
+ self._cleanup_staged_artifact(staged_history)
1167
+ self._cleanup_staged_artifact(staged_summary)
1168
+ self._cleanup_staged_artifact(staged_pins)
1169
+
1170
+ if (
1171
+ not published_history
1172
+ or not published_summary
1173
+ or (pins_text is not None and not published_pins)
1174
+ ):
1175
+ self._restore_execution_artifact(
1176
+ artifact_path=history_path,
1177
+ previous_bytes=None,
1178
+ )
1179
+ self._restore_execution_artifact(
1180
+ artifact_path=self._summary_path,
1181
+ previous_bytes=previous_summary,
1182
+ )
1183
+ if pins_text is not None:
1184
+ self._restore_execution_artifact(
1185
+ artifact_path=self._pins_path,
1186
+ previous_bytes=previous_pins,
1187
+ )
1188
+ self._store.append(
1189
+ "compaction_warning",
1190
+ {
1191
+ "warning": "execution_compaction_artifact_commit_failed",
1192
+ "history_path": self.artifact_display_reference(history_path),
1193
+ },
1194
+ )
1195
+ return None
1196
+
1197
+ self._store.append(
1198
+ "history_chunk_written",
1199
+ {
1200
+ "path": self.artifact_display_reference(history_path),
1201
+ "count": len(chunk_messages),
1202
+ "first_idx": first_idx,
1203
+ "last_idx": first_idx + max(0, len(chunk_messages) - 1),
1204
+ "history_chunk_index": next_chunk_index,
1205
+ },
1206
+ )
1207
+ return _ExecutionArtifactCommit(
1208
+ history_path=history_path,
1209
+ history_chunk_index=next_chunk_index,
1210
+ )
1211
+
1212
+ def _write_summary_file(self, summary: dict[str, Any]) -> None:
1213
+ try:
1214
+ self._memory_dir.mkdir(parents=True, exist_ok=True)
1215
+ self._summary_path.write_text(
1216
+ json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
1217
+ encoding="utf-8",
1218
+ )
1219
+ except OSError as exc:
1220
+ self._store.append(
1221
+ "compaction_warning",
1222
+ {
1223
+ "warning": "summary_write_failed",
1224
+ "error": str(exc),
1225
+ "path": self.artifact_display_reference(self._summary_path),
1226
+ },
1227
+ )
1228
+
1229
+ def _write_pins_file(self, pins: list[dict[str, Any]]) -> None:
1230
+ payload = {"pins": [self._public_pin(pin) for pin in pins]}
1231
+ try:
1232
+ self._memory_dir.mkdir(parents=True, exist_ok=True)
1233
+ self._pins_path.write_text(
1234
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1235
+ encoding="utf-8",
1236
+ )
1237
+ except OSError as exc:
1238
+ self._store.append(
1239
+ "compaction_warning",
1240
+ {
1241
+ "warning": "pins_write_failed",
1242
+ "error": str(exc),
1243
+ "path": self.artifact_display_reference(self._pins_path),
1244
+ },
1245
+ )
1246
+
1247
+ def _build_compactor_messages(
1248
+ self,
1249
+ *,
1250
+ existing_summary: dict[str, Any],
1251
+ prepared_chunk_messages: list[dict[str, Any]],
1252
+ focus: str | None,
1253
+ ) -> list[dict[str, str]]:
1254
+ payload = {
1255
+ "existing_summary": existing_summary,
1256
+ "new_messages": prepared_chunk_messages,
1257
+ "focus": (focus or "").strip() or None,
1258
+ }
1259
+ return [
1260
+ {
1261
+ "role": "system",
1262
+ "content": (
1263
+ "You maintain compact conversation memory for a coding agent. "
1264
+ "Return STRICT JSON only (no markdown). "
1265
+ "Schema keys: goal, constraints, decisions, work_done, "
1266
+ "open_threads, next_steps. "
1267
+ "Keep entries concise, deduplicated, and high-signal only. "
1268
+ "Preserve existing summary details unless contradicted."
1269
+ ),
1270
+ },
1271
+ {
1272
+ "role": "user",
1273
+ "content": json.dumps(payload, ensure_ascii=False),
1274
+ },
1275
+ ]
1276
+
1277
+ def _compactor_request_budget(self, *, ratio: float = 0.85) -> int:
1278
+ model_meta = self._model_registry.get(self.compactor_client.model)
1279
+ budget = compute_input_budget(
1280
+ model_meta,
1281
+ safety_margin=self._settings.safety_margin_tokens,
1282
+ )
1283
+ safe_ratio = min(0.95, max(0.1, float(ratio)))
1284
+ return max(256, int(budget * safe_ratio))
1285
+
1286
+ def _trim_message_content_to_tokens(self, content: Any, token_budget: int) -> Any:
1287
+ if token_budget <= 0:
1288
+ return ""
1289
+ if isinstance(content, str):
1290
+ trimmed, _ = trim_text_to_budget(content, token_budget)
1291
+ return trimmed
1292
+ if isinstance(content, list):
1293
+ out: list[Any] = []
1294
+ parts = [part for part in content]
1295
+ per_part_budget = max(16, token_budget // max(1, len(parts)))
1296
+ for part in parts:
1297
+ if not isinstance(part, dict):
1298
+ out.append(part)
1299
+ continue
1300
+ copied = dict(part)
1301
+ if copied.get("type") == "text":
1302
+ text = str(copied.get("text") or "")
1303
+ trimmed_text, _ = trim_text_to_budget(text, per_part_budget)
1304
+ copied["text"] = trimmed_text
1305
+ elif copied.get("type") == "image_url":
1306
+ copied["image_url"] = {"url": "<image>"}
1307
+ out.append(copied)
1308
+ return out
1309
+ return content
1310
+
1311
+ def _prepare_chunk_messages_for_compactor(
1312
+ self,
1313
+ chunk_messages: list[dict[str, Any]],
1314
+ max_tokens: int,
1315
+ ) -> list[dict[str, Any]]:
1316
+ prepared = sanitize_messages_for_estimation(chunk_messages)
1317
+ if max_tokens <= 0:
1318
+ return prepared
1319
+
1320
+ payload = json.dumps(prepared, ensure_ascii=False, sort_keys=True)
1321
+ if estimate_tokens(payload) <= max_tokens:
1322
+ return prepared
1323
+
1324
+ working = [dict(msg) for msg in prepared]
1325
+ rounds = 3
1326
+ for _ in range(rounds):
1327
+ current = json.dumps(working, ensure_ascii=False, sort_keys=True)
1328
+ current_tokens = estimate_tokens(current)
1329
+ if current_tokens <= max_tokens:
1330
+ break
1331
+ per_message_budget = max(32, max_tokens // max(1, len(working)))
1332
+ next_messages: list[dict[str, Any]] = []
1333
+ for msg in working:
1334
+ copied = dict(msg)
1335
+ copied["content"] = self._trim_message_content_to_tokens(
1336
+ copied.get("content"),
1337
+ per_message_budget,
1338
+ )
1339
+ next_messages.append(copied)
1340
+ working = next_messages
1341
+ return working
1342
+
1343
+ def _apply_llm_importance_overrides(
1344
+ self,
1345
+ scored_turns: list[ScoredTurn],
1346
+ ) -> list[ScoredTurn]:
1347
+ if not self._settings.importance_use_llm:
1348
+ return scored_turns
1349
+ if len(scored_turns) > self._settings.importance_llm_max_turns:
1350
+ self._store.append(
1351
+ "compaction_warning",
1352
+ {
1353
+ "warning": "importance_llm_skipped_too_many_turns",
1354
+ "turn_count": len(scored_turns),
1355
+ "max_turns": self._settings.importance_llm_max_turns,
1356
+ },
1357
+ )
1358
+ return scored_turns
1359
+
1360
+ request_payload = {
1361
+ "turns": [
1362
+ {
1363
+ "start": turn.start,
1364
+ "end": turn.end,
1365
+ "token_estimate": turn.token_estimate,
1366
+ "score": turn.score,
1367
+ "reasons": turn.reasons,
1368
+ "preview": turn.user_preview,
1369
+ }
1370
+ for turn in scored_turns
1371
+ ]
1372
+ }
1373
+ prompt_messages = [
1374
+ {
1375
+ "role": "system",
1376
+ "content": (
1377
+ "Rescore turn importance. Return JSON only as "
1378
+ '{"scores":[{"start":int,"end":int,"score":float}]}. '
1379
+ "Higher means more important."
1380
+ ),
1381
+ },
1382
+ {
1383
+ "role": "user",
1384
+ "content": json.dumps(request_payload, ensure_ascii=False),
1385
+ },
1386
+ ]
1387
+
1388
+ try:
1389
+ response = self.compactor_client.chat(
1390
+ messages=prompt_messages,
1391
+ tools=None,
1392
+ stream=False,
1393
+ )
1394
+ except LLMError as exc:
1395
+ self._store.append(
1396
+ "compaction_warning",
1397
+ {"warning": "importance_llm_error", "error": str(exc)},
1398
+ )
1399
+ return scored_turns
1400
+
1401
+ self._record_compactor_usage(
1402
+ response=response,
1403
+ messages=prompt_messages,
1404
+ operation="importance_llm",
1405
+ )
1406
+
1407
+ text = (response.content or "").strip()
1408
+ try:
1409
+ parsed = json.loads(text)
1410
+ except json.JSONDecodeError:
1411
+ extracted = _extract_first_json_object(text)
1412
+ if extracted is None:
1413
+ self._store.append(
1414
+ "compaction_warning",
1415
+ {"warning": "importance_llm_invalid_json"},
1416
+ )
1417
+ return scored_turns
1418
+ try:
1419
+ parsed = json.loads(extracted)
1420
+ except json.JSONDecodeError:
1421
+ self._store.append(
1422
+ "compaction_warning",
1423
+ {"warning": "importance_llm_invalid_json"},
1424
+ )
1425
+ return scored_turns
1426
+
1427
+ if not isinstance(parsed, dict):
1428
+ return scored_turns
1429
+ raw_scores = parsed.get("scores")
1430
+ if not isinstance(raw_scores, list):
1431
+ return scored_turns
1432
+
1433
+ overrides: dict[tuple[int, int], float] = {}
1434
+ for row in raw_scores:
1435
+ if not isinstance(row, dict):
1436
+ continue
1437
+ try:
1438
+ start = int(row.get("start"))
1439
+ end = int(row.get("end"))
1440
+ score = float(row.get("score"))
1441
+ except (TypeError, ValueError):
1442
+ continue
1443
+ if score < 0:
1444
+ continue
1445
+ overrides[(start, end)] = score
1446
+
1447
+ if not overrides:
1448
+ return scored_turns
1449
+
1450
+ updated: list[ScoredTurn] = []
1451
+ for turn in scored_turns:
1452
+ override_score = overrides.get((turn.start, turn.end))
1453
+ if override_score is None:
1454
+ updated.append(turn)
1455
+ continue
1456
+ reasons = list(turn.reasons)
1457
+ if "llm_importance_override" not in reasons:
1458
+ reasons.append("llm_importance_override")
1459
+ updated.append(
1460
+ ScoredTurn(
1461
+ start=turn.start,
1462
+ end=turn.end,
1463
+ token_estimate=turn.token_estimate,
1464
+ score=override_score,
1465
+ density=override_score / max(1, turn.token_estimate),
1466
+ reasons=reasons,
1467
+ user_preview=turn.user_preview,
1468
+ )
1469
+ )
1470
+ return updated
1471
+
1472
+ def _call_compactor(
1473
+ self,
1474
+ *,
1475
+ prompt_messages: list[dict[str, Any]],
1476
+ ) -> dict[str, Any] | None:
1477
+ try:
1478
+ response = self.compactor_client.chat(
1479
+ messages=prompt_messages,
1480
+ tools=None,
1481
+ stream=False,
1482
+ )
1483
+ except LLMError as exc:
1484
+ self._store.append(
1485
+ "compaction_warning",
1486
+ {"warning": "compactor_llm_error", "error": str(exc)},
1487
+ )
1488
+ return None
1489
+
1490
+ self._record_compactor_usage(
1491
+ response=response,
1492
+ messages=prompt_messages,
1493
+ operation="compactor_llm",
1494
+ )
1495
+
1496
+ text = (response.content or "").strip()
1497
+ parsed: Any
1498
+ try:
1499
+ parsed = json.loads(text)
1500
+ except json.JSONDecodeError:
1501
+ extracted = _extract_first_json_object(text)
1502
+ if extracted is None:
1503
+ self._store.append(
1504
+ "compaction_warning",
1505
+ {"warning": "compactor_invalid_json", "preview": text[:4000]},
1506
+ )
1507
+ return None
1508
+ try:
1509
+ parsed = json.loads(extracted)
1510
+ except json.JSONDecodeError:
1511
+ self._store.append(
1512
+ "compaction_warning",
1513
+ {"warning": "compactor_invalid_json", "preview": text[:4000]},
1514
+ )
1515
+ return None
1516
+
1517
+ normalized = _normalize_summary(parsed, self.state.summary)
1518
+ if normalized is None:
1519
+ self._store.append(
1520
+ "compaction_warning",
1521
+ {"warning": "compactor_json_not_object"},
1522
+ )
1523
+ return None
1524
+ return normalized
1525
+
1526
+ def _memory_message(self, summary: dict[str, Any]) -> dict[str, str]:
1527
+ payload = json.dumps(summary, separators=(",", ":"), ensure_ascii=False)
1528
+ return {"role": "user", "content": f"{MEMORY_MARKER}\n{payload}"}
1529
+
1530
+ def _public_pin(self, pin: dict[str, Any]) -> dict[str, Any]:
1531
+ return {
1532
+ "kind": str(pin.get("kind") or "context"),
1533
+ "text": str(pin.get("text") or ""),
1534
+ "reasons": [str(r) for r in pin.get("reasons", []) if str(r).strip()],
1535
+ "source": pin.get("source") if isinstance(pin.get("source"), dict) else {},
1536
+ }
1537
+
1538
+ def _pins_message(self, pins: list[dict[str, Any]]) -> dict[str, str]:
1539
+ payload = {"pins": [self._public_pin(pin) for pin in pins]}
1540
+ compact = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
1541
+ return {"role": "user", "content": f"{PINS_MARKER}\n{compact}"}
1542
+
1543
+ def _upsert_context_messages(
1544
+ self,
1545
+ messages: list[dict[str, Any]],
1546
+ *,
1547
+ summary: dict[str, Any],
1548
+ pins: list[dict[str, Any]],
1549
+ state: CompactionState | None = None,
1550
+ ) -> list[dict[str, Any]]:
1551
+ target_state = self.state if state is None else state
1552
+ filtered: list[dict[str, Any]] = []
1553
+ for msg in messages:
1554
+ if self._is_memory_message(msg):
1555
+ continue
1556
+ if self._is_pins_message(msg):
1557
+ continue
1558
+ filtered.append(msg)
1559
+
1560
+ insert_at = min(target_state.pinned_prefix_len, len(filtered))
1561
+ target_state.pins_message_index = None
1562
+ target_state.memory_message_index = None
1563
+
1564
+ if pins:
1565
+ filtered.insert(insert_at, self._pins_message(pins))
1566
+ target_state.pins_message_index = insert_at
1567
+ insert_at += 1
1568
+ filtered.insert(insert_at, self._memory_message(summary))
1569
+ target_state.memory_message_index = insert_at
1570
+ return filtered
1571
+
1572
+ def _turn_ranges(
1573
+ self,
1574
+ messages: list[dict[str, Any]],
1575
+ *,
1576
+ protected_prefix_len: int | None = None,
1577
+ ) -> list[tuple[int, int]]:
1578
+ turn_ranges: list[tuple[int, int]] = []
1579
+ start_idx: int | None = None
1580
+ scan_start = min(
1581
+ max(
1582
+ 0,
1583
+ self.state.pinned_prefix_len
1584
+ if protected_prefix_len is None
1585
+ else protected_prefix_len,
1586
+ ),
1587
+ len(messages),
1588
+ )
1589
+ for idx in range(scan_start, len(messages)):
1590
+ msg = messages[idx]
1591
+ if self._is_memory_message(msg) or self._is_pins_message(msg):
1592
+ continue
1593
+ if str(msg.get("role") or "") != "user":
1594
+ continue
1595
+ if start_idx is not None:
1596
+ turn_ranges.append((start_idx, idx))
1597
+ start_idx = idx
1598
+ if start_idx is not None:
1599
+ turn_ranges.append((start_idx, len(messages)))
1600
+ return turn_ranges
1601
+
1602
+ def _range_preview(
1603
+ self, range_messages: list[dict[str, Any]], fallback_limit: int = 200
1604
+ ) -> str:
1605
+ for msg in range_messages:
1606
+ preview = _trim_snippet(extract_text(msg), fallback_limit)
1607
+ if preview:
1608
+ return preview
1609
+ return ""
1610
+
1611
+ def _score_ranges(
1612
+ self,
1613
+ messages: list[dict[str, Any]],
1614
+ ranges: list[tuple[int, int]],
1615
+ ) -> list[ScoredTurn]:
1616
+ scored: list[ScoredTurn] = []
1617
+ for start, end in ranges:
1618
+ range_messages = messages[start:end]
1619
+ score, reasons, preview = score_turn(range_messages)
1620
+ if not preview:
1621
+ preview = self._range_preview(range_messages)
1622
+ token_estimate = estimate_turn_tokens(range_messages)
1623
+ density = score / max(1, token_estimate)
1624
+ scored.append(
1625
+ ScoredTurn(
1626
+ start=start,
1627
+ end=end,
1628
+ token_estimate=token_estimate,
1629
+ score=score,
1630
+ density=density,
1631
+ reasons=reasons,
1632
+ user_preview=preview,
1633
+ )
1634
+ )
1635
+ return self._apply_llm_importance_overrides(scored)
1636
+
1637
+ def _assistant_has_tool_calls(self, msg: dict[str, Any]) -> bool:
1638
+ tool_calls = msg.get("tool_calls")
1639
+ return isinstance(tool_calls, list) and len(tool_calls) > 0
1640
+
1641
+ def _first_execution_user_idx(
1642
+ self,
1643
+ messages: list[dict[str, Any]],
1644
+ *,
1645
+ protected_prefix_len: int | None = None,
1646
+ ) -> int | None:
1647
+ scan_start = min(
1648
+ max(
1649
+ 0,
1650
+ self.state.pinned_prefix_len
1651
+ if protected_prefix_len is None
1652
+ else protected_prefix_len,
1653
+ ),
1654
+ len(messages),
1655
+ )
1656
+ for idx in range(scan_start, len(messages)):
1657
+ msg = messages[idx]
1658
+ if self._is_memory_message(msg) or self._is_pins_message(msg):
1659
+ continue
1660
+ if str(msg.get("role") or "") == "user":
1661
+ return idx
1662
+ return None
1663
+
1664
+ def _assistant_tool_call_ids(self, msg: dict[str, Any]) -> list[str]:
1665
+ tool_calls = msg.get("tool_calls")
1666
+ if not isinstance(tool_calls, list):
1667
+ return []
1668
+ ids: list[str] = []
1669
+ seen: set[str] = set()
1670
+ for item in tool_calls:
1671
+ if not isinstance(item, dict):
1672
+ return []
1673
+ call_id = str(item.get("id") or "").strip()
1674
+ if not call_id or call_id in seen:
1675
+ return []
1676
+ seen.add(call_id)
1677
+ ids.append(call_id)
1678
+ return ids
1679
+
1680
+ def _execution_sequence_end(self, messages: list[dict[str, Any]], *, scan_start: int) -> int:
1681
+ for idx in range(scan_start, len(messages)):
1682
+ msg = messages[idx]
1683
+ if self._is_memory_message(msg) or self._is_pins_message(msg):
1684
+ continue
1685
+ if str(msg.get("role") or "") == "user":
1686
+ return idx
1687
+ return len(messages)
1688
+
1689
+ def _build_execution_tool_bundle(
1690
+ self,
1691
+ messages: list[dict[str, Any]],
1692
+ *,
1693
+ start: int,
1694
+ sequence_end: int,
1695
+ ) -> _ExecutionBundle | None:
1696
+ assistant_msg = messages[start]
1697
+ expected_tool_call_ids = set(self._assistant_tool_call_ids(assistant_msg))
1698
+ if not expected_tool_call_ids:
1699
+ return None
1700
+
1701
+ seen_tool_call_ids: set[str] = set()
1702
+ cursor = start + 1
1703
+ # Execution compaction must treat an assistant tool call, its tool results,
1704
+ # and the immediate consuming assistant response as one atomic bundle.
1705
+ while cursor < sequence_end:
1706
+ msg = messages[cursor]
1707
+ if self._is_memory_message(msg) or self._is_pins_message(msg):
1708
+ return None
1709
+ if str(msg.get("role") or "") != "tool":
1710
+ break
1711
+ tool_call_id = str(msg.get("tool_call_id") or "").strip()
1712
+ if (
1713
+ not tool_call_id
1714
+ or tool_call_id not in expected_tool_call_ids
1715
+ or tool_call_id in seen_tool_call_ids
1716
+ ):
1717
+ return None
1718
+ seen_tool_call_ids.add(tool_call_id)
1719
+ cursor += 1
1720
+
1721
+ if seen_tool_call_ids != expected_tool_call_ids:
1722
+ return None
1723
+
1724
+ if cursor < sequence_end:
1725
+ follow_up = messages[cursor]
1726
+ if self._is_memory_message(follow_up) or self._is_pins_message(follow_up):
1727
+ return None
1728
+ if str(
1729
+ follow_up.get("role") or ""
1730
+ ) == "assistant" and not self._assistant_has_tool_calls(follow_up):
1731
+ cursor += 1
1732
+
1733
+ return _ExecutionBundle(start=start, end=cursor)
1734
+
1735
+ def _build_execution_bundles(
1736
+ self,
1737
+ messages: list[dict[str, Any]],
1738
+ *,
1739
+ protected_prefix_len: int | None = None,
1740
+ ) -> tuple[list[_ExecutionBundle], int, int]:
1741
+ first_user_idx = self._first_execution_user_idx(
1742
+ messages,
1743
+ protected_prefix_len=protected_prefix_len,
1744
+ )
1745
+ if first_user_idx is None:
1746
+ return [], len(messages), len(messages)
1747
+
1748
+ scan_start = (
1749
+ first_user_idx + 1 if self._profile.preserve_first_user_turn else first_user_idx
1750
+ )
1751
+ sequence_end = self._execution_sequence_end(messages, scan_start=scan_start)
1752
+ bundles: list[_ExecutionBundle] = []
1753
+ idx = scan_start
1754
+ while idx < sequence_end:
1755
+ msg = messages[idx]
1756
+ if self._is_memory_message(msg) or self._is_pins_message(msg):
1757
+ idx += 1
1758
+ continue
1759
+
1760
+ role = str(msg.get("role") or "")
1761
+ if role == "tool":
1762
+ return bundles, idx, sequence_end
1763
+ if role == "assistant" and self._assistant_has_tool_calls(msg):
1764
+ bundle = self._build_execution_tool_bundle(
1765
+ messages,
1766
+ start=idx,
1767
+ sequence_end=sequence_end,
1768
+ )
1769
+ if bundle is None:
1770
+ return bundles, idx, sequence_end
1771
+ bundles.append(bundle)
1772
+ idx = bundle.end
1773
+ continue
1774
+ bundles.append(_ExecutionBundle(start=idx, end=idx + 1))
1775
+ idx += 1
1776
+ return bundles, sequence_end, sequence_end
1777
+
1778
+ def _execution_tail_bundle_index(self, bundles: list[_ExecutionBundle]) -> int:
1779
+ keep_messages = self._profile.recent_raw_tail_messages
1780
+ if keep_messages <= 0:
1781
+ return len(bundles)
1782
+ kept = 0
1783
+ for idx in range(len(bundles) - 1, -1, -1):
1784
+ kept += max(0, bundles[idx].end - bundles[idx].start)
1785
+ if kept >= keep_messages:
1786
+ return idx
1787
+ return 0
1788
+
1789
+ def _has_valid_tool_transcript(self, messages: list[dict[str, Any]]) -> bool:
1790
+ open_tool_call_ids: set[str] = set()
1791
+ for msg in messages:
1792
+ if self._is_memory_message(msg) or self._is_pins_message(msg):
1793
+ continue
1794
+ role = str(msg.get("role") or "")
1795
+ if role == "assistant":
1796
+ if self._assistant_has_tool_calls(msg):
1797
+ tool_call_ids = self._assistant_tool_call_ids(msg)
1798
+ if open_tool_call_ids or not tool_call_ids:
1799
+ return False
1800
+ open_tool_call_ids = set(tool_call_ids)
1801
+ continue
1802
+ if open_tool_call_ids:
1803
+ return False
1804
+ continue
1805
+ if role == "tool":
1806
+ tool_call_id = str(msg.get("tool_call_id") or "").strip()
1807
+ if not tool_call_id or tool_call_id not in open_tool_call_ids:
1808
+ return False
1809
+ open_tool_call_ids.remove(tool_call_id)
1810
+ continue
1811
+ if open_tool_call_ids:
1812
+ return False
1813
+ return not open_tool_call_ids
1814
+
1815
+ def _clone_state(self) -> CompactionState:
1816
+ return CompactionState(
1817
+ summary=deepcopy(self.state.summary),
1818
+ history_chunk_index=self.state.history_chunk_index,
1819
+ memory_message_index=self.state.memory_message_index,
1820
+ pinned_prefix_len=self.state.pinned_prefix_len,
1821
+ pins=deepcopy(self.state.pins),
1822
+ pins_message_index=self.state.pins_message_index,
1823
+ )
1824
+
1825
+ def _execution_candidate_stats(
1826
+ self,
1827
+ *,
1828
+ bundles: list[_ExecutionBundle],
1829
+ scored_bundles: list[ScoredTurn],
1830
+ start_idx: int,
1831
+ end_idx: int,
1832
+ ) -> tuple[int, int]:
1833
+ message_count = 0
1834
+ token_estimate = 0
1835
+ for idx in range(start_idx, end_idx + 1):
1836
+ bundle = bundles[idx]
1837
+ message_count += max(0, bundle.end - bundle.start)
1838
+ token_estimate += int(scored_bundles[idx].token_estimate)
1839
+ return message_count, token_estimate
1840
+
1841
+ def _execution_meets_minimum_removal_thresholds(
1842
+ self,
1843
+ *,
1844
+ message_count: int,
1845
+ token_estimate: int,
1846
+ ) -> bool:
1847
+ return (
1848
+ message_count >= self._settings.execution_min_removable_messages
1849
+ and token_estimate >= self._settings.execution_min_removable_tokens
1850
+ )
1851
+
1852
+ def _expand_execution_window_to_minimums(
1853
+ self,
1854
+ *,
1855
+ bundles: list[_ExecutionBundle],
1856
+ scored_bundles: list[ScoredTurn],
1857
+ start_idx: int,
1858
+ end_idx: int,
1859
+ ) -> tuple[int, int] | None:
1860
+ left = start_idx
1861
+ right = end_idx
1862
+ while True:
1863
+ message_count, token_estimate = self._execution_candidate_stats(
1864
+ bundles=bundles,
1865
+ scored_bundles=scored_bundles,
1866
+ start_idx=left,
1867
+ end_idx=right,
1868
+ )
1869
+ if self._execution_meets_minimum_removal_thresholds(
1870
+ message_count=message_count,
1871
+ token_estimate=token_estimate,
1872
+ ):
1873
+ return left, right
1874
+ if left > 0:
1875
+ left -= 1
1876
+ continue
1877
+ if right + 1 < len(bundles):
1878
+ right += 1
1879
+ continue
1880
+ return None
1881
+
1882
+ def _oldest_execution_window(
1883
+ self,
1884
+ *,
1885
+ bundles: list[_ExecutionBundle],
1886
+ scored_bundles: list[ScoredTurn],
1887
+ ) -> tuple[int, int] | None:
1888
+ if not bundles:
1889
+ return None
1890
+ right = 0
1891
+ while True:
1892
+ message_count, token_estimate = self._execution_candidate_stats(
1893
+ bundles=bundles,
1894
+ scored_bundles=scored_bundles,
1895
+ start_idx=0,
1896
+ end_idx=right,
1897
+ )
1898
+ if self._execution_meets_minimum_removal_thresholds(
1899
+ message_count=message_count,
1900
+ token_estimate=token_estimate,
1901
+ ):
1902
+ break
1903
+ if right + 1 >= len(bundles):
1904
+ return None
1905
+ right += 1
1906
+
1907
+ while right + 1 < len(bundles):
1908
+ next_message_count, _ = self._execution_candidate_stats(
1909
+ bundles=bundles,
1910
+ scored_bundles=scored_bundles,
1911
+ start_idx=0,
1912
+ end_idx=right + 1,
1913
+ )
1914
+ if next_message_count > self._settings.max_chunk_messages:
1915
+ break
1916
+ right += 1
1917
+ return 0, right
1918
+
1919
+ def _stage_execution_compaction_preview(
1920
+ self,
1921
+ *,
1922
+ working: list[dict[str, Any]],
1923
+ chunk_plan: _ChunkPlan,
1924
+ chunk_messages: list[dict[str, Any]],
1925
+ tool_list: list[dict[str, Any]] | None,
1926
+ used_tokens: int,
1927
+ focus: str | None,
1928
+ history_rel_path: str,
1929
+ request_messages_builder: RequestMessagesBuilder | None,
1930
+ ) -> _ExecutionCompactionPreview | None:
1931
+ compactor_budget = self._compactor_request_budget(ratio=0.85)
1932
+ prepared_chunk = self._prepare_chunk_messages_for_compactor(
1933
+ chunk_messages,
1934
+ compactor_budget,
1935
+ )
1936
+ prompt_messages = self._build_compactor_messages(
1937
+ existing_summary=self.state.summary,
1938
+ prepared_chunk_messages=prepared_chunk,
1939
+ focus=focus,
1940
+ )
1941
+ new_summary = self._call_compactor(prompt_messages=prompt_messages)
1942
+
1943
+ if new_summary is None:
1944
+ retry_budget = self._compactor_request_budget(ratio=0.60)
1945
+ retry_chunk = self._prepare_chunk_messages_for_compactor(
1946
+ chunk_messages,
1947
+ retry_budget,
1948
+ )
1949
+ retry_prompt_messages = self._build_compactor_messages(
1950
+ existing_summary=self.state.summary,
1951
+ prepared_chunk_messages=retry_chunk,
1952
+ focus=focus,
1953
+ )
1954
+ new_summary = self._call_compactor(prompt_messages=retry_prompt_messages)
1955
+
1956
+ if new_summary is None:
1957
+ self._store.append(
1958
+ "compaction_warning",
1959
+ {
1960
+ "warning": "compactor_failed_preserved_chunk",
1961
+ "history_path": history_rel_path,
1962
+ },
1963
+ )
1964
+ return None
1965
+
1966
+ extracted_pins = self._extract_pins_for_chunk(
1967
+ history_rel_path=history_rel_path,
1968
+ scored_turns=chunk_plan.scored_turns,
1969
+ )
1970
+ staged_pins = deepcopy(self.state.pins)
1971
+ if extracted_pins:
1972
+ staged_pins = self._bounded_pins([*staged_pins, *extracted_pins])
1973
+
1974
+ # Stage the execution compaction in memory first. Safety-critical:
1975
+ # do not mutate the active state or write artifacts until we prove the
1976
+ # new request is smaller than the current one.
1977
+ trial_state = self._clone_state()
1978
+ trial_state.summary = deepcopy(new_summary)
1979
+ trial_state.pins = deepcopy(staged_pins)
1980
+ reduced_messages = working[: chunk_plan.start] + working[chunk_plan.end :]
1981
+ updated_messages = self._upsert_context_messages(
1982
+ reduced_messages,
1983
+ summary=trial_state.summary,
1984
+ pins=trial_state.pins,
1985
+ state=trial_state,
1986
+ )
1987
+ provider_messages = self._provider_request_messages(
1988
+ updated_messages,
1989
+ request_messages_builder=request_messages_builder,
1990
+ )
1991
+ predicted_used_tokens = estimate_request_tokens(provider_messages, tool_list)
1992
+ if predicted_used_tokens >= used_tokens:
1993
+ removable_tokens = estimate_turn_tokens(chunk_messages)
1994
+ self._store.append(
1995
+ "compaction_warning",
1996
+ {
1997
+ "warning": "compaction_no_progress",
1998
+ "used_tokens_before": used_tokens,
1999
+ "used_tokens_after": predicted_used_tokens,
2000
+ "chunk_strategy": chunk_plan.strategy,
2001
+ "removable_messages": len(chunk_messages),
2002
+ "removable_estimated_tokens": removable_tokens,
2003
+ "history_path": history_rel_path,
2004
+ },
2005
+ )
2006
+ return None
2007
+
2008
+ return _ExecutionCompactionPreview(
2009
+ updated_messages=updated_messages,
2010
+ summary=trial_state.summary,
2011
+ pins=trial_state.pins,
2012
+ memory_message_index=trial_state.memory_message_index,
2013
+ pins_message_index=trial_state.pins_message_index,
2014
+ predicted_used_tokens=predicted_used_tokens,
2015
+ dropped_without_summary=False,
2016
+ )
2017
+
2018
+ def _build_chat_chunk_plan(
2019
+ self,
2020
+ messages: list[dict[str, Any]],
2021
+ *,
2022
+ protected_prefix_len: int | None = None,
2023
+ hard_pressure: bool = False,
2024
+ ) -> _ChunkPlan | None:
2025
+ turns = self._turn_ranges(messages, protected_prefix_len=protected_prefix_len)
2026
+ # Under a confirmed provider overflow, preserve the active/latest user
2027
+ # turn but allow older turns inside the normal recent window to be
2028
+ # compacted. A fixed recent-turn barrier must never make recovery
2029
+ # impossible merely because the conversation has fewer than N turns.
2030
+ keep_recent = (
2031
+ min(1, self._settings.recent_user_turns_to_keep)
2032
+ if hard_pressure
2033
+ else self._settings.recent_user_turns_to_keep
2034
+ )
2035
+ if len(turns) <= keep_recent:
2036
+ return None
2037
+ eligible_turns = turns[:-keep_recent] if keep_recent > 0 else turns
2038
+ if not eligible_turns:
2039
+ return None
2040
+
2041
+ scored = self._score_ranges(messages, eligible_turns)
2042
+ if scored:
2043
+ min_density = min(turn.density for turn in scored)
2044
+ max_density = max(turn.density for turn in scored)
2045
+ self._store.append(
2046
+ "importance_scored",
2047
+ {
2048
+ "eligible_turns": len(scored),
2049
+ "min_density": min_density,
2050
+ "max_density": max_density,
2051
+ "strategy": self._settings.importance_strategy,
2052
+ },
2053
+ )
2054
+
2055
+ if (
2056
+ self._settings.importance_enabled
2057
+ and self._settings.importance_strategy == "lowest_density"
2058
+ ):
2059
+ if not scored:
2060
+ return None
2061
+ selected = min(scored, key=lambda turn: (turn.density, turn.start))
2062
+ return _ChunkPlan(
2063
+ start=selected.start,
2064
+ end=selected.end,
2065
+ scored_turns=[selected],
2066
+ strategy="lowest_density",
2067
+ )
2068
+
2069
+ chunk_start = eligible_turns[0][0]
2070
+ chunk_end = chunk_start
2071
+ consumed = 0
2072
+ selected_ranges: list[tuple[int, int]] = []
2073
+ for turn_start, turn_end in eligible_turns:
2074
+ turn_size = max(0, turn_end - turn_start)
2075
+ if consumed > 0 and consumed + turn_size > self._settings.max_chunk_messages:
2076
+ break
2077
+ chunk_end = turn_end
2078
+ consumed += turn_size
2079
+ selected_ranges.append((turn_start, turn_end))
2080
+ if consumed >= self._settings.max_chunk_messages:
2081
+ break
2082
+ if chunk_end <= chunk_start:
2083
+ return None
2084
+
2085
+ selected_scored: list[ScoredTurn] = []
2086
+ selected_set = {(start, end) for start, end in selected_ranges}
2087
+ for turn in scored:
2088
+ if (turn.start, turn.end) in selected_set:
2089
+ selected_scored.append(turn)
2090
+
2091
+ return _ChunkPlan(
2092
+ start=chunk_start,
2093
+ end=chunk_end,
2094
+ scored_turns=selected_scored,
2095
+ strategy="oldest",
2096
+ )
2097
+
2098
+ def _build_execution_chunk_plan_for_window(
2099
+ self,
2100
+ *,
2101
+ messages: list[dict[str, Any]],
2102
+ bundles: list[_ExecutionBundle],
2103
+ scored_bundles: list[ScoredTurn],
2104
+ start_idx: int,
2105
+ end_idx: int,
2106
+ strategy: str,
2107
+ ) -> _ChunkPlan | None:
2108
+ selected_bundles = bundles[start_idx : end_idx + 1]
2109
+ selected_scored = scored_bundles[start_idx : end_idx + 1]
2110
+ chunk_plan = _ChunkPlan(
2111
+ start=selected_bundles[0].start,
2112
+ end=selected_bundles[-1].end,
2113
+ scored_turns=selected_scored,
2114
+ strategy=strategy,
2115
+ )
2116
+ reduced_messages = messages[: chunk_plan.start] + messages[chunk_plan.end :]
2117
+ if not self._has_valid_tool_transcript(reduced_messages):
2118
+ self._store.append(
2119
+ "compaction_warning",
2120
+ {
2121
+ "warning": "execution_compaction_rejected_invalid_transcript",
2122
+ "chunk_start": chunk_plan.start,
2123
+ "chunk_end": chunk_plan.end,
2124
+ "strategy": chunk_plan.strategy,
2125
+ },
2126
+ )
2127
+ return None
2128
+ return chunk_plan
2129
+
2130
+ def _build_execution_chunk_plans(
2131
+ self,
2132
+ messages: list[dict[str, Any]],
2133
+ *,
2134
+ protected_prefix_len: int | None = None,
2135
+ ) -> list[_ChunkPlan]:
2136
+ bundles, safe_boundary, sequence_end = self._build_execution_bundles(
2137
+ messages,
2138
+ protected_prefix_len=protected_prefix_len,
2139
+ )
2140
+ if not bundles:
2141
+ return []
2142
+ tail_bundle_idx = self._execution_tail_bundle_index(bundles)
2143
+ # Preserve the recent execution tail on whole-bundle boundaries so the
2144
+ # active conversation never starts in the middle of a tool exchange.
2145
+ tail_boundary = (
2146
+ bundles[tail_bundle_idx].start if tail_bundle_idx < len(bundles) else safe_boundary
2147
+ )
2148
+ removable_boundary = min(safe_boundary, tail_boundary)
2149
+ eligible_bundles = [bundle for bundle in bundles if bundle.end <= removable_boundary]
2150
+ if not eligible_bundles:
2151
+ return []
2152
+
2153
+ if safe_boundary < sequence_end:
2154
+ self._store.append(
2155
+ "compaction_warning",
2156
+ {
2157
+ "warning": "execution_compaction_stopped_at_unsafe_bundle_boundary",
2158
+ "blocked_start": safe_boundary,
2159
+ },
2160
+ )
2161
+
2162
+ ranges = [(bundle.start, bundle.end) for bundle in eligible_bundles]
2163
+ scored = self._score_ranges(messages, ranges)
2164
+ scored_by_range = {(turn.start, turn.end): turn for turn in scored}
2165
+ scored_bundles = [
2166
+ scored_by_range[(bundle.start, bundle.end)]
2167
+ for bundle in eligible_bundles
2168
+ if (bundle.start, bundle.end) in scored_by_range
2169
+ ]
2170
+ if scored:
2171
+ min_density = min(turn.density for turn in scored)
2172
+ max_density = max(turn.density for turn in scored)
2173
+ self._store.append(
2174
+ "importance_scored",
2175
+ {
2176
+ "eligible_turns": len(scored),
2177
+ "min_density": min_density,
2178
+ "max_density": max_density,
2179
+ "strategy": f"{self._settings.importance_strategy}:execution",
2180
+ },
2181
+ )
2182
+
2183
+ candidate_windows: list[tuple[int, int, str]] = []
2184
+ if (
2185
+ self._settings.importance_enabled
2186
+ and self._settings.importance_strategy == "lowest_density"
2187
+ and scored
2188
+ ):
2189
+ anchor_idx = min(
2190
+ range(len(scored_bundles)),
2191
+ key=lambda idx: (scored_bundles[idx].density, scored_bundles[idx].start),
2192
+ )
2193
+ selected_window = self._expand_execution_window_to_minimums(
2194
+ bundles=eligible_bundles,
2195
+ scored_bundles=scored_bundles,
2196
+ start_idx=anchor_idx,
2197
+ end_idx=anchor_idx,
2198
+ )
2199
+ if selected_window is not None:
2200
+ candidate_windows.append(
2201
+ (selected_window[0], selected_window[1], "execution_lowest_density")
2202
+ )
2203
+ oldest_window = self._oldest_execution_window(
2204
+ bundles=eligible_bundles,
2205
+ scored_bundles=scored_bundles,
2206
+ )
2207
+ if oldest_window is None and not candidate_windows:
2208
+ available_messages, available_tokens = self._execution_candidate_stats(
2209
+ bundles=eligible_bundles,
2210
+ scored_bundles=scored_bundles,
2211
+ start_idx=0,
2212
+ end_idx=len(eligible_bundles) - 1,
2213
+ )
2214
+ self._store.append(
2215
+ "compaction_warning",
2216
+ {
2217
+ "warning": "execution_compaction_below_minimum_removal_threshold",
2218
+ "available_messages": available_messages,
2219
+ "available_estimated_tokens": available_tokens,
2220
+ "required_messages": self._settings.execution_min_removable_messages,
2221
+ "required_tokens": self._settings.execution_min_removable_tokens,
2222
+ },
2223
+ )
2224
+ return []
2225
+ if oldest_window is not None and oldest_window != selected_window:
2226
+ candidate_windows.append(
2227
+ (oldest_window[0], oldest_window[1], "execution_oldest_activity")
2228
+ )
2229
+ else:
2230
+ selected_window = self._oldest_execution_window(
2231
+ bundles=eligible_bundles,
2232
+ scored_bundles=scored_bundles,
2233
+ )
2234
+ if selected_window is None:
2235
+ available_messages, available_tokens = self._execution_candidate_stats(
2236
+ bundles=eligible_bundles,
2237
+ scored_bundles=scored_bundles,
2238
+ start_idx=0,
2239
+ end_idx=len(eligible_bundles) - 1,
2240
+ )
2241
+ self._store.append(
2242
+ "compaction_warning",
2243
+ {
2244
+ "warning": "execution_compaction_below_minimum_removal_threshold",
2245
+ "available_messages": available_messages,
2246
+ "available_estimated_tokens": available_tokens,
2247
+ "required_messages": self._settings.execution_min_removable_messages,
2248
+ "required_tokens": self._settings.execution_min_removable_tokens,
2249
+ },
2250
+ )
2251
+ return []
2252
+ candidate_windows.append(
2253
+ (selected_window[0], selected_window[1], "execution_oldest_activity")
2254
+ )
2255
+
2256
+ plans: list[_ChunkPlan] = []
2257
+ for start_idx, end_idx, strategy in candidate_windows:
2258
+ plan = self._build_execution_chunk_plan_for_window(
2259
+ messages=messages,
2260
+ bundles=eligible_bundles,
2261
+ scored_bundles=scored_bundles,
2262
+ start_idx=start_idx,
2263
+ end_idx=end_idx,
2264
+ strategy=strategy,
2265
+ )
2266
+ if plan is not None:
2267
+ plans.append(plan)
2268
+ return plans
2269
+
2270
+ def _build_execution_chunk_plan(
2271
+ self,
2272
+ messages: list[dict[str, Any]],
2273
+ *,
2274
+ protected_prefix_len: int | None = None,
2275
+ ) -> _ChunkPlan | None:
2276
+ plans = self._build_execution_chunk_plans(
2277
+ messages,
2278
+ protected_prefix_len=protected_prefix_len,
2279
+ )
2280
+ if plans:
2281
+ return plans[0]
2282
+ return None
2283
+
2284
+ def _build_chunk_plan(
2285
+ self,
2286
+ messages: list[dict[str, Any]],
2287
+ *,
2288
+ protected_prefix_len: int | None = None,
2289
+ hard_pressure: bool = False,
2290
+ ) -> _ChunkPlan | None:
2291
+ if self._profile.selection_mode == "execution_activity":
2292
+ return self._build_execution_chunk_plan(
2293
+ messages,
2294
+ protected_prefix_len=protected_prefix_len,
2295
+ )
2296
+ return self._build_chat_chunk_plan(
2297
+ messages,
2298
+ protected_prefix_len=protected_prefix_len,
2299
+ hard_pressure=hard_pressure,
2300
+ )
2301
+
2302
+ def _pin_kind(self, turn: ScoredTurn) -> str:
2303
+ reasons_cf = {reason.casefold() for reason in turn.reasons}
2304
+ preview_cf = turn.user_preview.casefold()
2305
+ if "errors_or_failures" in reasons_cf:
2306
+ return "error"
2307
+ if (
2308
+ "requirements_or_constraints" in reasons_cf
2309
+ or "acceptance_criteria" in reasons_cf
2310
+ or "must" in preview_cf
2311
+ or "do not" in preview_cf
2312
+ or "never" in preview_cf
2313
+ ):
2314
+ return "constraint"
2315
+ if "shell_or_git_commands" in reasons_cf or "verification_commands" in reasons_cf:
2316
+ return "command"
2317
+ if "requirements_or_constraints" in reasons_cf:
2318
+ return "requirement"
2319
+ return "context"
2320
+
2321
+ def _extract_pins_for_chunk(
2322
+ self,
2323
+ *,
2324
+ history_rel_path: str,
2325
+ scored_turns: list[ScoredTurn],
2326
+ ) -> list[dict[str, Any]]:
2327
+ pins: list[dict[str, Any]] = []
2328
+ threshold = self._settings.pin_score_threshold
2329
+ for turn in scored_turns:
2330
+ if turn.score < threshold:
2331
+ continue
2332
+ snippet = _trim_snippet(turn.user_preview, self._settings.pin_snippet_chars)
2333
+ if not snippet:
2334
+ continue
2335
+ pin = {
2336
+ "kind": self._pin_kind(turn),
2337
+ "text": snippet,
2338
+ "reasons": list(turn.reasons),
2339
+ "score": float(turn.score),
2340
+ "source": {
2341
+ "history_path": history_rel_path,
2342
+ "idx_range": [turn.start, max(turn.start, turn.end - 1)],
2343
+ },
2344
+ }
2345
+ pins.append(pin)
2346
+ return pins
2347
+
2348
+ def _bounded_pins(self, pins: list[dict[str, Any]]) -> list[dict[str, Any]]:
2349
+ by_text: dict[str, dict[str, Any]] = {}
2350
+ for pin in pins:
2351
+ text = str(pin.get("text") or "")
2352
+ key = _normalize_pin_text(text)
2353
+ if not key:
2354
+ continue
2355
+ existing = by_text.get(key)
2356
+ if existing is None:
2357
+ by_text[key] = pin
2358
+ continue
2359
+ existing_score = float(existing.get("score") or 0.0)
2360
+ next_score = float(pin.get("score") or 0.0)
2361
+ if next_score > existing_score:
2362
+ by_text[key] = pin
2363
+
2364
+ ordered = sorted(
2365
+ by_text.values(),
2366
+ key=lambda pin: float(pin.get("score") or 0.0),
2367
+ reverse=True,
2368
+ )
2369
+ if len(ordered) > self._settings.max_pins:
2370
+ ordered = ordered[: self._settings.max_pins]
2371
+
2372
+ while ordered:
2373
+ payload = {"pins": [self._public_pin(pin) for pin in ordered]}
2374
+ size = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
2375
+ if size <= self._settings.max_pins_chars:
2376
+ break
2377
+ ordered.pop()
2378
+ return ordered
2379
+
2380
+ def _compact_loop(
2381
+ self,
2382
+ *,
2383
+ messages: list[dict[str, Any]],
2384
+ tool_list: list[dict[str, Any]] | None,
2385
+ main_model: str,
2386
+ cache_policy: Mapping[str, Any] | None = None,
2387
+ focus: str | None = None,
2388
+ force: bool = False,
2389
+ hard_pressure: bool = False,
2390
+ request_messages_builder: RequestMessagesBuilder | None = None,
2391
+ ) -> tuple[list[dict[str, Any]], bool]:
2392
+ working = list(messages)
2393
+ changed = False
2394
+ forced_once = False
2395
+
2396
+ while True:
2397
+ model_meta = self._model_registry.get(main_model)
2398
+ budget = compute_input_budget(
2399
+ model_meta,
2400
+ safety_margin=self._settings.safety_margin_tokens,
2401
+ )
2402
+ provider_request_messages = self._provider_request_messages(
2403
+ working,
2404
+ request_messages_builder=request_messages_builder,
2405
+ )
2406
+ request_has_media = request_contains_media(provider_request_messages)
2407
+ request_breakdown = _estimate_compaction_request_breakdown(
2408
+ messages=provider_request_messages,
2409
+ tool_list=tool_list,
2410
+ pinned_prefix_len=self.state.pinned_prefix_len,
2411
+ )
2412
+ used_tokens = request_breakdown.total_tokens
2413
+ prefix_shape = _cache_prefix_compaction_shape(
2414
+ settings=self._settings,
2415
+ messages=working,
2416
+ tool_list=tool_list,
2417
+ request_breakdown=request_breakdown,
2418
+ pinned_prefix_len=self.state.pinned_prefix_len,
2419
+ cache_policy=cache_policy,
2420
+ )
2421
+ calibration = self._usage_summary.recent_calibration_snapshot(
2422
+ requested_model=main_model,
2423
+ provider_key=self._calibration_filters.get("provider_key"),
2424
+ protocol=self._calibration_filters.get("protocol"),
2425
+ base_url_host=self._calibration_filters.get("base_url_host"),
2426
+ operation=self._calibration_filters.get("operation"),
2427
+ request_mode=self._calibration_filters.get("request_mode"),
2428
+ cache_strategy=(
2429
+ str(cache_policy.get("strategy") or "").strip()
2430
+ if isinstance(cache_policy, Mapping)
2431
+ else self._calibration_filters.get("cache_strategy")
2432
+ ),
2433
+ limit=20,
2434
+ )
2435
+ cache_aware = _cache_aware_compaction_decision(
2436
+ settings=self._settings,
2437
+ request_breakdown=request_breakdown,
2438
+ calibration=calibration,
2439
+ prefix_shape=prefix_shape,
2440
+ )
2441
+ adjusted_trigger_ratio = cache_aware.adjusted_trigger_ratio
2442
+ calibrated_used_tokens = cache_aware.calibrated_used_tokens
2443
+ comparison_tokens = (
2444
+ max(used_tokens, calibrated_used_tokens)
2445
+ if self._settings.cache_aware_compaction
2446
+ else used_tokens
2447
+ )
2448
+ trigger_tokens = int(budget * adjusted_trigger_ratio)
2449
+ target_tokens = int(budget * self._settings.target_ratio)
2450
+ input_measurement: InputTokenCount | None = None
2451
+ # Client-owned measurement is reserved for the policy-defined
2452
+ # uncertainty band between target and trigger. Native transports can
2453
+ # call an exact provider endpoint; compatible transports return an
2454
+ # explicitly estimated provider-shaped payload without extra I/O.
2455
+ input_measurement_required = (
2456
+ forced_once
2457
+ or comparison_tokens >= target_tokens
2458
+ or hard_pressure
2459
+ or request_has_media
2460
+ )
2461
+ if self._input_token_counter is not None and input_measurement_required:
2462
+ try:
2463
+ input_measurement = self._input_token_counter(
2464
+ provider_request_messages,
2465
+ tool_list,
2466
+ )
2467
+ except Exception as exc: # noqa: BLE001 -- optional preflight must degrade safely
2468
+ self._store.append(
2469
+ "compaction_warning",
2470
+ {
2471
+ "warning": "input_token_measurement_failed",
2472
+ "error": str(exc),
2473
+ },
2474
+ )
2475
+ (
2476
+ comparison_tokens,
2477
+ comparison_source,
2478
+ comparison_confidence,
2479
+ ) = _conservative_input_measurement(
2480
+ baseline_tokens=comparison_tokens,
2481
+ measurement=input_measurement,
2482
+ estimate_multiplier=(
2483
+ cache_aware.prompt_estimate_error_ratio_p90
2484
+ if self._settings.cache_aware_compaction
2485
+ and cache_aware.prompt_estimate_error_ratio_p90 is not None
2486
+ else 1.0
2487
+ ),
2488
+ )
2489
+ media_input_uncertain = request_has_media and (
2490
+ input_measurement is None
2491
+ or input_measurement.confidence != UsageConfidence.AUTHORITATIVE
2492
+ )
2493
+ effective_trigger_tokens = (
2494
+ min(trigger_tokens, target_tokens) if media_input_uncertain else trigger_tokens
2495
+ )
2496
+
2497
+ self._store.append(
2498
+ "compaction_check",
2499
+ {
2500
+ "used_tokens": used_tokens,
2501
+ "calibrated_used_tokens": calibrated_used_tokens,
2502
+ "budget_tokens": budget,
2503
+ "trigger_ratio": self._settings.trigger_ratio,
2504
+ "adjusted_trigger_ratio": adjusted_trigger_ratio,
2505
+ "target_ratio": self._settings.target_ratio,
2506
+ "trigger_tokens": trigger_tokens,
2507
+ "effective_trigger_tokens": effective_trigger_tokens,
2508
+ "target_tokens": target_tokens,
2509
+ "comparison_tokens": comparison_tokens,
2510
+ "token_count_source": comparison_source,
2511
+ "token_count_confidence": comparison_confidence,
2512
+ "input_measurement_tokens": (
2513
+ input_measurement.input_tokens if input_measurement is not None else None
2514
+ ),
2515
+ "media_input_uncertain": media_input_uncertain,
2516
+ "main_model": main_model,
2517
+ "cache_aware": {
2518
+ "enabled": self._settings.cache_aware_compaction,
2519
+ "reasons": list(cache_aware.reasons),
2520
+ "prompt_estimate_error_ratio_p90": (
2521
+ cache_aware.prompt_estimate_error_ratio_p90
2522
+ ),
2523
+ "cache_hit_ratio": cache_aware.cache_hit_ratio,
2524
+ "tool_schema_share": cache_aware.tool_schema_share,
2525
+ "inline_tool_transcript_share": (cache_aware.inline_tool_transcript_share),
2526
+ "request_shape": prefix_shape.to_payload(),
2527
+ },
2528
+ },
2529
+ )
2530
+
2531
+ if forced_once:
2532
+ if comparison_tokens <= target_tokens:
2533
+ return working, changed
2534
+ elif not force and not hard_pressure and comparison_tokens <= effective_trigger_tokens:
2535
+ return working, changed
2536
+
2537
+ execution_chunk_plans: list[_ChunkPlan] | None = None
2538
+ chunk_plan: _ChunkPlan | None
2539
+ protected_prefix_len = prefix_shape.protected_prefix_message_count
2540
+ if self._profile.name == "execution":
2541
+ execution_chunk_plans = self._build_execution_chunk_plans(
2542
+ working,
2543
+ protected_prefix_len=protected_prefix_len,
2544
+ )
2545
+ if (
2546
+ not execution_chunk_plans
2547
+ and protected_prefix_len > self.state.pinned_prefix_len
2548
+ ):
2549
+ execution_chunk_plans = self._build_execution_chunk_plans(
2550
+ working,
2551
+ protected_prefix_len=self.state.pinned_prefix_len,
2552
+ )
2553
+ if execution_chunk_plans:
2554
+ self._store.append(
2555
+ "compaction_warning",
2556
+ {
2557
+ "warning": "cache_prefix_protection_relaxed_no_safe_suffix",
2558
+ "protected_prefix_message_count": protected_prefix_len,
2559
+ "pinned_prefix_message_count": self.state.pinned_prefix_len,
2560
+ },
2561
+ )
2562
+ chunk_plan = execution_chunk_plans[0] if execution_chunk_plans else None
2563
+ else:
2564
+ chunk_plan = self._build_chunk_plan(
2565
+ working,
2566
+ protected_prefix_len=protected_prefix_len,
2567
+ hard_pressure=hard_pressure,
2568
+ )
2569
+ if chunk_plan is None and protected_prefix_len > self.state.pinned_prefix_len:
2570
+ chunk_plan = self._build_chunk_plan(
2571
+ working,
2572
+ protected_prefix_len=self.state.pinned_prefix_len,
2573
+ hard_pressure=hard_pressure,
2574
+ )
2575
+ if chunk_plan is not None:
2576
+ self._store.append(
2577
+ "compaction_warning",
2578
+ {
2579
+ "warning": "cache_prefix_protection_relaxed_no_safe_suffix",
2580
+ "protected_prefix_message_count": protected_prefix_len,
2581
+ "pinned_prefix_message_count": self.state.pinned_prefix_len,
2582
+ },
2583
+ )
2584
+
2585
+ if chunk_plan is None:
2586
+ self._store.append(
2587
+ "compaction_warning",
2588
+ {
2589
+ "warning": "no_compaction_chunk_available",
2590
+ "used_tokens": used_tokens,
2591
+ "trigger_tokens": trigger_tokens,
2592
+ "protected_prefix_message_count": (
2593
+ prefix_shape.protected_prefix_message_count
2594
+ ),
2595
+ "stable_prefix_message_count": prefix_shape.stable_prefix_message_count,
2596
+ "cache_prefix_reasons": list(prefix_shape.reasons),
2597
+ },
2598
+ )
2599
+ return working, changed
2600
+
2601
+ if self._profile.name == "execution":
2602
+ assert execution_chunk_plans is not None
2603
+ next_history_path = self._history_path(self.state.history_chunk_index + 1)
2604
+ predicted_history_rel_path = self.artifact_display_reference(next_history_path)
2605
+ preview: _ExecutionCompactionPreview | None = None
2606
+ selected_plan: _ChunkPlan | None = None
2607
+ selected_chunk_messages: list[dict[str, Any]] = []
2608
+ for candidate_plan in execution_chunk_plans:
2609
+ candidate_chunk_messages = working[candidate_plan.start : candidate_plan.end]
2610
+ preview = self._stage_execution_compaction_preview(
2611
+ working=working,
2612
+ chunk_plan=candidate_plan,
2613
+ chunk_messages=candidate_chunk_messages,
2614
+ tool_list=tool_list,
2615
+ used_tokens=used_tokens,
2616
+ focus=focus,
2617
+ history_rel_path=predicted_history_rel_path,
2618
+ request_messages_builder=request_messages_builder,
2619
+ )
2620
+ if preview is not None:
2621
+ selected_plan = candidate_plan
2622
+ selected_chunk_messages = candidate_chunk_messages
2623
+ break
2624
+ if preview is None or selected_plan is None:
2625
+ return working, changed
2626
+
2627
+ artifact_commit = self._commit_execution_artifacts(
2628
+ chunk_messages=selected_chunk_messages,
2629
+ first_idx=selected_plan.start,
2630
+ summary=preview.summary,
2631
+ pins=preview.pins,
2632
+ )
2633
+ if artifact_commit is None:
2634
+ return working, changed
2635
+ history_rel_path = self.artifact_display_reference(artifact_commit.history_path)
2636
+ if preview.dropped_without_summary:
2637
+ self._store.append(
2638
+ "compaction_warning",
2639
+ {
2640
+ "warning": "compactor_failed_drop_chunk",
2641
+ "history_path": history_rel_path,
2642
+ },
2643
+ )
2644
+ self.state.history_chunk_index = artifact_commit.history_chunk_index
2645
+ self.state.summary = preview.summary
2646
+ self.state.pins = preview.pins
2647
+ self.state.memory_message_index = preview.memory_message_index
2648
+ self.state.pins_message_index = preview.pins_message_index
2649
+ summary_json = json.dumps(
2650
+ preview.summary,
2651
+ separators=(",", ":"),
2652
+ ensure_ascii=False,
2653
+ )
2654
+ self._store.append(
2655
+ "conversation_summary_updated",
2656
+ {
2657
+ "summary_bytes": len(summary_json.encode("utf-8")),
2658
+ "history_chunk_index": self.state.history_chunk_index,
2659
+ "history_path": history_rel_path,
2660
+ "chunk_strategy": selected_plan.strategy,
2661
+ "pins_count": len(self.state.pins),
2662
+ "dropped_without_summary_update": preview.dropped_without_summary,
2663
+ "active_conversation_messages": deepcopy(
2664
+ preview.updated_messages[self.state.pinned_prefix_len :]
2665
+ ),
2666
+ },
2667
+ )
2668
+ working = preview.updated_messages
2669
+ changed = True
2670
+ forced_once = True
2671
+ # Re-enter the loop so the provider-bound request is verified
2672
+ # after compaction instead of trusting a prediction alone.
2673
+ continue
2674
+
2675
+ chunk_messages = working[chunk_plan.start : chunk_plan.end]
2676
+ next_history_path = self._history_path(self.state.history_chunk_index + 1)
2677
+ predicted_history_rel_path = self.artifact_display_reference(next_history_path)
2678
+
2679
+ compactor_budget = self._compactor_request_budget(ratio=0.85)
2680
+ prepared_chunk = self._prepare_chunk_messages_for_compactor(
2681
+ chunk_messages,
2682
+ compactor_budget,
2683
+ )
2684
+ prompt_messages = self._build_compactor_messages(
2685
+ existing_summary=self.state.summary,
2686
+ prepared_chunk_messages=prepared_chunk,
2687
+ focus=focus,
2688
+ )
2689
+ new_summary = self._call_compactor(prompt_messages=prompt_messages)
2690
+
2691
+ if new_summary is None:
2692
+ retry_budget = self._compactor_request_budget(ratio=0.60)
2693
+ retry_chunk = self._prepare_chunk_messages_for_compactor(
2694
+ chunk_messages,
2695
+ retry_budget,
2696
+ )
2697
+ retry_prompt_messages = self._build_compactor_messages(
2698
+ existing_summary=self.state.summary,
2699
+ prepared_chunk_messages=retry_chunk,
2700
+ focus=focus,
2701
+ )
2702
+ new_summary = self._call_compactor(prompt_messages=retry_prompt_messages)
2703
+
2704
+ if new_summary is None:
2705
+ self._store.append(
2706
+ "compaction_warning",
2707
+ {
2708
+ "warning": "compactor_failed_preserved_chunk",
2709
+ "history_path": predicted_history_rel_path,
2710
+ },
2711
+ )
2712
+ return working, changed
2713
+
2714
+ new_pins = self._extract_pins_for_chunk(
2715
+ history_rel_path=predicted_history_rel_path,
2716
+ scored_turns=chunk_plan.scored_turns,
2717
+ )
2718
+ next_pins = list(self.state.pins)
2719
+ if new_pins:
2720
+ next_pins = self._bounded_pins([*self.state.pins, *new_pins])
2721
+
2722
+ reduced_messages = working[: chunk_plan.start] + working[chunk_plan.end :]
2723
+ trial_state = self._clone_state()
2724
+ trial_state.summary = deepcopy(new_summary)
2725
+ trial_state.pins = deepcopy(next_pins)
2726
+ updated_messages = self._upsert_context_messages(
2727
+ reduced_messages,
2728
+ summary=new_summary,
2729
+ pins=next_pins,
2730
+ state=trial_state,
2731
+ )
2732
+ provider_updated_messages = self._provider_request_messages(
2733
+ updated_messages,
2734
+ request_messages_builder=request_messages_builder,
2735
+ )
2736
+ new_used_tokens = estimate_request_tokens(provider_updated_messages, tool_list)
2737
+ if new_used_tokens >= used_tokens:
2738
+ self._store.append(
2739
+ "compaction_warning",
2740
+ {
2741
+ "warning": "compaction_no_progress",
2742
+ "used_tokens_before": used_tokens,
2743
+ "used_tokens_after": new_used_tokens,
2744
+ },
2745
+ )
2746
+ return working, changed
2747
+
2748
+ artifact_commit = self._commit_execution_artifacts(
2749
+ chunk_messages=chunk_messages,
2750
+ first_idx=chunk_plan.start,
2751
+ summary=new_summary,
2752
+ pins=next_pins,
2753
+ )
2754
+ if artifact_commit is None:
2755
+ return working, changed
2756
+ history_rel_path = self.artifact_display_reference(artifact_commit.history_path)
2757
+ self.state.history_chunk_index = artifact_commit.history_chunk_index
2758
+ self.state.summary = deepcopy(new_summary)
2759
+ self.state.pins = deepcopy(next_pins)
2760
+ self.state.memory_message_index = trial_state.memory_message_index
2761
+ self.state.pins_message_index = trial_state.pins_message_index
2762
+ summary_json = json.dumps(new_summary, separators=(",", ":"), ensure_ascii=False)
2763
+ self._store.append(
2764
+ "conversation_summary_updated",
2765
+ {
2766
+ "summary_bytes": len(summary_json.encode("utf-8")),
2767
+ "history_chunk_index": self.state.history_chunk_index,
2768
+ "history_path": history_rel_path,
2769
+ "chunk_strategy": chunk_plan.strategy,
2770
+ "pins_count": len(self.state.pins),
2771
+ "dropped_without_summary_update": False,
2772
+ "active_conversation_messages": deepcopy(
2773
+ updated_messages[self.state.pinned_prefix_len :]
2774
+ ),
2775
+ },
2776
+ )
2777
+
2778
+ working = updated_messages
2779
+ changed = True
2780
+ forced_once = True
2781
+ # Re-enter the loop so provider-side counting can verify the exact
2782
+ # post-compaction request before it is allowed onto the wire.
2783
+ continue
2784
+
2785
+ def maybe_compact(
2786
+ self,
2787
+ *,
2788
+ messages: list[dict[str, Any]],
2789
+ tool_list: list[dict[str, Any]] | None,
2790
+ main_model: str,
2791
+ cache_policy: Mapping[str, Any] | None = None,
2792
+ focus: str | None = None,
2793
+ request_messages_builder: RequestMessagesBuilder | None = None,
2794
+ ) -> tuple[list[dict[str, Any]], bool]:
2795
+ return self._compact_loop(
2796
+ messages=messages,
2797
+ tool_list=tool_list,
2798
+ main_model=main_model,
2799
+ cache_policy=cache_policy,
2800
+ focus=focus,
2801
+ force=False,
2802
+ request_messages_builder=request_messages_builder,
2803
+ )
2804
+
2805
+ def compact_now(
2806
+ self,
2807
+ *,
2808
+ messages: list[dict[str, Any]],
2809
+ tool_list: list[dict[str, Any]] | None,
2810
+ main_model: str,
2811
+ cache_policy: Mapping[str, Any] | None = None,
2812
+ focus: str | None = None,
2813
+ request_messages_builder: RequestMessagesBuilder | None = None,
2814
+ ) -> tuple[list[dict[str, Any]], bool]:
2815
+ self._store.append("compaction_forced", {"focus": (focus or "").strip()})
2816
+ return self._compact_loop(
2817
+ messages=messages,
2818
+ tool_list=tool_list,
2819
+ main_model=main_model,
2820
+ cache_policy=cache_policy,
2821
+ focus=focus,
2822
+ force=True,
2823
+ request_messages_builder=request_messages_builder,
2824
+ )
2825
+
2826
+ def compact_for_overflow(
2827
+ self,
2828
+ *,
2829
+ messages: list[dict[str, Any]],
2830
+ tool_list: list[dict[str, Any]] | None,
2831
+ main_model: str,
2832
+ cache_policy: Mapping[str, Any] | None = None,
2833
+ focus: str | None = None,
2834
+ request_messages_builder: RequestMessagesBuilder | None = None,
2835
+ ) -> tuple[list[dict[str, Any]], bool]:
2836
+ """Recover from a provider-confirmed context overflow without data loss."""
2837
+
2838
+ self._store.append(
2839
+ "compaction_forced",
2840
+ {"focus": (focus or "").strip(), "reason": "provider_context_overflow"},
2841
+ )
2842
+ return self._compact_loop(
2843
+ messages=messages,
2844
+ tool_list=tool_list,
2845
+ main_model=main_model,
2846
+ cache_policy=cache_policy,
2847
+ focus=focus,
2848
+ force=True,
2849
+ hard_pressure=True,
2850
+ request_messages_builder=request_messages_builder,
2851
+ )
2852
+
2853
+ def request_fits_input_budget(
2854
+ self,
2855
+ *,
2856
+ messages: list[dict[str, Any]],
2857
+ tool_list: list[dict[str, Any]] | None,
2858
+ main_model: str,
2859
+ cache_policy: Mapping[str, Any] | None = None,
2860
+ request_messages_builder: RequestMessagesBuilder | None = None,
2861
+ ) -> bool:
2862
+ """Verify that a rebuilt request fits the model's usable input budget."""
2863
+
2864
+ provider_messages = self._provider_request_messages(
2865
+ messages,
2866
+ request_messages_builder=request_messages_builder,
2867
+ )
2868
+ request_has_media = request_contains_media(provider_messages)
2869
+ model_meta = self._model_registry.get(main_model)
2870
+ budget = compute_input_budget(
2871
+ model_meta,
2872
+ safety_margin=self._settings.safety_margin_tokens,
2873
+ )
2874
+ local_used_tokens = estimate_request_tokens(provider_messages, tool_list)
2875
+ calibration = self._usage_summary.recent_calibration_snapshot(
2876
+ requested_model=main_model,
2877
+ provider_key=self._calibration_filters.get("provider_key"),
2878
+ protocol=self._calibration_filters.get("protocol"),
2879
+ base_url_host=self._calibration_filters.get("base_url_host"),
2880
+ operation=self._calibration_filters.get("operation"),
2881
+ request_mode=self._calibration_filters.get("request_mode"),
2882
+ cache_strategy=(
2883
+ str(cache_policy.get("strategy") or "").strip()
2884
+ if isinstance(cache_policy, Mapping)
2885
+ else self._calibration_filters.get("cache_strategy")
2886
+ ),
2887
+ limit=20,
2888
+ )
2889
+ ratio_raw = calibration.get("prompt_estimate_error_ratio_p90")
2890
+ ratio = float(ratio_raw) if isinstance(ratio_raw, int | float) else 1.0
2891
+ if not math.isfinite(ratio) or ratio <= 0 or not self._settings.cache_aware_compaction:
2892
+ ratio = 1.0
2893
+ calibrated_used_tokens = math.ceil(local_used_tokens * max(1.0, ratio))
2894
+ counted: InputTokenCount | None = None
2895
+ if self._input_token_counter is not None:
2896
+ try:
2897
+ counted = self._input_token_counter(provider_messages, tool_list)
2898
+ except Exception as exc: # noqa: BLE001 - preflight measurement is optional
2899
+ self._store.append(
2900
+ "compaction_warning",
2901
+ {
2902
+ "warning": "overflow_retry_input_measurement_failed",
2903
+ "error": str(exc),
2904
+ },
2905
+ )
2906
+ used_tokens, token_count_source, token_count_confidence = _conservative_input_measurement(
2907
+ baseline_tokens=max(local_used_tokens, calibrated_used_tokens),
2908
+ measurement=counted,
2909
+ estimate_multiplier=ratio,
2910
+ )
2911
+ media_input_uncertain = request_has_media and (
2912
+ counted is None or counted.confidence != UsageConfidence.AUTHORITATIVE
2913
+ )
2914
+ verification_budget_tokens = (
2915
+ int(budget * self._settings.target_ratio) if media_input_uncertain else budget
2916
+ )
2917
+ self._store.append(
2918
+ "compaction_budget_verification",
2919
+ {
2920
+ "used_tokens": used_tokens,
2921
+ "local_used_tokens": local_used_tokens,
2922
+ "calibrated_used_tokens": calibrated_used_tokens,
2923
+ "budget_tokens": budget,
2924
+ "verification_budget_tokens": verification_budget_tokens,
2925
+ "fits": used_tokens <= verification_budget_tokens,
2926
+ "token_count_source": token_count_source,
2927
+ "token_count_confidence": token_count_confidence,
2928
+ "input_measurement_tokens": (counted.input_tokens if counted is not None else None),
2929
+ "media_input_uncertain": media_input_uncertain,
2930
+ },
2931
+ )
2932
+ return used_tokens <= verification_budget_tokens