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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Config
5
+ # Single source of truth for how reasoning/thinking preferences are resolved
6
+ # from config, shared by the LLM adapter gate and the CLI render path so they
7
+ # can never drift.
8
+ #
9
+ # Two orthogonal knobs:
10
+ # * display.reasoning — how reasoning is RENDERED (hidden | collapsed | full)
11
+ # * thinking.effort — how HARD the model thinks (off | low | medium | high)
12
+ #
13
+ # display.show_reasoning (legacy boolean) maps in for back-compat ONLY when
14
+ # display.reasoning is unset: true→full, false→hidden.
15
+ module ReasoningPrefs
16
+ RENDER_MODES = %i[hidden collapsed full].freeze
17
+ DEFAULT_MODE = :collapsed
18
+
19
+ EFFORTS = %i[off low medium high].freeze
20
+ DEFAULT_EFFORT = :medium
21
+
22
+ # Effort → Anthropic thinking-token budget. off disables thinking (0).
23
+ EFFORT_BUDGETS = {
24
+ off: 0,
25
+ low: 4_000,
26
+ medium: 8_000,
27
+ high: 16_000
28
+ }.freeze
29
+
30
+ module_function
31
+
32
+ # The render mode symbol for a config object. Prefers display.reasoning;
33
+ # falls back to the legacy display.show_reasoning boolean; else the default.
34
+ def mode(config)
35
+ raw = config&.dig("display", "reasoning")
36
+ sym = raw.to_s.strip.downcase.to_sym unless raw.nil?
37
+ return sym if RENDER_MODES.include?(sym)
38
+
39
+ legacy = config&.dig("display", "show_reasoning")
40
+ return :full if legacy == true
41
+ return :hidden if legacy == false
42
+
43
+ DEFAULT_MODE
44
+ end
45
+
46
+ # The effort symbol for a config object, or nil when thinking.effort is
47
+ # unset (so callers can fall back to the existing thinking_budget chain).
48
+ def effort(config)
49
+ raw = config&.dig("thinking", "effort")
50
+ return nil if raw.nil?
51
+ # YAML parses an unquoted `off` as the boolean false, which used to
52
+ # silently break thinking-budget gating (#79) — coerce it back to :off
53
+ # here, the single read boundary, so a doc-following config keeps working.
54
+ return :off if raw == false
55
+
56
+ sym = raw.to_s.strip.downcase.to_sym
57
+ EFFORTS.include?(sym) ? sym : nil
58
+ end
59
+
60
+ # Token budget for an effort symbol (nil/unknown → nil so the caller can
61
+ # fall back to its own default chain).
62
+ def effort_budget(effort)
63
+ EFFORT_BUDGETS[effort]
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Rubino
7
+ module Config
8
+ # Writes configuration changes back to the YAML file.
9
+ class Writer
10
+ def initialize(config_path:)
11
+ @config_path = config_path
12
+ end
13
+
14
+ # Sets a single key (dot-notation) to a value and persists
15
+ def set(key_path, value)
16
+ raw = load_raw
17
+ keys = key_path.split(".")
18
+ hash = raw
19
+
20
+ keys[0..-2].each_with_index do |k, i|
21
+ hash[k] ||= {}
22
+ hash = hash[k]
23
+ next if hash.is_a?(Hash)
24
+
25
+ traversed = keys[0..i].join(".")
26
+ raise ConfigurationError,
27
+ "cannot set '#{key_path}': '#{traversed}' is a scalar value, not a section"
28
+ end
29
+
30
+ hash[keys.last] = coerce_value(value)
31
+ save(raw)
32
+ end
33
+
34
+ # Returns the value at a dot-notation key path
35
+ def get(key_path)
36
+ raw = load_raw
37
+ keys = key_path.split(".")
38
+ # A scalar intermediate node (e.g. a String) has no #dig; treat such a
39
+ # path as "not found" rather than crashing with a TypeError.
40
+ raw.dig(*keys)
41
+ rescue TypeError
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ def load_raw
48
+ if File.exist?(@config_path)
49
+ YAML.safe_load_file(@config_path, permitted_classes: [Symbol]) || {}
50
+ else
51
+ {}
52
+ end
53
+ end
54
+
55
+ def save(raw)
56
+ FileUtils.mkdir_p(File.dirname(@config_path))
57
+ File.write(@config_path, raw.to_yaml)
58
+ end
59
+
60
+ def coerce_value(value)
61
+ case value
62
+ when "true" then true
63
+ when "false" then false
64
+ when "nil", "null" then nil
65
+ when /\A\d+\z/ then value.to_i
66
+ when /\A\d+\.\d+\z/ then value.to_f
67
+ else value
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Rubino
6
+ module Context
7
+ # Orchestrates context compaction: flush memory, split messages into
8
+ # head/middle/tail, generate summary, create child session.
9
+ class Compressor
10
+ def initialize(session_id:, config: nil, db: nil)
11
+ @session_id = session_id
12
+ @config = config || Rubino.configuration
13
+ @db = db || Rubino.database.db
14
+ @message_store = Session::Store.new(db: @db)
15
+ @session_repo = Session::Repository.new(db: @db)
16
+ @summary_store = Session::SummaryStore.new(db: @db)
17
+ end
18
+
19
+ # Performs full compaction and returns metadata
20
+ def compact!
21
+ session = @session_repo.find(@session_id)
22
+ raise CompactionError, "Session not found: #{@session_id}" unless session
23
+
24
+ messages = @message_store.for_session(@session_id)
25
+ return no_op_result if messages.size < minimum_messages
26
+
27
+ # 1. Flush memory before compaction
28
+ flush_memory!
29
+
30
+ # 2. Split messages into head / middle / tail
31
+ boundary = MessageBoundary.new(messages: messages, config: @config)
32
+ head = boundary.head
33
+ middle = boundary.middle
34
+ tail = boundary.tail
35
+
36
+ return no_op_result if middle.empty?
37
+
38
+ # 3. Sanitize tool pairs in middle
39
+ if @config.compression_preserve_tool_pairs?
40
+ sanitizer = ToolPairSanitizer.new
41
+ middle = sanitizer.sanitize(middle)
42
+ end
43
+
44
+ # 4. Load previous summary (capture id now, before the insert below
45
+ # overwrites "latest" — the lineage link must point at the prior row)
46
+ previous = @summary_store.latest(@session_id)
47
+ previous_summary = previous&.dig(:content)
48
+ previous_summary_id = previous&.dig(:id)
49
+
50
+ # 5. Generate new summary
51
+ summary_builder = SummaryBuilder.new(session_id: @session_id)
52
+ new_summary = summary_builder.build(
53
+ messages: middle,
54
+ previous_summary: previous_summary
55
+ )
56
+
57
+ # 6. Save summary (chains parent_summary_id to the previous row)
58
+ summary_id = @summary_store.insert(session_id: @session_id, content: new_summary)
59
+
60
+ # 7. Create child session with compacted context
61
+ child_session = create_child_session(session, head, new_summary, tail)
62
+
63
+ # 8. Record compaction lineage
64
+ record_compaction(
65
+ source_id: @session_id,
66
+ target_id: child_session[:id],
67
+ previous_summary_id: previous_summary_id,
68
+ new_summary_id: summary_id,
69
+ original_tokens: estimate_tokens(messages),
70
+ compacted_tokens: estimate_tokens(head + tail)
71
+ )
72
+
73
+ {
74
+ source_session_id: @session_id,
75
+ target_session_id: child_session[:id],
76
+ original_messages: messages.size,
77
+ compacted_messages: head.size + tail.size + 1, # +1 for summary
78
+ saved_tokens: estimate_tokens(middle),
79
+ summary_id: summary_id
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def flush_memory!
86
+ flusher = Memory::Flusher.new
87
+ flusher.flush_before_compaction!(@session_id)
88
+ end
89
+
90
+ def create_child_session(parent_session, head, summary, tail)
91
+ child = @session_repo.create(
92
+ source: "compaction",
93
+ model: parent_session[:model],
94
+ provider: parent_session[:provider],
95
+ title: parent_session[:title],
96
+ parent_session_id: parent_session[:id]
97
+ )
98
+
99
+ # Copy head messages — faithful copy preserves metadata[:tool_calls]
100
+ # and token_count, otherwise compaction strips the assistant toolUse
101
+ # block and orphans the matching tool result (400 on resume).
102
+ @message_store.copy_into(child[:id], head)
103
+
104
+ # Insert summary as system message
105
+ @message_store.create(
106
+ session_id: child[:id],
107
+ role: "system",
108
+ content: "[Compacted Summary]\n#{summary}"
109
+ )
110
+
111
+ # Copy tail messages (same faithful copy as head)
112
+ @message_store.copy_into(child[:id], tail)
113
+
114
+ # End the parent session
115
+ @session_repo.update(parent_session[:id], status: "compacted")
116
+
117
+ child
118
+ end
119
+
120
+ def record_compaction(source_id:, target_id:, previous_summary_id:, new_summary_id:,
121
+ original_tokens:, compacted_tokens:)
122
+ @db[:compactions].insert(
123
+ id: SecureRandom.uuid,
124
+ source_session_id: source_id,
125
+ target_session_id: target_id,
126
+ previous_summary_id: previous_summary_id,
127
+ new_summary_id: new_summary_id,
128
+ original_token_count: original_tokens,
129
+ compacted_token_count: compacted_tokens,
130
+ saved_token_count: original_tokens - compacted_tokens,
131
+ created_at: Time.now.utc.iso8601
132
+ )
133
+ end
134
+
135
+ def estimate_tokens(messages)
136
+ total = messages.sum { |m| (m.respond_to?(:content) ? m.content : m[:content] || "").length }
137
+ (total / 4.0).ceil
138
+ end
139
+
140
+ def minimum_messages
141
+ @config.compression_protect_first_n + @config.compression_protect_last_n + 5
142
+ end
143
+
144
+ def no_op_result
145
+ { source_session_id: @session_id, saved_tokens: 0, skipped: true }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "date"
5
+
6
+ module Rubino
7
+ module Context
8
+ # Builds the "[Environment]" block injected into every system prompt.
9
+ #
10
+ # Probes the host once per process for the static bits (OS, ruby/python
11
+ # versions, external utilities on PATH) and asks for the dynamic bits
12
+ # (date, cwd, git branch) on every build. The static cache survives the
13
+ # length of the process — long enough for an HTTP server's lifetime,
14
+ # short enough that a `gem install` between deploys repopulates it.
15
+ #
16
+ # The goal is a concrete, honest description of the *actual* runtime the
17
+ # model is talking to. If markitdown isn't installed in the VM image, we
18
+ # don't list it — the agent will then ask the user instead of confidently
19
+ # invoking a binary that doesn't exist.
20
+ class EnvironmentInspector
21
+ # External CLI tools we probe by default. The list mixes hard
22
+ # dependencies (git, ruby) and useful-but-optional binaries the agent
23
+ # may want to shell out to (markitdown, pandoc, …). Anything not
24
+ # found on PATH is silently dropped — see #available_utilities.
25
+ DEFAULT_UTILITIES = %w[
26
+ git gh rg jq curl wget
27
+ ruby python3 node npm bundle
28
+ docker psql sqlite3 redis-cli
29
+ ffmpeg pandoc markitdown pdftotext tesseract
30
+ ].freeze
31
+
32
+ class << self
33
+ # Process-wide cache of the static fields. Reset via #reset_cache!
34
+ # from specs.
35
+ def cache
36
+ @cache ||= {}
37
+ end
38
+
39
+ def reset_cache!
40
+ @cache = {}
41
+ end
42
+ end
43
+
44
+ def initialize(extra_utilities: [], cwd: nil, clock: -> { Time.now })
45
+ @extra_utilities = Array(extra_utilities).map(&:to_s)
46
+ @cwd = cwd
47
+ @clock = clock
48
+ end
49
+
50
+ # Returns the assembled [Environment] block, or nil if the caller
51
+ # disabled it at the config layer (PromptAssembler decides — this
52
+ # class always renders when asked).
53
+ def render
54
+ lines = []
55
+ lines << "[Environment]"
56
+ lines << "- Today's date: #{today}"
57
+ lines << "- Platform: #{platform}"
58
+ lines << "- Shell: #{shell}"
59
+ lines << "- Working dir: #{working_dir}"
60
+ git = git_description
61
+ lines << "- Git: #{git}" if git
62
+ lines << "- Runtimes: #{runtimes}"
63
+ utilities = available_utilities
64
+ lines << "- Available CLI tools on PATH: #{utilities.join(", ")}" if utilities.any?
65
+ docs = document_formats
66
+ if docs.any?
67
+ lines << "- Document reading: the `read_attachment` tool converts these formats " \
68
+ "to Markdown in-process (no external binary needed): #{docs.join(", ")}"
69
+ end
70
+ lines.join("\n")
71
+ end
72
+
73
+ # The CORE document formats readable in-process via read_attachment
74
+ # (driven by which optional extraction gems loaded). Advertised so the
75
+ # model knows it can read a docx/pdf even when no `markitdown` binary
76
+ # exists on PATH -- closing the gap this file's own comment describes.
77
+ def document_formats
78
+ self.class.cache[:document_formats] ||= begin
79
+ Rubino::Documents::Registry.available_formats
80
+ rescue StandardError
81
+ []
82
+ end
83
+ end
84
+
85
+ # Public for spec inspection. The list is sorted to keep the prompt
86
+ # stable turn-to-turn (otherwise reordering would invalidate the
87
+ # provider-side prompt cache).
88
+ def available_utilities
89
+ probes = (DEFAULT_UTILITIES + @extra_utilities).uniq
90
+ self.class.cache[:utilities] ||= probes.select { |bin| on_path?(bin) }.sort
91
+ end
92
+
93
+ private
94
+
95
+ def today
96
+ @clock.call.strftime("%Y-%m-%d")
97
+ end
98
+
99
+ def platform
100
+ self.class.cache[:platform] ||= begin
101
+ host_os = RbConfig::CONFIG["host_os"]
102
+ arch = RbConfig::CONFIG["host_cpu"]
103
+ os_name =
104
+ case host_os
105
+ when /darwin/ then "macOS"
106
+ when /linux/ then linux_distro || "Linux"
107
+ when /mswin|mingw|cygwin/ then "Windows"
108
+ else host_os
109
+ end
110
+ "#{os_name} (#{arch})"
111
+ end
112
+ end
113
+
114
+ def linux_distro
115
+ return nil unless File.readable?("/etc/os-release")
116
+
117
+ pretty = File.read("/etc/os-release").lines.find { |l| l.start_with?("PRETTY_NAME=") }
118
+ pretty&.split("=", 2)&.last&.strip&.delete('"')
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
123
+ def shell
124
+ self.class.cache[:shell] ||= File.basename(ENV["SHELL"] || "sh")
125
+ end
126
+
127
+ def working_dir
128
+ @cwd || Dir.pwd
129
+ rescue StandardError
130
+ "(unavailable)"
131
+ end
132
+
133
+ def git_description
134
+ dir = working_dir
135
+ return nil unless File.directory?(File.join(dir, ".git"))
136
+
137
+ branch = `git -C #{shellescape(dir)} rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
138
+ branch.empty? ? "repo (detached HEAD)" : "repo on branch #{branch}"
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ def runtimes
144
+ self.class.cache[:runtimes] ||= begin
145
+ parts = []
146
+ parts << "Ruby #{RUBY_VERSION}"
147
+ py = probe_version("python3", "--version")
148
+ parts << "Python #{py}" if py
149
+ node = probe_version("node", "--version")
150
+ parts << "Node #{node.sub(/\Av/, "")}" if node
151
+ parts.join(", ")
152
+ end
153
+ end
154
+
155
+ def probe_version(bin, flag)
156
+ return nil unless on_path?(bin)
157
+
158
+ out = `#{shellescape(bin)} #{flag} 2>&1`.strip
159
+ out.split.last
160
+ rescue StandardError
161
+ nil
162
+ end
163
+
164
+ def on_path?(bin)
165
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
166
+ path = File.join(dir, bin)
167
+ File.executable?(path) && !File.directory?(path)
168
+ end
169
+ end
170
+
171
+ def shellescape(str)
172
+ str.to_s.gsub(%r{([^A-Za-z0-9_\-.,:/@\n])}, "\\\\\\1")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Context
5
+ # Discovers and loads project context files from the working directory.
6
+ # Supports multiple file conventions (.rubino.md, AGENTS.md, etc.)
7
+ class FileDiscovery
8
+ CONTEXT_FILES = %w[
9
+ .rubino.md
10
+ RUBINO.md
11
+ AGENTS.md
12
+ CLAUDE.md
13
+ .cursorrules
14
+ ].freeze
15
+
16
+ def initialize(base_path: nil)
17
+ @base_path = base_path || Dir.pwd
18
+ end
19
+
20
+ # Loads and concatenates all found project context files
21
+ def load_project_context
22
+ files = discover_files
23
+ return nil if files.empty?
24
+
25
+ files.map { |f| File.read(f) }.join("\n\n---\n\n")
26
+ end
27
+
28
+ # Returns list of discovered context file paths
29
+ def discover_files
30
+ CONTEXT_FILES.filter_map do |filename|
31
+ path = File.join(@base_path, filename)
32
+ path if File.exist?(path)
33
+ end
34
+ end
35
+
36
+ # Checks a subdirectory for local context files
37
+ def local_context(subdir)
38
+ CONTEXT_FILES.filter_map do |filename|
39
+ path = File.join(@base_path, subdir, filename)
40
+ File.read(path) if File.exist?(path)
41
+ end.join("\n")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Context
5
+ # Splits messages into head (protected), middle (compressible), tail (protected).
6
+ class MessageBoundary
7
+ def initialize(messages:, config: nil)
8
+ @messages = messages
9
+ @config = config || Rubino.configuration
10
+ @protect_first = @config.compression_protect_first_n
11
+ @protect_last = @config.compression_protect_last_n
12
+ end
13
+
14
+ # Returns the protected head messages (system prompt + first N)
15
+ def head
16
+ @messages.first(@protect_first)
17
+ end
18
+
19
+ # Returns the compressible middle messages
20
+ def middle
21
+ return [] if @messages.size <= (@protect_first + @protect_last)
22
+
23
+ @messages[@protect_first...-@protect_last]
24
+ end
25
+
26
+ # Returns the protected tail messages (recent context)
27
+ def tail
28
+ return [] if @messages.size <= @protect_last
29
+
30
+ @messages.last(@protect_last)
31
+ end
32
+
33
+ # Returns true if there are enough messages to have a middle section
34
+ def has_compressible_middle?
35
+ !middle.empty?
36
+ end
37
+ end
38
+ end
39
+ end