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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module Octo
7
+ module Utils
8
+ # Manages user-space parsers in ~/.octo/parsers/.
9
+ #
10
+ # On first use, default parser scripts are copied from the gem's
11
+ # default_parsers/ directory into ~/.octo/parsers/. After that,
12
+ # the user-space version is always used — allowing the LLM to modify
13
+ # or extend parsers without touching the gem itself.
14
+ #
15
+ # CLI interface contract (all parsers must follow):
16
+ # ruby <parser>.rb <file_path>
17
+ # stdout → extracted text (UTF-8)
18
+ # stderr → error messages
19
+ # exit 0 → success
20
+ # exit 1 → failure
21
+ module ParserManager
22
+ PARSERS_DIR = File.expand_path("~/.octo/parsers").freeze
23
+ DEFAULT_PARSERS_DIR = File.expand_path("../default_parsers", __dir__).freeze
24
+
25
+ PARSER_FOR = {
26
+ ".pdf" => "pdf_parser.rb",
27
+ ".doc" => "doc_parser.rb",
28
+ ".docx" => "docx_parser.rb",
29
+ ".xlsx" => "xlsx_parser.rb",
30
+ ".xls" => "xlsx_parser.rb",
31
+ ".pptx" => "pptx_parser.rb",
32
+ ".ppt" => "pptx_parser.rb",
33
+ }.freeze
34
+
35
+ # Ensure ~/.octo/parsers/ exists and all default parsers are present.
36
+ # Called at Agent startup (idempotent — safe to run every time).
37
+ #
38
+ # Copies every file from default_parsers/ (not just the entry-point .rb
39
+ # scripts listed in PARSER_FOR). A parser may ship companion helper
40
+ # scripts — e.g. pdf_parser_ocr.py sits next to pdf_parser.rb and is
41
+ # invoked by relative path — so those helpers must be distributed too.
42
+ #
43
+ # Version upgrade policy:
44
+ # Each bundled parser declares `VERSION: <n>` in a header comment
45
+ # (works for Ruby `# VERSION: 2` and Python `# VERSION: 2` alike,
46
+ # scanned in the first 40 lines of the file).
47
+ #
48
+ # On startup, per-file:
49
+ # - If the file does NOT exist in ~/.octo/parsers/ → copy it.
50
+ # - If it exists:
51
+ # * bundled has no VERSION → never touch (bundled file
52
+ # is opting out of managed upgrades).
53
+ # * installed has no VERSION → treat it as legacy v0 and
54
+ # upgrade (lenient mode — covers users who installed before
55
+ # the VERSION scheme existed). The old file is backed up.
56
+ # * both have VERSION, bundled > installed → upgrade, backing
57
+ # up the old copy as `<script>.v<old>.bak`.
58
+ # * bundled ≤ installed → leave the user's copy alone
59
+ # (preserves LLM/user modifications).
60
+ #
61
+ # Backups live alongside the parser so the user can inspect
62
+ # their own edits after an upgrade. They are never removed
63
+ # automatically.
64
+ def self.setup!
65
+ FileUtils.mkdir_p(PARSERS_DIR)
66
+
67
+ Dir.glob(File.join(DEFAULT_PARSERS_DIR, "**", "*")).each do |src|
68
+ next unless File.file?(src)
69
+ basename = File.basename(src)
70
+ next if basename.start_with?(".") || basename.end_with?(".bak")
71
+
72
+ rel = src.sub(/^#{Regexp.escape(DEFAULT_PARSERS_DIR)}\/?/, "")
73
+ dest = File.join(PARSERS_DIR, rel)
74
+
75
+ if !File.exist?(dest)
76
+ FileUtils.mkdir_p(File.dirname(dest))
77
+ FileUtils.cp(src, dest)
78
+ # Preserve executable bit so sibling scripts can be run directly.
79
+ FileUtils.chmod(File.stat(src).mode, dest)
80
+ next
81
+ end
82
+
83
+ bundled_version = extract_version(src)
84
+ # Bundled file opts out of managed upgrades — never touch user copy.
85
+ next unless bundled_version
86
+
87
+ installed_version = extract_version(dest) || 0
88
+
89
+ if bundled_version > installed_version
90
+ backup = "#{dest}.v#{installed_version}.bak"
91
+ FileUtils.cp(dest, backup) unless File.exist?(backup)
92
+ FileUtils.cp(src, dest)
93
+ FileUtils.chmod(File.stat(src).mode, dest)
94
+ end
95
+ end
96
+ end
97
+
98
+ # Read the VERSION marker from a parser script (e.g. "# VERSION: 2").
99
+ # Works for any script language that uses `#` for comments
100
+ # (Ruby, Python, shell). Returns Integer or nil.
101
+ def self.extract_version(path)
102
+ return nil unless File.exist?(path)
103
+ # Only scan the first 40 lines — the marker lives in the header.
104
+ File.foreach(path).with_index do |line, i|
105
+ break if i >= 40
106
+ if (m = line.match(/^\s*#\s*VERSION:\s*(\d+)/i))
107
+ return m[1].to_i
108
+ end
109
+ end
110
+ nil
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ # Run the appropriate parser for the given file path.
116
+ #
117
+ # @param file_path [String] path to the file to parse
118
+ # @return [Hash] { success: bool, text: String, error: String, parser_path: String }
119
+ def self.parse(file_path)
120
+ ext = File.extname(file_path.to_s).downcase
121
+ script = PARSER_FOR[ext]
122
+
123
+ unless script
124
+ return { success: false, text: nil,
125
+ error: "No parser available for #{ext} files",
126
+ parser_path: nil }
127
+ end
128
+
129
+ parser_path = File.join(PARSERS_DIR, script)
130
+
131
+ unless File.exist?(parser_path)
132
+ return { success: false, text: nil,
133
+ error: "Parser not found: #{parser_path}",
134
+ parser_path: parser_path }
135
+ end
136
+
137
+ raw_stdout, raw_stderr, status = Open3.capture3(RbConfig.ruby, parser_path, file_path)
138
+
139
+ # capture3 returns ASCII-8BIT across the subprocess boundary on Ruby 2.6+.
140
+ # Normalise both streams to UTF-8 immediately so all downstream code is clean.
141
+ stdout = Octo::Utils::Encoding.to_utf8(raw_stdout)
142
+ stderr = Octo::Utils::Encoding.to_utf8(raw_stderr)
143
+
144
+ # Filter out Ruby/Bundler version warnings that pollute stderr
145
+ clean_stderr = stderr.lines.reject { |l| l.match?(/warning:|already initialized constant/) }.join.strip
146
+
147
+ if status.success? && stdout.strip.length > 0
148
+ { success: true, text: stdout.strip, error: nil, parser_path: parser_path }
149
+ else
150
+ { success: false, text: nil,
151
+ error: clean_stderr.empty? ? "Parser exited with code #{status.exitstatus}" : clean_stderr,
152
+ parser_path: parser_path }
153
+ end
154
+ end
155
+
156
+ # Returns the path to a parser script for a given extension.
157
+ # Used by agent to tell LLM where to find/modify the parser.
158
+ def self.parser_path_for(ext)
159
+ script = PARSER_FOR[ext.downcase]
160
+ return nil unless script
161
+ File.join(PARSERS_DIR, script)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Utils
5
+ module PathHelper
6
+ # Safely get basename from path, return placeholder if path is nil
7
+ def self.safe_basename(path, placeholder: "?")
8
+ return placeholder if path.nil? || path.to_s.empty?
9
+ File.basename(path.to_s)
10
+ rescue StandardError
11
+ placeholder
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Octo
6
+ module Utils
7
+ # Manages user-space shell scripts in ~/.octo/scripts/.
8
+ #
9
+ # On first use, bundled scripts are copied from the gem's scripts/
10
+ # directory into ~/.octo/scripts/. The user-space copy is always
11
+ # used so users can customise scripts without modifying the gem.
12
+ #
13
+ # Bundled scripts are re-copied when the gem is upgraded (detected
14
+ # via gem version stamp in ~/.octo/scripts/.version).
15
+ module ScriptsManager
16
+ SCRIPTS_DIR = File.expand_path("~/.octo/scripts").freeze
17
+ DEFAULT_SCRIPTS_DIR = File.expand_path("../../../scripts", __dir__).freeze
18
+ VERSION_FILE = File.join(SCRIPTS_DIR, ".version").freeze
19
+
20
+ SCRIPTS = %w[
21
+ install_browser.sh
22
+ install_system_deps.sh
23
+ install_rails_deps.sh
24
+ ].freeze
25
+
26
+ # Copy bundled scripts to ~/.octo/scripts/ if missing or outdated.
27
+ # Called once at agent startup — fast (no-op after first run).
28
+ def self.setup!
29
+ FileUtils.mkdir_p(SCRIPTS_DIR)
30
+
31
+ current_version = Octo::VERSION
32
+ stored_version = File.exist?(VERSION_FILE) ? File.read(VERSION_FILE).strip : nil
33
+
34
+ SCRIPTS.each do |script|
35
+ dest = File.join(SCRIPTS_DIR, script)
36
+ src = File.join(DEFAULT_SCRIPTS_DIR, script)
37
+ next unless File.exist?(src)
38
+
39
+ # Copy if missing or gem was upgraded
40
+ if !File.exist?(dest) || stored_version != current_version
41
+ FileUtils.cp(src, dest)
42
+ FileUtils.chmod(0o755, dest)
43
+ end
44
+ end
45
+
46
+ # Write version stamp after successful copy
47
+ File.write(VERSION_FILE, current_version)
48
+ end
49
+
50
+ # Returns the full path to a managed script.
51
+ # @param name [String] script filename, e.g. "install_browser.sh"
52
+ # @return [String, nil]
53
+ def self.path_for(name)
54
+ dest = File.join(SCRIPTS_DIR, name)
55
+ File.exist?(dest) ? dest : nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Utils
5
+ # Utilities for finding and matching strings in file content.
6
+ # Used by the Edit tool and edit preview to apply a consistent
7
+ # layered matching strategy: exact → trim → unescape → smart line match.
8
+ module StringMatcher
9
+ # Find a matching string in content using a layered strategy.
10
+ #
11
+ # Strategy (applied in order):
12
+ # 1. Exact match (original old_string)
13
+ # 2. Trimmed match (leading/trailing whitespace stripped)
14
+ # 3. Unescaped match (over-escaped sequences normalised)
15
+ # 4. Combined trim + unescape
16
+ # 5. Smart line-by-line match (tolerates indent differences)
17
+ #
18
+ # @param content [String] File content to search in
19
+ # @param old_string [String] String to locate
20
+ # @return [Hash, nil] { matched_string: String, occurrences: Integer }
21
+ # or nil when nothing matches
22
+ def self.find_match(content, old_string)
23
+ # Defensive: if either side contains invalid UTF-8 bytes (binary files,
24
+ # mixed-encoding content, etc.), Regexp#scan / String#include? with a
25
+ # UTF-8-tagged candidate can raise `ArgumentError: invalid byte sequence
26
+ # in UTF-8`. Scrub once at the entry point so every matching layer —
27
+ # including callers like the edit preview — is safe.
28
+ content = Octo::Utils::Encoding.to_utf8(content) unless content.nil?
29
+ old_string = Octo::Utils::Encoding.to_utf8(old_string) unless old_string.nil?
30
+
31
+ candidates = generate_candidates(old_string)
32
+
33
+ # Simple string matching for each candidate
34
+ candidates.each do |candidate|
35
+ next if candidate.empty?
36
+
37
+ if content.include?(candidate)
38
+ return {
39
+ matched_string: candidate,
40
+ occurrences: count_occurrences(content, candidate)
41
+ }
42
+ end
43
+ end
44
+
45
+ # Fall back to smart line-by-line matching (tabs vs spaces, etc.)
46
+ try_smart_match(content, old_string)
47
+ end
48
+
49
+ # Count non-overlapping occurrences of `needle` in `haystack` without
50
+ # going through Regexp (safer on mixed-encoding strings and avoids an
51
+ # extra escape step).
52
+ def self.count_occurrences(haystack, needle)
53
+ return 0 if needle.empty?
54
+ count = 0
55
+ offset = 0
56
+ while (idx = haystack.index(needle, offset))
57
+ count += 1
58
+ offset = idx + needle.length
59
+ end
60
+ count
61
+ end
62
+
63
+ # Generate candidate strings by applying different transformations.
64
+ #
65
+ # @param old_string [String]
66
+ # @return [Array<String>] Unique list of candidates
67
+ def self.generate_candidates(old_string)
68
+ trimmed = old_string.strip
69
+ unescaped = unescape_over_escaped(old_string)
70
+ unescaped_trimmed = unescape_over_escaped(trimmed)
71
+
72
+ [
73
+ old_string, # Original
74
+ trimmed, # Trim leading/trailing whitespace
75
+ unescaped, # Unescape over-escaped sequences
76
+ unescaped_trimmed # Combined: trim + unescape
77
+ ].uniq
78
+ end
79
+
80
+ # Convert over-escaped sequences back to their real characters.
81
+ # This handles the common case where LLMs double-escape backslashes.
82
+ #
83
+ # @param str [String]
84
+ # @return [String]
85
+ def self.unescape_over_escaped(str)
86
+ result = str.dup
87
+
88
+ # Unicode escapes: \uXXXX → actual Unicode character
89
+ result = result.gsub(/\\u([0-9a-fA-F]{4})/) { [$1.hex].pack("U") }
90
+
91
+ # Common escape sequences
92
+ result = result.gsub('\\n', "\n")
93
+ result = result.gsub('\\t', "\t")
94
+ result = result.gsub('\\r', "\r")
95
+ result = result.gsub('\\f', "\f")
96
+ result = result.gsub('\\b', "\b")
97
+ result = result.gsub('\\v', "\v")
98
+ result = result.gsub('\\"', '"')
99
+ result = result.gsub('\\\\', "\\")
100
+
101
+ result
102
+ end
103
+
104
+ # Try smart line-by-line matching that tolerates leading whitespace differences.
105
+ #
106
+ # @param content [String]
107
+ # @param old_string [String]
108
+ # @return [Hash, nil]
109
+ def self.try_smart_match(content, old_string)
110
+ candidates = generate_candidates(old_string)
111
+
112
+ candidates.each do |candidate|
113
+ next if candidate.empty?
114
+
115
+ candidate_lines = candidate.lines
116
+ next if candidate_lines.empty?
117
+
118
+ content_lines = content.lines
119
+ matches = []
120
+
121
+ (0..content_lines.length - candidate_lines.length).each do |start_idx|
122
+ slice = content_lines[start_idx, candidate_lines.length]
123
+ next unless slice
124
+
125
+ if lines_match_normalized?(slice, candidate_lines)
126
+ matches << { start: start_idx, matched_string: slice.join }
127
+ end
128
+ end
129
+
130
+ unless matches.empty?
131
+ return {
132
+ matched_string: matches.first[:matched_string],
133
+ occurrences: matches.length
134
+ }
135
+ end
136
+ end
137
+
138
+ nil
139
+ end
140
+
141
+ # Compare two arrays of lines after normalising leading whitespace.
142
+ #
143
+ # @param lines1 [Array<String>]
144
+ # @param lines2 [Array<String>]
145
+ # @return [Boolean]
146
+ def self.lines_match_normalized?(lines1, lines2)
147
+ return false unless lines1.length == lines2.length
148
+
149
+ lines1.zip(lines2).all? do |line1, line2|
150
+ norm1 = line1.sub(/^\s+/, " ").chomp
151
+ norm2 = line2.sub(/^\s+/, " ").chomp
152
+
153
+ norm1 == norm2 || norm1 == unescape_over_escaped(norm2)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module Octo
7
+ # Manages global trash directory at ~/.octo/trash
8
+ # Organizes trash by project directory using path hash
9
+ class TrashDirectory
10
+ GLOBAL_TRASH_ROOT = File.join(Dir.home, ".octo", "trash")
11
+
12
+ attr_reader :project_root, :trash_dir, :backup_dir
13
+
14
+ def initialize(project_root = Dir.pwd)
15
+ @project_root = File.expand_path(project_root)
16
+ @project_hash = generate_project_hash(@project_root)
17
+ @trash_dir = File.join(GLOBAL_TRASH_ROOT, @project_hash)
18
+ @backup_dir = File.join(@trash_dir, "backups")
19
+
20
+ setup_directories
21
+ end
22
+
23
+ # Generate a unique hash for project path
24
+ def generate_project_hash(path)
25
+ # Use MD5 hash of the absolute path, take first 16 chars for readability
26
+ hash = Digest::MD5.hexdigest(path)[0..15]
27
+ # Also include a readable suffix based on project name
28
+ project_name = File.basename(path).gsub(/[^a-zA-Z0-9_-]/, '_')[0..20]
29
+ "#{hash}_#{project_name}"
30
+ end
31
+
32
+ # Setup trash and backup directories with proper structure
33
+ def setup_directories
34
+ [@trash_dir, @backup_dir].each do |dir|
35
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
36
+
37
+ # Create .gitignore file to avoid trash files being committed
38
+ gitignore_path = File.join(dir, '.gitignore')
39
+ unless File.exist?(gitignore_path)
40
+ File.write(gitignore_path, "*\n!.gitignore\n")
41
+ end
42
+ end
43
+
44
+ # Create project metadata file
45
+ create_project_metadata
46
+ end
47
+
48
+ # Create or update metadata about this project
49
+ def create_project_metadata
50
+ metadata_file = File.join(@trash_dir, '.project_metadata.json')
51
+
52
+ metadata = {
53
+ project_root: @project_root,
54
+ project_name: File.basename(@project_root),
55
+ project_hash: @project_hash,
56
+ created_at: File.exist?(metadata_file) ? JSON.parse(File.read(metadata_file))['created_at'] : Time.now.iso8601,
57
+ last_accessed: Time.now.iso8601
58
+ }
59
+
60
+ File.write(metadata_file, JSON.pretty_generate(metadata))
61
+ rescue StandardError => e
62
+ # Log warning but don't block operation
63
+ warn "Warning: Could not create project metadata: #{e.message}"
64
+ end
65
+
66
+ # Get all project directories that have trash
67
+ def self.all_projects
68
+ return [] unless Dir.exist?(GLOBAL_TRASH_ROOT)
69
+
70
+ projects = []
71
+ Dir.glob(File.join(GLOBAL_TRASH_ROOT, "*", ".project_metadata.json")).each do |metadata_file|
72
+ begin
73
+ metadata = JSON.parse(File.read(metadata_file))
74
+ projects << {
75
+ project_root: metadata['project_root'],
76
+ project_name: metadata['project_name'],
77
+ project_hash: metadata['project_hash'],
78
+ trash_dir: File.dirname(metadata_file),
79
+ last_accessed: metadata['last_accessed']
80
+ }
81
+ rescue StandardError
82
+ # Skip corrupted metadata
83
+ end
84
+ end
85
+
86
+ projects.sort_by { |p| p[:last_accessed] }.reverse
87
+ end
88
+
89
+ # Get trash directory for a specific project
90
+ def self.for_project(project_root)
91
+ new(project_root)
92
+ end
93
+
94
+ # Clean up trash directories for non-existent projects
95
+ def self.cleanup_orphaned_projects
96
+ return 0 unless Dir.exist?(GLOBAL_TRASH_ROOT)
97
+
98
+ cleaned_count = 0
99
+ all_projects.each do |project|
100
+ unless Dir.exist?(project[:project_root])
101
+ # Project no longer exists, optionally remove trash
102
+ # For safety, we'll just mark it as orphaned
103
+ orphan_file = File.join(project[:trash_dir], '.orphaned')
104
+ File.write(orphan_file, "Original project path no longer exists: #{project[:project_root]}\n")
105
+ cleaned_count += 1
106
+ end
107
+ end
108
+
109
+ cleaned_count
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ module Utils
5
+ # Discovers .octorules files in the workspace.
6
+ # Used by both SystemPromptBuilder (rules injection) and WelcomeBanner (UI display).
7
+ module WorkspaceRules
8
+ RULES_FILENAMES = [".octorules", ".cursorrules", "CLAUDE.md"].freeze
9
+ SUB_PROJECT_SUMMARY_LINES = 5
10
+
11
+ # Find the main rules file in the given directory.
12
+ # @param dir [String] Directory to search
13
+ # @return [Hash, nil] { path:, name:, content: } or nil
14
+ def self.find_main(dir)
15
+ RULES_FILENAMES.each do |filename|
16
+ path = File.join(dir, filename)
17
+ next unless File.exist?(path)
18
+
19
+ content = File.read(path).strip
20
+ return { path: path, name: filename, content: content } unless content.empty?
21
+ end
22
+ nil
23
+ end
24
+
25
+ # Find all sub-project .octorules in immediate subdirectories.
26
+ # @param dir [String] Parent directory to scan
27
+ # @return [Array<Hash>] Array of { sub_name:, relative_path:, content:, summary: }
28
+ def self.find_sub_projects(dir)
29
+ Dir.glob(File.join(dir, "*", ".octorules")).sort.filter_map do |rules_path|
30
+ content = File.read(rules_path).strip
31
+ next if content.empty?
32
+
33
+ sub_name = File.basename(File.dirname(rules_path))
34
+ summary = content.lines.first(SUB_PROJECT_SUMMARY_LINES).map(&:chomp).join("\n")
35
+
36
+ {
37
+ sub_name: sub_name,
38
+ relative_path: "#{sub_name}/.octorules",
39
+ content: content,
40
+ summary: summary
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ VERSION = "0.11.2"
5
+ end