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,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+ require "octo"
6
+
7
+ module Octo
8
+ # Loader and registry for skills.
9
+ # Discovers skills from multiple locations and provides lookup functionality.
10
+ class SkillLoader
11
+ # Skill discovery locations (in priority order: lower index = lower priority)
12
+ LOCATIONS = [
13
+ :default, # gem's built-in default skills (lowest priority)
14
+ :global_octo, # ~/.octo/skills/
15
+ :project_octo # .octo/skills/ (highest priority)
16
+ ].freeze
17
+
18
+ # Initialize the skill loader and automatically load all skills
19
+ # @param working_dir [String, nil] Current working directory for project-level discovery.
20
+ # When nil, project-level skills (.octo/skills/) are not loaded,
21
+ # making the loader project-agnostic (used by WebUI server).
22
+ def initialize(working_dir:)
23
+ @working_dir = working_dir
24
+ @skills = {} # Map identifier -> Skill
25
+ @skills_by_command = {} # Map slash_command -> Skill
26
+ @errors = [] # Store loading errors
27
+ @loaded_from = {} # Track which location each skill was loaded from
28
+
29
+ load_all
30
+ end
31
+
32
+ # Load all skills from configured locations
33
+ # Clears previously loaded skills before loading to ensure idempotency
34
+ # @return [Array<Skill>] Loaded skills
35
+ def load_all
36
+ # Clear existing skills to ensure idempotent reloading
37
+ clear
38
+
39
+ load_default_skills
40
+ load_global_octo_skills
41
+
42
+ # Only load project-level skills when working_dir is explicitly provided.
43
+ # When nil (e.g. WebUI server mode), skip project skills to keep the loader
44
+ # project-agnostic and only expose global skills.
45
+ if @working_dir
46
+ load_project_octo_skills
47
+ end
48
+
49
+ all_skills
50
+ end
51
+
52
+ # Load skills from ~/.octo/skills/ (user global)
53
+ # @return [Array<Skill>]
54
+ def load_global_octo_skills
55
+ global_octo_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".octo", "skills")
56
+ load_skills_from_directory(global_octo_dir, :global_octo)
57
+ end
58
+
59
+ # Load skills from .octo/skills/ (project-level, highest priority)
60
+ # @return [Array<Skill>]
61
+ def load_project_octo_skills
62
+ project_octo_dir = Pathname.new(@working_dir).join(".octo", "skills")
63
+ load_skills_from_directory(project_octo_dir, :project_octo)
64
+ end
65
+
66
+ # Get all loaded skills
67
+ # @return [Array<Skill>]
68
+ def all_skills
69
+ @skills.values
70
+ end
71
+
72
+ # Get a skill by its identifier
73
+ # @param identifier [String] Skill name or directory name
74
+ # @return [Skill, nil]
75
+ def [](identifier)
76
+ @skills[identifier]
77
+ end
78
+
79
+ # Find a skill by its slash command
80
+ # @param command [String] e.g., "/explain-code"
81
+ # @return [Skill, nil]
82
+ def find_by_command(command)
83
+ @skills_by_command[command]
84
+ end
85
+
86
+ # Find a skill by its name (identifier)
87
+ # @param name [String] Skill identifier (e.g., "code-explorer", "pptx")
88
+ # @return [Skill, nil]
89
+ def find_by_name(name)
90
+ @skills[name]
91
+ end
92
+
93
+ # Get skills that can be invoked by user
94
+ # @return [Array<Skill>]
95
+ def user_invocable_skills
96
+ all_skills.select(&:user_invocable?)
97
+ end
98
+
99
+ # Get the count of loaded skills
100
+ # @return [Integer]
101
+ def count
102
+ @skills.size
103
+ end
104
+
105
+ # Get loading errors
106
+ # @return [Array<String>]
107
+ def errors
108
+ @errors.dup
109
+ end
110
+
111
+ # Get the source location for each loaded skill
112
+ # @return [Hash{String => Symbol}] Map of skill identifier to source location
113
+ def loaded_from
114
+ @loaded_from.dup
115
+ end
116
+
117
+ # Clear loaded skills and errors
118
+ def clear
119
+ @skills.clear
120
+ @skills_by_command.clear
121
+ @loaded_from.clear
122
+ @errors.clear
123
+ end
124
+
125
+ # Create a new skill directory and SKILL.md file
126
+ # @param name [String] Skill name (will be used for directory and slash command)
127
+ # @param content [String] Skill content (SKILL.md body)
128
+ # @param description [String] Skill description
129
+ # @param location [Symbol] Where to create: :global or :project
130
+ # @return [Skill] The created skill
131
+ def create_skill(name, content, description = nil, location: :global)
132
+ # Validate name
133
+ unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
134
+ raise Octo::AgentError,
135
+ "Invalid skill name '#{name}'. Use lowercase letters, numbers, and hyphens only."
136
+ end
137
+
138
+ # Determine directory path
139
+ skill_dir = case location
140
+ when :global
141
+ Pathname.new(ENV.fetch("HOME", "~")).join(".octo", "skills", name)
142
+ when :project
143
+ Pathname.new(@working_dir).join(".octo", "skills", name)
144
+ else
145
+ raise Octo::AgentError, "Unknown skill location: #{location}"
146
+ end
147
+
148
+ # Create directory if it doesn't exist
149
+ FileUtils.mkdir_p(skill_dir)
150
+
151
+ # Build frontmatter
152
+ frontmatter = { "name" => name, "description" => description }
153
+
154
+ # Write SKILL.md
155
+ skill_content = build_skill_content(frontmatter, content)
156
+ skill_file = skill_dir.join("SKILL.md")
157
+ skill_file.write(skill_content)
158
+
159
+ # Load the newly created skill
160
+ source_type = case location
161
+ when :global then :global_octo
162
+ when :project then :project_octo
163
+ else :global_octo
164
+ end
165
+ load_single_skill(skill_dir, skill_dir, name, source_type)
166
+ end
167
+
168
+ # Toggle a skill's disable-model-invocation field in its SKILL.md.
169
+ # System skills (source: :default) cannot be toggled — raises AgentError.
170
+ # @param name [String] Skill identifier
171
+ # @param enabled [Boolean] true = enable, false = disable
172
+ # @return [Skill] The reloaded skill
173
+ def toggle_skill(name, enabled:)
174
+ skill = @skills[name]
175
+ raise Octo::AgentError, "Skill not found: #{name}" unless skill
176
+ raise Octo::AgentError, "Cannot toggle system skill: #{name}" if @loaded_from[name] == :default
177
+
178
+ skill_file = skill.directory.join("SKILL.md")
179
+ fm = (skill.frontmatter || {}).dup
180
+
181
+ if enabled
182
+ fm["disable-model-invocation"] = false
183
+ else
184
+ fm["disable-model-invocation"] = true
185
+ end
186
+
187
+ skill_file.write(build_skill_content(fm, skill.content))
188
+
189
+ # Reload into registry
190
+ reloaded = Skill.new(skill.directory, source_path: skill.source_path)
191
+ @skills[reloaded.identifier] = reloaded
192
+ @skills_by_command[reloaded.slash_command] = reloaded
193
+ reloaded
194
+ end
195
+
196
+ # Delete a skill
197
+ # @param name [String] Skill name
198
+ # @return [Boolean] True if deleted, false if not found
199
+ def delete_skill(name)
200
+ skill = @skills[name]
201
+ return false unless skill
202
+
203
+ # Remove from registry
204
+ @skills.delete(name)
205
+ @skills_by_command.delete(skill.slash_command)
206
+
207
+ # Delete directory
208
+ FileUtils.rm_rf(skill.directory)
209
+
210
+ true
211
+ end
212
+
213
+
214
+ def load_skills_from_directory(dir, source_type)
215
+ return [] unless dir.exist?
216
+
217
+ source_path = case source_type
218
+ when :global_octo
219
+ Pathname.new(ENV.fetch("HOME", "~")).join(".octo")
220
+ when :project_octo
221
+ Pathname.new(@working_dir)
222
+ else
223
+ dir
224
+ end
225
+
226
+ skills = []
227
+ dir.children.select(&:directory?).each do |entry|
228
+ if entry.join("SKILL.md").exist?
229
+ # Direct skill directory
230
+ skill = load_single_skill(entry, source_path, entry.basename.to_s, source_type)
231
+ skills << skill if skill
232
+ else
233
+ # Treat as a category directory — scan one level deeper for skills.
234
+ # This allows grouping skills under ~/.octo/skills/<category>/<skill>/SKILL.md
235
+ # (e.g. openclaw-imports/my-skill/SKILL.md) without changing the loader contract.
236
+ entry.children.select(&:directory?).each do |skill_dir|
237
+ next unless skill_dir.join("SKILL.md").exist?
238
+
239
+ skill = load_single_skill(skill_dir, source_path, skill_dir.basename.to_s, source_type)
240
+ skills << skill if skill
241
+ end
242
+ end
243
+ end
244
+ skills
245
+ end
246
+
247
+ private def load_single_skill(skill_dir, source_path, skill_name, source_type)
248
+ skill = Skill.new(skill_dir, source_path: source_path)
249
+ register_skill(skill, source: source_type)
250
+ skill
251
+ rescue Octo::AgentError => e
252
+ @errors << "Error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
253
+ nil
254
+ rescue StandardError => e
255
+ @errors << "Unexpected error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
256
+ nil
257
+ end
258
+
259
+ # Register a skill into the internal lookup tables.
260
+ # - Always adds to @skills (by identifier) so the skill is discoverable in the UI.
261
+ # - Skips @skills_by_command registration when the skill is invalid (no valid slug
262
+ # to form a slash command from).
263
+ # @param skill [Skill]
264
+ # @param source [Symbol] one of :default, :global_octo, :project_octo
265
+ # @return [Skill, nil] nil when the skill was rejected (duplicate/limit)
266
+ private def register_skill(skill, source:)
267
+ id = skill.identifier
268
+ priority_order = %i[default global_octo project_octo]
269
+
270
+ # --- duplicate check ---
271
+ if (existing = @skills[id])
272
+ existing_source = @loaded_from[id]
273
+ if priority_order.index(source) > priority_order.index(existing_source)
274
+ # Incoming skill has higher priority — evict the existing one
275
+ @skills.delete(existing.identifier)
276
+ @skills_by_command.delete(existing.slash_command)
277
+ @loaded_from.delete(existing.identifier)
278
+ else
279
+ @errors << "Skipping duplicate skill '#{id}' (lower priority) from #{skill.directory}"
280
+ return nil
281
+ end
282
+ end
283
+
284
+ # Register in main skills hash
285
+ @skills[id] = skill
286
+ @loaded_from[id] = source
287
+ skill.source = source
288
+
289
+ # Invalid skills have no usable slug — skip slash command registration but
290
+ # still keep them in @skills so they appear (greyed-out) in the UI.
291
+ unless skill.invalid?
292
+ @skills_by_command[skill.slash_command] = skill
293
+ end
294
+
295
+ skill
296
+ end
297
+
298
+ def build_skill_content(frontmatter, content)
299
+ yaml = frontmatter
300
+ .reject { |_, v| v.nil? || v.to_s.empty? }
301
+ .to_yaml(line_width: 80)
302
+
303
+ "---\n#{yaml}---\n\n#{content}"
304
+ end
305
+
306
+ # Load default skills from gem's default_skills directory
307
+ private def load_default_skills
308
+ # Get the gem's lib directory
309
+ gem_lib_dir = File.expand_path("../", __dir__)
310
+ default_skills_dir = File.join(gem_lib_dir, "octo", "default_skills")
311
+
312
+ return unless Dir.exist?(default_skills_dir)
313
+
314
+ # Load each skill directory
315
+ Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
316
+ skill_dir = File.dirname(skill_file)
317
+ skill_name = File.basename(skill_dir)
318
+
319
+ begin
320
+ skill = Skill.new(Pathname.new(skill_dir))
321
+ register_skill(skill, source: :default)
322
+ rescue StandardError => e
323
+ @errors << "Failed to load default skill #{skill_name}: #{e.message}"
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Tools
5
+ class Base
6
+ class << self
7
+ attr_accessor :tool_name, :tool_description, :tool_parameters, :tool_category
8
+ end
9
+
10
+ def name
11
+ self.class.tool_name
12
+ end
13
+
14
+ def description
15
+ self.class.tool_description
16
+ end
17
+
18
+ def parameters
19
+ self.class.tool_parameters
20
+ end
21
+
22
+ def category
23
+ self.class.tool_category || "general"
24
+ end
25
+
26
+ # Execute the tool - must be implemented by subclasses
27
+ def execute(**_args)
28
+ raise NotImplementedError, "#{self.class.name} must implement #execute"
29
+ end
30
+
31
+ # Expand ~ to home directory only if path starts with ~
32
+ # Relative paths are resolved against working_dir if provided
33
+ # @param path [String, nil] The path to expand
34
+ # @param working_dir [String, nil] The working directory to resolve relative paths against
35
+ # @return [String, nil] The expanded path, or original if no ~ present
36
+ private def expand_path(path, working_dir: nil)
37
+ return path if path.nil? || path.strip.empty?
38
+ return File.expand_path(path) if path.start_with?("~")
39
+ return File.expand_path(path, working_dir) if working_dir && !path.start_with?("/")
40
+ # Always resolve relative paths to absolute (even without working_dir), so callers
41
+ # never receive a bare "." that resolves against the process cwd unexpectedly.
42
+ return File.expand_path(path) unless path.start_with?("/")
43
+
44
+ path
45
+ end
46
+
47
+ # Format tool call for display - can be overridden by subclasses
48
+ # @param args [Hash] The arguments passed to the tool
49
+ # @return [String] Formatted call description (e.g., "Read(file.rb)")
50
+ def format_call(args)
51
+ "#{name}(...)"
52
+ end
53
+
54
+ # Format tool result for display - can be overridden by subclasses
55
+ # @param result [Object] The result returned by execute
56
+ # @return [String] Formatted result summary (e.g., "Read 150 lines")
57
+ def format_result(result)
58
+ if result.is_a?(Hash) && result[:message]
59
+ result[:message]
60
+ elsif result.is_a?(String)
61
+ result.length > 100 ? "#{result[0..100]}..." : result
62
+ else
63
+ "Done"
64
+ end
65
+ end
66
+
67
+ # Format tool result as a structured hash for rich UI rendering.
68
+ # When a tool implements this, the WebUI can render a beautiful
69
+ # card instead of a plain text blob.
70
+ #
71
+ # @param result [Object] The result returned by execute
72
+ # @return [Hash, nil] A hash with :type and tool-specific fields,
73
+ # or nil to fall back to plain-text format_result.
74
+ #
75
+ # Supported types and their schemas:
76
+ #
77
+ # { type: "file_read", path:, lines_read:, total_lines:,
78
+ # truncated:, content_preview:, language: }
79
+ #
80
+ # { type: "file_list", path:, entries:[{name, is_dir}], total }
81
+ #
82
+ # { type: "search", pattern:, path:, matches:[{file, line_no, line, context?}],
83
+ # total_matches, files_with_matches, truncated }
84
+ #
85
+ # { type: "terminal", command:, exit_code:, output_preview:,
86
+ # output_truncated:, full_output_file? }
87
+ #
88
+ # { type: "web_fetch", url:, title?, content_preview: }
89
+ #
90
+ # { type: "web_search", query:, results:[{title, url, snippet}] }
91
+ #
92
+ # { type: "edit", path:, operation:, occurrences: }
93
+ #
94
+ # { type: "write", path:, is_new_file:, size_bytes: }
95
+ #
96
+ # { type: "todo", action:, todos:[{id, task, status}] }
97
+ #
98
+ # { type: "browser", action:, url?, title?, content_preview? }
99
+ #
100
+ # { type: "generic", title:, content:, status: "ok|error|warning" }
101
+ def format_result_for_ui(result)
102
+ nil
103
+ end
104
+
105
+ # Convert to OpenAI function calling format
106
+ def to_function_definition
107
+ {
108
+ type: "function",
109
+ function: {
110
+ name: name,
111
+ description: description,
112
+ parameters: parameters
113
+ }
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end