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,363 @@
1
+ # Dates, times, time zones & text encoding
2
+
3
+ Ruby 3.2–3.4 / Rails 7.1–8.x. Two independent topics that share a theme: **the default is rarely what you want, and the bug is silent.** Always be explicit about zone and encoding.
4
+
5
+ ---
6
+
7
+ ## PART 1 — Dates, times & time zones
8
+
9
+ ### `Time` vs `Date` vs `DateTime`
10
+
11
+ | Class | Use for | Notes |
12
+ |---|---|---|
13
+ | `Time` | timestamps, instants, anything with hours/minutes | Nanosecond precision, zone-aware. **Default choice.** |
14
+ | `Date` | calendar dates with no time-of-day (birthdays, due dates) | No zone, no time. |
15
+ | `DateTime` | **legacy — avoid in new code** | Slower, subtle calendar-reform quirks, superseded by `Time`. |
16
+
17
+ ```ruby
18
+ # DON'T introduce DateTime in new code
19
+ DateTime.now # WRONG: legacy class
20
+ # DO
21
+ Time.current # RIGHT (Rails, zone-aware)
22
+ Time.now # plain Ruby instant (system zone — see footgun below)
23
+ ```
24
+
25
+ If a library hands you a `DateTime`, convert: `datetime.to_time`.
26
+
27
+ ### The #1 Rails footgun: system zone vs app zone
28
+
29
+ `Time.now`, `Date.today`, `Time.at`, `Time.parse` use the **server's system zone** (`ENV["TZ"]`). In Rails you almost always want the **application zone** (`Time.zone`). They differ silently until production runs in a different zone than your laptop.
30
+
31
+ ```ruby
32
+ # WRONG — system zone, leaks server config into your data/logic
33
+ Time.now
34
+ Date.today
35
+ Time.now.beginning_of_day
36
+
37
+ # RIGHT — application zone (ActiveSupport::TimeWithZone)
38
+ Time.current # == Time.zone.now
39
+ Date.current # == Time.zone.today
40
+ Time.zone.now.beginning_of_day
41
+ Time.zone.local(2026, 6, 9, 14, 30)
42
+ Time.zone.at(epoch_seconds)
43
+ Time.zone.parse("2026-06-09 14:30")
44
+ ```
45
+
46
+ Rule: in a Rails app, if you typed `Time.now` or `Date.today`, it's a bug. Use `Time.current` / `Date.current` / `Time.zone.*`. (The `rubocop-rails` cop `Rails/TimeZone` enforces this.)
47
+
48
+ `Time.current` returns an `ActiveSupport::TimeWithZone` — quacks like `Time` but carries the zone. Comparisons across zones work because they normalize to UTC internally.
49
+
50
+ ### `config.time_zone` vs `config.active_record.default_timezone`
51
+
52
+ Two **different** settings, frequently confused:
53
+
54
+ ```ruby
55
+ # config/application.rb
56
+ config.time_zone = "America/Lima" # the app's DISPLAY zone (Time.zone)
57
+ config.active_record.default_timezone = :utc # how AR STORES/reads DB times
58
+ ```
59
+
60
+ - `config.time_zone` = what `Time.zone` / `Time.current` return; how times are presented to users.
61
+ - `config.active_record.default_timezone` = `:utc` (default & recommended) or `:local`; controls the zone AR assumes for DB columns.
62
+
63
+ **Store UTC, display local.** Keep DB in UTC (`:utc`), set `config.time_zone` to your users' zone, and let `TimeWithZone` convert at the edges. A user can also have a per-request zone:
64
+
65
+ ```ruby
66
+ around_action :use_user_zone
67
+ def use_user_zone(&block)
68
+ Time.use_zone(current_user&.time_zone || "UTC", &block)
69
+ end
70
+ ```
71
+
72
+ ### Parsing safely
73
+
74
+ `Time.parse` is convenient and dangerous: it **raises** on garbage, is lenient/ambiguous, and is locale-influenced. Never feed it raw user input unguarded.
75
+
76
+ ```ruby
77
+ # WRONG — raises ArgumentError on bad input, ambiguous formats
78
+ Time.parse(params[:when])
79
+
80
+ # RIGHT — explicit format, fail closed
81
+ def parse_when(str)
82
+ Time.zone.strptime(str, "%Y-%m-%d %H:%M") # zone-aware, exact format
83
+ rescue ArgumentError, TypeError
84
+ nil
85
+ end
86
+ ```
87
+
88
+ Prefer strict parsers when the format is known:
89
+
90
+ ```ruby
91
+ Time.iso8601("2026-06-09T14:30:00-05:00") # strict ISO 8601, raises on non-ISO
92
+ Date.iso8601("2026-06-09")
93
+ Time.zone.iso8601("2026-06-09T14:30:00Z") # -> TimeWithZone (Rails)
94
+ Time.at(1_749_500_000) # epoch seconds -> system zone
95
+ Time.zone.at(1_749_500_000) # epoch -> app zone (preferred in Rails)
96
+ Time.at(1_749_500_000.123456, in: "+00:00")
97
+ ```
98
+
99
+ Guidelines:
100
+ - Known machine format → `iso8601` / `strptime` with an explicit pattern.
101
+ - Epoch integer → `Time.at` (or `Time.zone.at`).
102
+ - Free-form human input → validate/normalize upstream; if you must use `Time.parse`, wrap in `rescue ArgumentError`.
103
+
104
+ ### Formatting — `strftime` cheatsheet
105
+
106
+ ```ruby
107
+ t.strftime("%Y-%m-%d") # 2026-06-09 (year-month-day)
108
+ t.strftime("%H:%M:%S") # 14:30:05 (24h:min:sec)
109
+ t.strftime("%Y-%m-%dT%H:%M:%S%z") # 2026-06-09T14:30:05-0500
110
+ ```
111
+
112
+ | Code | Means | Code | Means |
113
+ |---|---|---|---|
114
+ | `%Y` | 4-digit year | `%H` | hour 00–23 |
115
+ | `%m` | month 01–12 | `%M` | minute 00–59 |
116
+ | `%d` | day 01–31 | `%S` | second 00–59 |
117
+ | `%A` | weekday name (Monday) | `%z` | UTC offset `-0500` |
118
+ | `%B` | month name (June) | `%j` | day of year |
119
+ | `%p` | AM/PM | `%:z` | offset `-05:00` |
120
+
121
+ Don't hand-roll ISO strings — use the built-ins:
122
+
123
+ ```ruby
124
+ t.iso8601 # "2026-06-09T14:30:05-05:00"
125
+ t.to_fs(:iso8601) # Rails: same, via to_formatted_string
126
+ t.utc.iso8601 # normalize to UTC first when serializing
127
+ ```
128
+
129
+ In Rails, prefer **I18n** for human-facing output (locale-aware, not hardcoded):
130
+
131
+ ```ruby
132
+ I18n.l(Time.current, format: :short) # uses config/locales/*.yml :time formats
133
+ I18n.l(Date.current, format: :long)
134
+ # define :short/:long under time.formats / date.formats in your locale files
135
+ ```
136
+
137
+ ### Arithmetic & durations
138
+
139
+ Use ActiveSupport durations and calendar helpers — they're DST-aware:
140
+
141
+ ```ruby
142
+ 2.hours.ago # TimeWithZone, app zone
143
+ 3.days.from_now
144
+ Time.current.beginning_of_day
145
+ Time.current.end_of_month
146
+ date + 1.day # calendar-correct
147
+ (start..finish).to_a # range of dates
148
+ ```
149
+
150
+ ```ruby
151
+ # WRONG — treats a day as a fixed 86400s; breaks across DST
152
+ t + 86400
153
+ t + (24 * 60 * 60)
154
+
155
+ # RIGHT — calendar day, DST-aware
156
+ t + 1.day
157
+ t.tomorrow
158
+ t.advance(days: 1)
159
+ ```
160
+
161
+ Plain Ruby arithmetic is in **seconds** and is fine for true elapsed seconds, but never use it to mean "the next calendar day."
162
+
163
+ ### DST & ambiguity
164
+
165
+ Daylight Saving creates two hazards:
166
+ - **Spring-forward gap:** a wall-clock time that never existed (02:30 may not occur).
167
+ - **Fall-back overlap:** a wall-clock time that occurs twice (01:30 happens twice).
168
+
169
+ Defenses:
170
+ - **Compare and store in UTC.** Offsets and durations are unambiguous in UTC.
171
+ - Do arithmetic with ActiveSupport durations (`+ 1.day`), which respect DST.
172
+ - Convert at boundaries with `in_time_zone` / `change` carefully:
173
+
174
+ ```ruby
175
+ t.utc # normalize before comparing/storing
176
+ t.in_time_zone("America/Lima") # reinterpret instant in another zone (same UTC instant)
177
+ Time.current.in_time_zone("UTC")
178
+
179
+ # WRONG: comparing two local times across a DST boundary
180
+ local_a < local_b
181
+ # RIGHT: compare the underlying instants
182
+ local_a.utc < local_b.utc # (TimeWithZone#<=> already does this; force_zone bugs don't)
183
+ ```
184
+
185
+ `in_time_zone` keeps the same instant and changes the display zone. Don't confuse it with `change(...)` (which mutates fields and can land you in a DST gap).
186
+
187
+ ### Monotonic clock for measuring elapsed time
188
+
189
+ Wall-clock time can jump (NTP, DST, manual change). To measure **durations**, use the monotonic clock — it only moves forward.
190
+
191
+ ```ruby
192
+ # WRONG — wall clock can go backward; can yield negative/garbage durations
193
+ start = Time.now
194
+ do_work
195
+ elapsed = Time.now - start
196
+
197
+ # RIGHT — monotonic
198
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
199
+ do_work
200
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start # seconds (Float)
201
+ ```
202
+
203
+ Use `Time`/`Time.current` for *what time it is*; use `CLOCK_MONOTONIC` for *how long something took*.
204
+
205
+ ### Testing time
206
+
207
+ Freeze/travel with `ActiveSupport::Testing::TimeHelpers` (`travel_to`, `freeze_time`, `travel`) instead of stubbing `Time.now`. See `references/testing.md` for setup and patterns — not repeated here.
208
+
209
+ ---
210
+
211
+ ## PART 2 — String encoding & text
212
+
213
+ ### UTF-8 is the default
214
+
215
+ Modern Ruby is UTF-8 end to end: source files, string literals, and `Encoding.default_external` are UTF-8 unless something overrides them. You rarely need a `# encoding:` magic comment anymore.
216
+
217
+ ```ruby
218
+ Encoding.default_external # usually #<Encoding:UTF-8> (from locale/ENV)
219
+ Encoding.default_internal # usually nil (no auto-transcode on read)
220
+ "café".encoding # #<Encoding:UTF-8>
221
+ "café".valid_encoding? # true
222
+ ```
223
+
224
+ ### `force_encoding` vs `encode` — the classic bug
225
+
226
+ This is the single most common encoding mistake.
227
+
228
+ - **`encode`** = *transcode*: convert the actual bytes from one encoding to another. Bytes change, characters preserved.
229
+ - **`force_encoding`** = *relabel*: reinterpret the **same bytes** under a different encoding tag. Bytes unchanged; meaning may break.
230
+
231
+ ```ruby
232
+ # encode: real conversion (UTF-8 -> ISO-8859-1 bytes)
233
+ "café".encode("ISO-8859-1") # bytes re-encoded
234
+
235
+ # force_encoding: just changes the label, NO byte conversion
236
+ bytes = "café".dup.force_encoding("ASCII-8BIT") # now treated as raw bytes
237
+ bytes.force_encoding("UTF-8") # back to UTF-8 label, original bytes intact
238
+ ```
239
+
240
+ ```ruby
241
+ # WRONG — "fixing" a mojibake by relabeling; produces invalid strings
242
+ response_body.force_encoding("UTF-8") # when bytes are actually Latin-1
243
+ # RIGHT — transcode from the real source encoding
244
+ response_body.encode("UTF-8", "ISO-8859-1")
245
+ ```
246
+
247
+ Use `force_encoding` only when you *know* the bytes are already in the target encoding but were mislabeled (e.g., a string read in binary mode that you know is UTF-8). Use `encode` to actually convert.
248
+
249
+ ### Conversion errors & scrubbing
250
+
251
+ Two errors you'll hit:
252
+ - `Encoding::UndefinedConversionError` — a character has no representation in the target encoding.
253
+ - `Encoding::InvalidByteSequenceError` — bytes aren't valid in the source encoding.
254
+
255
+ ```ruby
256
+ # Replace un-mappable / invalid bytes instead of raising
257
+ clean = raw.encode("UTF-8", invalid: :replace, undef: :replace)
258
+ clean = raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
259
+
260
+ # scrub: drop/replace invalid bytes, staying in the same encoding
261
+ "bad\xFFstring".scrub # "bad�string"
262
+ "bad\xFFstring".scrub("") # remove them
263
+ str.valid_encoding? # check before trusting
264
+ ```
265
+
266
+ `String#scrub` is the quickest way to make an untrusted UTF-8 string safe to log/store. For a transcode you also want `invalid:`/`undef:`.
267
+
268
+ ### Bytes vs characters — `ASCII-8BIT` / BINARY
269
+
270
+ `ASCII-8BIT` (alias `BINARY`) means "this is raw bytes, not text." Use it for binary protocols, hashing, image data, etc.
271
+
272
+ ```ruby
273
+ "\xDE\xAD".b # ASCII-8BIT literal (raw bytes)
274
+ File.binread("logo.png") # ASCII-8BIT, no transcode
275
+ io = File.open(path, "rb") # binary mode
276
+ digest = Digest::SHA256.digest(bytes) # operate on bytes
277
+ ```
278
+
279
+ Work in **bytes** for I/O boundaries, crypto, and length-prefixed protocols; work in **characters** for anything user-facing text.
280
+
281
+ ### Length, slicing, normalization
282
+
283
+ ```ruby
284
+ "café".length # 4 (characters / code points)
285
+ "café".size # 4 (alias)
286
+ "café".bytesize # 5 (UTF-8: é is 2 bytes)
287
+ "café".byteslice(0, 3) # slice by BYTES (can split a multibyte char!)
288
+ "café"[0, 3] # slice by CHARACTERS -> "caf"
289
+ ```
290
+
291
+ Don't assume one char == one byte. For truncating to a byte budget (DB column, network frame), use `byteslice` then `scrub` to repair a possibly-split tail character.
292
+
293
+ **Unicode normalization** — "é" can be one code point (NFC) or `e` + combining accent (NFD). They look identical but aren't `==`. Normalize before comparing user input, filenames (esp. macOS, which uses NFD), or building dedupe keys:
294
+
295
+ ```ruby
296
+ a = "café" # could be NFC or NFD depending on source
297
+ a.unicode_normalize(:nfc) # canonical composed form (default; use for comparison/storage)
298
+ a.unicode_normalize(:nfd) # decomposed
299
+ a.unicode_normalized?(:nfc) # boolean check
300
+ ```
301
+
302
+ Pick **NFC** as your canonical form on the way in.
303
+
304
+ ### Reading/writing files with explicit encoding
305
+
306
+ Be explicit at the I/O boundary; don't rely on the process default.
307
+
308
+ ```ruby
309
+ File.read(path, encoding: "UTF-8")
310
+ File.write(path, str) # writes in str's encoding
311
+ File.open(path, "r:UTF-8") { |f| f.read }
312
+ File.open(path, "r:BOM|UTF-8") { |f| ... } # strip a leading UTF-8 BOM
313
+ File.open(path, "rb") { |f| f.read } # raw bytes (ASCII-8BIT)
314
+
315
+ # external:internal — transcode on read
316
+ File.open(path, "r:ISO-8859-1:UTF-8") { |f| f.read } # read Latin-1, hand back UTF-8
317
+ ```
318
+
319
+ `"BOM|UTF-8"` handles the byte-order-mark that Windows/Excel exports often prepend — without it the BOM (``) sneaks into your first field/line.
320
+
321
+ ### External data: CSV & HTTP
322
+
323
+ ```ruby
324
+ require "csv"
325
+ # Tell CSV the file's real encoding (Excel often emits Windows-1252 or BOM'd UTF-8)
326
+ CSV.foreach(path, encoding: "bom|utf-8", headers: true) { |row| ... }
327
+ CSV.read(path, encoding: "ISO-8859-1:UTF-8") # transcode while parsing
328
+ ```
329
+
330
+ HTTP bodies arrive as bytes; the client may tag them `ASCII-8BIT` or guess wrong. Re-tag/transcode from the response's declared `charset`:
331
+
332
+ ```ruby
333
+ # If you KNOW the bytes are UTF-8 but they're labeled binary:
334
+ body = response.body.dup.force_encoding("UTF-8")
335
+ body = body.scrub unless body.valid_encoding?
336
+ # If charset says something else, TRANSCODE instead:
337
+ body = response.body.encode("UTF-8", "Shift_JIS")
338
+ ```
339
+
340
+ Decide: are the bytes already UTF-8 (relabel with `force_encoding`) or in another charset (convert with `encode`)? Getting this wrong is the mojibake bug.
341
+
342
+ ### Symbols & frozen strings
343
+
344
+ Encoding interacts with frozen-string literals and symbol interning, but those topics live elsewhere: see `references/language-idioms.md` (string/symbol idioms, `frozen_string_literal`) and `references/performance.md` (allocation/interning cost, parse cost of repeated `Time.parse`/`encode`). For Active Record column storage and zone config in the DB, see `references/rails.md`.
345
+
346
+ ---
347
+
348
+ ## Quick checklist
349
+
350
+ - Rails: use `Time.current` / `Date.current` / `Time.zone.*` — never `Time.now` / `Date.today`.
351
+ - New code: use `Time` or `Date`; never `DateTime`.
352
+ - Store times in **UTC** (`default_timezone = :utc`), display in `config.time_zone`. "Store UTC, display local."
353
+ - Parse with explicit formats: `iso8601` / `strptime`; wrap `Time.parse` in `rescue ArgumentError`, never trust raw input.
354
+ - Add `1.day`, not `86400` — durations are DST-aware; raw seconds are not.
355
+ - Compare/serialize across zones in **UTC**; convert display with `in_time_zone`.
356
+ - Measure elapsed time with `Process.clock_gettime(Process::CLOCK_MONOTONIC)`, never `Time.now - Time.now`.
357
+ - Test time with `travel_to` / `freeze_time` (see `references/testing.md`).
358
+ - `encode` = convert bytes; `force_encoding` = relabel bytes. Don't relabel to "fix" mojibake.
359
+ - Scrub untrusted text: `encode("UTF-8", invalid: :replace, undef: :replace)` or `String#scrub`; check `valid_encoding?`.
360
+ - Use `bytesize`/`byteslice` for byte budgets, `length`/`[]` for characters — one char ≠ one byte.
361
+ - Normalize user input/filenames to **NFC** with `unicode_normalize(:nfc)` before comparing.
362
+ - At I/O boundaries be explicit: `File.read(path, encoding: "UTF-8")`, `"r:BOM|UTF-8"`, `"rb"` for raw bytes.
363
+ - CSV/HTTP: know the source charset; `bom|utf-8` for Excel exports; transcode (`encode`) when charset ≠ UTF-8.