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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require_relative "ui_interface"
6
+
7
+ module Octo
8
+ # JsonUIController implements UIInterface for JSON (NDJSON) output mode.
9
+ # All output is written as one-JSON-per-line to stdout.
10
+ # Confirmation requests read responses from stdin.
11
+ class JsonUIController
12
+ include Octo::UIInterface
13
+
14
+ def initialize(output: $stdout, input: $stdin)
15
+ @output = output
16
+ @input = input
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # Emit a raw NDJSON event
21
+ def emit(type, **data)
22
+ event = { type: type }.merge(data)
23
+ @mutex.synchronize do
24
+ @output.puts(JSON.generate(event))
25
+ @output.flush
26
+ end
27
+ end
28
+
29
+ # === Output display ===
30
+
31
+ def show_assistant_message(content, files:)
32
+ return if (content.nil? || content.strip.empty?) && files.empty?
33
+
34
+ data = { content: content.to_s }
35
+ data[:files] = files if files.any?
36
+ emit("assistant_message", **data)
37
+ end
38
+
39
+ def show_tool_call(name, args)
40
+ args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
41
+ emit("tool_call", name: name, args: args_data)
42
+ end
43
+
44
+ def show_tool_result(result)
45
+ emit("tool_result", result: result)
46
+ end
47
+
48
+ def show_tool_error(error)
49
+ error_msg = error.is_a?(Exception) ? error.message : error.to_s
50
+ emit("tool_error", error: error_msg)
51
+ end
52
+
53
+ def show_tool_args(formatted_args)
54
+ emit("tool_args", args: formatted_args)
55
+ end
56
+
57
+ def show_file_write_preview(path, is_new_file:)
58
+ emit("file_preview", path: path, operation: "write", is_new_file: is_new_file)
59
+ end
60
+
61
+ def show_file_edit_preview(path)
62
+ emit("file_preview", path: path, operation: "edit")
63
+ end
64
+
65
+ def show_file_error(error_message)
66
+ emit("file_error", error: error_message)
67
+ end
68
+
69
+ def show_shell_preview(command)
70
+ emit("shell_preview", command: command)
71
+ end
72
+
73
+ def show_diff(old_content, new_content, max_lines: 50)
74
+ emit("diff", old_size: old_content.bytesize, new_size: new_content.bytesize)
75
+ end
76
+
77
+ def show_token_usage(token_data)
78
+ emit("token_usage", **token_data)
79
+ end
80
+
81
+ def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
82
+ data = { iterations: iterations }
83
+ data[:duration] = duration if duration
84
+ data[:cache_stats] = cache_stats if cache_stats
85
+ data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
86
+ emit("complete", **data)
87
+ end
88
+
89
+ # append_output is a no-op in JSON mode (content is already emitted via semantic methods)
90
+ def append_output(content)
91
+ # no-op
92
+ end
93
+
94
+ # === Status messages ===
95
+
96
+ def show_info(message, prefix_newline: true)
97
+ emit("info", message: message)
98
+ end
99
+
100
+ def show_warning(message)
101
+ emit("warning", message: message)
102
+ end
103
+
104
+ def show_error(message)
105
+ emit("error", message: message)
106
+ end
107
+
108
+ def show_success(message)
109
+ emit("success", message: message)
110
+ end
111
+
112
+ def log(message, level: :info)
113
+ emit("log", level: level.to_s, message: message)
114
+ end
115
+
116
+ # === Progress ===
117
+
118
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
119
+ @progress_start_time = Time.now if phase == "active"
120
+
121
+ data = {
122
+ message: message,
123
+ progress_type: progress_type,
124
+ phase: phase,
125
+ status: phase == "active" ? "start" : "stop" # backward compat
126
+ }
127
+ data[:metadata] = metadata unless metadata.empty?
128
+ data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
129
+
130
+ emit("progress", **data)
131
+
132
+ @progress_start_time = nil if phase == "done"
133
+ end
134
+
135
+ # === State updates ===
136
+
137
+ def update_sessionbar(tasks: nil, status: nil, latency: nil)
138
+ data = {}
139
+ data[:tasks] = tasks if tasks
140
+ data[:status] = status if status
141
+ data[:latency] = latency if latency
142
+ emit("session_update", **data) unless data.empty?
143
+ end
144
+
145
+ def update_todos(todos)
146
+ emit("todo_update", todos: todos)
147
+ end
148
+
149
+ def set_working_status
150
+ emit("session_update", status: "working")
151
+ end
152
+
153
+ def set_idle_status
154
+ emit("session_update", status: "idle")
155
+ end
156
+
157
+ # === Blocking interaction ===
158
+
159
+ def request_confirmation(message, default: true)
160
+ conf_id = "conf_#{SecureRandom.hex(4)}"
161
+ emit("request_confirmation", id: conf_id, message: message, default: default)
162
+
163
+ # Read response from stdin (blocking)
164
+ line = @input.gets
165
+ return default if line.nil?
166
+
167
+ begin
168
+ response = JSON.parse(line.strip)
169
+ result = response["result"] || response[:result]
170
+
171
+ case result.to_s.downcase
172
+ when "yes", "y" then true
173
+ when "no", "n" then false
174
+ else
175
+ # Return as feedback text
176
+ result.to_s
177
+ end
178
+ rescue JSON::ParserError
179
+ default
180
+ end
181
+ end
182
+
183
+ # === Input control (no-ops in JSON mode) ===
184
+
185
+ def clear_input
186
+ # no-op
187
+ end
188
+
189
+ def set_input_tips(message, type: :info)
190
+ # no-op
191
+ end
192
+
193
+ def show_next_message_suggestion(text)
194
+ emit("next_message_suggestion", text: text.to_s)
195
+ end
196
+
197
+ # === Lifecycle ===
198
+
199
+ def stop
200
+ # no-op
201
+ end
202
+
203
+ end
204
+ end
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module MessageFormat
5
+ # Static helpers for Anthropic API message format.
6
+ #
7
+ # Responsibilities:
8
+ # - Identify Anthropic-style messages stored in @messages
9
+ # - Convert internal @messages → Anthropic API request body
10
+ # - Parse Anthropic API response → internal format
11
+ # - Format tool results for the next turn
12
+ #
13
+ # Internal @messages always use OpenAI-style canonical format:
14
+ # assistant tool_calls: { role: "assistant", tool_calls: [{id:, function:{name:,arguments:}}] }
15
+ # tool result: { role: "tool", tool_call_id:, content: }
16
+ #
17
+ # This module converts that canonical format to Anthropic native on the way OUT,
18
+ # and converts Anthropic native back to canonical on the way IN.
19
+ module Anthropic
20
+ module_function
21
+
22
+ # ── Message type identification ───────────────────────────────────────────
23
+
24
+ # Returns true if the message is an Anthropic-native tool result stored in
25
+ # @messages (role: "user" with content array containing tool_result blocks).
26
+ # NOTE: After the refactor, new tool results are stored in canonical format
27
+ # (role: "tool"). This helper handles legacy messages that might exist in
28
+ # older sessions.
29
+ def tool_result_message?(msg)
30
+ msg[:role] == "user" &&
31
+ msg[:content].is_a?(Array) &&
32
+ msg[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
33
+ end
34
+
35
+ # Returns the tool_use_ids referenced in an Anthropic-native tool result message.
36
+ def tool_use_ids(msg)
37
+ return [] unless tool_result_message?(msg)
38
+
39
+ msg[:content].select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
40
+ end
41
+
42
+ # ── Request building ──────────────────────────────────────────────────────
43
+
44
+ # Convert canonical @messages + tools into an Anthropic API request body.
45
+ # @param messages [Array<Hash>] canonical messages (may include system)
46
+ # @param model [String]
47
+ # @param tools [Array<Hash>] OpenAI-style tool definitions
48
+ # @param max_tokens [Integer]
49
+ # @param caching_enabled [Boolean]
50
+ # @param reasoning_effort [String, nil]
51
+ # @param base_url [String, nil] used to detect third-party Anthropic-compatible
52
+ # endpoints (e.g. Kimi /coding, DeepSeek /anthropic) so we can strip
53
+ # thinking-block signatures they cannot re-validate.
54
+ # @return [Hash] ready to serialize as JSON body
55
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, base_url: nil)
56
+ system_messages = messages.select { |m| m[:role] == "system" }
57
+ regular_messages = messages.reject { |m| m[:role] == "system" }
58
+
59
+ system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")
60
+
61
+ # Detect non-native Anthropic endpoints that speak the Anthropic protocol
62
+ # but cannot validate Anthropic-proprietary thinking signatures.
63
+ strip_thinking_signatures = base_url && !native_anthropic_endpoint?(base_url)
64
+
65
+ api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled, strip_thinking_signatures) }
66
+ api_tools = tools&.map { |t| to_api_tool(t) }
67
+
68
+ if caching_enabled && api_tools&.any?
69
+ api_tools.last[:cache_control] = { type: "ephemeral" }
70
+ end
71
+
72
+ body = { model: model, max_tokens: max_tokens, messages: api_messages }
73
+ body[:system] = system_text unless system_text.empty?
74
+ body[:tools] = api_tools if api_tools&.any?
75
+
76
+ if (effort = normalized_effort(reasoning_effort))
77
+ body[:thinking] = { type: "adaptive" }
78
+ body[:output_config] = { effort: effort }
79
+ end
80
+
81
+ body
82
+ end
83
+
84
+ # Returns true for the official Anthropic API endpoint.
85
+ # Anything else (Kimi /coding, DeepSeek /anthropic, self-hosted proxies,
86
+ # etc.) is treated as third-party and gets thinking signatures stripped.
87
+ private_class_method def self.native_anthropic_endpoint?(base_url)
88
+ base_url.to_s.start_with?("https://api.anthropic.com")
89
+ end
90
+
91
+ private_class_method def self.normalized_effort(effort)
92
+ return nil if effort.nil? || effort.to_s.empty?
93
+ s = effort.to_s
94
+ %w[low medium high].include?(s) ? s : nil
95
+ end
96
+
97
+ # ── Response parsing ──────────────────────────────────────────────────────
98
+
99
+ # Parse Anthropic API response into canonical internal format.
100
+ # @param data [Hash] parsed JSON response body
101
+ # @return [Hash] canonical response: { content:, tool_calls:, finish_reason:, usage: }
102
+ def parse_response(data)
103
+ blocks = data["content"] || []
104
+ usage = data["usage"] || {}
105
+
106
+ content = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("")
107
+
108
+ reasoning_content = blocks.select { |b| b["type"] == "thinking" }.map { |b| b["thinking"] }.join("")
109
+
110
+ # tool_calls use canonical format (id, function: {name, arguments})
111
+ tool_calls = blocks.select { |b| b["type"] == "tool_use" }.map do |tc|
112
+ args = tc["input"].is_a?(String) ? tc["input"] : tc["input"].to_json
113
+ { id: tc["id"], type: "function", name: tc["name"], arguments: args }
114
+ end
115
+
116
+ finish_reason = case data["stop_reason"]
117
+ when "end_turn" then "stop"
118
+ when "tool_use" then "tool_calls"
119
+ when "max_tokens" then "length"
120
+ else data["stop_reason"]
121
+ end
122
+
123
+ # Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
124
+ # input — cache_read_input_tokens and cache_creation_input_tokens are
125
+ # reported separately and are disjoint from input_tokens.
126
+ #
127
+ # Normalise to the codebase's canonical shape (OpenAI-style) so downstream
128
+ # (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
129
+ # provider-agnostic:
130
+ #
131
+ # prompt_tokens = non_cached + cache_read (OpenAI convention:
132
+ # includes cache_read
133
+ # but NOT cache_write;
134
+ # ModelPricing does
135
+ # `regular_input = prompt_tokens - cache_read`.)
136
+ # completion_tokens = output
137
+ # total_tokens = THIS TURN'S new compute volume
138
+ # = raw_input + cache_creation + output
139
+ # (cache_read is excluded because hits are ~free /
140
+ # already-paid-for; cache_creation IS new work this
141
+ # turn even though it's billed at write_rate.)
142
+ # cache_read_input_tokens / cache_creation_input_tokens → independent fields
143
+ #
144
+ # total_tokens is purely presentational. CostTracker treats it as the
145
+ # per-iteration delta directly (no subtraction of previous_total), which
146
+ # is the correct reading when total_tokens already means "new work this
147
+ # turn" rather than "cumulative".
148
+ raw_input_tokens = usage["input_tokens"].to_i
149
+ cache_read = usage["cache_read_input_tokens"].to_i
150
+ cache_creation = usage["cache_creation_input_tokens"].to_i
151
+ output_tokens = usage["output_tokens"].to_i
152
+
153
+ prompt_tokens = raw_input_tokens + cache_read
154
+
155
+ usage_data = {
156
+ prompt_tokens: prompt_tokens,
157
+ completion_tokens: output_tokens,
158
+ # Per-turn new compute: what the server freshly processed this request.
159
+ # Excludes cache_read (nearly free, already-paid-for).
160
+ total_tokens: raw_input_tokens + cache_creation + output_tokens,
161
+ # Signal to CostTracker: total_tokens above is already the per-turn
162
+ # delta (not a running cumulative like OpenAI's). CostTracker should
163
+ # NOT subtract previous_total when this flag is truthy.
164
+ # OpenAI parse leaves this field unset; Bedrock may adopt the same
165
+ # convention in future if we normalise it there too.
166
+ total_is_per_turn: true
167
+ }
168
+ usage_data[:cache_read_input_tokens] = cache_read if cache_read > 0
169
+ usage_data[:cache_creation_input_tokens] = cache_creation if cache_creation > 0
170
+
171
+ result = {
172
+ content: content, tool_calls: tool_calls, finish_reason: finish_reason,
173
+ usage: usage_data, raw_api_usage: usage
174
+ }
175
+ result[:reasoning_content] = reasoning_content unless reasoning_content.empty?
176
+ result
177
+ end
178
+
179
+ # ── Tool result formatting ────────────────────────────────────────────────
180
+
181
+ # Format tool results into canonical messages to append to @messages.
182
+ # Input: response (canonical, has :tool_calls), tool_results array
183
+ # Output: canonical messages: [{ role: "tool", tool_call_id:, content: }]
184
+ def format_tool_results(response, tool_results)
185
+ results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
186
+
187
+ response[:tool_calls].map do |tc|
188
+ result = results_map[tc[:id]]
189
+ {
190
+ role: "tool",
191
+ tool_call_id: tc[:id],
192
+ content: result ? result[:content] : { error: "Tool result missing" }.to_json
193
+ }
194
+ end
195
+ end
196
+
197
+ # ── Private helpers ───────────────────────────────────────────────────────
198
+
199
+ # Convert a single canonical message to Anthropic API format.
200
+ # @param msg [Hash] canonical message
201
+ # @param _caching_enabled [Boolean] kept for signature compatibility
202
+ # @param strip_thinking_signatures [Boolean] when true, strip `signature`
203
+ # and `data` from thinking blocks — required for third-party endpoints
204
+ # (Kimi /coding, DeepSeek /anthropic, etc.) that cannot re-validate
205
+ # Anthropic-proprietary signatures.
206
+ private_class_method def self.to_api_message(msg, _caching_enabled, strip_thinking_signatures = false)
207
+ role = msg[:role]
208
+ content = msg[:content]
209
+ tool_calls = msg[:tool_calls]
210
+ reasoning_content = msg[:reasoning_content]
211
+
212
+ # Build thinking block from reasoning_content if present.
213
+ # Kimi returns thinking as reasoning_content field, but Anthropic API
214
+ # expects it as a thinking content block. Convert here so the field
215
+ # doesn't leak as an unknown key to the API.
216
+ thinking_block = nil
217
+ if role == "assistant" && reasoning_content.is_a?(String) && !reasoning_content.empty?
218
+ thinking_block = { type: "thinking", thinking: reasoning_content }
219
+ end
220
+
221
+ # assistant with tool_calls → content blocks with tool_use
222
+ if role == "assistant" && tool_calls&.any?
223
+ blocks = []
224
+ blocks << thinking_block if thinking_block
225
+ blocks << { type: "text", text: content } if content.is_a?(String) && !content.empty?
226
+ if content.is_a?(Array)
227
+ blocks.concat(content_to_blocks(content, strip_thinking_signatures))
228
+ end
229
+
230
+ tool_calls.each do |tc|
231
+ func = tc[:function] || tc
232
+ name = func[:name] || tc[:name]
233
+ raw_args = func[:arguments] || tc[:arguments]
234
+ input =
235
+ if raw_args.is_a?(String)
236
+ begin
237
+ JSON.parse(raw_args)
238
+ rescue JSON::ParserError => e
239
+ Octo::Logger.warn("message_format.anthropic.tool_args_parse_failed",
240
+ tool_name: name.to_s,
241
+ tool_call_id: tc[:id].to_s,
242
+ args_len: raw_args.length,
243
+ args_head: raw_args[0, 120],
244
+ error: e.message
245
+ ) if defined?(Octo::Logger)
246
+ {}
247
+ end
248
+ else
249
+ raw_args
250
+ end
251
+ blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
252
+ end
253
+
254
+ return { role: "assistant", content: blocks }
255
+ end
256
+
257
+ # canonical tool result (role: "tool") → Anthropic user message with tool_result block
258
+ if role == "tool"
259
+ # Strip any cache_control that Client#apply_message_caching may have
260
+ # embedded INSIDE msg[:content] (it wraps string content as
261
+ # [{type:"text", text:..., cache_control:{...}}]). We hoist that
262
+ # marker up to the tool_result block itself below — that's where
263
+ # Anthropic expects the marker for a tool_result turn.
264
+ #
265
+ # CRITICAL: if we leave cache_control on the inner text block, the
266
+ # tool_result.content shape flips between "string" and
267
+ # "[{text,cache_control}]" depending on whether this message is the
268
+ # current cache breakpoint — which mutates the cached prefix every
269
+ # turn and destroys cache_read hit-rate (the classic "cache_read
270
+ # stuck at tiny number" symptom).
271
+ hoisted_cache_control = nil
272
+ raw_content = msg[:content]
273
+ if raw_content.is_a?(Array) &&
274
+ raw_content.length == 1 &&
275
+ raw_content.first.is_a?(Hash) &&
276
+ raw_content.first[:type] == "text" &&
277
+ raw_content.first[:cache_control]
278
+ hoisted_cache_control = raw_content.first[:cache_control]
279
+ raw_content = raw_content.first[:text]
280
+ end
281
+
282
+ # If content is an Array of canonical blocks (e.g. image_url + text from file_reader),
283
+ # convert each block to Anthropic format via content_to_blocks.
284
+ # Plain strings pass through unchanged.
285
+ tool_content = if raw_content.is_a?(Array)
286
+ content_to_blocks(raw_content, strip_thinking_signatures)
287
+ else
288
+ raw_content
289
+ end
290
+ block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: tool_content }
291
+ block[:cache_control] = hoisted_cache_control if hoisted_cache_control
292
+ return { role: "user", content: [block] }
293
+ end
294
+
295
+ # legacy Anthropic-native tool result already in user+tool_result format — pass through
296
+ if role == "user" && content.is_a?(Array) && content.any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
297
+ return { role: "user", content: content }
298
+ end
299
+
300
+ # regular user/assistant message
301
+ # NOTE: cache_control markers are applied by Client#apply_message_caching before
302
+ # build_request_body is called. We must NOT add extra cache_control here, because:
303
+ # 1. apply_message_caching already placed the marker on the correct breakpoint message.
304
+ # 2. Adding cache_control to every user message causes Anthropic to treat every
305
+ # user message as a cache breakpoint, which invalidates the intended cache boundary
306
+ # and results in cache misses (cache_read=0) every turn.
307
+ blocks = content_to_blocks(content, strip_thinking_signatures)
308
+ # Prepend thinking block for assistant messages with reasoning_content
309
+ blocks.unshift(thinking_block) if thinking_block
310
+ # Anthropic rejects messages with an empty content array — use a placeholder text block.
311
+ blocks = [{ type: "text", text: "..." }] if blocks.empty?
312
+ { role: role, content: blocks }
313
+ end
314
+
315
+ # Convert content (String or Array) to Anthropic content block array.
316
+ # @param content [String, Array] canonical content
317
+ # @param strip_thinking_signatures [Boolean] when true, strip `signature`
318
+ # and `data` from thinking blocks for third-party endpoints.
319
+ private_class_method def self.content_to_blocks(content, strip_thinking_signatures = false)
320
+ case content
321
+ when String
322
+ # Anthropic rejects blank text blocks — skip empty strings
323
+ return [] if content.empty?
324
+
325
+ [{ type: "text", text: content }]
326
+ when Array
327
+ content.map { |b| normalize_block(b, strip_thinking_signatures) }.compact
328
+ else
329
+ str = content.to_s
330
+ return [] if str.empty?
331
+
332
+ [{ type: "text", text: str }]
333
+ end
334
+ end
335
+
336
+ # Normalize a single content block to Anthropic format.
337
+ # @param block [Hash] canonical block
338
+ # @param strip_thinking_signatures [Boolean] when true, strip `signature`
339
+ # and `data` from thinking blocks for third-party endpoints.
340
+ private_class_method def self.normalize_block(block, strip_thinking_signatures = false)
341
+ return block unless block.is_a?(Hash)
342
+
343
+ case block[:type]
344
+ when "text"
345
+ # Anthropic rejects blank text blocks — drop them instead of sending { type:"text", text:"" }
346
+ text = block[:text]
347
+ return nil if text.nil? || text.empty?
348
+
349
+ # Preserve cache_control if present (placed by Client#apply_message_caching)
350
+ result = { type: "text", text: text }
351
+ result[:cache_control] = block[:cache_control] if block[:cache_control]
352
+ result
353
+ when "image_url"
354
+ url = block.dig(:image_url, :url) || block[:url]
355
+ url_to_image_block(url)
356
+ when "image"
357
+ block # already Anthropic format
358
+ when "tool_result", "tool_use"
359
+ block # pass through
360
+ when "thinking"
361
+ # Third-party Anthropic-compatible endpoints (Kimi /coding, DeepSeek
362
+ # /anthropic, etc.) return thinking blocks with real signatures that
363
+ # they cannot re-validate on replay. Strip the proprietary fields
364
+ # and emit only the minimal shape {type, thinking} those endpoints
365
+ # accept, while preserving the thinking text for history validation.
366
+ if strip_thinking_signatures
367
+ thinking_text = block[:thinking] || block["thinking"] || ""
368
+ { type: "thinking", thinking: thinking_text }
369
+ else
370
+ block
371
+ end
372
+ else
373
+ block
374
+ end
375
+ end
376
+
377
+ # Convert an image URL to Anthropic image block.
378
+ private_class_method def self.url_to_image_block(url)
379
+ return nil unless url
380
+
381
+ if url.start_with?("data:")
382
+ match = url.match(/^data:([^;]+);base64,(.*)$/)
383
+ if match
384
+ { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
385
+ else
386
+ { type: "image", source: { type: "url", url: url } }
387
+ end
388
+ else
389
+ { type: "image", source: { type: "url", url: url } }
390
+ end
391
+ end
392
+
393
+ # Convert OpenAI-style tool definition to Anthropic format.
394
+ private_class_method def self.to_api_tool(tool)
395
+ func = tool[:function] || tool
396
+ { name: func[:name], description: func[:description], input_schema: func[:parameters] }
397
+ end
398
+
399
+ # Extract plain text from content (String or Array).
400
+ private_class_method def self.extract_text(content)
401
+ case content
402
+ when String then content
403
+ when Array then content.map { |b| b.is_a?(Hash) ? (b[:text] || "") : b.to_s }.join("\n")
404
+ else content.to_s
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end