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,569 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Config
5
+ # Default configuration values for the entire system.
6
+ # These mirror the Rich config structure adapted for Ruby.
7
+ module Defaults
8
+ # Sentinel for the default database path. When config still carries this
9
+ # value, Configuration#database_path resolves it against the resolved
10
+ # home (RUBINO_HOME) instead of a literal ~/.rubino (issue #96).
11
+ DEFAULT_DATABASE_PATH = "<RUBINO_HOME>/rubino.sqlite3"
12
+
13
+ # Sentinel for the user-home commands directory. Resolved at read time
14
+ # (Commands::Loader/Executor) against the resolved home (RUBINO_HOME)
15
+ # instead of a literal ~/.rubino so commands in a custom home are
16
+ # actually discovered (issue #38).
17
+ HOME_COMMANDS_PATH = "<RUBINO_HOME>/commands"
18
+
19
+ MODULE_DEFAULTS = {
20
+ "model" => {
21
+ "default" => "openai/gpt-4.1",
22
+ "provider" => "auto",
23
+ "context_length" => nil,
24
+ "temperature" => 0.3,
25
+ # Max output tokens for the anthropic-family path (anthropic_compatible
26
+ # MiniMax, native anthropic, bedrock). ruby_llm defaults the Anthropic
27
+ # max_tokens to 4096, which a reasoning model can exhaust on thinking
28
+ # tokens alone → empty visible text. nil = use the adapter default
29
+ # (16384). providers.<name>.max_tokens overrides per-backend.
30
+ "max_tokens" => nil,
31
+ # Thinking/reasoning token budget for the anthropic-family path. nil =
32
+ # adapter default (8000, the reference "medium"). 0 disables thinking.
33
+ # providers.<name>.thinking_budget overrides per-backend.
34
+ "thinking_budget" => nil,
35
+ # Visible-output headroom (tokens) reserved on top of the thinking
36
+ # budget so the model can think AND answer. Mirrors the reference +4096.
37
+ "max_tokens_text_headroom" => 4096,
38
+ # nil = auto-detect from model_id via LLM::ContentBuilder.supports_vision?.
39
+ # Set to true/false to override (e.g. when running behind a gateway that
40
+ # hides the real upstream model name, like the gateway provider's `auto`).
41
+ "supports_vision" => nil
42
+ },
43
+ "providers" => {
44
+ "openai" => {
45
+ "base_url" => nil,
46
+ # Per-READ socket inactivity (resets on every streamed chunk), NOT a
47
+ # total — this is the agent's first-token + inter-token idle bound,
48
+ # same as the OpenAI/Anthropic SDK default. A silent socket fails
49
+ # within this window and is retried pre-first-token. Raise it for a
50
+ # large local Ollama that cold-loads for minutes before token #1.
51
+ "request_timeout_seconds" => 600,
52
+ "stale_timeout_seconds" => 300
53
+ },
54
+ "anthropic" => {
55
+ "base_url" => nil,
56
+ "request_timeout_seconds" => 600
57
+ },
58
+ "bedrock" => {
59
+ "region" => "us-east-1",
60
+ "request_timeout_seconds" => 600
61
+ },
62
+ "gemini" => {
63
+ "request_timeout_seconds" => 600
64
+ },
65
+ # Opt-in provider for an OpenAI-compatible gateway. Point it at any
66
+ # gateway that exposes an OpenAI-style /v1/* API: set base_url and
67
+ # api_key and the agent routes everything here regardless of model id.
68
+ # The gateway decides which upstream (OpenAI/Anthropic/…) and model
69
+ # to call. Set model.provider: "gateway" to enable.
70
+ "gateway" => {
71
+ "openai_compatible" => true,
72
+ "assume_model_exists" => true,
73
+ "base_url" => nil,
74
+ "request_timeout_seconds" => 600
75
+ }
76
+ },
77
+ "auxiliary" => {
78
+ "compression" => {
79
+ "provider" => "main",
80
+ "model" => "",
81
+ "base_url" => nil,
82
+ "timeout" => 120
83
+ },
84
+ "approval" => {
85
+ "provider" => "main",
86
+ "model" => "",
87
+ "base_url" => nil,
88
+ "timeout" => 30
89
+ },
90
+ # Multimodal aux. When set, the `vision` tool delegates here so a
91
+ # text-only primary can still "see" an image. `provider: "main"`
92
+ # reuses the primary's provider/base_url; otherwise both can be
93
+ # overridden. Set `model: "auto-vision"` to let the gateway proxy
94
+ # pick a vision model from the model catalog.
95
+ "vision" => {
96
+ "provider" => "main",
97
+ "model" => "",
98
+ "base_url" => nil,
99
+ "timeout" => 120
100
+ },
101
+ # Document summarization. The `summarize_file` tool delegates here so
102
+ # the raw bytes of a huge file are map-reduced in these aux calls and
103
+ # never enter the main agent context (only the final summary returns).
104
+ # `provider: "main"` reuses the primary's provider/model.
105
+ "summarize" => {
106
+ "provider" => "main",
107
+ "model" => "",
108
+ "base_url" => nil,
109
+ "timeout" => 300
110
+ }
111
+ },
112
+ "agent" => {
113
+ "max_turns" => 90,
114
+ "max_tool_iterations" => 8,
115
+ "max_turn_seconds" => 120,
116
+ # 5 retries with exponential backoff = 1+2+4+8+16 = 31s total wait.
117
+ # Sized to absorb common provider blips (MiniMax intl in particular
118
+ # has been observed returning "API server error - please try again"
119
+ # for ~15-25 seconds before recovering) without timing out the user.
120
+ "api_max_retries" => 5,
121
+ # Hard ceiling (seconds) on a single full-jitter backoff draw between
122
+ # retries: sleep = max(0.2, rand * min(2^(n-1), cap)). Caps worst-case
123
+ # per-retry wait so a flapping backend can't stall a turn for minutes.
124
+ "api_retry_backoff_cap_seconds" => 16,
125
+ # Higher ceiling used ONLY for overload (529/503) and MiniMax "unknown
126
+ # error" blips: those backends stay overloaded for tens of seconds, so
127
+ # the 16s cap retries too eagerly back into a still-hot endpoint. 60s
128
+ # lets the backoff ride out the overload window (the reference uses 120s).
129
+ "api_retry_backoff_overload_cap_seconds" => 60,
130
+ # In-turn retries for a 200-OK-but-EMPTY model response (no text, no
131
+ # tool calls). After this many re-issues of the same turn the Loop
132
+ # raises EmptyModelResponseError → run marked failed (never a silent
133
+ # "completed but empty"). Mirrors the reference treating an empty/invalid
134
+ # response as retryable-then-terminal.
135
+ "empty_response_max_retries" => 2,
136
+ # Provider/model fallback chain (Slice 7 — Agent::FallbackChain). An
137
+ # ORDERED list of backends to rotate to when the primary keeps failing
138
+ # (invalid/empty responses, rate-limit, overload, exhausted retries).
139
+ # The primary is implicit (index 0); these are the fallbacks tried in
140
+ # order. EMPTY by default → no fallback, behaviour byte-identical to a
141
+ # single-provider setup. Each entry:
142
+ # { "provider" => "anthropic", "model" => "claude-...",
143
+ # "base_url" => nil, "api_key" => nil }
144
+ # provider + model are required; base_url/api_key override the
145
+ # providers.<name> config for that entry (custom endpoints). An entry
146
+ # that resolves to the current provider/model/base_url is skipped
147
+ # (dedup) so we never fall back to the backend that just failed.
148
+ "fallback_models" => [],
149
+ "disabled_toolsets" => [],
150
+ "tool_use_enforcement" => "auto"
151
+ },
152
+ "run" => {
153
+ # SSE watchdog: when a run is "running" but no new event has been
154
+ # written for this many seconds, EventsOperation marks it failed and
155
+ # emits a synthetic run.failed frame. Covers cases the executor's
156
+ # rescue can't (model in infinite tool loop, provider stream hung,
157
+ # OS-level thread death). Set to nil to disable.
158
+ "idle_event_timeout" => 300
159
+ },
160
+ "database" => {
161
+ # Sentinel: resolved at read time (Configuration#database_path) to
162
+ # "<resolved home>/rubino.sqlite3" so the DB follows
163
+ # RUBINO_HOME like config/.env/skills do. An explicit override
164
+ # in config.yml replaces this and is used verbatim (issue #96).
165
+ "path" => DEFAULT_DATABASE_PATH
166
+ },
167
+ "paths" => {
168
+ "home" => "~/.rubino",
169
+ "memory" => "~/.rubino/memories",
170
+ "skills" => "~/.rubino/skills",
171
+ "cron" => "~/.rubino/cron",
172
+ "sessions" => "~/.rubino/sessions",
173
+ "logs" => "~/.rubino/logs"
174
+ },
175
+ "ui" => {
176
+ "adapter" => "cli",
177
+ "theme" => "default",
178
+ "verbose" => false
179
+ },
180
+ "display" => {
181
+ "streaming" => true,
182
+ # Tri-state reasoning render (display.reasoning): "hidden" suppresses
183
+ # thinking entirely, "collapsed" buffers it and commits a one-liner cue
184
+ # ("thought for Ns"), "full" renders the whole reasoning as a dim aside
185
+ # above the answer. Deliberately NOT seeded here (#132): defaults
186
+ # injecting it made the documented legacy display.show_reasoning
187
+ # mapping (true→full, false→hidden, applied only when
188
+ # display.reasoning is unset) unreachable for every config loaded
189
+ # normally. Config::ReasoningPrefs supplies the "collapsed" default
190
+ # when neither key is set.
191
+ "language" => "en",
192
+ "runtime_footer" => { "enabled" => false },
193
+ "interim_assistant_messages" => false,
194
+ # The dim status bar pinned UNDER the chat input (model id + context
195
+ # saturation), refreshed at turn boundaries. Omitted automatically
196
+ # off a TTY or on terminals narrower than 40 columns.
197
+ "statusbar" => true,
198
+ # Head lines of each tool's output shown in the transcript before a
199
+ # dim "… +N lines (full output → context)" marker. DISPLAY-ONLY —
200
+ # the model always receives the full (truncation-capped) output.
201
+ # 0 disables the collapse (old full dump).
202
+ "tool_output_preview_lines" => 3,
203
+ # Cap on the chat input's visual rows: a long/multi-line prompt
204
+ # wraps and grows the input downward up to this many rows, then
205
+ # scrolls vertically (caret kept in view).
206
+ "input_max_rows" => 8
207
+ },
208
+ "paste" => {
209
+ # File-backed paste pipeline (UI::PasteStore). A paste with MORE
210
+ # than collapse_lines lines collapses to a "[Pasted text #N +M
211
+ # lines]" placeholder in the chat input, expanded to the full body
212
+ # when the message is sent (the transcript echo keeps the
213
+ # placeholder). A paste estimated above file_threshold_tokens
214
+ # (chars/4) is written to <home>/sessions/<id>/paste_N.txt instead
215
+ # and the sent message carries a read-tool pointer to it.
216
+ "collapse_lines" => 5,
217
+ "file_threshold_tokens" => 8000
218
+ },
219
+ "notifications" => {
220
+ # Attention signals (UI::Notifier) for the moments the agent needs
221
+ # human eyes: a long turn finishing, an approval prompt, a blocked
222
+ # subagent. CLI-only; never emitted into a pipe.
223
+ "enabled" => true,
224
+ # Ring the terminal bell (BEL). On iTerm2 an OSC 9 escape is also
225
+ # sent so it surfaces as a native macOS notification.
226
+ "bell" => true,
227
+ # Optional shell command spawned non-blocking per event with
228
+ # RUBINO_EVENT (turn_finished|needs_approval|blocked) and
229
+ # RUBINO_MESSAGE in its env — e.g. osascript / notify-send.
230
+ "command" => nil,
231
+ # A turn must run at least this many seconds before its completion
232
+ # notifies; quick turns stay silent.
233
+ "min_turn_seconds" => 10
234
+ },
235
+ "thinking" => {
236
+ # Reasoning effort: off | low | medium | high. Mapped to an Anthropic
237
+ # thinking-token budget (off→0, low→4000, medium→8000, high→16000) on
238
+ # the anthropic-family path. "off" disables thinking. When SET it wins
239
+ # over the model/provider thinking_budget chain; left nil (the default)
240
+ # the budget falls through that chain, whose own default is 8000 — i.e.
241
+ # the effective default effort is already "medium". /think reports
242
+ # "medium" for the nil case.
243
+ "effort" => nil
244
+ },
245
+ "streaming" => {
246
+ "enabled" => true,
247
+ "transport" => "off",
248
+ "edit_interval" => 0.3,
249
+ "buffer_threshold" => 40,
250
+ "cursor" => " \u2589"
251
+ },
252
+ "context" => {
253
+ "engine" => "compressor",
254
+ "max_tokens" => nil
255
+ },
256
+ "compression" => {
257
+ "enabled" => true,
258
+ "threshold" => 0.50,
259
+ "gateway_threshold" => 0.85,
260
+ "target_ratio" => 0.20,
261
+ "protect_first_n" => 3,
262
+ "protect_last_n" => 20,
263
+ "max_summary_tokens" => 12_000,
264
+ "preserve_tool_pairs" => true
265
+ },
266
+ "memory" => {
267
+ "enabled" => true,
268
+ "backend" => "sqlite",
269
+ "auto_extract" => true,
270
+ "auto_save" => true,
271
+ "user_profile_enabled" => true,
272
+ "project_context_enabled" => true,
273
+ "memory_char_limit" => 2200,
274
+ "user_char_limit" => 1375,
275
+ # Ingest/store cap for the live memory set, kept SEPARATE from the
276
+ # injection budget above. `memory_char_limit` only bounds what gets
277
+ # packed into the prompt at RETRIEVAL time; storing facts must not be
278
+ # throttled by it or long multi-session conversations stall once the
279
+ # injection budget fills. `nil` = unbounded ingest (the default).
280
+ "ingest_char_limit" => nil,
281
+ # tiny-Zep SQLite backend tuning. `vector` enables best-effort
282
+ # sqlite-vec/RubyLLM.embed KNN on top of the always-on FTS5 hybrid;
283
+ # off by default so the stock install needs no extra deps. `graph`
284
+ # is the graph-lite 1-hop entity/edge blend (on by default).
285
+ "sqlite" => {
286
+ "vector" => false,
287
+ "graph" => true
288
+ }
289
+ },
290
+ "jobs" => {
291
+ "mode" => "inline",
292
+ "poll_interval" => 2,
293
+ "max_attempts" => 3,
294
+ "retry_backoff_seconds" => 30
295
+ },
296
+ # Nested-subagent (the `task` delegation tool) caps. A subagent CAN now
297
+ # spawn its own subagents; these three caps bound the tree so depth ×
298
+ # fan-out cannot blow past the process's thread/cost budget. All three are
299
+ # enforced in ONE place — Tools::BackgroundTasks#reserve — which refuses a
300
+ # spawn (the tool then surfaces a clear at-capacity / max-depth message).
301
+ "tasks" => {
302
+ # Max nesting depth. depth 0 = a human/top-level-spawned child; the cap
303
+ # bounds chains of subagents-spawning-subagents. 2 ⇒ human→child→grandchild
304
+ # (no deeper).
305
+ "max_depth" => 2,
306
+ # Max LIVE direct children one node (human/top-level or a single
307
+ # subagent) may have at once.
308
+ "max_children_per_node" => 3,
309
+ # Hard global ceiling on total LIVE subagents across the whole tree.
310
+ "max_concurrent_total" => 8,
311
+ # Per-child budget for BILLED live probes (`probe(live:true)`): how many
312
+ # times an owner may run a one-shot model peek over a single child's
313
+ # transcript. Over budget → the model is told to use the FREE
314
+ # live:false snapshot instead. Free snapshots are unlimited.
315
+ "max_live_probes_per_child" => 5,
316
+ # Bound (seconds) a BLOCKING ask_parent waits before the child
317
+ # self-heals and proceeds with its best judgement (S5a). Matches the
318
+ # approvals wait-timeout default — never "forever".
319
+ "ask_parent_timeout" => 900
320
+ },
321
+ "tools" => {
322
+ # Sandbox write/edit/delete tools to workspace_root (terminal.cwd
323
+ # or Dir.pwd). Set to false to let the model touch any path the
324
+ # process can reach — only do this if you trust the model + the
325
+ # approval flow alone.
326
+ "workspace_strict" => true,
327
+ "git" => true,
328
+ # Default ON: the agent ships to run inside an isolated per-customer
329
+ # VM where running shell commands is the whole point. The blast radius
330
+ # is the VM, and security.require_confirmation_for_shell (default true)
331
+ # still gates every command behind an approval prompt.
332
+ "shell" => true,
333
+ "ruby" => true,
334
+
335
+ "web" => false,
336
+ "memory" => true
337
+ },
338
+ "tool_output" => {
339
+ "max_bytes" => 50_000,
340
+ "max_lines" => 2000,
341
+ "max_line_length" => 2000
342
+ },
343
+ "file_read" => {
344
+ "max_chars" => 100_000
345
+ },
346
+ "terminal" => {
347
+ "backend" => "local",
348
+ "cwd" => nil,
349
+ "file_sync_enabled" => false,
350
+ "file_sync_max_mb" => 100
351
+ },
352
+ "approvals" => {
353
+ "mode" => "manual",
354
+ # Auto-allow provably READ-ONLY shell commands (ls, pwd, cat, grep,
355
+ # git log, ...) without an approval prompt. The whole line must
356
+ # parse as safe (Security::ReadonlyCommands): no redirection or
357
+ # command/process substitution, every pipe/&&/; segment from the
358
+ # read-only set, no mutating flags (find -exec/-delete, ...).
359
+ # Anything ambiguous still prompts. The hardline floor and
360
+ # permissions:deny always run first, so this never weakens them.
361
+ "auto_allow_readonly" => true,
362
+ # Extra command names (or leading-token prefixes, e.g. "docker ps")
363
+ # merged into the built-in read-only set. The same parse validation
364
+ # applies to every segment.
365
+ "readonly_commands" => [],
366
+ # How long (seconds) a run waits on a human approval/clarification
367
+ # before giving up. On expiry the gate AUTO-DENIES (never approves)
368
+ # and frees the worker thread — an abandoned approval (closed tab, no
369
+ # answer) must not park a server worker indefinitely (W1). A sane
370
+ # bound (15 min), not the old 24h that effectively never released.
371
+ # Set to nil for a truly unbounded wait (interruptible only by an
372
+ # explicit run stop; discouraged on shared servers). While a decision
373
+ # is pending the SSE idle watchdog is suspended for that run
374
+ # (EventsOperation), so the run is never reaped mid-wait.
375
+ "wait_timeout_seconds" => 900
376
+ },
377
+
378
+ # SSRF guard for Run::AttachmentDownloader. Only URLs whose host is in
379
+ # this list (case-insensitive) are fetched into the run workspace; the
380
+ # downloader refuses everything else. ENV["ALLOWED_FILE_URL_HOSTS"]
381
+ # (comma-separated) is merged in too, so a downstream consumer can keep
382
+ # using its existing env knob. Loopback hosts (localhost, 127.0.0.1, ::1) are
383
+ # ALWAYS allowed on top of this list, since an HTTP client co-located on the
384
+ # same host produces loopback attachment URLs.
385
+ # Empty list + empty env = only loopback is fetchable.
386
+ "attachments" => {
387
+ "allowed_hosts" => [],
388
+ # Secure-by-default policy for the universal file-attachment handler
389
+ # (Attachments::Classify / Preamble). Every default is on the secure
390
+ # branch; explicit user config wins (Configuration merges over these).
391
+ # Fail closed: oversize / unsafe / disallowed-kind => warn + skip.
392
+ "policy" => {
393
+ # Hard cap on accepted file size, enforced via lstat BEFORE reading.
394
+ "max_file_bytes" => 26_214_400, # 25 MB
395
+ # Inline budget for text files; over budget => head + read-rest note.
396
+ "inline_text_budget_bytes" => 100_000, # ~25k tokens
397
+ # Kinds the handler will process. Deny one by removing it.
398
+ "allow_kinds" => %w[image text document archive binary],
399
+ # Documents are hint-only by default (cost / injection blast radius);
400
+ # the flag is reserved for a future in-process extract path.
401
+ "auto_extract_documents" => false,
402
+ # Routing an image to an EXTERNAL aux model is data egress; on by
403
+ # default to preserve the existing aux-vision behaviour.
404
+ "aux_vision_egress" => true,
405
+ # Caps for any in-process archive listing (hint-only today, so
406
+ # unused unless listing is enabled).
407
+ "archive" => {
408
+ "max_entries" => 2000,
409
+ "max_uncompressed_bytes" => 268_435_456,
410
+ "max_entry_ratio" => 100,
411
+ "max_total_ratio" => 50,
412
+ "max_nesting_depth" => 1
413
+ }
414
+ }
415
+ },
416
+ "security" => {
417
+ # Prompt policy for shell commands not otherwise allowed/denied:
418
+ # confirm_all (DEFAULT) every such command prompts for approval.
419
+ # dangerous_only (reference-faithful) safe commands run unprompted;
420
+ # only DangerousPatterns matches prompt.
421
+ # Intentionally NOT defaulted here: when the key is absent the
422
+ # accessor derives it from require_confirmation_for_shell below
423
+ # (true -> confirm_all, false -> dangerous_only). Setting the key
424
+ # explicitly makes confirm_policy win over the legacy alias. The
425
+ # hardline floor and permissions:deny always precede this regardless
426
+ # of policy, so dangerous_only never weakens the non-bypassable floor.
427
+ #
428
+ # "confirm_policy" => "confirm_all",
429
+ #
430
+ # Legacy alias for confirm_policy (see above). Kept working for any
431
+ # existing readers. When true, every `shell` command goes through the
432
+ # approval prompt regardless of the tool's own risk level. Default ON.
433
+ "require_confirmation_for_shell" => true,
434
+ "command_allowlist" => [
435
+ "git status",
436
+ "git diff",
437
+ "bundle exec rspec"
438
+ ],
439
+
440
+ "website_blocklist" => {
441
+ "enabled" => false,
442
+ "domains" => [],
443
+ "shared_files" => []
444
+ }
445
+ },
446
+ "privacy" => {
447
+ "redact_pii" => false
448
+ },
449
+ "clarify" => {
450
+ "timeout" => 120
451
+ },
452
+ "worktree" => {
453
+ "enabled" => false
454
+ },
455
+ # System-prompt layering. Defaults ship the built-in role prompts
456
+ # from lib/rubino/agent/prompts/*.txt. Customers customise via
457
+ # config.yml:
458
+ # prompts.preamble — single block prepended after the role
459
+ # identity; the natural place for "You are running inside
460
+ # <product>" customer context.
461
+ # prompts.environment.enabled — when true (default) the assembler
462
+ # injects an [Environment] block with date/OS/cwd/git/runtimes
463
+ # and the list of CLI utilities found on PATH. Cached per
464
+ # process — re-probed every boot, not every turn.
465
+ # prompts.environment.extra_utilities — additional binaries to
466
+ # probe beyond EnvironmentInspector::DEFAULT_UTILITIES.
467
+ # prompts.overrides.<role> — full replacement of the built-in
468
+ # role prompt (escape hatch; prefer preamble for incremental
469
+ # tweaks).
470
+ "prompts" => {
471
+ "preamble" => nil,
472
+ "environment" => {
473
+ "enabled" => true,
474
+ "extra_utilities" => []
475
+ },
476
+ "overrides" => {}
477
+ },
478
+ "quick_commands" => {},
479
+ "mcp" => {
480
+ "servers" => {}
481
+ },
482
+ "skills" => {
483
+ "enabled" => true,
484
+ # Post-turn skill distillation (Variant B). When true, a successful,
485
+ # tool-heavy turn enqueues DistillSkillJob, which spends ONE auxiliary
486
+ # model call to distil a reusable SKILL.md. Mirrors memory.auto_extract:
487
+ # a separate toggle from `enabled` (which only controls whether skills
488
+ # are loaded/usable) so a deployment — or a test that scripts a fixed
489
+ # number of LLM turns — can keep skills usable while turning off the
490
+ # extra background aux call.
491
+ "auto_distill" => true,
492
+ # Discover the skills shipped *inside the gem* (skills/<name>/SKILL.md),
493
+ # so every install gets the built-in catalogue (e.g. ruby-expert) with
494
+ # no copy step, on top of the user paths below. Built-ins are scanned
495
+ # first, so a same-named user skill still overrides them. Set false to
496
+ # run with only your own skills.
497
+ "include_builtin" => true,
498
+ "paths" => [
499
+ ".rubino/skills",
500
+ "~/.rubino/skills"
501
+ ]
502
+ },
503
+ "commands" => {
504
+ "paths" => [
505
+ ".rubino/commands",
506
+ HOME_COMMANDS_PATH
507
+ ],
508
+ # When false (default), !`shell` interpolation in command templates is
509
+ # disabled. Set to true only in trusted environments where you explicitly
510
+ # want command templates to execute shell commands.
511
+ "shell_injection_enabled" => false
512
+ },
513
+ "permissions" => {},
514
+ "formatters" => {},
515
+ "agents" => {},
516
+ "server" => {
517
+ "port" => 4820,
518
+ "auth" => false
519
+ },
520
+ "api" => {
521
+ # Hard cap on JSON request bodies. Anything past this (whether
522
+ # advertised by Content-Length or revealed mid-read) is rejected
523
+ # with 413 before the parser allocates the full payload — keeps a
524
+ # multi-GB POST from OOM-killing the process.
525
+ "max_body_bytes" => 5 * 1024 * 1024,
526
+ # Hard cap on multipart upload payload (POST /v1/files). Checked
527
+ # against Content-Length first, then enforced mid-stream so a
528
+ # truncated/missing Content-Length cannot saturate the disk.
529
+ "max_upload_bytes" => 50 * 1024 * 1024,
530
+ # Token-bucket rate limiter. Unauth bucket (per remote IP) protects
531
+ # /v1/health and /v1/metrics from public floods; auth bucket (per
532
+ # bearer token) caps authenticated callers. Storage is in-memory,
533
+ # so multi-process deployments need a shared backend before this
534
+ # gives meaningful protection across workers.
535
+ "rate_limit_enabled" => true,
536
+ "rate_limit_unauth_per_minute" => 60,
537
+ "rate_limit_auth_per_minute" => 600
538
+ }
539
+ }.freeze
540
+
541
+ class << self
542
+ # Deep copy so a Configuration#set on a never-overridden nested section
543
+ # (e.g. display.reasoning) mutates the per-config hash, NOT the shared
544
+ # MODULE_DEFAULTS constant. A shallow .dup left nested section hashes
545
+ # aliased to the constant, so the first /reasoning or /think write
546
+ # poisoned the process-wide default.
547
+ def to_hash
548
+ deep_dup(MODULE_DEFAULTS)
549
+ end
550
+
551
+ def deep_dup(obj)
552
+ case obj
553
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
554
+ when Array then obj.map { |v| deep_dup(v) }
555
+ else obj
556
+ end
557
+ end
558
+
559
+ def to_yaml
560
+ MODULE_DEFAULTS.to_yaml
561
+ end
562
+
563
+ def dig(*keys)
564
+ MODULE_DEFAULTS.dig(*keys)
565
+ end
566
+ end
567
+ end
568
+ end
569
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Rubino
7
+ module Config
8
+ class ConfigError < StandardError; end
9
+
10
+ # Responsible for loading configuration from YAML files and environment.
11
+ # Searches in order: project-local, user home, defaults.
12
+ class Loader
13
+ CONFIG_FILENAME = "config.yml"
14
+ ENV_FILENAME = ".env"
15
+ ENV_VAR_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/
16
+
17
+ attr_reader :home_path, :config_path, :env_path
18
+
19
+ # Single source of truth for the home directory: RUBINO_HOME when
20
+ # set, else ~/.rubino. Rubino.home_path delegates here so the
21
+ # server (which loads config via the Loader) and the CLI commands
22
+ # (config/setup/doctor) resolve the SAME directory — previously the
23
+ # server honoured $RUBINO_HOME while the CLI recomputed
24
+ # File.join(Rubino.home_path, "config.yml") off the YAML
25
+ # `paths.home` default (~/.rubino), a split brain at first boot.
26
+ def self.default_home_path
27
+ env = ENV["RUBINO_HOME"].to_s.strip
28
+ env.empty? ? File.expand_path("~/.rubino") : File.expand_path(env)
29
+ end
30
+
31
+ def initialize(home_path: nil)
32
+ @home_path = home_path || self.class.default_home_path
33
+ @config_path = File.join(@home_path, CONFIG_FILENAME)
34
+ @env_path = File.join(@home_path, ENV_FILENAME)
35
+ end
36
+
37
+ # Loads configuration from file, merging with defaults
38
+ def load
39
+ raw =
40
+ if File.exist?(@config_path)
41
+ begin
42
+ YAML.safe_load_file(@config_path, permitted_classes: [Symbol]) || {}
43
+ rescue Psych::SyntaxError => e
44
+ raise ConfigError,
45
+ "Invalid YAML in #{@config_path} at line #{e.line}, column #{e.column}: #{e.problem}"
46
+ end
47
+ else
48
+ {}
49
+ end
50
+
51
+ load_env_file if File.exist?(@env_path)
52
+
53
+ deep_merge(Defaults.to_hash, expand_env_vars(raw))
54
+ end
55
+
56
+ # Returns true if a config file exists
57
+ def config_exists?
58
+ File.exist?(@config_path)
59
+ end
60
+
61
+ # Creates the initial config file with defaults
62
+ def create_default_config!
63
+ FileUtils.mkdir_p(@home_path)
64
+ File.write(@config_path, Defaults.to_yaml)
65
+ @config_path
66
+ end
67
+
68
+ private
69
+
70
+ def load_env_file
71
+ File.readlines(@env_path).each do |line|
72
+ line = line.strip
73
+ next if line.empty? || line.start_with?("#")
74
+
75
+ key, value = line.split("=", 2)
76
+ next unless key && value
77
+
78
+ ENV[key.strip] = strip_env_quotes(value.strip)
79
+ end
80
+ end
81
+
82
+ # Strips matched surrounding single or double quotes (a common .env
83
+ # convention: FOO="bar baz" → bar baz). Unbalanced quotes are preserved
84
+ # verbatim so they aren't silently mangled.
85
+ def strip_env_quotes(value)
86
+ return value if value.length < 2
87
+
88
+ first = value[0]
89
+ last = value[-1]
90
+ return value[1..-2] if ['"', "'"].include?(first) && first == last
91
+
92
+ value
93
+ end
94
+
95
+ def expand_env_vars(node)
96
+ case node
97
+ when Hash then node.transform_values { |v| expand_env_vars(v) }
98
+ when Array then node.map { |v| expand_env_vars(v) }
99
+ when String then node.gsub(ENV_VAR_PATTERN) { ENV[Regexp.last_match(1)] || "" }
100
+ else node
101
+ end
102
+ end
103
+
104
+ def deep_merge(base, override)
105
+ base.merge(override) do |_key, old_val, new_val|
106
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
107
+ deep_merge(old_val, new_val)
108
+ else
109
+ new_val
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end