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,989 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module Octo
8
+ # ClaudeCode environment variable compatibility layer
9
+ # Provides configuration detection from ClaudeCode's environment variables
10
+ module ClaudeCodeEnv
11
+ # Environment variable names used by ClaudeCode
12
+ ENV_API_KEY = "ANTHROPIC_API_KEY"
13
+ ENV_AUTH_TOKEN = "ANTHROPIC_AUTH_TOKEN"
14
+ ENV_BASE_URL = "ANTHROPIC_BASE_URL"
15
+
16
+ # Default Anthropic API endpoint
17
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
18
+
19
+ class << self
20
+ # Check if any ClaudeCode authentication is configured
21
+ def configured?
22
+ !api_key.nil? && !api_key.empty?
23
+ end
24
+
25
+ # Get API key - prefer ANTHROPIC_API_KEY, fallback to ANTHROPIC_AUTH_TOKEN
26
+ def api_key
27
+ if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
28
+ ENV[ENV_API_KEY]
29
+ elsif ENV[ENV_AUTH_TOKEN] && !ENV[ENV_AUTH_TOKEN].empty?
30
+ ENV[ENV_AUTH_TOKEN]
31
+ end
32
+ end
33
+
34
+ # Get base URL from environment, or return default Anthropic API URL
35
+ def base_url
36
+ ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty? ? ENV[ENV_BASE_URL] : DEFAULT_BASE_URL
37
+ end
38
+
39
+ # Get configuration as a hash (includes configured values)
40
+ # Returns api_key and base_url (always available as there's a default)
41
+ def to_h
42
+ {
43
+ "api_key" => api_key,
44
+ "base_url" => base_url
45
+ }.compact
46
+ end
47
+ end
48
+ end
49
+
50
+ # Octo environment variable layer
51
+ # Provides configuration from OCTO_XXX environment variables
52
+ module OctoEnv
53
+ # Environment variable names for default model
54
+ ENV_API_KEY = "OCTO_API_KEY"
55
+ ENV_BASE_URL = "OCTO_BASE_URL"
56
+ ENV_MODEL = "OCTO_MODEL"
57
+ ENV_ANTHROPIC_FORMAT = "OCTO_ANTHROPIC_FORMAT"
58
+
59
+ # Environment variable names for lite model
60
+ ENV_LITE_API_KEY = "OCTO_LITE_API_KEY"
61
+ ENV_LITE_BASE_URL = "OCTO_LITE_BASE_URL"
62
+ ENV_LITE_MODEL = "OCTO_LITE_MODEL"
63
+ ENV_LITE_ANTHROPIC_FORMAT = "OCTO_LITE_ANTHROPIC_FORMAT"
64
+
65
+ # Default model name (only for model, not base_url)
66
+ DEFAULT_MODEL = "claude-sonnet-4-5"
67
+
68
+ class << self
69
+ # Check if default model is configured via environment variables
70
+ def default_configured?
71
+ !default_api_key.nil? && !default_api_key.empty?
72
+ end
73
+
74
+ # Check if lite model is configured via environment variables
75
+ def lite_configured?
76
+ !lite_api_key.nil? && !lite_api_key.empty?
77
+ end
78
+
79
+ # Get default model API key
80
+ def default_api_key
81
+ ENV[ENV_API_KEY] if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
82
+ end
83
+
84
+ # Get default model base URL (no default, must be explicitly set)
85
+ def default_base_url
86
+ ENV[ENV_BASE_URL] if ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty?
87
+ end
88
+
89
+ # Get default model name
90
+ def default_model
91
+ ENV[ENV_MODEL] && !ENV[ENV_MODEL].empty? ? ENV[ENV_MODEL] : DEFAULT_MODEL
92
+ end
93
+
94
+ # Get default model anthropic_format flag
95
+ def default_anthropic_format
96
+ return true if ENV[ENV_ANTHROPIC_FORMAT].nil? || ENV[ENV_ANTHROPIC_FORMAT].empty?
97
+ ENV[ENV_ANTHROPIC_FORMAT].downcase == "true"
98
+ end
99
+
100
+ # Get default model configuration as a hash
101
+ def default_model_config
102
+ {
103
+ "type" => "default",
104
+ "api_key" => default_api_key,
105
+ "base_url" => default_base_url,
106
+ "model" => default_model,
107
+ "anthropic_format" => default_anthropic_format
108
+ }.compact
109
+ end
110
+
111
+ # Get lite model API key
112
+ def lite_api_key
113
+ ENV[ENV_LITE_API_KEY] if ENV[ENV_LITE_API_KEY] && !ENV[ENV_LITE_API_KEY].empty?
114
+ end
115
+
116
+ # Get lite model base URL (no default, must be explicitly set)
117
+ def lite_base_url
118
+ ENV[ENV_LITE_BASE_URL] if ENV[ENV_LITE_BASE_URL] && !ENV[ENV_LITE_BASE_URL].empty?
119
+ end
120
+
121
+ # Get lite model name
122
+ def lite_model
123
+ ENV[ENV_LITE_MODEL] && !ENV[ENV_LITE_MODEL].empty? ? ENV[ENV_LITE_MODEL] : "claude-haiku-4"
124
+ end
125
+
126
+ # Get lite model anthropic_format flag
127
+ def lite_anthropic_format
128
+ return true if ENV[ENV_LITE_ANTHROPIC_FORMAT].nil? || ENV[ENV_LITE_ANTHROPIC_FORMAT].empty?
129
+ ENV[ENV_LITE_ANTHROPIC_FORMAT].downcase == "true"
130
+ end
131
+
132
+ # Get lite model configuration as a hash
133
+ def lite_model_config
134
+ {
135
+ "type" => "lite",
136
+ "api_key" => lite_api_key,
137
+ "base_url" => lite_base_url,
138
+ "model" => lite_model,
139
+ "anthropic_format" => lite_anthropic_format
140
+ }.compact
141
+ end
142
+ end
143
+ end
144
+
145
+ class AgentConfig
146
+ CONFIG_DIR = File.join(Dir.home, ".octo")
147
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
148
+
149
+ # Default model for ClaudeCode environment
150
+ CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-5"
151
+
152
+ PERMISSION_MODES = [:auto_approve, :confirm_safes, :confirm_all].freeze
153
+
154
+ attr_accessor :permission_mode, :max_tokens, :verbose,
155
+ :enable_compression, :enable_prompt_caching,
156
+ :models, :current_model_index, :current_model_id,
157
+ :memory_update_enabled, :skill_evolution,
158
+ :next_message_suggestion_enabled,
159
+ :max_running_agents, :max_idle_agents,
160
+ :default_working_dir
161
+
162
+ def initialize(options = {})
163
+ @permission_mode = validate_permission_mode(options[:permission_mode])
164
+ @max_tokens = options[:max_tokens] || 16384
165
+ @verbose = options[:verbose] || false
166
+ @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
167
+ # Enable prompt caching by default for cost savings
168
+ @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
169
+
170
+ # Models configuration
171
+ @models = options[:models] || []
172
+ # Ensure every model has a stable runtime id — this is the single
173
+ # invariant the rest of the system relies on. Regardless of how the
174
+ # config was built (load from yml, direct .new in tests, add_model,
175
+ # api_save_config), every model in @models will have an id.
176
+ @models.each { |m| m["id"] ||= SecureRandom.uuid }
177
+
178
+ @current_model_index = options[:current_model_index] || 0
179
+ # Stable runtime id for the currently-selected model. Preferred over
180
+ # @current_model_index because ids are immune to list reordering,
181
+ # additions, and edits to model fields. Ids are injected at load time
182
+ # and never persisted to config.yml (backward compatible with old files).
183
+ # If caller didn't specify current_model_id, prefer the model marked
184
+ # as `type: default` (the documented convention), falling back to
185
+ # models[current_model_index] only if no default marker exists.
186
+ @current_model_id = options[:current_model_id] ||
187
+ (@models.find { |m| m["type"] == "default" } || @models[@current_model_index])&.dig("id")
188
+
189
+ # Memory and skill evolution configuration
190
+ @memory_update_enabled = options[:memory_update_enabled].nil? ? true : options[:memory_update_enabled]
191
+ @next_message_suggestion_enabled = options[:next_message_suggestion_enabled].nil? ? true : options[:next_message_suggestion_enabled]
192
+ @skill_evolution = options[:skill_evolution] || {
193
+ enabled: true,
194
+ auto_create_threshold: 12,
195
+ reflection_mode: "llm_analysis"
196
+ }
197
+ # Deep-symbolize keys — YAML-loaded hashes come with string keys,
198
+ # but the rest of the codebase accesses with symbols.
199
+ @skill_evolution = @skill_evolution.transform_keys(&:to_sym)
200
+ @skill_evolution.transform_values! { |v| v.is_a?(Hash) ? v.transform_keys(&:to_sym) : v }
201
+
202
+ @max_running_agents = options[:max_running_agents] || 10
203
+ @max_idle_agents = options[:max_idle_agents] || 10
204
+
205
+ @default_working_dir = options[:default_working_dir] || ENV["OCTO_WORKSPACE_DIR"]
206
+
207
+ # Per-session virtual model overlay.
208
+ # When set, #current_model returns a *merged* hash (the resolved @models
209
+ # entry merged with this overlay) without mutating the shared @models
210
+ # array. Used by fork_subagent's virtual-lite path so a forked subagent
211
+ # can run on different credentials (e.g. Haiku instead of Opus) without
212
+ # polluting the parent agent's shared @models hashes.
213
+ # Keys honored: "api_key", "base_url", "model", "anthropic_format".
214
+ # @return [Hash, nil]
215
+ @virtual_model_overlay = options[:virtual_model_overlay]
216
+ end
217
+
218
+ # Load configuration from file
219
+ def self.load(config_file = CONFIG_FILE)
220
+ # Load from config file first
221
+ if File.exist?(config_file)
222
+ data = YAML.load_file(config_file)
223
+ else
224
+ data = nil
225
+ end
226
+
227
+ # Extract settings from hash-format config (new format).
228
+ # Old flat-array configs have no settings section — all defaults.
229
+ loaded_settings = {}
230
+ if data.is_a?(Hash) && data["settings"].is_a?(Hash)
231
+ loaded_settings = data["settings"]
232
+ end
233
+
234
+ # Parse models from config
235
+ models = parse_models(data)
236
+
237
+ # Priority: config file > OCTO_XXX env vars > ClaudeCode env vars
238
+ if models.empty?
239
+ # Try OCTO_XXX environment variables first
240
+ if OctoEnv.default_configured?
241
+ models << OctoEnv.default_model_config
242
+ # ClaudeCode (Anthropic) environment variable support is disabled
243
+ # elsif ClaudeCodeEnv.configured?
244
+ # models << {
245
+ # "type" => "default",
246
+ # "api_key" => ClaudeCodeEnv.api_key,
247
+ # "base_url" => ClaudeCodeEnv.base_url,
248
+ # "model" => CLAUDE_DEFAULT_MODEL,
249
+ # "anthropic_format" => true
250
+ # }
251
+ end
252
+
253
+ # Add OCTO_LITE_XXX if configured (only when loading from env)
254
+ if OctoEnv.lite_configured?
255
+ models << OctoEnv.lite_model_config
256
+ end
257
+ else
258
+ # Config file exists, but check if we need to add env-based models
259
+ # Only add if no model with that type exists
260
+ has_default = models.any? { |m| m["type"] == "default" }
261
+ has_lite = models.any? { |m| m["type"] == "lite" }
262
+
263
+ # Add OCTO default if not in config and env is set
264
+ if !has_default && OctoEnv.default_configured?
265
+ models << OctoEnv.default_model_config
266
+ end
267
+
268
+ # Add OCTO lite if not in config and env is set
269
+ if !has_lite && OctoEnv.lite_configured?
270
+ models << OctoEnv.lite_model_config
271
+ end
272
+
273
+ # Ensure at least one model has type: default
274
+ # If no model has type: default, assign it to the first model
275
+ unless models.any? { |m| m["type"] == "default" }
276
+ models.first["type"] = "default" if models.any?
277
+ end
278
+ end
279
+
280
+ # Auto-inject lite model from provider preset is **no longer materialized
281
+ # into @models**. Lite is now a virtual, on-demand view derived from the
282
+ # currently-selected primary model — see `#lite_model_config_for_current`.
283
+ # This keeps @models a clean "list of user-facing models" and lets the
284
+ # lite companion track the current model at runtime, rather than being
285
+ # frozen at load time to whichever model happened to be the default.
286
+ #
287
+ # Legacy note: prior versions injected an entry here with
288
+ # `auto_injected: true`. That flag is still honored in to_yaml for
289
+ # safety (never persisted), but new injections never happen.
290
+
291
+ # Ensure every model has a stable runtime id — covers env-injected
292
+ # models (OCTO_XXX, CLAUDE_XXX) that don't go through parse_models.
293
+ # Ids are NOT persisted to config.yml (see to_yaml).
294
+ models.each { |m| m["id"] ||= SecureRandom.uuid }
295
+
296
+ # Find the index of the model marked as "default" (type: default)
297
+ # Fall back to 0 if no model has type: default
298
+ default_index = models.find_index { |m| m["type"] == "default" } || 0
299
+ default_id = models[default_index] && models[default_index]["id"]
300
+
301
+ # Build constructor args from loaded settings (new hash-format config)
302
+ # plus the parsed models. Only pass settings that have explicit values;
303
+ # omitted keys get their default from AgentConfig#initialize.
304
+ constructor_args = {
305
+ models: models,
306
+ current_model_index: default_index,
307
+ current_model_id: default_id
308
+ }
309
+ CONFIG_SETTINGS_KEYS.each do |key|
310
+ if loaded_settings.key?(key)
311
+ constructor_args[key.to_sym] = loaded_settings[key]
312
+ end
313
+ end
314
+
315
+ new(**constructor_args)
316
+ end
317
+
318
+ # Auto-injection of provider-preset lite models into @models has been
319
+ # removed. Lite is now a virtual, on-demand role derived per-call from
320
+ # the currently-active primary model — see the instance method
321
+ # `#lite_model_config_for_current`. This class-level helper is kept as
322
+ # a no-op stub purely so older call sites (if any remain) don't blow up;
323
+ # it will be dropped in a future release.
324
+ private_class_method def self.inject_provider_lite_model(_models)
325
+ # no-op: lite is now a virtual view, not a materialized @models entry
326
+ end
327
+
328
+ # Create a per-session copy of this config.
329
+ #
330
+ # Plan B (shared models): we deliberately share the SAME @models array
331
+ # reference with all sessions (no deep clone). This is the key design
332
+ # decision that keeps session and global views in sync:
333
+ # - User adds a model in Settings → every live session sees it instantly.
334
+ # - User edits api_key/base_url → every live session's next API call
335
+ # picks up the new credentials (via current_model lookup).
336
+ # - Model ids are stable across edits, so each session's
337
+ # @current_model_id continues to resolve correctly.
338
+ #
339
+ # Per-session state that MUST stay isolated (permission_mode,
340
+ # @current_model_id, @current_model_index, fallback state) are scalar
341
+ # copies via `dup` and don't leak between sessions.
342
+ #
343
+ # Before Plan B, sessions held deep-copied @models — which silently
344
+ # diverged from the global list any time the user added/edited a model
345
+ # in Settings, producing bugs like "Failed to switch model" for newly
346
+ # added models on Windows and Linux. See http_server.rb#api_switch_session_model
347
+ # and http_server.rb#api_save_config for the companion logic.
348
+ def deep_copy
349
+ # dup gives us a new AgentConfig with independent scalar ivars but
350
+ # the same @models reference — exactly what we want.
351
+ copy = dup
352
+ # But @virtual_model_overlay must be independent: a forked subagent
353
+ # setting/clearing its own overlay must NOT leak into the parent.
354
+ # (dup copies the ivar reference; an unset overlay is nil which is
355
+ # already independent, but an active overlay must be cloned.)
356
+ if @virtual_model_overlay
357
+ copy.instance_variable_set(:@virtual_model_overlay, @virtual_model_overlay.dup)
358
+ end
359
+ copy
360
+ end
361
+
362
+ def save(config_file = CONFIG_FILE)
363
+ config_dir = File.dirname(config_file)
364
+ FileUtils.mkdir_p(config_dir)
365
+ File.write(config_file, to_yaml)
366
+ FileUtils.chmod(0o600, config_file)
367
+ end
368
+
369
+ # Convert to YAML format (top-level array)
370
+ # Auto-injected lite models (auto_injected: true) are excluded from persistence —
371
+ # they are regenerated at load time from the provider preset.
372
+ # Runtime-only fields (id, auto_injected) are stripped before writing so
373
+ # config.yml remains backward compatible with users on older versions.
374
+ RUNTIME_ONLY_FIELDS = %w[id auto_injected].freeze
375
+
376
+ # Settings keys that are persisted to config.yml.
377
+ # These map directly to AgentConfig accessors.
378
+ CONFIG_SETTINGS_KEYS = %w[
379
+ enable_compression enable_prompt_caching memory_update_enabled
380
+ next_message_suggestion_enabled
381
+ skill_evolution max_running_agents max_idle_agents
382
+ default_working_dir
383
+ ].freeze
384
+
385
+ # Serialize the current agent configuration to YAML.
386
+ # Outputs a hash with "settings" and "models" keys (new format).
387
+ # Backward compatibility: old flat-array format is still readable by .load.
388
+ def to_yaml
389
+ persistable_models = @models.reject { |m| m["auto_injected"] }.map do |m|
390
+ m.reject { |k, _| RUNTIME_ONLY_FIELDS.include?(k) }
391
+ end
392
+ settings = {
393
+ "enable_compression" => @enable_compression,
394
+ "enable_prompt_caching" => @enable_prompt_caching,
395
+ "memory_update_enabled" => @memory_update_enabled,
396
+ "next_message_suggestion_enabled" => @next_message_suggestion_enabled,
397
+ "skill_evolution" => @skill_evolution,
398
+ "max_running_agents" => @max_running_agents,
399
+ "max_idle_agents" => @max_idle_agents,
400
+ "default_working_dir" => @default_working_dir
401
+ }
402
+ YAML.dump("settings" => settings, "models" => persistable_models)
403
+ end
404
+
405
+ # Check if any model is configured
406
+ def models_configured?
407
+ !@models.empty? && !current_model.nil?
408
+ end
409
+
410
+ # NOTE: current_model is defined below (near the id-aware lookup path)
411
+ # — the earlier duplicate definition was removed. Ruby silently picks the
412
+ # last definition, but keeping only one avoids confusion.
413
+
414
+ # Get model by index
415
+ def get_model(index)
416
+ @models[index]
417
+ end
418
+
419
+ # Switch the current session to a specific model, identified by its
420
+ # stable runtime id.
421
+ #
422
+ # This is a **per-session** operation:
423
+ # - Updates this AgentConfig's `@current_model_id` (primary truth)
424
+ # - Updates `@current_model_index` for back-compat observers
425
+ # - Does NOT mutate the shared `@models` array's `type: "default"`
426
+ # marker. The "default model" is a global setting (initial model
427
+ # for new sessions) and is only changed via the Settings UI
428
+ # "save config" flow (`api_save_config`).
429
+ #
430
+ # @param id [String] the model's runtime id (see parse_models)
431
+ # @return [Boolean] true if switched, false if id not found
432
+ def switch_model_by_id(id)
433
+ return false if id.nil? || id.to_s.empty?
434
+
435
+ index = @models.find_index { |m| m["id"] == id }
436
+ return false if index.nil?
437
+
438
+ @current_model_id = id
439
+ @current_model_index = index
440
+
441
+ true
442
+ end
443
+
444
+ # Switch to a model by its display name (fuzzy match, case-insensitive).
445
+ #
446
+ # @param name [String] the model name to search for (e.g. "gpt-5.3-codex")
447
+ # @return [Boolean] true if switched, false if name not found
448
+ def switch_model_by_name(name)
449
+ return false if name.nil? || name.to_s.strip.empty?
450
+
451
+ name_str = name.to_s.strip.downcase
452
+ index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
453
+ return false if index.nil?
454
+
455
+ @current_model_id = @models[index]["id"]
456
+ @current_model_index = index
457
+
458
+ true
459
+ end
460
+
461
+ # Set the **global** default model marker (`type: "default"`).
462
+ #
463
+ # This is separate from `switch_model_by_id`:
464
+ # - `switch_model_by_id` only changes this session's current model.
465
+ # - `set_default_model_by_id` mutates the shared `@models` array by
466
+ # moving the `type: "default"` marker to the given model.
467
+ #
468
+ # Use cases:
469
+ # - CLI (single-session): when the user picks a model, we both switch
470
+ # this session AND update the global default so future CLI launches
471
+ # use the same model. Caller must `save` to persist.
472
+ # - Web UI Settings save flow: also uses this (via payload).
473
+ #
474
+ # Do NOT call from per-session model switching in multi-session contexts
475
+ # (Web UI session-level switch), since it would leak into other sessions
476
+ # and change what new sessions start with.
477
+ #
478
+ # Only one model may carry `type: "default"` at a time — this method
479
+ # clears the marker on any other model that had it.
480
+ #
481
+ # Note: if the target model currently has `type: "lite"`, this method
482
+ # will overwrite it with `"default"`. That matches the existing
483
+ # single-slot `type` field semantics in the codebase.
484
+ #
485
+ # @param id [String] the model's runtime id
486
+ # @return [Boolean] true if marker was moved, false if id not found
487
+ def set_default_model_by_id(id)
488
+ return false if id.nil? || id.to_s.empty?
489
+
490
+ target = @models.find { |m| m["id"] == id }
491
+ return false if target.nil?
492
+
493
+ # Clear existing default marker(s) — there should only be one, but
494
+ # be defensive in case of corrupted config.
495
+ @models.each do |m|
496
+ next if m["id"] == id
497
+ m.delete("type") if m["type"] == "default"
498
+ end
499
+
500
+ target["type"] = "default"
501
+ true
502
+ end
503
+
504
+ # List all model names
505
+ def model_names
506
+ @models.map { |m| m["model"] }
507
+ end
508
+
509
+ # Get API key for current model
510
+ def api_key
511
+ current_model&.dig("api_key")
512
+ end
513
+
514
+ # Set API key for current model.
515
+ # When a virtual overlay is active, writes into the overlay (not the
516
+ # shared @models hash) to keep session-level isolation.
517
+ def api_key=(value)
518
+ return unless resolve_current_model_entry
519
+ if @virtual_model_overlay
520
+ @virtual_model_overlay["api_key"] = value
521
+ else
522
+ resolve_current_model_entry["api_key"] = value
523
+ end
524
+ end
525
+
526
+ # Get base URL for current model
527
+ def base_url
528
+ current_model&.dig("base_url")
529
+ end
530
+
531
+ # Set base URL for current model (overlay-aware; see #api_key=).
532
+ def base_url=(value)
533
+ return unless resolve_current_model_entry
534
+ if @virtual_model_overlay
535
+ @virtual_model_overlay["base_url"] = value
536
+ else
537
+ resolve_current_model_entry["base_url"] = value
538
+ end
539
+ end
540
+
541
+ # Get model name for current model
542
+ def model_name
543
+ current_model&.dig("model")
544
+ end
545
+
546
+ # Set model name for current model (overlay-aware; see #api_key=).
547
+ def model_name=(value)
548
+ return unless resolve_current_model_entry
549
+ if @virtual_model_overlay
550
+ @virtual_model_overlay["model"] = value
551
+ else
552
+ resolve_current_model_entry["model"] = value
553
+ end
554
+ end
555
+
556
+ # Check if should use Anthropic format for current model
557
+ def anthropic_format?
558
+ current_model&.dig("anthropic_format") || false
559
+ end
560
+
561
+ # Check if current model uses Bedrock Converse API (ABSK key prefix or abs- model prefix)
562
+ def bedrock?
563
+ Octo::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s, model_name.to_s)
564
+ end
565
+
566
+ # Add a new model configuration
567
+ def add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil)
568
+ @models << {
569
+ "id" => SecureRandom.uuid,
570
+ "api_key" => api_key,
571
+ "base_url" => base_url,
572
+ "model" => model,
573
+ "anthropic_format" => anthropic_format,
574
+ "type" => type
575
+ }.compact
576
+ end
577
+
578
+ # Find model by type (default or lite)
579
+ # Returns the model hash or nil if not found
580
+ def find_model_by_type(type)
581
+ @models.find { |m| m["type"] == type }
582
+ end
583
+
584
+ # Find model by composite key (model name + base_url).
585
+ # Used when restoring a session to match its original model without relying
586
+ # on the runtime-only id (which changes on every process restart).
587
+ # base_url is optional for backward compatibility with sessions saved
588
+ # before base_url was persisted.
589
+ # @param model_name [String] the model's "model" field (e.g. "dsk-deepseek-v4-pro")
590
+ # @param base_url [String, nil] the model's "base_url" field
591
+ # @return [Hash, nil] the matching model entry or nil
592
+ def find_model_by_name_and_url(model_name, base_url = nil)
593
+ @models.find do |m|
594
+ m["model"] == model_name &&
595
+ (base_url.nil? || m["base_url"] == base_url)
596
+ end
597
+ end
598
+
599
+ # Get the default model (type: default)
600
+ # Falls back to current_model for backward compatibility
601
+ def default_model
602
+ find_model_by_type("default") || current_model
603
+ end
604
+
605
+ # Explicit lite model entry (type: "lite") — only present when the user
606
+ # configured `OCTO_LITE_*` environment variables. Returns nil otherwise.
607
+ #
608
+ # This is the "user override" path. The preferred way for subagents to
609
+ # obtain a lite model is `#lite_model_config_for_current`, which falls
610
+ # back to this method when an explicit lite exists.
611
+ def lite_model
612
+ find_model_by_type("lite")
613
+ end
614
+
615
+ # Return a *complete* lite model config hash for the currently-active
616
+ # primary model, or nil if none is available.
617
+ #
618
+ # Resolution order:
619
+ # 1. Explicit user-configured lite (type: "lite", from OCTO_LITE_*
620
+ # env vars). Wins over provider presets so power users retain full
621
+ # control.
622
+ # 2. Provider preset: look up the current model's provider, consult its
623
+ # per-family `lite_models` table (e.g. octo: Claude → Haiku,
624
+ # DeepSeek V4-pro → DeepSeek V4-flash). If matched, return a virtual
625
+ # hash that reuses the current model's api_key / base_url — only
626
+ # the model name (and anthropic_format, if provider-specific) differ.
627
+ # 3. nil — either the provider has no lite mapping for this primary
628
+ # (e.g. the current model is already lite-class like Haiku), or the
629
+ # provider is unknown. Callers should treat this as "no lite
630
+ # available; use the primary as-is".
631
+ #
632
+ # The returned hash is **not** added to @models. It's consumed directly
633
+ # by `Agent#fork_subagent(model: "lite")`, which applies the fields to
634
+ # the forked config. This means:
635
+ # - Switching the primary model automatically changes which lite is
636
+ # used, with zero additional bookkeeping.
637
+ # - @models stays a clean list of user-facing models (no phantom
638
+ # auto-injected entries cluttering the model picker in the UI).
639
+ #
640
+ # @return [Hash, nil] a hash with keys api_key, base_url, model,
641
+ # anthropic_format, plus an "id" of the form "lite:<primary_id>" for
642
+ # logging/debugging; nil if no lite is resolvable.
643
+ def lite_model_config_for_current
644
+ # 1) Explicit user-configured lite wins
645
+ explicit = find_model_by_type("lite")
646
+ return explicit if explicit
647
+
648
+ # 2) Provider preset derivation
649
+ primary = current_model
650
+ return nil unless primary && primary["base_url"] && primary["model"]
651
+
652
+ # Use resolve_provider (base_url first, then octo-* api_key fallback
653
+ # for local-debug / self-hosted proxies).
654
+ provider_id = Octo::Providers.resolve_provider(
655
+ base_url: primary["base_url"],
656
+ api_key: primary["api_key"]
657
+ )
658
+ return nil unless provider_id
659
+
660
+ lite_name = Octo::Providers.lite_model(provider_id, primary["model"])
661
+ return nil unless lite_name
662
+
663
+ # If the current primary IS already a lite-class model, skip.
664
+ return nil if lite_name == primary["model"]
665
+
666
+ {
667
+ "id" => "lite:#{primary["id"]}",
668
+ "type" => "lite",
669
+ "api_key" => primary["api_key"],
670
+ "base_url" => primary["base_url"],
671
+ "model" => lite_name,
672
+ "anthropic_format" => primary["anthropic_format"] || false,
673
+ "virtual" => true # marker: not a real @models entry
674
+ }
675
+ end
676
+
677
+ # How long to stay on the fallback model before probing the primary again.
678
+ FALLBACK_COOLING_OFF_SECONDS = 30 * 60 # 30 minutes
679
+
680
+ # Look up the fallback model name for the given model name.
681
+ # Uses the provider preset's fallback_models table.
682
+ # Returns nil if no fallback is configured for this model.
683
+ # @param model_name [String] the primary model name (e.g. "abs-claude-sonnet-4-6")
684
+ # @return [String, nil]
685
+ def fallback_model_for(model_name)
686
+ m = current_model
687
+ return nil unless m
688
+
689
+ provider_id = Octo::Providers.resolve_provider(
690
+ base_url: m["base_url"],
691
+ api_key: m["api_key"]
692
+ )
693
+ return nil unless provider_id
694
+
695
+ Octo::Providers.fallback_model(provider_id, model_name)
696
+ end
697
+
698
+ # Switch to fallback model and start the cooling-off clock.
699
+ # Idempotent — calling again while already in :fallback_active renews the timestamp.
700
+ # @param fallback_model_name [String] the fallback model to use
701
+ def activate_fallback!(fallback_model_name)
702
+ @fallback_state = :fallback_active
703
+ @fallback_since = Time.now
704
+ @fallback_model = fallback_model_name
705
+ end
706
+
707
+ # Called at the start of every call_llm.
708
+ # If cooling-off has expired, transition from :fallback_active → :probing
709
+ # so the next request will silently test the primary model.
710
+ # No-op in any other state.
711
+ def maybe_start_probing
712
+ return unless @fallback_state == :fallback_active
713
+ return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS
714
+
715
+ @fallback_state = :probing
716
+ end
717
+
718
+ # Called when a successful API response is received.
719
+ # If we were :probing (testing primary after cooling-off), this confirms
720
+ # the primary model is healthy again and resets everything.
721
+ # No-op in :primary_ok or :fallback_active states.
722
+ def confirm_fallback_ok!
723
+ return unless @fallback_state == :probing
724
+
725
+ @fallback_state = nil
726
+ @fallback_since = nil
727
+ @fallback_model = nil
728
+ end
729
+
730
+ # Returns true when a fallback model is currently being used
731
+ # (:fallback_active or :probing states).
732
+ def fallback_active?
733
+ @fallback_state == :fallback_active || @fallback_state == :probing
734
+ end
735
+
736
+ # Returns true only when we are silently probing the primary model.
737
+ def probing?
738
+ @fallback_state == :probing
739
+ end
740
+
741
+ # The effective model name to use for API calls.
742
+ # - :primary_ok / nil → configured model_name (primary)
743
+ # - :fallback_active → fallback model
744
+ # - :probing → configured model_name (trying primary silently)
745
+ def effective_model_name
746
+ case @fallback_state
747
+ when :fallback_active
748
+ @fallback_model || model_name
749
+ else
750
+ # :primary_ok (nil) and :probing both use the primary model
751
+ model_name
752
+ end
753
+ end
754
+
755
+ # Get current model configuration.
756
+ #
757
+ # Resolution order:
758
+ # 1. @current_model_id (primary source of truth — stable across list edits)
759
+ # 2. type: default (for config.yml that sets a default explicitly)
760
+ # 3. @current_model_index (back-compat for very old code paths)
761
+ def current_model
762
+ return nil if @models.empty?
763
+
764
+ resolved = resolve_current_model_entry
765
+ return nil unless resolved
766
+
767
+ # If a virtual overlay is active (e.g. subagent running on lite-model
768
+ # credentials), return a *merged copy* so callers see the overlay fields
769
+ # but the shared @models hash is never mutated.
770
+ if @virtual_model_overlay && !@virtual_model_overlay.empty?
771
+ resolved.merge(@virtual_model_overlay)
772
+ else
773
+ resolved
774
+ end
775
+ end
776
+
777
+ # Internal: resolve the current model entry from @models (no overlay).
778
+ # Extracted from the old #current_model so overlay logic sits in one place.
779
+ # @return [Hash, nil]
780
+ private def resolve_current_model_entry
781
+ if @current_model_id
782
+ m = @models.find { |mm| mm["id"] == @current_model_id }
783
+ return m if m
784
+ # id no longer exists (model was deleted). Fall through to other
785
+ # resolution strategies below, and clear the stale id.
786
+ @current_model_id = nil
787
+ end
788
+
789
+ default_model = find_model_by_type("default")
790
+ if default_model
791
+ # Opportunistically re-anchor to this default's id so subsequent
792
+ # lookups are O(1) and survive list reordering.
793
+ @current_model_id = default_model["id"]
794
+ return default_model
795
+ end
796
+
797
+ # Fallback to index-based for backward compatibility
798
+ m = @models[@current_model_index]
799
+ @current_model_id = m["id"] if m
800
+ m
801
+ end
802
+
803
+ # Apply a virtual model overlay for this session (and only this session).
804
+ # The overlay fields are merged on top of the current model entry when
805
+ # #current_model is called, without ever mutating the shared @models
806
+ # array or its hashes.
807
+ #
808
+ # Used by Agent#fork_subagent when routing a subagent through a virtual
809
+ # lite model (Haiku for Claude family, Flash for DeepSeek, ...). Apply on
810
+ # the forked config only — the parent config is untouched.
811
+ #
812
+ # @param overlay [Hash, nil] fields to overlay; pass nil or {} to clear.
813
+ # Recognized keys: "api_key", "base_url", "model", "anthropic_format".
814
+ # @return [void]
815
+ def apply_virtual_model_overlay!(overlay)
816
+ if overlay.nil? || overlay.empty?
817
+ @virtual_model_overlay = nil
818
+ else
819
+ # Dup so later mutations to the passed-in hash don't leak in.
820
+ @virtual_model_overlay = overlay.dup
821
+ end
822
+ end
823
+
824
+ # @return [Hash, nil] the active overlay (read-only view; dup before mutating)
825
+ def virtual_model_overlay
826
+ @virtual_model_overlay
827
+ end
828
+
829
+ # Query whether the *current* model supports a given capability.
830
+ #
831
+ # This is the single entry-point callers (Agent, downgrade pipeline, UI)
832
+ # should use instead of poking Providers directly. Benefits:
833
+ # - Always reflects the current model — switching with `/model` takes
834
+ # effect immediately, no caching, no stale warnings.
835
+ # - Handles the "custom base_url / unknown provider" case with a
836
+ # conservative default (assume supported), so self-hosted or new
837
+ # providers don't get accidentally downgraded.
838
+ #
839
+ # @param capability [String, Symbol] capability name (e.g. :vision)
840
+ # @return [Boolean] true if supported (or unknown); false only when the
841
+ # preset explicitly declares the capability as unsupported.
842
+ def current_model_supports?(capability)
843
+ m = current_model
844
+ # No model configured yet → nothing to judge; assume supported so we
845
+ # don't preemptively downgrade before a model is even picked.
846
+ return true unless m && m["base_url"]
847
+
848
+ provider_id = Octo::Providers.find_by_base_url(m["base_url"])
849
+ # Custom / self-hosted base_url not in our preset list → be conservative.
850
+ return true unless provider_id
851
+
852
+ Octo::Providers.supports?(provider_id, capability, model_name: m["model"])
853
+ end
854
+
855
+ # Set a model's type (default or lite)
856
+ # Ensures only one model has each type
857
+ # @param index [Integer] the model index
858
+ # @param type [String, nil] "default", "lite", or nil to remove type
859
+ # Returns true if successful
860
+ def set_model_type(index, type)
861
+ return false if index < 0 || index >= @models.length
862
+ return false unless ["default", "lite", nil].include?(type)
863
+
864
+ if type
865
+ # Remove type from any other model that has it
866
+ @models.each do |m|
867
+ m.delete("type") if m["type"] == type
868
+ end
869
+
870
+ # Set type on target model
871
+ @models[index]["type"] = type
872
+ else
873
+ # Remove type from target model
874
+ @models[index].delete("type")
875
+ end
876
+
877
+ true
878
+ end
879
+
880
+ # Remove a model by index
881
+ # Returns true if removed, false if index out of range or it's the last model
882
+ def remove_model(index)
883
+ # Don't allow removing the last model
884
+ return false if @models.length <= 1
885
+ return false if index < 0 || index >= @models.length
886
+
887
+ removed = @models.delete_at(index)
888
+
889
+ # Adjust current_model_index if necessary
890
+ if @current_model_index >= @models.length
891
+ @current_model_index = @models.length - 1
892
+ end
893
+
894
+ # If the removed model was the current one, clear @current_model_id.
895
+ # current_model will then fall back to type: default / current_model_index.
896
+ if removed && @current_model_id == removed["id"]
897
+ @current_model_id = nil
898
+ end
899
+
900
+ true
901
+ end
902
+
903
+ private def validate_permission_mode(mode)
904
+ mode ||= :confirm_safes
905
+ mode = mode.to_sym
906
+
907
+ unless PERMISSION_MODES.include?(mode)
908
+ raise ArgumentError, "Invalid permission mode: #{mode}. Must be one of #{PERMISSION_MODES.join(', ')}"
909
+ end
910
+
911
+ mode
912
+ end
913
+
914
+ # Parse models from config data
915
+ # Supports new top-level array format and old formats for backward compatibility
916
+ private_class_method def self.parse_models(data)
917
+ models = []
918
+
919
+ # Handle nil or empty data
920
+ return models if data.nil?
921
+
922
+ if data.is_a?(Array)
923
+ # New format: top-level array of model configurations
924
+ models = data.map do |m|
925
+ # Deep copy to avoid shared references between models
926
+ m = m.dup.transform_values { |v| v.is_a?(String) ? v.dup : v }
927
+ # Convert old name-based format to new model-based format if needed
928
+ if m["name"] && !m["model"]
929
+ m["model"] = m["name"]
930
+ m.delete("name")
931
+ end
932
+ m
933
+ end
934
+ elsif data.is_a?(Hash) && data["models"]
935
+ # Old format with "models:" key
936
+ if data["models"].is_a?(Array)
937
+ # Array under models key
938
+ models = data["models"].map do |m|
939
+ # Convert old name-based format to new model-based format
940
+ if m["name"] && !m["model"]
941
+ m["model"] = m["name"]
942
+ m.delete("name")
943
+ end
944
+ m
945
+ end
946
+ elsif data["models"].is_a?(Hash)
947
+ # Hash format with tier names as keys (very old format)
948
+ data["models"].each do |tier_name, config|
949
+ if config.is_a?(Hash)
950
+ model_config = {
951
+ "api_key" => config["api_key"],
952
+ "base_url" => config["base_url"],
953
+ "model" => config["model_name"] || config["model"] || tier_name,
954
+ "anthropic_format" => config["anthropic_format"] || false
955
+ }
956
+ models << model_config
957
+ elsif config.is_a?(String)
958
+ # Old-style tier with just model name
959
+ model_config = {
960
+ "api_key" => data["api_key"],
961
+ "base_url" => data["base_url"],
962
+ "model" => config,
963
+ "anthropic_format" => data["anthropic_format"] || false
964
+ }
965
+ models << model_config
966
+ end
967
+ end
968
+ end
969
+ elsif data.is_a?(Hash) && data["api_key"]
970
+ # Very old format: single model with global config
971
+ models << {
972
+ "api_key" => data["api_key"],
973
+ "base_url" => data["base_url"],
974
+ "model" => data["model"] || CLAUDE_DEFAULT_MODEL,
975
+ "anthropic_format" => data["anthropic_format"] || false
976
+ }
977
+ end
978
+
979
+ # Inject a runtime-only stable id for each model. Ids are NOT written
980
+ # back to config.yml (see `to_yaml`) so this is fully backward
981
+ # compatible — old yml files without ids just get fresh ids on load.
982
+ # The id is the source of truth for session→model identity and is
983
+ # immune to list reordering, additions, and field edits (api_key, etc).
984
+ models.each { |m| m["id"] ||= SecureRandom.uuid }
985
+
986
+ models
987
+ end
988
+ end
989
+ end