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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ class Agent
5
+ # Long-term memory update functionality.
6
+ #
7
+ # Runs at the end of a qualifying task to persist important knowledge
8
+ # into ~/.octo/memories/. The LLM decides:
9
+ # - Which topics were discussed
10
+ # - Which memory files to update or create
11
+ # - How to merge new info with existing content
12
+ # - What to drop to stay within the per-file token limit
13
+ #
14
+ # Architecture:
15
+ # Memory update runs as a **forked subagent**, NOT inline in the
16
+ # main agent's loop. The subagent inherits the main agent's history
17
+ # (so it can see what happened) via +fork_subagent+'s standard
18
+ # deep-clone, and inherits the same model/tools so prompt-cache is
19
+ # reused maximally. The subagent runs synchronously; when it returns,
20
+ # the main agent prints +show_complete+.
21
+ #
22
+ # This gives us, structurally:
23
+ # - Clean main-agent history (no memory_update messages to clean up)
24
+ # - Correct visual ordering ([OK] Task Complete is the LAST thing
25
+ # printed — the memory-update progress finishes before it)
26
+ # - Independent cost accounting (task cost vs. memory update cost)
27
+ # - Natural recursion guard (+@is_subagent+ blocks re-entry)
28
+ #
29
+ # Trigger condition:
30
+ # - Iteration count >= MEMORY_UPDATE_MIN_ITERATIONS (skip trivial tasks)
31
+ # - Not already a subagent (no recursion)
32
+ # - Memory update is enabled in config
33
+ module MemoryUpdater
34
+ # Minimum LLM iterations for this task before triggering memory update.
35
+ # Set high enough to skip short utility tasks (commit, deploy, etc.)
36
+ MEMORY_UPDATE_MIN_ITERATIONS = 10
37
+
38
+ MEMORIES_DIR = File.expand_path("~/.octo/memories")
39
+
40
+ # Check if memory update should be triggered for this task.
41
+ # Only triggers when the task had enough LLM iterations,
42
+ # skipping short utility tasks (e.g. commit, deploy).
43
+ # @return [Boolean]
44
+ def should_update_memory?
45
+ return false unless memory_update_enabled?
46
+ return false if @is_subagent # Subagents never update memory
47
+
48
+ task_iterations = @iterations - (@task_start_iterations || 0)
49
+ task_iterations >= MEMORY_UPDATE_MIN_ITERATIONS
50
+ end
51
+
52
+ # Run memory update as a forked subagent.
53
+ #
54
+ # This is called by +Agent#run+ on the success path, AFTER the main
55
+ # loop exits and BEFORE +show_complete+ is printed. It blocks until
56
+ # the subagent finishes, so the visual order is structurally correct:
57
+ #
58
+ # ... task output ...
59
+ # [progress] Updating long-term memory… (spinner)
60
+ # [progress finishes]
61
+ # [OK] Task Complete
62
+ #
63
+ # Safe to call unconditionally; returns early if preconditions fail.
64
+ # Never raises for "no update needed" — only propagates genuine errors
65
+ # (+Octo::AgentInterrupted+ for Ctrl+C, other exceptions are caught
66
+ # and logged so memory-update failures never mask the parent task's
67
+ # result).
68
+ def run_memory_update_subagent
69
+ return unless should_update_memory?
70
+
71
+ handle = @ui&.start_progress(message: "Updating long-term memory…", style: :primary)
72
+
73
+ # Fork subagent inheriting main agent's model, tools, and history.
74
+ # Maximizes prompt-cache reuse: same model, same tool set, same
75
+ # cloned history — only the +system_prompt_suffix+ (the memory
76
+ # update instructions) and the final "Please proceed." user turn
77
+ # are new, landing on top of a warm cache.
78
+ subagent = fork_subagent(system_prompt_suffix: build_memory_update_prompt)
79
+
80
+ # Memory update is a background consolidation task — never prompt
81
+ # the user for confirmation on memory file writes. The subagent
82
+ # has its own config copy (fork_subagent does deep_copy), so this
83
+ # doesn't affect the parent.
84
+ sub_config = subagent.instance_variable_get(:@config)
85
+ sub_config.permission_mode = :auto_approve if sub_config.respond_to?(:permission_mode=)
86
+
87
+ begin
88
+ result = subagent.run("Please proceed.")
89
+ rescue Octo::AgentInterrupted
90
+ # User pressed Ctrl+C during memory update. Propagate so the
91
+ # parent agent's interrupt handler runs.
92
+ raise
93
+ rescue StandardError => e
94
+ # Memory update failures are NEVER fatal to the parent task.
95
+ # Log and move on — the user's actual work is already done.
96
+ @debug_logs << {
97
+ timestamp: Time.now.iso8601,
98
+ event: "memory_update_error",
99
+ error_class: e.class.name,
100
+ error_message: e.message,
101
+ backtrace: e.backtrace&.first(10)
102
+ }
103
+ Octo::Logger.error("memory_update_error", error: e)
104
+ return
105
+ ensure
106
+ handle&.finish
107
+ end
108
+
109
+ return unless result
110
+
111
+ # Only surface a completion info line if the subagent actually
112
+ # wrote something to memory. The common "No memory updates needed."
113
+ # path stays silent to avoid visual noise.
114
+ if subagent_wrote_memory?(subagent)
115
+ @ui&.show_info("Memory updated: #{result[:iterations]} iterations")
116
+ end
117
+ end
118
+
119
+ private def memory_update_enabled?
120
+ # Check config flag; default to true if not set
121
+ return true unless @config.respond_to?(:memory_update_enabled)
122
+
123
+ @config.memory_update_enabled != false
124
+ end
125
+
126
+ # Inspect the subagent's history for a successful write/edit tool
127
+ # call targeting a memory file. Used to decide whether to surface a
128
+ # "Memory updated" info line (option C — silent when nothing changed).
129
+ # @param subagent [Octo::Agent]
130
+ # @return [Boolean]
131
+ private def subagent_wrote_memory?(subagent)
132
+ return false unless subagent.respond_to?(:history) && subagent.history
133
+
134
+ subagent.history.to_a.any? do |msg|
135
+ next false unless msg.is_a?(Hash)
136
+
137
+ # Match OpenAI-style tool_calls on assistant messages …
138
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
139
+ if tool_calls.is_a?(Array) && tool_calls.any?
140
+ next true if tool_calls.any? do |tc|
141
+ name = tc.dig(:function, :name) || tc.dig("function", "name") || tc[:name] || tc["name"]
142
+ %w[write edit].include?(name.to_s)
143
+ end
144
+ end
145
+
146
+ # … and Anthropic-style content blocks with type=tool_use.
147
+ content = msg[:content] || msg["content"]
148
+ if content.is_a?(Array)
149
+ next true if content.any? do |block|
150
+ block.is_a?(Hash) &&
151
+ (block[:type] == "tool_use" || block["type"] == "tool_use") &&
152
+ %w[write edit].include?((block[:name] || block["name"]).to_s)
153
+ end
154
+ end
155
+
156
+ false
157
+ end
158
+ rescue StandardError
159
+ # Defensive: never let introspection errors break memory update.
160
+ false
161
+ end
162
+
163
+ # Build the memory update prompt for the forked subagent.
164
+ #
165
+ # Architecture:
166
+ # - Decision (whitelist) lives HERE — MemoryUpdater is the trigger
167
+ # and decides whether/what to persist.
168
+ # - Execution (file naming, merging, frontmatter, size limits) lives
169
+ # in the persist-memory skill — MemoryUpdater loads SKILL.md
170
+ # directly via SkillManager and embeds it as the executor manual.
171
+ #
172
+ # We do NOT call invoke_skill here (that would fork a second
173
+ # subagent — the persist-memory skill is fork_agent:true). Instead
174
+ # the subagent we already forked plays both roles: it reads the
175
+ # whitelist, decides what (if anything) to persist, and follows
176
+ # the embedded SKILL.md rules to write the files.
177
+ #
178
+ # @return [String]
179
+ private def build_memory_update_prompt
180
+ executor_manual = load_persist_memory_skill_body
181
+
182
+ <<~PROMPT
183
+ ═══════════════════════════════════════════════════════════════
184
+ MEMORY UPDATE MODE
185
+ ═══════════════════════════════════════════════════════════════
186
+ The conversation above has ended. You are now in MEMORY UPDATE MODE.
187
+
188
+ ## Default: Do NOT write anything.
189
+
190
+ Memory writes are expensive. Only write if the session contains at least one of the
191
+ following high-value signals. If NONE apply, respond immediately with:
192
+ "No memory updates needed." and STOP — do not use any tools.
193
+
194
+ ## Whitelist: Write ONLY if at least one condition is met
195
+
196
+ 1. **Explicit decision** — The user made a clear technical, product, or process decision
197
+ that will affect future work (e.g. "we'll use X instead of Y going forward").
198
+ 2. **New persistent context** — The user introduced project background, constraints, or
199
+ goals that are not already obvious from the code (e.g. a new feature direction,
200
+ a deployment target, a team convention).
201
+ 3. **Correction of prior knowledge** — The user corrected a previous misunderstanding
202
+ or the agent discovered that an existing memory is wrong or outdated.
203
+ 4. **Stated preference** — The user expressed a clear personal or team preference about
204
+ how they want the agent to behave, communicate, or write code.
205
+
206
+ ## What does NOT qualify (skip these entirely)
207
+
208
+ - Running tests, fixing lint, formatting code
209
+ - Committing, deploying, or releasing
210
+ - Answering a one-off question or explaining a concept
211
+ - Any task that produced no lasting decisions or preferences
212
+ - Repeating or slightly rephrasing what is already in memory
213
+
214
+ ═══════════════════════════════════════════════════════════════
215
+ EXECUTOR MANUAL (from persist-memory skill)
216
+ ═══════════════════════════════════════════════════════════════
217
+ If — and ONLY if — the whitelist matched, follow the manual below
218
+ to actually write the files. The manual owns file naming, merging,
219
+ frontmatter, and size limits. Treat it as authoritative for
220
+ execution; ignore any "should I write?" framing inside it (that
221
+ decision has already been made above).
222
+
223
+ #{executor_manual}
224
+
225
+ ───────────────────────────────────────────────────────────────
226
+ Begin by checking the whitelist. If no condition is met, stop immediately.
227
+ PROMPT
228
+ end
229
+
230
+ # Load the persist-memory skill's expanded body (frontmatter stripped,
231
+ # template variables like <%= memories_meta %> resolved).
232
+ #
233
+ # The persist-memory skill is a built-in default skill — it is always
234
+ # present. If it isn't, that's a build/install bug and we want it to
235
+ # surface loudly rather than silently degrade.
236
+ #
237
+ # @return [String]
238
+ private def load_persist_memory_skill_body
239
+ skill = @skill_loader.find_by_name("persist-memory")
240
+ raise "persist-memory skill not found — built-in skill is missing" unless skill
241
+
242
+ skill.process_content(template_context: build_template_context)
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ # Message compressor using Insert-then-Compress strategy
5
+ #
6
+ # New Strategy: Instead of creating a separate API call for compression,
7
+ # we insert a compression instruction into the current conversation flow.
8
+ # This allows us to reuse the existing cache (system prompt + tools) and
9
+ # only pay for processing the new compression instruction.
10
+ #
11
+ # Flow:
12
+ # 1. Agent detects compression threshold is reached
13
+ # 2. Compressor builds a compression instruction message
14
+ # 3. Agent inserts this message and calls LLM (with cache reuse!)
15
+ # 4. LLM returns compressed summary
16
+ # 5. Compressor rebuilds message list: system + summary + recent messages
17
+ # 6. Agent continues with new message list (cache will rebuild from here)
18
+ #
19
+ # Benefits:
20
+ # - Compression call reuses existing cache (huge token savings)
21
+ # - Only one cache rebuild after compression (vs two with old approach)
22
+ #
23
+ class MessageCompressor
24
+ COMPRESSION_PROMPT = <<~PROMPT.freeze
25
+ ═══════════════════════════════════════════════════════════════
26
+ CRITICAL: TASK CHANGE - MEMORY COMPRESSION MODE
27
+ ═══════════════════════════════════════════════════════════════
28
+ The conversation above has ENDED. You are now in MEMORY COMPRESSION MODE.
29
+
30
+ CRITICAL INSTRUCTIONS - READ CAREFULLY:
31
+
32
+ 1. This is NOT a continuation of the conversation
33
+ 2. DO NOT respond to any requests in the conversation above
34
+ 3. DO NOT call ANY tools or functions
35
+ 4. DO NOT use tool_calls in your response
36
+ 5. Your response MUST be PURE TEXT ONLY
37
+
38
+ YOUR ONLY TASK: Create a comprehensive summary of the conversation above.
39
+
40
+ REQUIRED RESPONSE FORMAT:
41
+ First output a <topics> line listing 3-6 key topic phrases (comma-separated, concise).
42
+ Then output the full summary wrapped in <summary> tags.
43
+
44
+ Example format:
45
+ <topics>Rails setup, database config, deploy pipeline, Tailwind CSS</topics>
46
+ <summary>
47
+ ...full summary text...
48
+ </summary>
49
+
50
+ Focus on:
51
+ - User's explicit requests and intents
52
+ - Key technical concepts and code changes
53
+ - Files examined and modified
54
+ - Errors encountered and fixes applied
55
+ - Current work status and pending tasks
56
+
57
+ Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then <summary>.
58
+ PROMPT
59
+
60
+ def initialize(client, model: nil)
61
+ @client = client
62
+ @model = model
63
+ end
64
+
65
+ # Generate compression instruction message to be inserted into conversation
66
+ # This enables cache reuse by using the same API call with tools
67
+ #
68
+ # SIMPLIFIED APPROACH:
69
+ # - Don't duplicate conversation history in the compression message
70
+ # - LLM can already see all messages, just ask it to compress
71
+ # - Keep the instruction small for better cache efficiency
72
+ #
73
+ # @param messages [Array<Hash>] Original conversation messages
74
+ # @param recent_messages [Array<Hash>] Recent messages to keep uncompressed (optional)
75
+ # @return [Hash] Compression instruction message to insert, or nil if nothing to compress
76
+ def build_compression_message(messages, recent_messages: [])
77
+ # Get messages to compress (exclude system message and recent messages)
78
+ messages_to_compress = messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
79
+
80
+ # If nothing to compress, return nil
81
+ return nil if messages_to_compress.empty?
82
+
83
+ # Simple compression instruction - LLM can see the history already
84
+ {
85
+ role: "user",
86
+ content: COMPRESSION_PROMPT,
87
+ system_injected: true
88
+ }
89
+ end
90
+
91
+ # Parse LLM response and rebuild message list with compression
92
+ # @param compressed_content [String] The compressed summary from LLM
93
+ # @param original_messages [Array<Hash>] Original messages before compression
94
+ # @param recent_messages [Array<Hash>] Recent messages to preserve
95
+ # @param chunk_path [String, nil] Path to the archived chunk MD file (if saved)
96
+ # @param pulled_back_messages [Array<Hash>] Messages temporarily popped from the
97
+ # tail of @history before the compression LLM call (to free up token budget so
98
+ # the compression call itself doesn't overflow context). These are NOT discarded —
99
+ # they are reattached to the tail of the rebuilt history so recent task progress
100
+ # is preserved. Default: [] (normal compression path doesn't need this).
101
+ # @return [Array<Hash>] Rebuilt message list: system + compressed + recent + pulled_back
102
+ def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil, topics: nil, previous_chunks: [], pulled_back_messages: [])
103
+ # Find and preserve system message
104
+ system_msg = original_messages.find { |m| m[:role] == "system" }
105
+
106
+ # Parse the compressed result, embedding previous chunk references so the
107
+ # new summary carries a complete index of all older archives. This avoids
108
+ # keeping all prior compressed_summary messages in active history while
109
+ # still giving the AI a path to find old conversations via file_reader.
110
+ parsed_messages = parse_compressed_result(compressed_content,
111
+ chunk_path: chunk_path,
112
+ topics: topics,
113
+ previous_chunks: previous_chunks)
114
+
115
+ # If parsing fails or returns empty, raise error
116
+ if parsed_messages.nil? || parsed_messages.empty?
117
+ raise "LLM compression failed: unable to parse compressed messages"
118
+ end
119
+
120
+ # Return system message + compressed messages + recent messages + pulled_back messages.
121
+ # Strip any system messages from recent_messages as a safety net —
122
+ # get_recent_messages_with_tool_pairs already excludes them, but this
123
+ # guard ensures we never end up with duplicate system prompts even if
124
+ # the caller passes an unfiltered list.
125
+ #
126
+ # pulled_back_messages: messages that were temporarily popped from the tail
127
+ # of @history before the compression LLM call (to free up token budget so
128
+ # the compression call itself doesn't overflow context). They are reattached
129
+ # here to preserve recent task progress.
130
+ safe_recent = recent_messages.reject { |m| m[:role] == "system" }
131
+ safe_pulled_back = pulled_back_messages.reject { |m| m[:role] == "system" }
132
+ [system_msg, *parsed_messages, *safe_recent, *safe_pulled_back].compact
133
+ end
134
+
135
+
136
+ # Parse topics tag from compressed content.
137
+ # Returns the topics string if found, nil otherwise.
138
+ # e.g. "<topics>Rails setup, database config</topics>" → "Rails setup, database config"
139
+ def parse_topics(content)
140
+ m = content.match(/<topics>(.*?)<\/topics>/m)
141
+ m ? m[1].strip : nil
142
+ end
143
+
144
+ def parse_compressed_result(result, chunk_path: nil, topics: nil, previous_chunks: [])
145
+ # Return the compressed result as a single user message (role: "user").
146
+ #
147
+ # Why role:"user" instead of "assistant":
148
+ # When all original user messages get archived into the chunk during compression
149
+ # (e.g. a long single-turn `/slash` task), the rebuilt history can end up as
150
+ # `system → assistant(summary) → assistant(tool_calls) → tool → …` with NO user
151
+ # message anywhere. Strict providers (notably DeepSeek V4 thinking mode) reject
152
+ # this as a malformed turn structure with a misleading
153
+ # "reasoning_content must be passed back" 400 error.
154
+ #
155
+ # Marking it as a user message gives the conversation a valid turn boundary.
156
+ # `system_injected: true` ensures the UI's replay_history still hides it from
157
+ # the chat panel (the real-user filter excludes system_injected messages), while
158
+ # INTERNAL_FIELDS in MessageHistory strips the marker before the API payload is
159
+ # built — so DeepSeek/OpenAI/Anthropic only see a plain `{role:"user", content:…}`.
160
+ #
161
+ # The `compressed_summary: true` flag is preserved so that replay_history still
162
+ # routes this message through the chunk-expansion path (which keys off that flag,
163
+ # not the role).
164
+ #
165
+ # @param topics [String, nil] Short topic description extracted from <topics> tag
166
+ # @param previous_chunks [Array<Hash>] Info about older chunk files
167
+ # Each hash: { basename:, path:, topics: }
168
+ content = result.to_s.strip
169
+
170
+ if content.empty?
171
+ []
172
+ else
173
+ # Strip out the <topics> block — it's metadata for the chunk file, not for AI context
174
+ content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "").strip
175
+
176
+ # Build previous chunks index section — links to older chunk files so the AI
177
+ # can find earlier conversations without keeping all prior compressed_summary
178
+ # messages in the active history. Shows newest chunks first (reverse order),
179
+ # capped at 10 to keep the message size bounded.
180
+ previous_chunks_section = ""
181
+ if previous_chunks.any?
182
+ max_visible = 10
183
+ visible = previous_chunks.last(max_visible).reverse
184
+ older_count = previous_chunks.size - visible.size
185
+
186
+ previous_chunks_section = "\n\n---\n📁 **Previous chunks (newest first):**\n"
187
+ visible.each do |pc|
188
+ topic_str = pc[:topics] ? " — #{pc[:topics]}" : ""
189
+ previous_chunks_section += "- `#{pc[:basename]}`#{topic_str}\n"
190
+ end
191
+
192
+ if older_count > 0
193
+ oldest = previous_chunks.first
194
+ previous_chunks_section += "- ... and #{older_count} older chunks back to `#{oldest[:basename]}`\n"
195
+ end
196
+
197
+ previous_chunks_section += "_Use `file_reader` to recall details from these chunks._"
198
+ end
199
+
200
+ # Inject chunk anchor so AI knows where to find original conversation for THIS chunk
201
+ anchor = ""
202
+ if chunk_path
203
+ anchor = "\n\n---\n📁 **Current chunk archived at:** `#{chunk_path}`\n" \
204
+ "_Use `file_reader` tool to recall details from this chunk._"
205
+ end
206
+
207
+ # Prefix lets the model recognise this is injected context, not a user utterance.
208
+ # Order: summary → previous chunks → current anchor (chronological)
209
+ framed_content = "[Compressed conversation summary — previous turns archived]\n\n" \
210
+ "#{content_without_topics}" \
211
+ "#{previous_chunks_section}" \
212
+ "#{anchor}"
213
+
214
+ [{
215
+ role: "user",
216
+ content: framed_content,
217
+ compressed_summary: true,
218
+ chunk_path: chunk_path,
219
+ topics: topics,
220
+ system_injected: true
221
+ }]
222
+ end
223
+ end
224
+ end
225
+ end