octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,869 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ class Agent
5
+ # Message compression functionality for managing conversation history
6
+ # Handles automatic compression when token limits are exceeded
7
+ module MessageCompressorHelper
8
+ # Compression thresholds
9
+ COMPRESSION_THRESHOLD = 150_000 # Trigger compression when exceeding this (in tokens)
10
+ MESSAGE_COUNT_THRESHOLD = 200 # Trigger compression when exceeding this (in message count)
11
+ MAX_RECENT_MESSAGES = 20 # Keep this many recent message pairs intact
12
+ TARGET_COMPRESSED_TOKENS = 10_000 # Target size after compression
13
+ IDLE_COMPRESSION_THRESHOLD = 20_000 # Minimum messages needed for idle compression
14
+
15
+ # Trigger compression during idle time (user-friendly, interruptible)
16
+ # Returns true if compression was performed, false otherwise
17
+ def trigger_idle_compression
18
+ # Check if we should compress (force mode) BEFORE opening any UI, so
19
+ # "skipped" doesn't flash a spinner on screen.
20
+ compression_context = compress_messages_if_needed(force: true)
21
+ if compression_context.nil?
22
+ Octo::Logger.info(
23
+ "Idle compression skipped",
24
+ enable_compression: @config.enable_compression,
25
+ previous_total_tokens: @previous_total_tokens,
26
+ history_size: @history.size,
27
+ idle_threshold: IDLE_COMPRESSION_THRESHOLD,
28
+ max_recent_messages: MAX_RECENT_MESSAGES
29
+ )
30
+ return false
31
+ end
32
+
33
+ # Own the progress indicator through +with_progress+: the ensure
34
+ # block guarantees the spinner/ticker is released even when the
35
+ # user interrupts mid-way (AgentInterrupted from current thread)
36
+ # or the LLM call fails. No more orphan gray tickers.
37
+ #
38
+ # When @ui is nil (tests / headless) we still need to run the
39
+ # compression work — safe-navigation with a block would silently
40
+ # skip it, so branch explicitly.
41
+ compression_message = compression_context[:compression_message]
42
+ @history.append(compression_message)
43
+
44
+ run_compression = lambda do |handle|
45
+ begin
46
+ response = call_llm
47
+ handle_compression_response(response, compression_context, progress: handle)
48
+ true
49
+ rescue Octo::AgentInterrupted => e
50
+ # User cancelled the idle compression — finish the quiet progress
51
+ # slot in place so the user sees exactly what happened (rather
52
+ # than the "Idle detected..." line being silently removed).
53
+ final = "Idle compression cancelled: #{e.message}"
54
+ if handle
55
+ handle.finish(final_message: final)
56
+ else
57
+ @ui&.log(final, level: :info)
58
+ end
59
+ @history.rollback_before(compression_message)
60
+ Octo::Logger.info("[idle-compress] cancelled: #{e.message}")
61
+ false
62
+ rescue => e
63
+ # Compression failed (most commonly: network errors after all
64
+ # LlmCaller retries exhausted). Previously this only wrote an
65
+ # @ui.log(:error) that was easy to miss — especially when no
66
+ # other output followed. Now we:
67
+ # 1. Replace the active quiet progress line with the error so
68
+ # the user always sees *something* where the spinner was.
69
+ # 2. Emit a show_warning for a more prominent entry.
70
+ # 3. Persist to Octo::Logger so post-mortem is possible even
71
+ # if the terminal scrollback has rolled past.
72
+ final = "Idle compression failed: #{e.message}"
73
+ if handle
74
+ handle.finish(final_message: final)
75
+ else
76
+ @ui&.log(final, level: :error)
77
+ end
78
+ @ui&.show_warning(final)
79
+ Octo::Logger.warn(
80
+ "[idle-compress] failed",
81
+ error_class: e.class.name,
82
+ error_message: e.message,
83
+ backtrace: e.backtrace&.first(5)
84
+ )
85
+ @history.rollback_before(compression_message)
86
+ false
87
+ end
88
+ end
89
+
90
+ if @ui
91
+ result = nil
92
+ @ui.with_progress(
93
+ message: "Idle detected. Compressing conversation to optimize costs...",
94
+ style: :quiet
95
+ ) do |handle|
96
+ result = run_compression.call(handle)
97
+ end
98
+ result
99
+ else
100
+ run_compression.call(nil)
101
+ end
102
+ end
103
+
104
+ # Check if compression is needed and return compression context
105
+ # @param force [Boolean] Force compression even if thresholds not met
106
+ # @param pull_back_from_tail [Integer] Number of messages to temporarily pop
107
+ # from the tail of history before building the compression instruction.
108
+ # Used by the context-overflow recovery path: when the current history
109
+ # is already at/over the model's context window, we cannot append even
110
+ # a small compression instruction without overflowing. Popping K messages
111
+ # from the tail frees up token budget for the compression call itself.
112
+ #
113
+ # Cache-preservation note: thanks to the model's two-checkpoint prompt
114
+ # cache (cache#A at second-to-last, cache#B at last), pulling back K=1
115
+ # message keeps cache#A intact — the compression LLM call still hits the
116
+ # cached prefix [system, m1..m(N-1)]. K>=2 sacrifices cache hits but is
117
+ # only used as fallback when one message isn't enough headroom.
118
+ #
119
+ # The popped messages are NOT discarded — they ride along in the
120
+ # returned context and are reattached to the rebuilt history's tail by
121
+ # handle_compression_response, so recent task progress is preserved.
122
+ # @return [Hash, nil] Compression context or nil if not needed
123
+ def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
124
+ # Check if compression is enabled
125
+ return nil unless @config.enable_compression
126
+
127
+ # Use actual API-reported tokens from last request
128
+ total_tokens = @previous_total_tokens
129
+ message_count = @history.size
130
+
131
+ # Force compression (for idle compression) - use lower threshold
132
+ if force
133
+ # Only compress if we have more than MAX_RECENT_MESSAGES + system message
134
+ return nil unless message_count > MAX_RECENT_MESSAGES + 1
135
+ # Also require minimum message count to make compression worthwhile
136
+ return nil unless total_tokens >= IDLE_COMPRESSION_THRESHOLD
137
+ else
138
+ # Normal compression - check thresholds
139
+ # Either: token count exceeds threshold OR message count exceeds threshold
140
+ token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
141
+ message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
142
+
143
+ # Only compress if we exceed at least one threshold
144
+ return nil unless token_threshold_exceeded || message_count_exceeded
145
+ end
146
+
147
+ # Calculate how much we need to reduce
148
+ reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
149
+
150
+ # Don't compress if reduction is minimal (< 10% of current size)
151
+ # Only apply this check when triggered by token threshold (not for force mode)
152
+ if !force && token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
153
+ return nil
154
+ end
155
+
156
+ # If only message count threshold is exceeded, force compression
157
+ # to keep conversation history manageable
158
+
159
+ # Calculate target size for recent messages based on compression level
160
+ target_recent_count = calculate_target_recent_count(reduction_needed)
161
+
162
+ # Increment compression level for progressive summarization
163
+ @compression_level += 1
164
+
165
+ # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
166
+ all_messages = @history.to_a
167
+
168
+ # Pull back K messages from the tail (context-overflow recovery path).
169
+ # We *physically* remove them from @history so the next call_llm
170
+ # (which reads @history.to_api) doesn't include them in the prompt.
171
+ # They will be reattached to the rebuilt history's tail by
172
+ # handle_compression_response after compression succeeds. If compression
173
+ # fails, the caller is responsible for restoring them via the returned
174
+ # context (rollback path).
175
+ pulled_back_messages = []
176
+ if pull_back_from_tail > 0
177
+ k = [pull_back_from_tail, all_messages.size - 1].min # never pop the system message
178
+ k.times do
179
+ popped = @history.pop_last
180
+ pulled_back_messages.unshift(popped) if popped
181
+ end
182
+ # Recompute all_messages from the now-shrunk history so downstream
183
+ # logic (recent_messages selection, build_compression_message) sees
184
+ # the post-pop view.
185
+ all_messages = @history.to_a
186
+ end
187
+
188
+ recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
189
+ recent_messages = [] if recent_messages.nil?
190
+
191
+ # Build compression instruction message (to be inserted into conversation)
192
+ compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
193
+
194
+ return nil if compression_message.nil?
195
+
196
+ # Return compression context for agent to handle
197
+ {
198
+ compression_message: compression_message,
199
+ recent_messages: recent_messages,
200
+ pulled_back_messages: pulled_back_messages,
201
+ original_token_count: total_tokens,
202
+ original_message_count: @history.size,
203
+ compression_level: @compression_level
204
+ }
205
+ end
206
+
207
+ # Handle compression response and rebuild message list
208
+ # @param response [Hash] LLM response
209
+ # @param compression_context [Hash] context returned by +compress_messages_if_needed+
210
+ # @param progress [#finish, nil] Owned progress handle from the caller's
211
+ # with_progress block. When provided, the final summary message is
212
+ # delivered via +progress.finish(final_message: ...)+ instead of the
213
+ # legacy +show_progress(phase: "done")+ — this lets +ensure+ in the
214
+ # caller guarantee cleanup even if this method raises mid-way.
215
+ def handle_compression_response(response, compression_context, progress: nil)
216
+ # Extract compressed content from response
217
+ compressed_content = response[:content]
218
+
219
+ # Note: Cost tracking is already handled by call_llm, no need to track again here
220
+
221
+ # Rebuild message list with compression
222
+ # Note: we need to remove the compression instruction message we just added
223
+ original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
224
+
225
+ # Archive compressed messages to a chunk MD file before discarding them.
226
+ #
227
+ # IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
228
+ # message history. Each compression's rebuild_with_compression keeps only
229
+ # ONE compressed_summary message (the new one), dropping older summaries
230
+ # and embedding their references into the new summary's content. So
231
+ # counting compressed_summary messages in history caps at 1 from the
232
+ # second compression onward — causing chunk-2.md to be overwritten on
233
+ # every subsequent compression, and losing references to chunk-1.md.
234
+ #
235
+ # Disk is the only durable source of truth: chunk files survive process
236
+ # restarts, session reloads, and message rebuilds. SessionManager owns
237
+ # all chunk file I/O (naming, writing, discovery) — we just ask it.
238
+ sm = session_manager
239
+ existing_chunks = sm.chunks_for_current(@session_id, @created_at)
240
+ chunk_index = sm.next_chunk_index(@session_id, @created_at)
241
+
242
+ # Extract topics from the LLM response to store in both the chunk MD front
243
+ # matter and the compressed_summary message hash (for future chunk indexing).
244
+ topics = @message_compressor.parse_topics(compressed_content)
245
+
246
+ chunk_path = save_compressed_chunk(
247
+ original_messages,
248
+ compression_context[:recent_messages],
249
+ chunk_index: chunk_index,
250
+ compression_level: compression_context[:compression_level],
251
+ topics: topics
252
+ )
253
+
254
+ # Build previous_chunks index from the disk-discovered chunks (already
255
+ # sorted by index ascending). This gives the new summary a complete
256
+ # chronological index of all older archives so the AI can recall any
257
+ # past chunk via file_reader, not just the most recent one.
258
+ previous_chunks = existing_chunks.map do |c|
259
+ { basename: c[:basename], path: c[:path], topics: c[:topics] }
260
+ end
261
+
262
+ @history.replace_all(@message_compressor.rebuild_with_compression(
263
+ compressed_content,
264
+ original_messages: original_messages,
265
+ recent_messages: compression_context[:recent_messages],
266
+ chunk_path: chunk_path,
267
+ topics: topics,
268
+ previous_chunks: previous_chunks,
269
+ pulled_back_messages: compression_context[:pulled_back_messages] || []
270
+ ))
271
+
272
+ # Reset to the estimated size of the rebuilt (small) history.
273
+ # The compression call_llm reported the OLD large token count, so
274
+ # @previous_total_tokens would still be above COMPRESSION_THRESHOLD —
275
+ # without this reset the very next think() would re-trigger compression
276
+ # immediately, causing an infinite loop (especially after image uploads
277
+ # where base64 data inflates token counts dramatically).
278
+ @previous_total_tokens = @history.estimate_tokens
279
+
280
+ # Track this compression
281
+ @compressed_summaries << {
282
+ level: compression_context[:compression_level],
283
+ message_count: compression_context[:original_message_count],
284
+ timestamp: Time.now.iso8601,
285
+ strategy: :insert_then_compress,
286
+ chunk_path: chunk_path
287
+ }
288
+
289
+ # Show compression info (use estimated tokens from rebuilt history)
290
+ compression_summary = "History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
291
+ "level #{compression_context[:compression_level]})"
292
+ if progress
293
+ # Owned-handle path: the caller's ensure block will still call
294
+ # handle.finish; finishing here with a final_message means that
295
+ # later finish (with no final_message) is a no-op (idempotent).
296
+ progress.finish(final_message: compression_summary)
297
+ else
298
+ @ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
299
+ end
300
+ end
301
+
302
+ # Get recent messages while preserving tool_calls/tool_results pairs.
303
+ # Handles both canonical format (role: "tool") and legacy Anthropic-native
304
+ # format (role: "user" with tool_result content blocks).
305
+ # @param messages [Array] All messages
306
+ # @param count [Integer] Target number of recent messages to keep
307
+ # @return [Array] Recent messages with complete tool pairs
308
+ def get_recent_messages_with_tool_pairs(messages, count)
309
+ return [] if messages.nil? || messages.empty?
310
+
311
+ messages_to_include = Set.new
312
+ i = messages.size - 1
313
+ messages_collected = 0
314
+
315
+ while i >= 0 && messages_collected < count
316
+ msg = messages[i]
317
+
318
+ # Never include the system message — it is always prepended separately
319
+ # by rebuild_with_compression. Including it here would cause it to appear
320
+ # twice in the rebuilt history, inflating token counts on every compression.
321
+ if msg[:role] == "system"
322
+ i -= 1
323
+ next
324
+ end
325
+
326
+ if messages_to_include.include?(i)
327
+ i -= 1
328
+ next
329
+ end
330
+
331
+ messages_to_include.add(i)
332
+ messages_collected += 1
333
+
334
+ # assistant with tool_calls → also pull in all following tool results
335
+ if msg[:role] == "assistant" && msg[:tool_calls]&.any?
336
+ pull_tool_results_after(messages, i, messages_to_include)
337
+ end
338
+
339
+ # tool result (canonical or legacy Anthropic) → also pull in its assistant
340
+ if tool_result_message?(msg)
341
+ pull_assistant_before(messages, i, messages_to_include) do |added|
342
+ messages_collected += 1 if added
343
+ end
344
+ end
345
+
346
+ i -= 1
347
+ end
348
+
349
+ recent_messages = messages_to_include.to_a.sort.map { |idx| messages[idx] }
350
+
351
+ # Truncate large tool results to prevent token bloat
352
+ recent_messages.map do |msg|
353
+ truncate_tool_result(msg)
354
+ end
355
+ end
356
+
357
+
358
+ # Returns true if msg is a tool result, regardless of storage format.
359
+ # Canonical: role:"tool" | Legacy Anthropic-native: role:"user" + tool_result blocks
360
+ def tool_result_message?(msg)
361
+ MessageFormat::OpenAI.tool_result_message?(msg) ||
362
+ MessageFormat::Anthropic.tool_result_message?(msg)
363
+ end
364
+
365
+ # Returns the tool_call IDs referenced in a tool result message.
366
+ def tool_result_ids(msg)
367
+ if MessageFormat::OpenAI.tool_result_message?(msg)
368
+ MessageFormat::OpenAI.tool_call_ids(msg)
369
+ else
370
+ MessageFormat::Anthropic.tool_use_ids(msg)
371
+ end
372
+ end
373
+
374
+ # Returns true if msg is a tool result that matches any of the given call IDs.
375
+ def tool_result_for?(msg, call_ids)
376
+ tool_result_message?(msg) && (tool_result_ids(msg) & call_ids).any?
377
+ end
378
+
379
+ # Mark all tool results immediately following messages[assistant_idx].
380
+ # Stops at the first non-tool-result message.
381
+ def pull_tool_results_after(messages, assistant_idx, include_set)
382
+ call_ids = messages[assistant_idx][:tool_calls].map { |tc| tc[:id] }
383
+ j = assistant_idx + 1
384
+ while j < messages.size
385
+ nxt = messages[j]
386
+ if tool_result_for?(nxt, call_ids)
387
+ include_set.add(j)
388
+ elsif !tool_result_message?(nxt)
389
+ break
390
+ end
391
+ j += 1
392
+ end
393
+ end
394
+
395
+ # Walk backwards from tool_result_idx to find and mark its assistant message.
396
+ # Also marks all sibling tool results for that assistant.
397
+ # Yields true if the assistant was newly added (for caller to increment count).
398
+ def pull_assistant_before(messages, tool_result_idx, include_set)
399
+ result_ids = tool_result_ids(messages[tool_result_idx])
400
+
401
+ j = tool_result_idx - 1
402
+ while j >= 0
403
+ prev = messages[j]
404
+ if prev[:role] == "assistant" && prev[:tool_calls]&.any?
405
+ call_ids = prev[:tool_calls].map { |tc| tc[:id] }
406
+ if (call_ids & result_ids).any?
407
+ newly_added = include_set.add?(j)
408
+ yield newly_added
409
+
410
+ # Also pull all sibling tool results for this assistant
411
+ pull_tool_results_after(messages, j, include_set)
412
+ break
413
+ end
414
+ end
415
+ j -= 1
416
+ end
417
+ end
418
+
419
+ # Truncate oversized tool result content to avoid token bloat.
420
+ def truncate_tool_result(msg)
421
+ if MessageFormat::OpenAI.tool_result_message?(msg) &&
422
+ msg[:content].is_a?(String) && msg[:content].length > 2000
423
+ msg.merge(content: msg[:content][0..2000] + "...\n[Content truncated - exceeded 2000 characters]")
424
+ else
425
+ msg
426
+ end
427
+ end
428
+
429
+ # Lazy accessor for a SessionManager instance used by compression chunk I/O.
430
+ # We keep this local to the helper rather than threading a manager instance
431
+ # through the Agent constructor — Agent itself doesn't persist sessions
432
+ # (CLI / HTTP server do that), but the compression archive lives in the
433
+ # same directory under SessionManager's ownership.
434
+ #
435
+ # NOTE: Uses Octo::SessionManager::SESSIONS_DIR by default. Tests can
436
+ # stub that constant to point at a tmpdir.
437
+ private def session_manager
438
+ @session_manager ||= Octo::SessionManager.new
439
+ end
440
+
441
+ # Save the messages being compressed to a chunk MD file for future recall.
442
+ # The filesystem concerns (path, write, chmod) are delegated to SessionManager;
443
+ # this method is responsible only for the business rules of WHAT gets archived.
444
+ #
445
+ # @param original_messages [Array<Hash>] All messages before compression (excluding compression instruction)
446
+ # @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
447
+ # @param chunk_index [Integer] Sequential chunk number
448
+ # @param compression_level [Integer] Compression level
449
+ # @param topics [String, nil] Short topic description for chunk index card
450
+ # @return [String, nil] Path to saved chunk file, or nil if save failed
451
+ def save_compressed_chunk(original_messages, recent_messages, chunk_index:, compression_level:, topics: nil)
452
+ return nil unless @session_id && @created_at
453
+
454
+ # Messages being compressed = original minus system message minus recent messages
455
+ # Also exclude system-injected scaffolding (session context, memory prompts, etc.)
456
+ # — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
457
+ # Also exclude previous compressed_summary messages: they are index cards pointing
458
+ # to older chunk files and must NOT be embedded inside a new chunk, otherwise
459
+ # parse_chunk_md_to_rounds would follow the nested reference and create circular
460
+ # chunk chains (chunk-2 → chunk-1 → ... → chunk-2).
461
+ recent_set = recent_messages.to_a
462
+ messages_to_archive = original_messages.reject do |m|
463
+ m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
464
+ end
465
+
466
+ return nil if messages_to_archive.empty?
467
+
468
+ md_content = build_chunk_md(messages_to_archive,
469
+ chunk_index: chunk_index,
470
+ compression_level: compression_level,
471
+ topics: topics)
472
+
473
+ # Delegate filesystem concerns (path assembly, write, chmod) to SessionManager —
474
+ # it owns the on-disk layout for sessions and their chunk archives.
475
+ session_manager.write_chunk(@session_id, @created_at, chunk_index, md_content)
476
+ rescue => e
477
+ @ui&.log("Failed to save chunk MD: #{e.message}", level: :warn)
478
+ nil
479
+ end
480
+
481
+ # Build markdown content from a list of messages
482
+ # @param messages [Array<Hash>] Messages to render
483
+ # @param chunk_index [Integer] Chunk number for metadata
484
+ # @param compression_level [Integer] Compression level
485
+ # @param topics [String, nil] Short topic description extracted from LLM summary
486
+ # @return [String] Markdown content
487
+ def build_chunk_md(messages, chunk_index:, compression_level:, topics: nil)
488
+ lines = []
489
+
490
+ # Front matter
491
+ lines << "---"
492
+ lines << "session_id: #{@session_id}"
493
+ lines << "chunk: #{chunk_index}"
494
+ lines << "compression_level: #{compression_level}"
495
+ lines << "archived_at: #{Time.now.iso8601}"
496
+ lines << "message_count: #{messages.size}"
497
+ lines << "topics: #{topics}" if topics
498
+ lines << "---"
499
+ lines << ""
500
+ lines << "# Session Chunk #{chunk_index}"
501
+ lines << ""
502
+ lines << "> This file contains the original conversation archived during compression."
503
+ lines << "> Use `file_reader` to recall specific details from this conversation."
504
+ lines << ""
505
+
506
+ messages.each do |msg|
507
+ role = msg[:role]
508
+ content = msg[:content]
509
+
510
+ case role
511
+ when "user"
512
+ lines << "## User"
513
+ lines << ""
514
+ lines << format_message_content(content)
515
+ lines << ""
516
+ when "assistant"
517
+ # If this message is itself a compressed summary, annotate the header
518
+ # so the reader knows the original conversation is in the referenced chunk
519
+ if msg[:compressed_summary] && msg[:chunk_path]
520
+ prev_chunk = File.basename(msg[:chunk_path])
521
+ lines << "## Assistant [Compressed Summary — original conversation at: #{prev_chunk}]"
522
+ else
523
+ lines << "## Assistant"
524
+ end
525
+ lines << ""
526
+ # Include tool calls summary if present
527
+ # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
528
+ if msg[:tool_calls]&.any?
529
+ tc_parts = msg[:tool_calls].map do |tc|
530
+ name = tc.dig(:function, :name) || tc[:name] || ""
531
+ next nil if name.empty?
532
+
533
+ args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
534
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
535
+ if args.is_a?(Hash) && !args.empty?
536
+ # Truncate large string values to keep chunk MD readable
537
+ compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
538
+ "#{name} | #{compact.to_json}"
539
+ else
540
+ name
541
+ end
542
+ end.compact
543
+ lines << "_Tool calls: #{tc_parts.join("; ")}_"
544
+ lines << ""
545
+ end
546
+ lines << format_message_content(content) if content
547
+ lines << ""
548
+ when "tool"
549
+ tool_name = msg[:name] || "tool"
550
+ lines << "### Tool Result: #{tool_name}"
551
+ lines << ""
552
+ lines << "```"
553
+ lines << truncate_content(content.to_s, max_length: 500)
554
+ lines << "```"
555
+ lines << ""
556
+ end
557
+ end
558
+
559
+ lines.join("\n")
560
+ end
561
+
562
+ # Format message content (handles string or array of content blocks)
563
+ def format_message_content(content)
564
+ return "" if content.nil?
565
+ return content.to_s if content.is_a?(String)
566
+
567
+ # Handle array of content blocks (e.g., text + images)
568
+ if content.is_a?(Array)
569
+ content.map do |block|
570
+ if block.is_a?(Hash) && block[:type] == "text"
571
+ block[:text].to_s
572
+ else
573
+ "[#{block[:type] || 'content'}]"
574
+ end
575
+ end.join("\n")
576
+ else
577
+ content.to_s
578
+ end
579
+ end
580
+
581
+ # Truncate long content with a note
582
+ def truncate_content(text, max_length: 500)
583
+ return text if text.length <= max_length
584
+ "#{text[0...max_length]}\n... [truncated, #{text.length} chars total]"
585
+ end
586
+
587
+ # Calculate how many recent messages to keep based on how much we need to compress
588
+ def calculate_target_recent_count(reduction_needed)
589
+ # We want recent messages to be around 20-30% of the total target
590
+ # This keeps the context window useful without being too large
591
+ tokens_per_message = 500 # Average estimate for a message with content
592
+
593
+ # Target recent messages budget (~20% of target compressed size)
594
+ recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
595
+ target_messages = (recent_budget / tokens_per_message).to_i
596
+
597
+ # Clamp to reasonable bounds
598
+ [[target_messages, 20].max, MAX_RECENT_MESSAGES].min
599
+ end
600
+
601
+ # Generate hierarchical summary based on compression level
602
+ # Level 1: Detailed summary with files, decisions, features
603
+ # Level 2: Concise summary with key items
604
+ # Level 3: Minimal summary (just project type)
605
+ # Level 4+: Ultra-minimal (single line)
606
+ def generate_hierarchical_summary(messages)
607
+ level = @compression_level
608
+
609
+ # Extract key information from messages
610
+ extracted = extract_key_information(messages)
611
+
612
+ summary_text = case level
613
+ when 1
614
+ generate_level1_summary(extracted)
615
+ when 2
616
+ generate_level2_summary(extracted)
617
+ when 3
618
+ generate_level3_summary(extracted)
619
+ else
620
+ generate_level4_summary(extracted)
621
+ end
622
+
623
+ {
624
+ role: "user",
625
+ content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
626
+ system_injected: true,
627
+ compression_level: level
628
+ }
629
+ end
630
+
631
+ # Extract key information from messages for summarization
632
+ def extract_key_information(messages)
633
+ return empty_extraction_data if messages.nil?
634
+
635
+ {
636
+ # Message counts
637
+ user_msgs: messages.count { |m| m[:role] == "user" },
638
+ assistant_msgs: messages.count { |m| m[:role] == "assistant" },
639
+ tool_msgs: messages.count { |m| m[:role] == "tool" },
640
+
641
+ # Tools used
642
+ tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
643
+
644
+ # Files created/modified
645
+ files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
646
+ files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
647
+
648
+ # Key decisions (limit to first 5)
649
+ decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
650
+
651
+ # Completed tasks (from TODO results)
652
+ completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
653
+
654
+ # Current in-progress work
655
+ in_progress: find_in_progress(messages),
656
+
657
+ # Key results from shell commands
658
+ shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
659
+ }
660
+ end
661
+
662
+ # Helper: safely extract from messages with proper nil handling
663
+ def extract_from_messages(messages, role_filter = nil, &block)
664
+ return [] if messages.nil?
665
+
666
+ results = messages
667
+ .select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
668
+ .map(&block)
669
+ .compact
670
+
671
+ # Flatten if we have nested arrays (from methods returning arrays of items)
672
+ results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
673
+ end
674
+
675
+ # Helper: extract tool names from tool_calls
676
+ def extract_tool_names(tool_calls)
677
+ return [] unless tool_calls.is_a?(Array)
678
+ tool_calls.map { |tc| tc.dig(:function, :name) }
679
+ end
680
+
681
+ # Helper: filter write results by action
682
+ def filter_write_results(result, action)
683
+ result && result[:action] == action ? result[:file] : nil
684
+ end
685
+
686
+ # Helper: filter todo results by status
687
+ def filter_todo_results(result, status)
688
+ result && result[:status] == status ? result[:task] : nil
689
+ end
690
+
691
+ # Helper: extract decision text from content (returns array of decisions or empty array)
692
+ def extract_decision_text(content)
693
+ return [] unless content.is_a?(String)
694
+ return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
695
+
696
+ sentences = content.split(/[.!?]/).select do |s|
697
+ s.include?("decision") || s.include?("chose") || s.include?("using") ||
698
+ s.include?("decided") || s.include?("will use") || s.include?("selected")
699
+ end
700
+ sentences.map(&:strip).map { |s| s[0..100] }
701
+ end
702
+
703
+ # Helper: find in-progress task
704
+ def find_in_progress(messages)
705
+ return nil if messages.nil?
706
+
707
+ messages.reverse_each do |m|
708
+ if m[:role] == "tool"
709
+ content = m[:content].to_s
710
+ if content.include?("in progress") || content.include?("working on")
711
+ return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
712
+ end
713
+ end
714
+ end
715
+ nil
716
+ end
717
+
718
+ # Helper: empty extraction data
719
+ def empty_extraction_data
720
+ {
721
+ user_msgs: 0,
722
+ assistant_msgs: 0,
723
+ tool_msgs: 0,
724
+ tools_used: [],
725
+ files_created: [],
726
+ files_modified: [],
727
+ decisions: [],
728
+ completed_tasks: [],
729
+ in_progress: nil,
730
+ shell_results: []
731
+ }
732
+ end
733
+
734
+ def parse_write_result(content)
735
+ return nil unless content.is_a?(String)
736
+
737
+ # Check for "Created: path" or "Updated: path" patterns
738
+ if content.include?("Created:")
739
+ { action: :created, file: content[/Created:\s*(.+)/, 1]&.strip }
740
+ elsif content.include?("Updated:") || content.include?("modified")
741
+ { action: :modified, file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
742
+ else
743
+ nil
744
+ end
745
+ end
746
+
747
+ def parse_todo_result(content)
748
+ return nil unless content.is_a?(String)
749
+
750
+ if content.include?("completed")
751
+ { status: :completed, task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
752
+ elsif content.include?("added")
753
+ { status: :added, task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
754
+ else
755
+ nil
756
+ end
757
+ end
758
+
759
+ def parse_shell_result(content)
760
+ return nil unless content.is_a?(String)
761
+
762
+ if content.include?("passed") || content.include?("success")
763
+ "tests passed"
764
+ elsif content.include?("failed") || content.include?("error")
765
+ "command failed"
766
+ elsif content =~ /bundle install|npm install|go mod download/
767
+ "dependencies installed"
768
+ elsif content.include?("Installed")
769
+ content[/Installed:\s*(.+)/, 1]&.strip
770
+ else
771
+ nil
772
+ end
773
+ end
774
+
775
+ # Level 1: Detailed summary (for first compression)
776
+ def generate_level1_summary(data)
777
+ parts = []
778
+
779
+ parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
780
+
781
+ # Files created
782
+ if data[:files_created].any?
783
+ files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
784
+ parts << "Created: #{files_list}"
785
+ end
786
+
787
+ # Files modified
788
+ if data[:files_modified].any?
789
+ files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
790
+ parts << "Modified: #{files_list}"
791
+ end
792
+
793
+ # Completed tasks
794
+ if data[:completed_tasks].any?
795
+ tasks_list = data[:completed_tasks].first(3).join(", ")
796
+ parts << "Completed: #{tasks_list}"
797
+ end
798
+
799
+ # In progress
800
+ if data[:in_progress]
801
+ parts << "In Progress: #{data[:in_progress]}"
802
+ end
803
+
804
+ # Key decisions
805
+ if data[:decisions].any?
806
+ decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
807
+ parts << "Decisions: #{decisions_text}"
808
+ end
809
+
810
+ # Tools used
811
+ if data[:tools_used].any?
812
+ parts << "Tools: #{data[:tools_used].join(', ')}"
813
+ end
814
+
815
+ parts << "Continuing with recent conversation..."
816
+ parts.join("\n")
817
+ end
818
+
819
+ # Level 2: Concise summary (for second compression)
820
+ def generate_level2_summary(data)
821
+ parts = []
822
+
823
+ parts << "Conversation summary:"
824
+
825
+ # Key files (limit to most important)
826
+ all_files = (data[:files_created] + data[:files_modified]).uniq
827
+ if all_files.any?
828
+ key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
829
+ parts << "Files: #{key_files}"
830
+ end
831
+
832
+ # Key accomplishments
833
+ accomplishments = []
834
+ accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
835
+ accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
836
+ accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
837
+
838
+ parts << accomplishments.join(", ") if accomplishments.any?
839
+
840
+ parts << "Recent context follows..."
841
+ parts.join("\n")
842
+ end
843
+
844
+ # Level 3: Minimal summary (for third compression)
845
+ def generate_level3_summary(data)
846
+ parts = []
847
+
848
+ parts << "Project progress:"
849
+
850
+ # Just counts and key items
851
+ all_files = (data[:files_created] + data[:files_modified]).uniq
852
+ parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
853
+
854
+ if data[:in_progress]
855
+ parts << "Currently: #{data[:in_progress]}"
856
+ end
857
+
858
+ parts << "See recent messages for details."
859
+ parts.join("\n")
860
+ end
861
+
862
+ # Level 4: Ultra-minimal summary (for fourth+ compression)
863
+ def generate_level4_summary(data)
864
+ all_files = (data[:files_created] + data[:files_modified]).uniq
865
+ "Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
866
+ end
867
+ end
868
+ end
869
+ end