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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # An IO-shaped shim that routes everything written to it through a
6
+ # {BottomComposer#print_above}, so the ~30 existing +$stdout.print/puts+ call
7
+ # sites across UI::CLI / PrinterBase need ZERO changes. While a turn is
8
+ # active, the chat command swaps +$stdout+ for one of these (prompt_toolkit's
9
+ # +StdoutProxy+ model); on turn end it swaps the real IO back.
10
+ #
11
+ # Line buffering — the critical streaming nuance:
12
+ # UI::CLI#stream emits PARTIAL tokens with NO trailing newline during model
13
+ # streaming. A naive "print each write above the prompt" would scroll every
14
+ # token onto its own row. Instead we hold the in-progress line in
15
+ # +@partial+ and re-render it (the accumulating line) ABOVE the composer via
16
+ # {BottomComposer#set_partial} as it grows — a transient row redrawn in
17
+ # place — committing it to scrollback (via {BottomComposer#print_above})
18
+ # only when a newline arrives. The way prompt_toolkit buffers and batches:
19
+ # each newline-terminated segment becomes one committed row; the trailing
20
+ # partial keeps showing live.
21
+ #
22
+ # The render mutex lives in the composer, so concurrent writes from the
23
+ # streaming thread and keystroke redraws stay serialized.
24
+ class StdoutProxy
25
+ # @param composer [BottomComposer] coordinator that owns print_above.
26
+ def initialize(composer)
27
+ @composer = composer
28
+ @partial = +""
29
+ end
30
+
31
+ # The two methods UI code actually uses are #print and #puts; #write backs
32
+ # both formattings and is also what e.g. StringIO/IO duck-typers call.
33
+ def write(*args)
34
+ args.sum do |a|
35
+ append(a.to_s)
36
+ a.to_s.bytesize
37
+ end
38
+ end
39
+
40
+ def print(*args)
41
+ args.each { |a| append(a.to_s) }
42
+ nil
43
+ end
44
+
45
+ def puts(*args)
46
+ if args.empty?
47
+ append("\n")
48
+ else
49
+ args.each do |a|
50
+ if a.is_a?(Array)
51
+ a.each { |e| puts(e) }
52
+ else
53
+ # Append the line and its terminating newline in ONE append so the
54
+ # text commits straight to scrollback. Appending them separately
55
+ # showed the line as a TRANSIENT partial row below the subagent
56
+ # cards for a frame before the commit moved it above them — the
57
+ # user saw the same line twice around the live card block (#153).
58
+ s = a.to_s
59
+ append(s.end_with?("\n") ? s : "#{s}\n")
60
+ end
61
+ end
62
+ end
63
+ nil
64
+ end
65
+
66
+ def printf(format, *)
67
+ append(format(format, *))
68
+ nil
69
+ end
70
+
71
+ def <<(obj)
72
+ append(obj.to_s)
73
+ self
74
+ end
75
+
76
+ # Streaming writers call flush after each token. We treat flush as "show
77
+ # what you have now": re-render the accumulating partial line above the
78
+ # composer so streamed text appears live, without committing it to
79
+ # scrollback (it has no newline yet).
80
+ def flush
81
+ render_partial
82
+ self
83
+ end
84
+
85
+ # REPLACE the live region with +str+ (replace, not accumulate). The normal
86
+ # #append path GROWS @partial — right for token-by-token line buffering, but
87
+ # wrong for the streaming-markdown tail, which is the WHOLE in-progress block
88
+ # re-shown each time it changes. So we reset our own buffer and hand the raw
89
+ # tail straight to the composer's transient row. Used by UI::CLI#stream to
90
+ # show the incomplete block live while completed blocks commit above it.
91
+ def live(str)
92
+ @partial = +""
93
+ @composer.set_partial(str.to_s)
94
+ self
95
+ end
96
+
97
+ # Commit any held partial line as a final row. Called when the proxy is
98
+ # torn down so an unterminated last line (e.g. a stream that ended without
99
+ # stream_end) isn't lost.
100
+ def finish
101
+ return if @partial.empty?
102
+
103
+ line = @partial
104
+ @partial = +""
105
+ @composer.print_above(line)
106
+ end
107
+
108
+ # Best-effort IO compatibility for code that probes the stream.
109
+ def tty? = false
110
+ def isatty = false
111
+ def sync = true
112
+ def fileno = nil
113
+
114
+ # A faithful IO duck MUST answer #close: stdlib Logger::LogDevice treats a
115
+ # logdev that responds to :write but NOT :close as a FILENAME and does
116
+ # File.open(it) → "no implicit conversion of StdoutProxy into String" if a
117
+ # Logger is ever built against $stdout while we hold the swap. No-op close.
118
+ def close; end
119
+ def closed? = false
120
+
121
+ def sync=(_)
122
+ true
123
+ end
124
+
125
+ private
126
+
127
+ # Accumulate text, committing each complete (newline-terminated) line to
128
+ # scrollback via print_above and keeping any trailing remainder as the live
129
+ # partial. The partial is shown via #flush; many writers flush right after,
130
+ # but we also render it here so a partial that arrives without a following
131
+ # flush still appears.
132
+ def append(str)
133
+ return if str.nil? || str.empty?
134
+
135
+ @partial << str
136
+ commit_complete_lines
137
+ render_partial
138
+ end
139
+
140
+ def commit_complete_lines
141
+ while (idx = @partial.index("\n"))
142
+ line = @partial[0...idx]
143
+ @partial = @partial[(idx + 1)..] || +""
144
+ # A committed line is a finished row; embedded "\r" (e.g. the CLI's
145
+ # in-place clear before a streamed chunk) is preserved so print_above's
146
+ # clear-line semantics still apply.
147
+ @composer.print_above(line)
148
+ end
149
+ end
150
+
151
+ # Show the in-progress (un-newlined) line above the composer without
152
+ # committing it. set_partial renders it on a transient row directly above
153
+ # the input line, redrawn in place — so the live partial grows in place as
154
+ # tokens stream in rather than scrolling a copy per token. When the partial
155
+ # is empty (just committed a line), clear the transient row.
156
+ def render_partial
157
+ @composer.set_partial(@partial)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Incremental block splitter for streamed markdown.
6
+ #
7
+ # The model streams an assistant message token-by-token; we want to render
8
+ # COMPLETED markdown blocks above the composer as soon as they finish, while
9
+ # showing the still-incoming (incomplete) block raw in the live region. This
10
+ # buffer accumulates streamed text and decides where one block ends and the
11
+ # next begins, so {UI::CLI} can render+commit a finished block and leave only
12
+ # the in-progress tail live.
13
+ #
14
+ # Block boundary detection — a small line-oriented fence state machine (the
15
+ # mainstream approach used by md2term / mdterm / Glamour-style streamers: you
16
+ # must NOT render a fenced code block until its closing ``` arrives, or
17
+ # half-open fences render as garbage):
18
+ #
19
+ # * Lines are split on "\n". A line is "complete" once its terminating "\n"
20
+ # has been seen; the trailing remainder (no "\n" yet) is the live tail.
21
+ # * A line matching ^\s*``` toggles the fence state.
22
+ # - Entering a fence STARTS a code block (the fence line joins it).
23
+ # - Leaving a fence ENDS the code block (the closing fence joins it);
24
+ # the block is reported complete.
25
+ # * While INSIDE a fence, blank lines do NOT split — code keeps its blanks.
26
+ # * While OUTSIDE a fence, a blank line ENDS the current prose block. The
27
+ # blank line itself is consumed as the separator (not re-emitted).
28
+ #
29
+ # API:
30
+ # feed(text) -> Array<String> newly-completed block texts (state advances)
31
+ # tail -> String the current incomplete block, raw (live)
32
+ # flush -> String|nil the remaining buffered block on stream end
33
+ # (an unclosed fence is returned so the caller
34
+ # can emit it as plain text — never lost)
35
+ class StreamingMarkdown
36
+ FENCE_RE = /\A\s*```/
37
+ # An ordered ("1. ", "2) ") or unordered ("- ", "* ", "+ ") list item.
38
+ # Used so a loose list (blank lines BETWEEN items) is kept as ONE block
39
+ # instead of being split per-item: each split item was re-rendered on its
40
+ # own, and kramdown restarts ordered numbering at 1 for every block, which
41
+ # produced the "1. Mercury / 1. Venus / 1. Earth" off-by-one (B4).
42
+ LIST_ITEM_RE = /\A\s*(?:[-*+]|\d+[.)])\s/
43
+
44
+ def initialize
45
+ @pending = +"" # un-newlined remainder (the live tail-in-progress line)
46
+ @block = [] # completed lines accumulated for the current block
47
+ @in_fence = false
48
+ @in_list = false # current block is a markdown list (keep loose items together)
49
+ @blanks = 0 # blank lines buffered inside a list, re-emitted iff it continues
50
+ end
51
+
52
+ # Accumulate streamed text; return the list of block texts that became
53
+ # COMPLETE as a result of this feed (possibly empty). Advances state.
54
+ def feed(text)
55
+ return [] if text.nil? || text.empty?
56
+
57
+ @pending << text
58
+ completed = []
59
+
60
+ while (idx = @pending.index("\n"))
61
+ line = @pending[0...idx]
62
+ @pending = @pending[(idx + 1)..] || +""
63
+ block = consume_line(line)
64
+ completed << block if block
65
+ end
66
+
67
+ completed
68
+ end
69
+
70
+ # The current incomplete block as raw text: any lines already buffered for
71
+ # the in-progress block plus the un-newlined remainder. Shown live; it gets
72
+ # re-rendered + committed once its block completes.
73
+ def tail
74
+ parts = @block.dup
75
+ parts << @pending unless @pending.empty?
76
+ parts.join("\n")
77
+ end
78
+
79
+ # The in-progress tail to show live (raw): the LAST +rows+ lines of the
80
+ # in-flight block — its most recent already-newlined lines plus the
81
+ # un-newlined remainder. Newline-joined; the live region renders one row
82
+ # per line.
83
+ #
84
+ # Why a rolling window and not the whole #tail: the live region must stay
85
+ # bounded (a long open fence/table must never push the prompt off-screen),
86
+ # so we keep "only the last block can change" (Textual/Rich, Streamdown,
87
+ # Glamour-style streamers) but show a FEW trailing lines instead of just
88
+ # the one being typed — a long list block used to vanish line-by-line as
89
+ # each item completed, leaving a single flickering raw line until the
90
+ # whole block committed (#127). Earlier lines stay buffered and the block
91
+ # still snaps to rendered markdown the moment it completes.
92
+ def live_tail(rows = 1)
93
+ lines = @block.last(rows)
94
+ lines += [@pending] unless @pending.empty?
95
+ lines.last(rows).join("\n")
96
+ end
97
+
98
+ # Drain the remainder on stream end. Promotes any un-newlined remainder to
99
+ # a final line, then returns the buffered block text (or nil if empty). An
100
+ # unclosed fence is returned all the same — the caller emits it as plain so
101
+ # output is never dropped.
102
+ def flush
103
+ unless @pending.empty?
104
+ @block << @pending
105
+ @pending = +""
106
+ end
107
+ return nil if @block.empty?
108
+
109
+ text = @block.join("\n")
110
+ @block = []
111
+ @in_fence = false
112
+ @in_list = false
113
+ @blanks = 0
114
+ text
115
+ end
116
+
117
+ private
118
+
119
+ # Feed one complete (newline-stripped) line through the state machine.
120
+ # Returns the finished block's text when this line closes a block, else nil.
121
+ def consume_line(line)
122
+ if @in_fence
123
+ @block << line
124
+ if line.match?(FENCE_RE) # closing fence ends the code block
125
+ @in_fence = false
126
+ return take_block
127
+ end
128
+ return nil
129
+ end
130
+
131
+ if line.match?(FENCE_RE) # opening fence starts a code block
132
+ @in_fence = true
133
+ flush_blanks
134
+ @block << line
135
+ return nil
136
+ end
137
+
138
+ if line.strip.empty?
139
+ # A blank line inside a list is BUFFERED, not a separator: it only ends
140
+ # the block if the list doesn't continue (handled when the next
141
+ # non-blank, non-item line arrives, or at flush). Outside a list a
142
+ # blank line ends the current prose block (separator consumed).
143
+ if @in_list
144
+ @blanks += 1
145
+ return nil
146
+ end
147
+ return nil if @block.empty?
148
+
149
+ return take_block
150
+ end
151
+
152
+ is_item = line.match?(LIST_ITEM_RE)
153
+
154
+ # A non-item line after a blank-separated list closes the list block
155
+ # first (so the list renders as one well-numbered unit), then this line
156
+ # starts a fresh block — its buffered blanks are dropped as the separator.
157
+ if @in_list && !is_item && @blanks.positive?
158
+ @blanks = 0 # drop the trailing blank(s) that separated list from this line
159
+ finished = take_block
160
+ @block << line
161
+ return finished
162
+ end
163
+
164
+ flush_blanks
165
+ @in_list = true if is_item
166
+ @block << line
167
+ nil
168
+ end
169
+
170
+ # Re-emit blank lines buffered inside a continuing list so loose-list
171
+ # spacing is preserved in the committed block text.
172
+ def flush_blanks
173
+ @blanks.times { @block << "" }
174
+ @blanks = 0
175
+ end
176
+
177
+ def take_block
178
+ flush_blanks
179
+ text = @block.join("\n")
180
+ @block = []
181
+ @in_list = false
182
+ text
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubino
6
+ module UI
7
+ # Formats BackgroundTasks registry entries into the COLLAPSED LIVE CARDS the
8
+ # parent shows while one or more background subagents run (Variant A of the
9
+ # orchestration-UX blueprint). This is the single source of card text: the
10
+ # live region (UI::CLI#set_subagent_cards → BottomComposer) renders it while a
11
+ # turn runs, and the /agents drill-in reuses the same formatter for the
12
+ # expanded view. Pure formatting — it never touches the registry mutex itself
13
+ # (callers pass a snapshot) and writes nothing; the renderer decides where the
14
+ # lines go.
15
+ #
16
+ # Collapsed card (one row per running subagent, updates in place):
17
+ # ▸ sa_9ae4 · explore · running · 14 tools · 38s · grep "def authenticate"
18
+ # plus a single shared hint line under the block.
19
+ #
20
+ # An entry parked on a human approval shows the approval prominently instead:
21
+ # ● sa_9ae4 · explore · needs approval · shell rm -rf build
22
+ #
23
+ # Up to MAX_CARDS cards stack; a longer list collapses the overflow into a
24
+ # "+N more" tail so the live region stays bounded (and the single-row clamp
25
+ # in the composer never has to host an unbounded block).
26
+ class SubagentCards
27
+ # Cap the live block so it never grows past the registry's own
28
+ # MAX_CONCURRENT (3) live children — but defend against a stale/over-long
29
+ # list anyway with an explicit overflow tail.
30
+ MAX_CARDS = Tools::BackgroundTasks::MAX_CONCURRENT
31
+
32
+ # Collapsed glyph (a running card) / approval glyph (needs the human) /
33
+ # BLOCKED glyph (an escalated ask_parent waiting on the human — RESERVED for
34
+ # "the tree is blocked on you" and nothing else, the distinct-signal rule).
35
+ COLLAPSED = "▸"
36
+ APPROVAL = "●"
37
+ BLOCKED = "⛔"
38
+
39
+ def initialize(pastel: Pastel.new)
40
+ @pastel = pastel
41
+ end
42
+
43
+ # Renders the live CARD BLOCK for the running (or approval-pending)
44
+ # children in +entries+ as an array of ready-to-print lines. Returns [] when
45
+ # nothing is live, so the renderer can clear the region. +entries+ is a
46
+ # snapshot (BackgroundTasks#running) taken under the registry mutex by the
47
+ # caller — this method only reads the plain struct fields.
48
+ def card_lines(entries)
49
+ live = entries.select { |e| live?(e) }
50
+ return [] if live.empty?
51
+
52
+ shown = live.first(MAX_CARDS)
53
+ overflow = live.size - shown.size
54
+ lines = shown.map { |e| card_line(e) }
55
+ lines << @pastel.dim(" + #{overflow} more · /agents") if overflow.positive?
56
+ lines << hint_line(shown)
57
+ lines
58
+ end
59
+
60
+ # One collapsed card row for a single entry.
61
+ def card_line(entry)
62
+ if entry.status == :blocked_on_human
63
+ blocked_card_line(entry)
64
+ elsif entry.status == :needs_approval
65
+ approval_card_line(entry)
66
+ else
67
+ glyph = @pastel.cyan(COLLAPSED)
68
+ state = entry.status == :stopping ? "stopping" : "running"
69
+ count = entry.tool_count.to_i
70
+ body = "#{entry.id} · #{entry.subagent} · #{state} · " \
71
+ "#{count} tool#{"s" if count != 1} · #{elapsed(entry)}"
72
+ body += " · #{entry.last_activity}" unless entry.last_activity.to_s.empty?
73
+ " #{glyph} #{body}"
74
+ end
75
+ end
76
+
77
+ # A card for a child parked on an escalated ask_parent — the ⛔ "tree is
78
+ # blocked on YOU" row, the loudest state. Leads with the red ⛔ glyph and
79
+ # the question, and points at /reply <id> (the answer verb), distinct from
80
+ # the approval row's /agents <id>.
81
+ def blocked_card_line(entry)
82
+ glyph = @pastel.red(BLOCKED)
83
+ question = entry.ask_question.to_s
84
+ " #{glyph} #{entry.id} · #{entry.subagent} · " +
85
+ @pastel.red("waiting on you") + ": #{first_line(question, 60)} " \
86
+ "· /reply #{entry.id}"
87
+ end
88
+
89
+ # A card for a child parked on a human approval — the approval is the most
90
+ # important thing on the row, so it leads (amber ●) with the command.
91
+ def approval_card_line(entry)
92
+ glyph = @pastel.yellow(APPROVAL)
93
+ command = entry.approval_command.to_s
94
+ command = entry.approval_question.to_s if command.empty?
95
+ " #{glyph} #{entry.id} · #{entry.subagent} · " +
96
+ @pastel.yellow("needs approval") + ": #{first_line(command, 60)} " \
97
+ "· /agents #{entry.id}"
98
+ end
99
+
100
+ private
101
+
102
+ def live?(entry)
103
+ %i[running needs_approval blocked_on_human stopping].include?(entry.status)
104
+ end
105
+
106
+ # Shared hint under the block. When something needs approval the hint leads
107
+ # with the answer affordance; otherwise it's the watch/stop hint.
108
+ def hint_line(shown)
109
+ blocked = shown.count { |e| e.status == :blocked_on_human }
110
+ if blocked.positive?
111
+ @pastel.red(" \u26d4 #{blocked} subagent waiting on you · /reply <id> to answer")
112
+ elsif shown.any? { |e| e.status == :needs_approval }
113
+ @pastel.dim(" └ /agents <id> to approve · --stop to cancel")
114
+ else
115
+ @pastel.dim(" └ /agents <id> to watch · --stop to cancel")
116
+ end
117
+ end
118
+
119
+ def elapsed(entry)
120
+ return "" unless entry.started_at
121
+
122
+ finish = entry.finished_at || Time.now
123
+ Rubino::Util::Duration.human_duration(finish - entry.started_at)
124
+ end
125
+
126
+ # First NON-BLANK line, elided to +max+. A ruby/shell approval command
127
+ # often starts with a newline or a blank line — taking `.lines.first`
128
+ # there rendered an EMPTY "needs approval:" body on the card (#141).
129
+ def first_line(text, max)
130
+ Rubino::Util::Output.first_line(text, max)
131
+ end
132
+ end
133
+ end
134
+ end