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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Abstract base class for all tools.
6
+ # Each tool must implement: name, description, input_schema, risk_level, call.
7
+ class Base
8
+ # Set by ToolExecutor before each call so long-running tools (shell,
9
+ # http, watchers) can poll for user cancellation. Default is nil — the
10
+ # tool should treat that as "no cancellation possible" and not crash.
11
+ attr_accessor :cancel_token
12
+
13
+ # Session-scoped ReadTracker injected by ToolExecutor. ReadTool
14
+ # registers successful reads; EditTool / MultiEditTool consult it
15
+ # before writing so they can refuse to edit a file the model never
16
+ # opened in this session. Nil-tolerant: tools that don't care just
17
+ # ignore it.
18
+ attr_accessor :read_tracker
19
+
20
+ # Optional Proc, injected by ToolExecutor, that the tool can call with
21
+ # incremental output chunks during a long-running call. ShellTool uses
22
+ # this to stream stdout/stderr lines as the subprocess writes them
23
+ # instead of dumping everything at end-of-command. Nil-tolerant: a
24
+ # tool with no streamable output (read, edit, glob) just ignores it.
25
+ attr_accessor :stream_chunk
26
+
27
+ # Convenience guard so tools don't sprinkle nil-checks at every emit.
28
+ def emit_chunk(text)
29
+ return if text.nil? || text.to_s.empty?
30
+
31
+ @stream_chunk&.call(text.to_s)
32
+ end
33
+
34
+ # True when the user has requested cancellation. Cheap, lock-protected.
35
+ # Use in tight loops; on true, terminate gracefully and either return
36
+ # an "interrupted" string or raise Rubino::Interrupted.
37
+ def cancellation_requested?
38
+ @cancel_token&.cancelled?
39
+ end
40
+
41
+ # Returns the tool name (used in LLM tool definitions)
42
+ def name
43
+ raise NotImplementedError, "#{self.class}#name not implemented"
44
+ end
45
+
46
+ # The `tools.<key>` config gate that enables/disables this tool. Single
47
+ # source of truth shared with Registry#tool_enabled_in_config? and the
48
+ # `tools` CLI command, so the displayed state always matches the state
49
+ # the registry actually enforces. Defaults to the tool's own name;
50
+ # tools whose config key differs (webfetch/websearch both gate on
51
+ # `tools.web`) override this. Returning a key absent from config means
52
+ # the tool is enabled (opt-out model).
53
+ def config_key
54
+ name
55
+ end
56
+
57
+ # Returns a description for the LLM
58
+ def description
59
+ raise NotImplementedError, "#{self.class}#description not implemented"
60
+ end
61
+
62
+ # Returns the JSON schema for input parameters
63
+ def input_schema
64
+ raise NotImplementedError, "#{self.class}#input_schema not implemented"
65
+ end
66
+
67
+ # Returns the risk level: :low, :medium, :high
68
+ def risk_level
69
+ :low
70
+ end
71
+
72
+ # Executes the tool with given arguments, returns output string
73
+ def call(arguments)
74
+ raise NotImplementedError, "#{self.class}#call not implemented"
75
+ end
76
+
77
+ # Returns true if this tool requires user confirmation
78
+ def risky?
79
+ %i[medium high].include?(risk_level)
80
+ end
81
+
82
+ # Returns the tool definition for LLM registration
83
+ def to_tool_definition
84
+ {
85
+ name: name,
86
+ description: description,
87
+ parameters: input_schema
88
+ }
89
+ end
90
+
91
+ protected
92
+
93
+ # Filesystem sandbox for write/edit/delete operations.
94
+ #
95
+ # Defaults to Dir.pwd, overridable via terminal.cwd in config. Mutating
96
+ # tools must call within_workspace? before touching the disk so a prompt
97
+ # injection that asks for `file_path: "/etc/passwd"` is refused at the
98
+ # tool boundary, before the approval prompt even sees the path.
99
+ #
100
+ # The check resolves every symlink with File.realpath before comparing
101
+ # against the workspace root: dropping a `link → /etc` inside the
102
+ # workspace and writing through it used to bypass the boundary because
103
+ # expand_path alone never crosses the symlink. realpath walks the
104
+ # filesystem and gives us the canonical destination, so an in-workspace
105
+ # path that ultimately points outside is rejected like any other escape.
106
+ # For non-existent targets (write-creates-new-file) we resolve the
107
+ # deepest existing ancestor and re-attach the remainder — the new file
108
+ # will land at that ancestor, so the ancestor is what we sandbox.
109
+ #
110
+ # Set tools.workspace_strict=false in config.yml to disable globally
111
+ # (the agent then trusts the model + the approval flow alone).
112
+ # The directory tools sandbox to. Exposed as a class method so the
113
+ # File API operations can root their Workspace at the SAME place
114
+ # (otherwise produced artifacts under this root look like traversal
115
+ # escapes relative to paths_home and the download 422s).
116
+ # The PRIMARY root — terminal.cwd or the launch cwd. Kept as the single
117
+ # source of truth for "the" directory: the @-picker, shell/test cwd, the
118
+ # File API workspace and the attachment downloader all root here so they
119
+ # agree. The write/edit SANDBOX, however, spans every root (see
120
+ # #within_workspace?) so an added dir is also writable.
121
+ def self.workspace_root
122
+ Workspace.primary_root
123
+ end
124
+
125
+ # Every allowed root (primary + any --add-dir / /add-dir dirs). The
126
+ # sandbox accepts a target under ANY of these.
127
+ def self.workspace_roots
128
+ Workspace.roots
129
+ end
130
+
131
+ def workspace_root
132
+ self.class.workspace_root
133
+ end
134
+
135
+ def workspace_roots
136
+ self.class.workspace_roots
137
+ end
138
+
139
+ def workspace_strict?
140
+ Rubino.configuration.dig("tools", "workspace_strict") != false
141
+ end
142
+
143
+ # True when +expanded+ resolves under ANY allowed root. Generalised from
144
+ # the old single-root check so a write/edit/multi_edit under a dir added
145
+ # via --add-dir / /add-dir is accepted, while a path outside every root
146
+ # is still refused. Symlinks are resolved (canonical_path) before the
147
+ # comparison so an in-workspace symlink to /etc can't escape.
148
+ def within_workspace?(expanded)
149
+ return true unless workspace_strict?
150
+
151
+ target_real = canonical_path(expanded)
152
+ return false unless target_real
153
+
154
+ Workspace.canonical_roots.any? do |root_real|
155
+ target_real == root_real ||
156
+ target_real.start_with?("#{root_real}#{File::SEPARATOR}")
157
+ end
158
+ end
159
+
160
+ # Resolves `path` through every symlink to its canonical destination.
161
+ # When the path doesn't exist yet (create-new-file flow) walks up to
162
+ # the deepest existing ancestor, realpaths that, then re-joins the
163
+ # missing tail. The tail itself can't traverse — expand_path already
164
+ # collapsed `..` segments before we got here.
165
+ def canonical_path(path)
166
+ return nil if path.nil? || path.to_s.empty?
167
+
168
+ expanded = File.expand_path(path.to_s)
169
+ return File.realpath(expanded) if File.exist?(expanded)
170
+
171
+ ancestor = expanded
172
+ tail = []
173
+ until File.exist?(ancestor)
174
+ parent = File.dirname(ancestor)
175
+ break if parent == ancestor
176
+
177
+ tail.unshift(File.basename(ancestor))
178
+ ancestor = parent
179
+ end
180
+ return nil unless File.exist?(ancestor)
181
+
182
+ File.join(File.realpath(ancestor), *tail)
183
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
184
+ nil
185
+ end
186
+
187
+ def workspace_violation_message(path)
188
+ roots = workspace_roots
189
+ where = roots.length == 1 ? roots.first : "any allowed root (#{roots.join(", ")})"
190
+ "Error: refusing to access '#{path}' — outside #{where}. " \
191
+ "Set tools.workspace_strict=false in config.yml to disable this check."
192
+ end
193
+
194
+ # Read-before-edit gate shared by EditTool and MultiEditTool. Refuses the
195
+ # write when the model never read this file in the current session, or
196
+ # read it but the file changed on disk since. Returns nil (proceed) or an
197
+ # error Hash carrying error_code: :stale_read for the model to recover
198
+ # from. No tracker injected → no gate (single-tool unit tests, MCP calls).
199
+ #
200
+ # `verb` is the only token that varies between callers ("edit" /
201
+ # "edits"); the wording is otherwise identical, so it lives here.
202
+ def read_gate_error(expanded, display_path, verb:)
203
+ return nil unless @read_tracker
204
+
205
+ unless @read_tracker.seen?(expanded)
206
+ return { output: "Error: must use the read tool on #{display_path} in this session before editing it. " \
207
+ "Read it first so the #{verb} can verify the surrounding context.",
208
+ error_code: :stale_read }
209
+ end
210
+
211
+ stashed = @read_tracker.mtime_at_read(expanded)
212
+ current = File.mtime(expanded)
213
+ return nil if stashed.nil? || current <= stashed
214
+
215
+ { output: "Error: #{display_path} changed on disk since the last read " \
216
+ "(read at #{stashed.utc.iso8601}, now #{current.utc.iso8601}). " \
217
+ "Re-read the file before editing so the #{verb} reflect the current contents.",
218
+ error_code: :stale_read }
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Loads user-defined tools from .rubino/tools/ directories.
6
+ # Users can define tools using a simple Ruby DSL.
7
+ #
8
+ # Example tool file (.rubino/tools/my_tool.rb):
9
+ #
10
+ # Rubino.define_tool do
11
+ # name "my_custom_tool"
12
+ # description "Does something custom"
13
+ # input_schema type: "object", properties: { input: { type: "string" } }
14
+ # risk_level :low
15
+ #
16
+ # execute do |args|
17
+ # "Result: #{args['input']}"
18
+ # end
19
+ # end
20
+ #
21
+ class CustomToolLoader
22
+ TOOL_GLOB = "*.rb"
23
+
24
+ # HOME-only by design (#44). This loader `load`s arbitrary Ruby, so it
25
+ # must NEVER read from a project's cwd `.rubino/tools` — that would let
26
+ # any directory you start rubino in execute code with zero prompt, the
27
+ # exact foot-gun the folder-trust model exists to prevent. The only
28
+ # allowed source is the user's own config dir under RUBINO_HOME, which is
29
+ # not attacker-controllable by cd-ing into a repo. (Previously the path
30
+ # list led with the cwd `.rubino/tools`; that entry is removed.)
31
+ def self.tool_paths
32
+ [File.join(Rubino.home_path, "tools")]
33
+ end
34
+
35
+ def initialize(paths: nil)
36
+ @paths = paths || self.class.tool_paths
37
+ end
38
+
39
+ # Loads all custom tools and registers them
40
+ def load_all!
41
+ loaded = 0
42
+
43
+ @paths.each do |dir|
44
+ expanded = File.expand_path(dir)
45
+ next unless File.directory?(expanded)
46
+
47
+ Dir.glob(File.join(expanded, TOOL_GLOB)).each do |path|
48
+ load_tool_file(path)
49
+ loaded += 1
50
+ rescue StandardError => e
51
+ Rubino.ui.warning("Failed to load tool #{path}: #{e.message}")
52
+ end
53
+ end
54
+
55
+ loaded
56
+ end
57
+
58
+ private
59
+
60
+ def load_tool_file(path)
61
+ # Load in a clean context
62
+ load(path)
63
+ end
64
+ end
65
+
66
+ # DSL builder for custom tools
67
+ class CustomToolBuilder
68
+ attr_reader :_name, :_description, :_input_schema, :_risk_level, :_execute_block
69
+
70
+ def initialize
71
+ @_risk_level = :low
72
+ @_input_schema = { type: "object", properties: {} }
73
+ end
74
+
75
+ def name(val)
76
+ @_name = val
77
+ end
78
+
79
+ def description(val)
80
+ @_description = val
81
+ end
82
+
83
+ def input_schema(val)
84
+ @_input_schema = val
85
+ end
86
+
87
+ def risk_level(val)
88
+ @_risk_level = val
89
+ end
90
+
91
+ def execute(&block)
92
+ @_execute_block = block
93
+ end
94
+
95
+ # Builds a Tool instance from the DSL
96
+ def build
97
+ builder = self
98
+ Class.new(Base) do
99
+ define_method(:name) { builder._name }
100
+ define_method(:description) { builder._description }
101
+ define_method(:input_schema) { builder._input_schema }
102
+ define_method(:risk_level) { builder._risk_level }
103
+ define_method(:call) { |args| builder._execute_block.call(args) }
104
+ end.new
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Module-level DSL method for defining custom tools
111
+ module Rubino
112
+ def self.define_tool(&)
113
+ builder = Tools::CustomToolBuilder.new
114
+ builder.instance_eval(&)
115
+ tool = builder.build
116
+ Tools::Registry.register(tool)
117
+ tool
118
+ end
119
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Tool for performing exact string replacements in files.
6
+ # Replaces a specific old string with a new string - more precise than full file writes.
7
+ class EditTool < Base
8
+ def name
9
+ "edit"
10
+ end
11
+
12
+ def description
13
+ "Perform exact string replacement in a file. " \
14
+ "Specify the old text to find and the new text to replace it with. " \
15
+ "The old text must match exactly (including whitespace/indentation). " \
16
+ "Use replace_all to replace all occurrences."
17
+ end
18
+
19
+ def input_schema
20
+ {
21
+ type: "object",
22
+ properties: {
23
+ file_path: {
24
+ type: "string",
25
+ description: "The path to the file to edit"
26
+ },
27
+ old_string: {
28
+ type: "string",
29
+ description: "The exact text to find and replace"
30
+ },
31
+ new_string: {
32
+ type: "string",
33
+ description: "The text to replace it with"
34
+ },
35
+ replace_all: {
36
+ type: "boolean",
37
+ description: "Replace all occurrences (default: false, replaces first only)"
38
+ }
39
+ },
40
+ required: %w[file_path old_string new_string]
41
+ }
42
+ end
43
+
44
+ def risk_level
45
+ :medium
46
+ end
47
+
48
+ def call(arguments)
49
+ file_path = arguments["file_path"] || arguments[:file_path]
50
+ old_string = arguments["old_string"] || arguments[:old_string]
51
+ new_string = arguments["new_string"] || arguments[:new_string]
52
+ replace_all = arguments["replace_all"] || arguments[:replace_all] || false
53
+
54
+ expanded = File.expand_path(file_path)
55
+ return workspace_violation_message(file_path) unless within_workspace?(expanded)
56
+
57
+ return "Error: File not found: #{file_path}" unless File.exist?(expanded)
58
+
59
+ if (gate = read_gate_error(expanded, file_path, verb: "edit"))
60
+ return gate
61
+ end
62
+
63
+ content = File.read(expanded)
64
+
65
+ unless content.include?(old_string)
66
+ return "Error: old_string not found in file content. " \
67
+ "Make sure the text matches exactly including whitespace."
68
+ end
69
+
70
+ # Count occurrences
71
+ count = content.scan(old_string).size
72
+ if count > 1 && !replace_all
73
+ return "Error: Found #{count} matches for old_string. " \
74
+ "Provide more surrounding context to make it unique, " \
75
+ "or set replace_all: true to replace all occurrences."
76
+ end
77
+
78
+ # Perform replacement — use block form so new_string is treated as a
79
+ # literal string, not a pattern (avoids \0, \1, \& interpolation bugs).
80
+ new_content = if replace_all
81
+ content.gsub(old_string) { new_string }
82
+ else
83
+ content.sub(old_string) { new_string }
84
+ end
85
+
86
+ File.write(expanded, new_content)
87
+
88
+ replaced_count = replace_all ? count : 1
89
+ added = new_string.to_s.lines.size
90
+ removed = old_string.to_s.lines.size
91
+ { output: "Edit applied: #{replaced_count} replacement(s) in #{file_path}",
92
+ metrics: "#{replaced_count} replacement#{"s" if replaced_count != 1} · " \
93
+ "+#{added * replaced_count} −#{removed * replaced_count}",
94
+ body: build_diff_preview(old_string, new_string, replaced_count),
95
+ body_kind: :diff }
96
+ end
97
+
98
+ private
99
+
100
+ # Inline diff shown between the `tool · edit` and `done · edit` headers.
101
+ # Not a real unified diff — just `- old` then `+ new` so the user can
102
+ # see at a glance what the model is changing without scrolling back to
103
+ # the approval prompt. Trimmed to the first 12 lines; long edits still
104
+ # apply, the body is only a preview.
105
+ MAX_DIFF_LINES = 12
106
+
107
+ def build_diff_preview(old_str, new_str, replaced_count)
108
+ minus = old_str.to_s.lines.map { |l| "- #{l.chomp}" }
109
+ plus = new_str.to_s.lines.map { |l| "+ #{l.chomp}" }
110
+ lines = minus + plus
111
+ suffix = []
112
+ if lines.size > MAX_DIFF_LINES
113
+ dropped = lines.size - MAX_DIFF_LINES
114
+ lines = lines.first(MAX_DIFF_LINES)
115
+ suffix << " [… #{dropped} more line(s)]"
116
+ end
117
+ suffix << " (× #{replaced_count} occurrences)" if replaced_count > 1
118
+ (lines + suffix).join("\n")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Tool for git operations: status, diff, log, branch info.
6
+ class GitTool < Base
7
+ def name
8
+ "git"
9
+ end
10
+
11
+ def description
12
+ "Execute git commands to inspect repository state. " \
13
+ "Supports status, diff, log, branch, and show operations."
14
+ end
15
+
16
+ def input_schema
17
+ {
18
+ type: "object",
19
+ properties: {
20
+ command: {
21
+ type: "string",
22
+ enum: %w[status diff log branch show],
23
+ description: "The git command to execute"
24
+ },
25
+ args: {
26
+ type: "string",
27
+ description: "Additional arguments for the command"
28
+ }
29
+ },
30
+ required: %w[command]
31
+ }
32
+ end
33
+
34
+ def risk_level
35
+ :low # Read-only git operations
36
+ end
37
+
38
+ def call(arguments)
39
+ command = arguments["command"] || arguments[:command]
40
+ args = arguments["args"] || arguments[:args] || ""
41
+
42
+ case command
43
+ when "status"
44
+ execute_git("status", args)
45
+ when "diff"
46
+ execute_git("diff", args)
47
+ when "log"
48
+ execute_git("log --oneline -20", args)
49
+ when "branch"
50
+ execute_git("branch", args)
51
+ when "show"
52
+ execute_git("show", args)
53
+ else
54
+ "Unknown git command: #{command}"
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def execute_git(cmd, args)
61
+ # Split cmd into tokens and append sanitised args to avoid shell injection.
62
+ # IO.popen with an argv array never passes the arguments through a shell.
63
+ argv = ["git"] + cmd.split + args.split
64
+ result = IO.popen(argv, err: %i[child out], &:read)
65
+ result.empty? ? "(no output)" : result
66
+ rescue StandardError => e
67
+ "Git error: #{e.message}"
68
+ end
69
+ end
70
+ end
71
+ end