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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Util
5
+ # Smart truncation of long tool output for the scrollback preview.
6
+ #
7
+ # Rule shape (5 head + 10 tail + marker, threshold 30) follows the
8
+ # pattern that emerged from surveying Codex, Gemini CLI, Roo, and
9
+ # Aider: tail bias because errors, exit codes, and command summaries
10
+ # live at the end. A head-heavy split (which would be intuitive for
11
+ # "show me the start") consistently hides the part the user actually
12
+ # needs when something failed.
13
+ #
14
+ # The FULL output still goes to the model and the session DB — this
15
+ # is only what the user sees in the live scroll. The marker tells
16
+ # them so they don't think they're missing something irrecoverable.
17
+ module Output
18
+ DEFAULT_MAX = 30
19
+ DEFAULT_HEAD = 5
20
+ DEFAULT_TAIL = 10
21
+
22
+ # Returns either the full text (when total lines <= max) or a
23
+ # head + marker + tail preview. Pure function — no side effects,
24
+ # no IO. Caller decides where to render the result.
25
+ #
26
+ # @param text [String] the raw output
27
+ # @param max [Integer] line count above which we trim
28
+ # @param head [Integer] lines to keep from the top
29
+ # @param tail [Integer] lines to keep from the bottom
30
+ # @return [String] the preview (always a String, never nil)
31
+ def self.preview(text, max: DEFAULT_MAX, head: DEFAULT_HEAD, tail: DEFAULT_TAIL)
32
+ return "" if text.nil? || text.to_s.empty?
33
+
34
+ lines = text.to_s.lines.map(&:chomp)
35
+ return lines.join("\n") if lines.size <= max
36
+
37
+ omitted = lines.size - head - tail
38
+ head_pt = lines.first(head)
39
+ tail_pt = lines.last(tail)
40
+ marker = "… [#{omitted} more lines · full in DB] …"
41
+
42
+ (head_pt + [marker] + tail_pt).join("\n")
43
+ end
44
+
45
+ # Single-line elision to +max+ characters with a trailing ellipsis.
46
+ # Shared by the parent-note tools (AnswerChild/Task/Steer) that all
47
+ # carried a byte-identical private `truncate`. Pure function.
48
+ #
49
+ # @param text [#to_s] the raw text (nil becomes "")
50
+ # @param max [Integer] character budget before eliding
51
+ # @return [String] the text, or its first +max+ chars + "…"
52
+ def self.elide(text, max)
53
+ s = text.to_s
54
+ s.length > max ? "#{s[0, max]}…" : s
55
+ end
56
+
57
+ # First NON-BLANK line of +text+, stripped (or "" when all-blank). A
58
+ # multi-line ruby/shell command often starts with a blank line, so a
59
+ # naive `.lines.first` rendered an empty approval/activity hint (#141).
60
+ # Pure function shared by the subagent card / view rows and the task
61
+ # tool's approval preview, which each carried this extraction inline.
62
+ def self.first_nonblank_line(text)
63
+ text.to_s.each_line.map(&:strip).find { |l| !l.empty? }.to_s
64
+ end
65
+
66
+ # First NON-BLANK line, elided to +max+ chars (max-1 + "…"). The single
67
+ # source for the subagent card and view rows, which carried a
68
+ # byte-identical private copy. Distinct from #elide (which keeps +max+
69
+ # chars before the ellipsis) — this row shape budgets the ellipsis IN.
70
+ def self.first_line(text, max)
71
+ first = first_nonblank_line(text)
72
+ first.length > max ? "#{first[0, max - 1]}…" : first
73
+ end
74
+
75
+ # Truncates long tool output to stay within byte/line limits, with
76
+ # tail-bias because the part the agent (and a human reading the log)
77
+ # actually need is at the end: exit-code suffix, error message,
78
+ # backtrace, "X failures" line. Head-only truncation drops exactly
79
+ # the bytes that matter when something blows up at byte 49,999.
80
+ #
81
+ # Shape: keep ~10% head + bulk of the budget in the tail + a marker
82
+ # in the middle saying how many bytes/lines were elided. Mirrors the
83
+ # pattern #preview already uses for the scrollback body.
84
+ #
85
+ # When +spill+ is supplied it is called with the full pre-truncation
86
+ # text and must return a path (or nil); the marker then points the
87
+ # model at it, so the elided middle isn't lost — the model can `read`
88
+ # the file with offset/limit to recover any part. (Claude-Code-style
89
+ # spill.) Pure aside from that injected callback.
90
+ def self.truncate(text, max_bytes:, max_lines:, spill: nil)
91
+ text = text.to_s
92
+ over_bytes = text.bytesize > max_bytes
93
+ over_lines = text.lines.size > max_lines
94
+ return text unless over_bytes || over_lines
95
+
96
+ spill_path = spill&.call(text)
97
+ text = tail_bias_bytes(text, max_bytes, spill_path) if over_bytes
98
+ text = tail_bias_lines(text, max_lines, spill_path) if text.lines.size > max_lines
99
+ text
100
+ end
101
+
102
+ def self.tail_bias_bytes(text, max_bytes, spill_path = nil)
103
+ encoding = text.encoding
104
+ recover = spill_path ? " · full output saved to #{spill_path} — read it with offset/limit" : ""
105
+ marker_template = "\n... [%d bytes elided#{recover} · use grep/head to narrow] ...\n"
106
+ marker_max = (marker_template % 999_999_999).bytesize
107
+ head_budget = (max_bytes * 0.1).to_i
108
+ tail_budget = max_bytes - head_budget - marker_max
109
+
110
+ # Below ~200 bytes the marker eats the entire budget, so fall back
111
+ # to a simple head truncation (old behavior). Realistic caps go
112
+ # through the head+tail path.
113
+ if tail_budget <= 0
114
+ truncated = text.byteslice(0, max_bytes).to_s.force_encoding(encoding).scrub("")
115
+ tail_note = spill_path ? " · full output: #{spill_path}" : ""
116
+ return "#{truncated}\n... [truncated at #{max_bytes} bytes#{tail_note}]"
117
+ end
118
+
119
+ head = text.byteslice(0, head_budget).to_s.force_encoding(encoding).scrub("")
120
+ tail = text.byteslice(-tail_budget, tail_budget).to_s.force_encoding(encoding).scrub("")
121
+ elided = text.bytesize - head.bytesize - tail.bytesize
122
+ "#{head}#{format(marker_template, elided)}#{tail}"
123
+ end
124
+
125
+ def self.tail_bias_lines(text, max_lines, spill_path = nil)
126
+ lines = text.lines
127
+ return text if lines.size <= max_lines
128
+
129
+ recover = spill_path ? " · full output saved to #{spill_path} — read it with offset/limit" : ""
130
+ head_count = [max_lines / 10, 5].max
131
+ tail_count = max_lines - head_count - 1
132
+ # Vanishing budget falls back to head-only truncation.
133
+ if tail_count <= 0
134
+ tail_note = spill_path ? " · full output: #{spill_path}" : ""
135
+ return "#{lines.first(max_lines).join}\n... [truncated at #{max_lines} lines#{tail_note}]"
136
+ end
137
+
138
+ elided = lines.size - head_count - tail_count
139
+ head = lines.first(head_count).join
140
+ tail = lines.last(tail_count).join
141
+ "#{head}... [#{elided} lines elided#{recover} · use grep/head to narrow] ...\n#{tail}"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Util
5
+ # Heuristic masking for credentials in tool arguments. The model often
6
+ # passes secrets through cleanly (env vars, config files), but a stray
7
+ # `command: "curl -H 'Authorization: Bearer sk_live_…'"` showing up in
8
+ # an approval prompt — or, worse, in the persistent scrollback — is a
9
+ # leak waiting to happen. Mask aggressively on display; the underlying
10
+ # tool still receives the real value.
11
+ module SecretsMask
12
+ SECRET_KEY_TOKENS = %w[
13
+ password passwd
14
+ secret
15
+ token bearer
16
+ api_key apikey api-key
17
+ access_key accesskey access-key
18
+ private_key privatekey private-key
19
+ auth authorization
20
+ ].freeze
21
+
22
+ # Pattern that matches `key=value`, `key: value`, `key value` for the
23
+ # secret-named keys, inside a free-text string (shell command, URL
24
+ # query). The trailing value is grabbed up to whitespace or a known
25
+ # delimiter; quoted values are grabbed whole. `Bearer <token>` is
26
+ # treated as a single value so `Authorization: Bearer XYZ` masks
27
+ # the whole token instead of leaving XYZ exposed.
28
+ INLINE_RE = /
29
+ (?<key>password|passwd|secret|token|
30
+ api[_-]?key|access[_-]?key|private[_-]?key|
31
+ authorization|auth|bearer)
32
+ (?<sep>\s*[:=]\s*|\s+)
33
+ (?<val>"[^"]+"|'[^']+'|(?:Bearer\s+)?[^"'\s]+)
34
+ /xi
35
+
36
+ MASK = "***"
37
+
38
+ # True if the given key looks sensitive on its own (used when the
39
+ # caller already has key/value pairs, e.g. a Hash of arguments).
40
+ def self.sensitive_key?(key)
41
+ k = key.to_s.downcase.tr("-", "_")
42
+ SECRET_KEY_TOKENS.any? { |t| k == t.tr("-", "_") || k.include?(t.tr("-", "_")) }
43
+ end
44
+
45
+ # Mask a single value, given the key it belongs to. Returns MASK if
46
+ # the key is sensitive; otherwise scans the value for inline secrets.
47
+ def self.mask_value(value, key: nil)
48
+ return value if value.nil?
49
+ return MASK if key && sensitive_key?(key)
50
+
51
+ mask_inline(value.to_s)
52
+ end
53
+
54
+ # Mask inline patterns like `Authorization: Bearer XYZ` in any string,
55
+ # whether or not the caller knows the surrounding context. Quoted
56
+ # values keep their quotes around the mask so the surrounding
57
+ # structure (`-H "Authorization: ***"`) stays balanced — otherwise
58
+ # the mask would eat a quote and the rest of the string would look
59
+ # like one long open string.
60
+ def self.mask_inline(text)
61
+ text.to_s.gsub(INLINE_RE) do
62
+ m = Regexp.last_match
63
+ val = m[:val]
64
+ masked = case val[0]
65
+ when '"' then %("#{MASK}")
66
+ when "'" then "'#{MASK}'"
67
+ else MASK
68
+ end
69
+ "#{m[:key]}#{m[:sep]}#{masked}"
70
+ end
71
+ end
72
+
73
+ # Convenience for Hash arguments: returns a new Hash with sensitive
74
+ # values masked, leaving the original untouched (the real value still
75
+ # has to reach the tool).
76
+ def self.mask_hash(hash)
77
+ return hash unless hash.is_a?(Hash)
78
+
79
+ hash.each_with_object({}) { |(k, v), out| out[k] = mask_value(v, key: k) }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # The set of directory roots the agent is allowed to work in.
5
+ #
6
+ # Historically rubino had exactly ONE root, resolved at launch from
7
+ # terminal.cwd or Dir.pwd, and every tool re-derived it. This module turns
8
+ # that single root into an ordered SET of roots: the primary (launch) root
9
+ # plus any directories added via `--add-dir` / `/add-dir`. The default — no
10
+ # extra dirs — is byte-identical to the old single-root behaviour.
11
+ #
12
+ # Modelled on Claude Code's `--add-dir`: extra roots widen the write/edit
13
+ # sandbox (see Tools::Base#within_workspace?) so the agent can touch files
14
+ # under any allowed root, e.g. a service and its client library at once.
15
+ module Workspace
16
+ @added = []
17
+ @mutex = Mutex.new
18
+
19
+ class << self
20
+ # The primary root: terminal.cwd when set, else the process cwd. This is
21
+ # the same rule Tools::Base#workspace_root has always used, kept as the
22
+ # single source of truth so the @-picker, shell/test cwd, file API and
23
+ # attachment downloader all agree on "the" root.
24
+ def primary_root
25
+ Rubino.configuration&.dig("terminal", "cwd") || Dir.pwd
26
+ end
27
+
28
+ # Every allowed root: the primary first, then each added dir, de-duped on
29
+ # canonical path so re-adding the launch dir (or the same dir twice) is a
30
+ # no-op. Returns plain strings.
31
+ def roots
32
+ @mutex.synchronize do
33
+ ordered = [primary_root, *@added]
34
+ seen = Set.new
35
+ ordered.filter_map do |dir|
36
+ real = canonical(dir)
37
+ next unless real
38
+ next if seen.include?(real)
39
+
40
+ seen << real
41
+ dir
42
+ end
43
+ end
44
+ end
45
+
46
+ # Canonical (realpath, symlinks resolved) form of every root — what the
47
+ # sandbox compares against.
48
+ def canonical_roots
49
+ roots.filter_map { |dir| canonical(dir) }
50
+ end
51
+
52
+ # Adds an extra allowed root. Returns the canonical path on success, or
53
+ # raises ArgumentError with a human-readable reason when the dir doesn't
54
+ # exist / isn't a readable directory. realpath-resolves so a symlinked
55
+ # add-dir lands on its true destination (and matches the sandbox check).
56
+ def add(dir)
57
+ expanded = File.expand_path(dir.to_s)
58
+ raise ArgumentError, "no such directory: #{dir}" unless File.directory?(expanded)
59
+ raise ArgumentError, "not readable: #{dir}" unless File.readable?(expanded)
60
+
61
+ real = File.realpath(expanded)
62
+ @mutex.synchronize do
63
+ @added << real unless @added.include?(real) || canonical(primary_root) == real
64
+ end
65
+ real
66
+ end
67
+
68
+ # Test/teardown hook: drop all added roots (the primary is always derived
69
+ # live from config/cwd, so it can't be reset here).
70
+ def reset!
71
+ @mutex.synchronize { @added = [] }
72
+ end
73
+
74
+ private
75
+
76
+ def canonical(path)
77
+ return nil if path.nil? || path.to_s.empty?
78
+
79
+ File.realpath(File.expand_path(path.to_s))
80
+ rescue StandardError
81
+ nil
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shim so `require "rubino-agent"` (matching the gem name on RubyGems)
4
+ # resolves to the canonical entry point in lib/rubino.rb.
5
+ require "rubino"
data/lib/rubino.rb ADDED
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "dry-configurable"
5
+ require "fileutils"
6
+
7
+ # Main module for the Rubino gem.
8
+ # Provides an agentic framework with persistent memory, sessions,
9
+ # context compaction, and extensible tool system built on ruby_llm.
10
+ module Rubino
11
+ class Error < StandardError; end
12
+ class ConfigurationError < Error; end
13
+ class DatabaseError < Error; end
14
+ class SessionError < Error; end
15
+
16
+ # Raised when --resume <query> matches more than one session by id-prefix
17
+ # or title-substring. Carries the matches so the CLI can list them and
18
+ # ask the user to disambiguate, instead of silently picking the first.
19
+ class AmbiguousSessionError < SessionError
20
+ attr_reader :query, :matches
21
+
22
+ def initialize(query, matches)
23
+ @query = query
24
+ @matches = matches
25
+ super(build_message)
26
+ end
27
+
28
+ private
29
+
30
+ def build_message
31
+ lines = ["Ambiguous --resume '#{@query}': #{@matches.size} sessions match."]
32
+ @matches.first(10).each do |s|
33
+ lines << " #{s[:id][0, 8]} #{s[:title] || "(no title)"} [#{s[:status]}]"
34
+ end
35
+ lines << "Use --resume <full-id> (8+ chars) to pick one."
36
+ lines.join("\n")
37
+ end
38
+ end
39
+
40
+ class ToolError < Error; end
41
+ class CompactionError < Error; end
42
+ class JobError < Error; end
43
+ end
44
+
45
+ require_relative "rubino/errors"
46
+
47
+ module Rubino
48
+ class << self
49
+ # Returns the Zeitwerk loader for autoloading
50
+ def loader
51
+ @loader ||= begin
52
+ loader = Zeitwerk::Loader.for_gem
53
+ loader.inflector.inflect(
54
+ # Acronym modules
55
+ "cli" => "CLI",
56
+ "llm" => "LLM",
57
+ "ui" => "UI",
58
+ "api" => "API",
59
+ "tls" => "TLS",
60
+ "mcp" => "MCP",
61
+ "oauth" => "OAuth",
62
+ # Files with compound names that need exact mapping
63
+ "ruby_llm_adapter" => "RubyLLMAdapter",
64
+ "mcp_tool_wrapper" => "MCPToolWrapper",
65
+ "bedrock_bearer_client" => "BedrockBearerClient",
66
+ "adapter_response" => "AdapterResponse",
67
+ "indented_io" => "IndentedIO",
68
+ "webfetch_tool" => "WebFetchTool",
69
+ "websearch_tool" => "WebSearchTool",
70
+ "github_tool" => "GitHubTool",
71
+ "skill_tool" => "SkillTool",
72
+ "custom_tool_loader" => "CustomToolLoader",
73
+ "custom_tool_builder" => "CustomToolBuilder",
74
+ "tool_pair_sanitizer" => "ToolPairSanitizer",
75
+ "degenerate_recovery" => "DegenerateResponseRecovery"
76
+ )
77
+ # Migrations are plain SQL files, not Ruby constants
78
+ loader.ignore(
79
+ File.expand_path("rubino/database/migrations", __dir__)
80
+ )
81
+ # errors.rb defines multiple constants in Rubino (NotFoundError, ...),
82
+ # not a single Rubino::Errors module — loaded manually via require_relative.
83
+ loader.ignore(File.expand_path("rubino/errors.rb", __dir__))
84
+ # rubino-agent.rb is a require shim matching the gem name; it maps to no
85
+ # Rubino constant (and "Rubino-agent" isn't a valid cname). Zeitwerk must
86
+ # not try to manage it.
87
+ loader.ignore(File.expand_path("rubino-agent.rb", __dir__))
88
+ loader
89
+ end
90
+ end
91
+
92
+ # Returns the current configuration instance
93
+ def configuration
94
+ @configuration ||= Config::Configuration.new
95
+ end
96
+
97
+ # Yields the configuration for block-style setup
98
+ def configure
99
+ yield(configuration) if block_given?
100
+ configuration
101
+ end
102
+
103
+ # Drops the memoized configuration so the next #configuration reload reads
104
+ # config.yml / .env fresh. Used after the first-run onboarding wizard writes
105
+ # them mid-process so the just-saved key is visible without a restart.
106
+ def reload_configuration!
107
+ @configuration = nil
108
+ configuration
109
+ end
110
+
111
+ # Returns the current UI adapter instance.
112
+ #
113
+ # A thread-local override (set via #with_ui) wins over the process-global
114
+ # adapter. This is what lets the API server run many runs concurrently:
115
+ # each run executes in its own thread (Run::Executor#start) with its own
116
+ # gated UI::API, and tools that reach for the global adapter
117
+ # (QuestionTool#ask, TaskTool) resolve to THAT run's UI — not a shared,
118
+ # gate-less global that would silently drop interactive prompts (the
119
+ # clarify/`question` flow) and could cross-talk between runs.
120
+ def ui
121
+ Thread.current[:rubino_ui] || (@ui ||= UI.build(configuration.ui_adapter))
122
+ end
123
+
124
+ # Sets the process-global UI adapter (CLI boot, tests).
125
+ attr_writer :ui
126
+
127
+ # Runs the block with +adapter+ as the thread-scoped UI, restoring the
128
+ # previous value afterwards (nested-safe). Used by Run::Executor to bind
129
+ # the run's gated UI::API for the duration of the worker thread so global
130
+ # `Rubino.ui` lookups inside tools hit the right, gated instance.
131
+ def with_ui(adapter)
132
+ prev = Thread.current[:rubino_ui]
133
+ Thread.current[:rubino_ui] = adapter
134
+ yield
135
+ ensure
136
+ Thread.current[:rubino_ui] = prev
137
+ end
138
+
139
+ # The EventBus of the CURRENTLY-RUNNING parent turn. The API/server path
140
+ # injects a fresh per-run bus (Run::Executor) that its Recorder is attached
141
+ # to; the CLI path uses the process-global bus. A backgrounded `task`
142
+ # subagent emits its SPAWNED/COMPLETED/FAILED lifecycle events here so they
143
+ # reach THAT run's recorder (and SSE stream) rather than a detached global
144
+ # bus. Falls back to the global bus when no turn-scoped bus is bound.
145
+ def active_event_bus
146
+ Thread.current[:rubino_event_bus] || event_bus
147
+ end
148
+
149
+ # Binds +bus+ as the turn-scoped event bus for the duration of the block
150
+ # (set by Interaction::Lifecycle around the loop run, like #with_ui binds
151
+ # the UI). Thread-local so a tool reaches it with no signature churn.
152
+ def with_event_bus(bus)
153
+ prev = Thread.current[:rubino_event_bus]
154
+ Thread.current[:rubino_event_bus] = bus
155
+ yield
156
+ ensure
157
+ Thread.current[:rubino_event_bus] = prev
158
+ end
159
+
160
+ # The InputQueue of the CURRENTLY-RUNNING parent turn, if any. A background
161
+ # subagent (TaskTool) reads this to deliver its completion notification back
162
+ # into the parent's live loop — the parent picks it up at its next iteration
163
+ # boundary via Loop#inject_steered_input, so the notice lands as a user
164
+ # message between turns, NEVER between an assistant tool_use and its results.
165
+ # Nil on the API/server path (no steering queue) — there the result is still
166
+ # reachable via the BackgroundTasks registry / `task_result`.
167
+ def background_sink
168
+ Thread.current[:rubino_background_sink]
169
+ end
170
+
171
+ # Binds +queue+ as the background-subagent notification sink for the
172
+ # duration of the block (set by Interaction::Lifecycle around the turn,
173
+ # exactly like #with_ui binds the run's UI). Thread-local so a tool can
174
+ # reach it with zero signature churn through the loop/executor.
175
+ def with_background_sink(queue)
176
+ prev = Thread.current[:rubino_background_sink]
177
+ Thread.current[:rubino_background_sink] = queue
178
+ yield
179
+ ensure
180
+ Thread.current[:rubino_background_sink] = prev
181
+ end
182
+
183
+ # The BackgroundTasks entry id of the subagent run executing on THIS thread,
184
+ # if any. Set by TaskTool#run_child_thread around the child Runner#run! so a
185
+ # tool the child invokes (today: ask_parent) can find its own registry entry
186
+ # — the card it surfaces on, the steer queue it receives answers through —
187
+ # without threading the id through the loop/executor/tool signatures. Nil on
188
+ # the parent thread and on any non-delegated (top-level) run, which is the
189
+ # signal ask_parent uses to refuse (a top-level agent has no parent to ask).
190
+ def current_subagent_id
191
+ Thread.current[:rubino_current_subagent_id]
192
+ end
193
+
194
+ # Binds +id+ as the current subagent id for the duration of the block
195
+ # (set by TaskTool around the child run, exactly like #with_ui / the
196
+ # background sink). Thread-local so the child's tools reach it with zero
197
+ # signature churn.
198
+ def with_current_subagent_id(id)
199
+ prev = Thread.current[:rubino_current_subagent_id]
200
+ Thread.current[:rubino_current_subagent_id] = id
201
+ yield
202
+ ensure
203
+ Thread.current[:rubino_current_subagent_id] = prev
204
+ end
205
+
206
+ # Returns the current structured logger.
207
+ def logger
208
+ @logger ||= Logger.new
209
+ end
210
+
211
+ # Sets the logger (useful for testing).
212
+ attr_writer :logger
213
+
214
+ # Returns the database connection
215
+ def database
216
+ @database ||= Database::Connection.new(configuration.database_path)
217
+ end
218
+
219
+ # First-run guard for any DB-touching entry point. A brand-new RUBINO_HOME
220
+ # has no schema yet (setup/chat hasn't migrated it), so a read path like
221
+ # `rubino sessions list` would otherwise hit a raw
222
+ # `SQLite3::SQLException: no such table` backtrace (#35). `healthy?` only
223
+ # runs `SELECT 1`, which passes the moment SQLite lazily creates the empty
224
+ # file — the tables are still missing — so we also check migrator.pending?.
225
+ # Migrations are idempotent, so this is safe to call on every command. This
226
+ # is the same logic the interactive `chat` command already used; promoted
227
+ # here so the read CLIs (sessions/memory/jobs) share one implementation.
228
+ # Returns true when the schema is ready, false when initialization failed
229
+ # (callers decide whether that's fatal or degrades to an empty state).
230
+ def ensure_database_ready!
231
+ connection = database
232
+ migrator = Database::Migrator.new(connection)
233
+ return true unless connection.healthy? == false || migrator.pending?
234
+
235
+ ensure_directories!
236
+ migrator.migrate!
237
+ true
238
+ rescue StandardError => e
239
+ logger.debug(event: "ensure_database_ready_failed", error: "#{e.class}: #{e.message}")
240
+ false
241
+ end
242
+
243
+ # Returns the event bus instance
244
+ def event_bus
245
+ @event_bus ||= Interaction::EventBus.new
246
+ end
247
+
248
+ # Returns the shared agent registry (primary/subagent/utility definitions).
249
+ # Memoized process-wide so the `task` tool can resolve a subagent by name
250
+ # at call time without each boot path having to thread an instance through
251
+ # the tool executor. Both entry points (CLI ChatCommand, API ServerCommand)
252
+ # touch this at boot so delegation works identically over /v1 and in chat;
253
+ # the tool also reads it lazily here, so a stripped boot still resolves.
254
+ def agent_registry
255
+ @agent_registry ||= Agent::AgentRegistry.new
256
+ end
257
+
258
+ # Sets the agent registry (useful for testing / custom boots).
259
+ attr_writer :agent_registry
260
+
261
+ # Returns the plugin registry
262
+ def plugin_registry
263
+ Plugins.registry
264
+ end
265
+
266
+ # DSL for defining plugins
267
+ def plugin(&)
268
+ Plugins.registry.instance_eval(&)
269
+ end
270
+
271
+ # Resets all memoized state (useful for testing)
272
+ def reset!
273
+ @configuration = nil
274
+ @ui = nil
275
+ @database = nil
276
+ @event_bus = nil
277
+ @agent_registry = nil
278
+ Plugins.reset!
279
+ end
280
+
281
+ # Returns the home directory path. Delegates to the SAME resolver the
282
+ # config Loader uses (RUBINO_HOME → else ~/.rubino) so the server
283
+ # (which loads config.yml through the Loader) and the CLI (config/setup/
284
+ # doctor + ensure_directories!) never disagree about where state lives.
285
+ # Previously this read the YAML `paths.home` default (~/.rubino) and
286
+ # ignored $RUBINO_HOME, splitting the brain at first boot / for .env.
287
+ def home_path
288
+ Rubino::Config::Loader.default_home_path
289
+ end
290
+
291
+ # Ensures the home directory and subdirectories exist. The home holds
292
+ # secrets (.env) and the database, so it is forced to 0700 here — the
293
+ # single code path every entry point (setup/chat/prompt/doctor) goes
294
+ # through to materialize the home — not just when `setup` ran first
295
+ # (#65): an auto-created home used to be left at the umask's 0755.
296
+ def ensure_directories!
297
+ home = home_path
298
+ FileUtils.mkdir_p(home)
299
+ File.chmod(0o700, home)
300
+ %w[memories sessions logs skills commands tools plugins].each do |subdir|
301
+ dir = File.join(home, subdir)
302
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
303
+ end
304
+ end
305
+ end
306
+ end
307
+
308
+ # Setup autoloading
309
+ Rubino.loader.setup
310
+
311
+ # Register the built-in memory backends. The default backend wraps the
312
+ # existing Store/Retriever/Extractor, so an unset `memory.backend` is
313
+ # byte-identical to the pre-pluggable behavior.
314
+ Rubino::Memory::Backends.register(Rubino::Memory::Backends::Default)
315
+ # The "tiny-Zep" SQLite backend: LLM-extracted atomic facts, bi-temporal
316
+ # supersession, and hybrid FTS5 + recency recall. Switch with
317
+ # `rubino memory backend sqlite`.
318
+ Rubino::Memory::Backends.register(Rubino::Memory::Backends::Sqlite)
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.3.3"