rubino-agent 0.3.0

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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Rubino
7
+ module Tools
8
+ # Tool for fetching web page content and converting to text/markdown.
9
+ class WebFetchTool < Base
10
+ MAX_BODY_SIZE = 100_000
11
+ TIMEOUT = 30
12
+
13
+ def name
14
+ "webfetch"
15
+ end
16
+
17
+ # Gated by `tools.web` (shared with websearch), not `tools.webfetch`.
18
+ def config_key
19
+ "web"
20
+ end
21
+
22
+ def description
23
+ "Fetch content from a URL and return it as text. " \
24
+ "Useful for reading documentation, API references, and web pages."
25
+ end
26
+
27
+ def input_schema
28
+ {
29
+ type: "object",
30
+ properties: {
31
+ url: {
32
+ type: "string",
33
+ description: "The URL to fetch content from"
34
+ },
35
+ format: {
36
+ type: "string",
37
+ enum: %w[text html],
38
+ description: "Output format: 'text' (default, strips HTML) or 'html' (raw)"
39
+ }
40
+ },
41
+ required: %w[url]
42
+ }
43
+ end
44
+
45
+ def risk_level
46
+ :low
47
+ end
48
+
49
+ def call(arguments)
50
+ url = arguments["url"] || arguments[:url]
51
+ format = arguments["format"] || arguments[:format] || "text"
52
+
53
+ fetch_url(url, format: format)
54
+ end
55
+
56
+ private
57
+
58
+ def fetch_url(url, format:, redirects: 5)
59
+ return "Error: Too many redirects" if redirects <= 0
60
+
61
+ uri = URI.parse(url)
62
+ uri = URI.parse("https://#{url}") unless uri.scheme
63
+
64
+ http = Net::HTTP.new(uri.host, uri.port)
65
+ http.use_ssl = (uri.scheme == "https")
66
+ http.open_timeout = TIMEOUT
67
+ http.read_timeout = TIMEOUT
68
+
69
+ request = Net::HTTP::Get.new(uri.request_uri)
70
+ request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
71
+ request["Accept"] = "text/html,text/plain,application/json"
72
+
73
+ response = http.request(request)
74
+
75
+ case response
76
+ when Net::HTTPRedirection
77
+ fetch_url(response["location"], format: format, redirects: redirects - 1)
78
+ when Net::HTTPSuccess
79
+ content_type = response["content-type"].to_s
80
+ return binary_refusal(url, content_type) if binary_content_type?(content_type)
81
+
82
+ # Force UTF-8 + scrub so gsub! in strip_html doesn't trip
83
+ # "source sequence is illegal/malformed utf-8" when the upstream
84
+ # response is labelled text/* but contains stray non-UTF-8 bytes
85
+ # (which is the common case for misencoded HTML / CRLF logs).
86
+ body = response.body.to_s.dup.force_encoding("UTF-8").scrub("?")
87
+ if body.bytesize > MAX_BODY_SIZE
88
+ body = body.byteslice(0,
89
+ MAX_BODY_SIZE).to_s.force_encoding("UTF-8").scrub("?")
90
+ end
91
+
92
+ if format == "html"
93
+ body
94
+ else
95
+ strip_html(body)
96
+ end
97
+ else
98
+ "Error: HTTP #{response.code} - #{response.message}"
99
+ end
100
+ rescue StandardError => e
101
+ "Error fetching URL: #{e.message}"
102
+ end
103
+
104
+ BINARY_TYPE_PATTERNS = [
105
+ %r{\Aapplication/(pdf|zip|x-tar|x-gzip|x-bzip2|x-7z-compressed|x-rar|octet-stream|x-msdownload|vnd\.openxmlformats|vnd\.ms-)},
106
+ %r{\Aimage/}, %r{\Aaudio/}, %r{\Avideo/},
107
+ %r{\Afont/}
108
+ ].freeze
109
+
110
+ def binary_content_type?(content_type)
111
+ type = content_type.to_s.split(";").first.to_s.strip.downcase
112
+ BINARY_TYPE_PATTERNS.any? { |re| type.match?(re) }
113
+ end
114
+
115
+ def binary_refusal(url, content_type)
116
+ "Error: refusing to fetch binary content as text " \
117
+ "(URL=#{url}, Content-Type=#{content_type.split(";").first.to_s.strip}). " \
118
+ "Use a dedicated tool (e.g. read_file after downloading, attach_file, " \
119
+ "or an image-aware model) for binary assets."
120
+ end
121
+
122
+ def strip_html(html)
123
+ # Basic HTML to text conversion
124
+ text = html.dup
125
+
126
+ # Remove script and style blocks
127
+ text.gsub!(%r{<script[^>]*>.*?</script>}mi, "")
128
+ text.gsub!(%r{<style[^>]*>.*?</style>}mi, "")
129
+
130
+ # Convert common elements
131
+ text.gsub!(%r{<br\s*/?>}i, "\n")
132
+ text.gsub!(%r{</(p|div|h[1-6]|li|tr)>}i, "\n")
133
+ text.gsub!(/<(h[1-6])[^>]*>/i, "\n## ")
134
+ text.gsub!(/<li[^>]*>/i, "- ")
135
+
136
+ # Remove remaining tags
137
+ text.gsub!(/<[^>]+>/, "")
138
+
139
+ # Decode common entities
140
+ text.gsub!("&amp;", "&")
141
+ text.gsub!("&lt;", "<")
142
+ text.gsub!("&gt;", ">")
143
+ text.gsub!("&quot;", '"')
144
+ text.gsub!("&#39;", "'")
145
+ text.gsub!("&nbsp;", " ")
146
+
147
+ # Clean up whitespace
148
+ text.gsub!(/\n{3,}/, "\n\n")
149
+ text.strip
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Rubino
8
+ module Tools
9
+ # Tool for performing web searches via external search APIs.
10
+ # Supports Tavily, SearXNG, and a fallback DuckDuckGo scraper.
11
+ class WebSearchTool < Base
12
+ def name
13
+ "websearch"
14
+ end
15
+
16
+ # Gated by `tools.web` (shared with webfetch), not `tools.websearch`.
17
+ def config_key
18
+ "web"
19
+ end
20
+
21
+ def description
22
+ "Search the web for information. Returns relevant results with titles, " \
23
+ "URLs, and snippets. Useful for finding documentation, researching " \
24
+ "dependencies, and answering questions about external topics."
25
+ end
26
+
27
+ def input_schema
28
+ {
29
+ type: "object",
30
+ properties: {
31
+ query: {
32
+ type: "string",
33
+ description: "The search query"
34
+ },
35
+ max_results: {
36
+ type: "integer",
37
+ description: "Maximum number of results (default: 5)"
38
+ }
39
+ },
40
+ required: %w[query]
41
+ }
42
+ end
43
+
44
+ def risk_level
45
+ :low
46
+ end
47
+
48
+ def call(arguments)
49
+ query = arguments["query"] || arguments[:query]
50
+ max_results = arguments["max_results"] || arguments[:max_results] || 5
51
+
52
+ if ENV["TAVILY_API_KEY"]
53
+ search_tavily(query, max_results)
54
+ elsif ENV["SEARXNG_URL"]
55
+ search_searxng(query, max_results)
56
+ else
57
+ search_ddg(query, max_results)
58
+ end
59
+ rescue StandardError => e
60
+ "Search error: #{e.message}"
61
+ end
62
+
63
+ private
64
+
65
+ # Tavily API (preferred - high quality results)
66
+ def search_tavily(query, max_results)
67
+ uri = URI("https://api.tavily.com/search")
68
+ body = {
69
+ api_key: ENV.fetch("TAVILY_API_KEY", nil),
70
+ query: query,
71
+ max_results: max_results,
72
+ include_answer: true,
73
+ search_depth: "basic"
74
+ }
75
+
76
+ response = post_json(uri, body)
77
+ data = JSON.parse(response)
78
+
79
+ results = []
80
+ results << "**Answer:** #{data["answer"]}\n" if data["answer"]
81
+
82
+ (data["results"] || []).each do |r|
83
+ results << format_result(r["title"], r["url"], r["content"])
84
+ end
85
+
86
+ results.empty? ? "No results found for: #{query}" : results.join("\n\n")
87
+ end
88
+
89
+ # SearXNG (self-hosted, privacy-focused)
90
+ def search_searxng(query, max_results)
91
+ base_url = ENV["SEARXNG_URL"].chomp("/")
92
+ uri = URI("#{base_url}/search")
93
+ uri.query = URI.encode_www_form(
94
+ q: query,
95
+ format: "json",
96
+ pageno: 1
97
+ )
98
+
99
+ response = get_json(uri)
100
+ data = JSON.parse(response)
101
+
102
+ results = (data["results"] || []).first(max_results).map do |r|
103
+ format_result(r["title"], r["url"], r["content"])
104
+ end
105
+
106
+ results.empty? ? "No results found for: #{query}" : results.join("\n\n")
107
+ end
108
+
109
+ # DuckDuckGo HTML scraper (fallback, no API key needed)
110
+ def search_ddg(query, max_results)
111
+ uri = URI("https://html.duckduckgo.com/html/")
112
+ body = URI.encode_www_form(q: query)
113
+
114
+ http = Net::HTTP.new(uri.host, uri.port)
115
+ http.use_ssl = true
116
+ request = Net::HTTP::Post.new(uri.path)
117
+ request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
118
+ request.body = body
119
+
120
+ response = http.request(request)
121
+ parse_ddg_html(response.body, max_results)
122
+ end
123
+
124
+ def parse_ddg_html(html, max_results)
125
+ results = []
126
+
127
+ # Extract result blocks
128
+ html.scan(%r{<a rel="nofollow" class="result__a" href="([^"]+)"[^>]*>(.+?)</a>.*?<a class="result__snippet"[^>]*>(.+?)</a>}m) do |url, title, snippet|
129
+ clean_title = title.gsub(/<[^>]+>/, "").strip
130
+ clean_snippet = snippet.gsub(/<[^>]+>/, "").strip
131
+ clean_url = url.strip
132
+
133
+ # DuckDuckGo wraps URLs in redirects
134
+ if clean_url.include?("uddg=")
135
+ clean_url = begin
136
+ URI.decode_www_form_component(clean_url.match(/uddg=([^&]+)/)[1])
137
+ rescue StandardError
138
+ clean_url
139
+ end
140
+ end
141
+
142
+ results << format_result(clean_title, clean_url, clean_snippet)
143
+ break if results.size >= max_results
144
+ end
145
+
146
+ results.empty? ? "No results found (DDG fallback)" : results.join("\n\n")
147
+ end
148
+
149
+ def format_result(title, url, snippet)
150
+ "**#{title}**\n#{url}\n#{snippet}"
151
+ end
152
+
153
+ def post_json(uri, body)
154
+ http = Net::HTTP.new(uri.host, uri.port)
155
+ http.use_ssl = (uri.scheme == "https")
156
+ http.open_timeout = 10
157
+ http.read_timeout = 15
158
+
159
+ request = Net::HTTP::Post.new(uri.request_uri)
160
+ request["Content-Type"] = "application/json"
161
+ request.body = JSON.generate(body)
162
+
163
+ http.request(request).body
164
+ end
165
+
166
+ def get_json(uri)
167
+ http = Net::HTTP.new(uri.host, uri.port)
168
+ http.use_ssl = (uri.scheme == "https")
169
+ http.open_timeout = 10
170
+ http.read_timeout = 15
171
+
172
+ request = Net::HTTP::Get.new(uri.request_uri)
173
+ request["Accept"] = "application/json"
174
+
175
+ http.request(request).body
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Writes content to a file, creating parent directories if needed.
8
+ # Overwrites existing files (the LLM is expected to Read first when in
9
+ # doubt). Kept intentionally narrow — no append mode, no partial writes;
10
+ # those belong in `edit` / `multi_edit`.
11
+ class WriteTool < Base
12
+ def name
13
+ "write"
14
+ end
15
+
16
+ def description
17
+ "Write content to a file, overwriting any existing content. " \
18
+ "Creates parent directories if they do not exist. " \
19
+ "Use `edit` or `multi_edit` to modify an existing file in place."
20
+ end
21
+
22
+ def input_schema
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ file_path: { type: "string", description: "Absolute or relative file path" },
27
+ content: { type: "string", description: "Full file content to write" }
28
+ },
29
+ required: %w[file_path content]
30
+ }
31
+ end
32
+
33
+ def risk_level
34
+ :medium
35
+ end
36
+
37
+ def call(arguments)
38
+ file_path = arguments["file_path"] || arguments[:file_path]
39
+ content = arguments["content"] || arguments[:content] || ""
40
+
41
+ return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
42
+
43
+ expanded = File.expand_path(file_path)
44
+ return workspace_violation_message(file_path) unless within_workspace?(expanded)
45
+
46
+ FileUtils.mkdir_p(File.dirname(expanded))
47
+
48
+ existed = File.exist?(expanded)
49
+ File.write(expanded, content)
50
+
51
+ verb = existed ? "overwrote" : "created"
52
+ bytes = content.to_s.bytesize
53
+ lines = content.to_s.lines.size
54
+ { output: "#{verb} #{file_path} (#{bytes} bytes)",
55
+ metrics: "#{lines} line#{"s" if lines != 1} · #{bytes}B" }
56
+ rescue StandardError => e
57
+ "Error writing #{file_path}: #{e.message}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Rubino
7
+ # Proportionate folder-trust, modelled on VS Code Workspace Trust and Claude
8
+ # Code's directory-trust dialog — but DELIBERATELY lighter, because rubino
9
+ # auto-RUNS no code from a project directory (config is HOME-only, there are
10
+ # no folder-open hooks, custom slash commands are user-triggered, and the
11
+ # arbitrary-Ruby tool loader can no longer load from cwd — see #44).
12
+ #
13
+ # What the gate protects: the ONE thing rubino auto-loads from a directory is
14
+ # *text into the system prompt* — its AGENTS.md / CLAUDE.md / .rubino.md /
15
+ # .cursorrules project-context files and its .rubino/skills catalogue. A
16
+ # hostile repo can use those to STEER the agent (prompt injection) the moment
17
+ # you start there. So, like VS Code's Restricted Mode, an untrusted directory
18
+ # still works — it just runs WITHOUT that directory's project context and
19
+ # skills until you vouch for it.
20
+ #
21
+ # What it does NOT do: there is no feature-disabling Restricted Mode (no
22
+ # auto-executed code to disable) and no per-tool gating — that would be
23
+ # ceremony without payoff given rubino's actual exposure.
24
+ #
25
+ # The decision is remembered in trusted_dirs.json under RUBINO_HOME so a
26
+ # trusted directory is never re-prompted (mirrors trustedDirectories).
27
+ module Trust
28
+ FILENAME = "trusted_dirs.json"
29
+
30
+ class << self
31
+ # True when +dir+ has been remembered as trusted. Compares on canonical
32
+ # (realpath) form so a symlinked/relative path matches its stored entry.
33
+ def trusted?(dir)
34
+ real = canonical(dir)
35
+ return false unless real
36
+
37
+ load_dirs.any? { |d| canonical(d) == real }
38
+ end
39
+
40
+ # Remembers +dir+ as trusted (idempotent). Stores the canonical path so
41
+ # later lookups match regardless of how the dir is later referenced.
42
+ def remember(dir)
43
+ real = canonical(dir)
44
+ return unless real
45
+
46
+ dirs = load_dirs
47
+ return if dirs.any? { |d| canonical(d) == real }
48
+
49
+ save_dirs(dirs + [real])
50
+ end
51
+
52
+ # The remembered list, canonicalised (for display / tests).
53
+ def trusted_dirs
54
+ load_dirs
55
+ end
56
+
57
+ def store_path
58
+ File.join(Rubino.home_path, FILENAME)
59
+ end
60
+
61
+ private
62
+
63
+ def load_dirs
64
+ return [] unless File.exist?(store_path)
65
+
66
+ data = JSON.parse(File.read(store_path))
67
+ data.is_a?(Array) ? data.map(&:to_s) : []
68
+ rescue StandardError
69
+ []
70
+ end
71
+
72
+ def save_dirs(dirs)
73
+ FileUtils.mkdir_p(File.dirname(store_path))
74
+ File.write(store_path, JSON.pretty_generate(dirs.uniq))
75
+ rescue StandardError
76
+ nil
77
+ end
78
+
79
+ def canonical(path)
80
+ return nil if path.nil? || path.to_s.empty?
81
+
82
+ File.realpath(File.expand_path(path.to_s))
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end