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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Applies an ordered list of exact string replacements to a single file
6
+ # in one transactional shot. If any edit fails (string not found, or
7
+ # non-unique without replace_all) the file is left untouched — the LLM
8
+ # gets a single error pointing at the offending edit index.
9
+ #
10
+ # Each subsequent edit sees the result of prior edits in the same call,
11
+ # so you can rename A→B and then change a line that contains B.
12
+ class MultiEditTool < Base
13
+ def name
14
+ "multi_edit"
15
+ end
16
+
17
+ def description
18
+ "Apply multiple exact string replacements to a single file atomically. " \
19
+ "Edits are applied sequentially in the given order; later edits see " \
20
+ "the result of earlier ones. If any edit fails, NO changes are written."
21
+ end
22
+
23
+ def input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ file_path: {
28
+ type: "string",
29
+ description: "Path to the file to edit"
30
+ },
31
+ edits: {
32
+ type: "array",
33
+ description: "Ordered list of edits to apply",
34
+ items: {
35
+ type: "object",
36
+ properties: {
37
+ old_string: { type: "string", description: "Exact text to find" },
38
+ new_string: { type: "string", description: "Replacement text" },
39
+ replace_all: { type: "boolean", description: "Replace all occurrences (default false)" }
40
+ },
41
+ required: %w[old_string new_string]
42
+ }
43
+ }
44
+ },
45
+ required: %w[file_path edits]
46
+ }
47
+ end
48
+
49
+ def risk_level
50
+ :medium
51
+ end
52
+
53
+ def call(arguments)
54
+ file_path = arguments["file_path"] || arguments[:file_path]
55
+ edits = arguments["edits"] || arguments[:edits] || []
56
+
57
+ return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
58
+ return "Error: edits must be a non-empty array" if !edits.is_a?(Array) || edits.empty?
59
+
60
+ expanded = File.expand_path(file_path)
61
+ return workspace_violation_message(file_path) unless within_workspace?(expanded)
62
+ return "Error: File not found: #{file_path}" unless File.exist?(expanded)
63
+
64
+ if (gate = read_gate_error(expanded, file_path, verb: "edits"))
65
+ return gate
66
+ end
67
+
68
+ content = File.read(expanded)
69
+ working = content.dup
70
+ applied_count = 0
71
+
72
+ edits.each_with_index do |edit, idx|
73
+ if cancellation_requested?
74
+ return "Cancelled before edit ##{idx + 1} — no changes written " \
75
+ "(multi_edit is atomic: stages in memory, writes once)"
76
+ end
77
+
78
+ old_s = edit["old_string"] || edit[:old_string]
79
+ new_s = edit["new_string"] || edit[:new_string]
80
+ replace_all = edit["replace_all"] || edit[:replace_all] || false
81
+
82
+ return "Error: edit ##{idx + 1} is missing old_string or new_string" if old_s.nil? || new_s.nil?
83
+ return "Error: edit ##{idx + 1}: old_string and new_string are identical" if old_s == new_s
84
+ unless working.include?(old_s)
85
+ return "Error: edit ##{idx + 1}: old_string not found (check whitespace; " \
86
+ "remember edits see the result of prior edits)"
87
+ end
88
+
89
+ count = working.scan(old_s).size
90
+ if count > 1 && !replace_all
91
+ return "Error: edit ##{idx + 1}: #{count} matches for old_string. " \
92
+ "Add surrounding context to disambiguate, or set replace_all: true."
93
+ end
94
+
95
+ working = if replace_all
96
+ working.gsub(old_s) { new_s }
97
+ else
98
+ working.sub(old_s) { new_s }
99
+ end
100
+ applied_count += replace_all ? count : 1
101
+ end
102
+
103
+ File.write(expanded, working)
104
+ "Applied #{edits.size} edit(s), #{applied_count} replacement(s) in #{file_path}"
105
+ rescue StandardError => e
106
+ "Error: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # Tool for applying unified diff patches to files.
8
+ class PatchTool < Base
9
+ def name
10
+ "apply_patch"
11
+ end
12
+
13
+ def description
14
+ "Apply a unified diff patch to one or more files. " \
15
+ "Accepts standard unified diff format (like output from 'git diff')."
16
+ end
17
+
18
+ def input_schema
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ patch: {
23
+ type: "string",
24
+ description: "The unified diff patch content to apply"
25
+ },
26
+ base_path: {
27
+ type: "string",
28
+ description: "Base directory for relative paths in the patch (defaults to cwd)"
29
+ }
30
+ },
31
+ required: %w[patch]
32
+ }
33
+ end
34
+
35
+ def risk_level
36
+ :medium
37
+ end
38
+
39
+ def call(arguments)
40
+ patch = arguments["patch"] || arguments[:patch]
41
+ base_path = arguments["base_path"] || arguments[:base_path] || Dir.pwd
42
+
43
+ hunks = parse_patch(patch)
44
+ return "No changes applied" if hunks.empty?
45
+
46
+ # Pass 1: validate every hunk against current disk state and compute
47
+ # the new content for each file in memory. NO disk writes here. If
48
+ # any single hunk fails (missing file, context mismatch, workspace
49
+ # escape) we abort the whole patch — partial application across
50
+ # multiple files leaves the tree in a state neither the user nor
51
+ # the agent can easily reason about, and reverting it requires
52
+ # knowing the prior contents which we no longer have.
53
+ pending, error = plan_operations(hunks, base_path)
54
+ return error if error
55
+
56
+ # Pass 2: execute. cancellation_requested? polled between operations
57
+ # so a Ctrl+C lands cleanly — at most one application is in flight.
58
+ apply_operations(pending)
59
+ end
60
+
61
+ private
62
+
63
+ def plan_operations(hunks, base_path)
64
+ pending = []
65
+
66
+ hunks.each do |hunk|
67
+ file_path = File.expand_path(hunk[:file], base_path)
68
+
69
+ unless within_workspace?(file_path)
70
+ return [nil, workspace_violation_message(hunk[:file]) +
71
+ " (no changes applied — apply_patch is two-phase)"]
72
+ end
73
+
74
+ if hunk[:new_file]
75
+ pending << { kind: :create,
76
+ path: file_path,
77
+ display: hunk[:file],
78
+ content: hunk[:additions].join("\n") + "\n" }
79
+ elsif hunk[:delete_file]
80
+ pending << { kind: :delete,
81
+ path: file_path,
82
+ display: hunk[:file] }
83
+ else
84
+ return [nil, "Error: File not found: #{hunk[:file]} (no changes applied)"] unless File.exist?(file_path)
85
+
86
+ content = File.read(file_path)
87
+ new_content, drift, fuzzy = apply_hunk(content, hunk)
88
+ if new_content.nil?
89
+ return [nil, "Error: Could not apply hunk to #{hunk[:file]} - " \
90
+ "context mismatch (no changes applied)"]
91
+ end
92
+
93
+ pending << { kind: :patch,
94
+ path: file_path,
95
+ display: hunk[:file],
96
+ content: new_content,
97
+ drift: drift,
98
+ fuzzy: fuzzy,
99
+ adds: hunk[:additions].size,
100
+ dels: hunk[:deletions].size }
101
+ end
102
+ end
103
+
104
+ [pending, nil]
105
+ end
106
+
107
+ def apply_operations(pending)
108
+ results = []
109
+
110
+ pending.each do |op|
111
+ if cancellation_requested?
112
+ remaining = pending.size - results.size
113
+ results << "[cancelled — #{remaining} operation(s) skipped]"
114
+ break
115
+ end
116
+
117
+ case op[:kind]
118
+ when :create
119
+ FileUtils.mkdir_p(File.dirname(op[:path]))
120
+ File.write(op[:path], op[:content])
121
+ results << "Created: #{op[:display]}"
122
+ when :delete
123
+ File.delete(op[:path]) if File.exist?(op[:path])
124
+ results << "Deleted: #{op[:display]}"
125
+ when :patch
126
+ File.write(op[:path], op[:content])
127
+ results << patch_result_line(op)
128
+ end
129
+ end
130
+
131
+ results.join("\n")
132
+ end
133
+
134
+ # The drift note is the bit that distinguishes "applied exactly where
135
+ # the diff said" from "found by fuzzy search ±20 lines away". The old
136
+ # tool silently let the fuzzy case through and reported success — if
137
+ # the model was off by 50 lines we'd write to the wrong place and
138
+ # claim it worked.
139
+ def patch_result_line(op)
140
+ base = "Patched: #{op[:display]} (#{op[:adds]} additions, #{op[:dels]} deletions)"
141
+ return base unless op[:fuzzy]
142
+
143
+ offset = op[:drift]
144
+ signed = "#{"+" if offset.positive?}#{offset}"
145
+ "#{base} [fuzzy match: applied #{signed} line(s) from requested position]"
146
+ end
147
+
148
+ def parse_patch(patch)
149
+ hunks = []
150
+ current_file = nil
151
+ # Flags carried at the file level — set before any @@ hunk header
152
+ pending_new_file = false
153
+ pending_delete_file = false
154
+
155
+ patch.each_line do |line|
156
+ case line
157
+ when %r{^--- /dev/null}
158
+ # New file: source is /dev/null
159
+ pending_new_file = true
160
+ when %r{^--- a/(.*)}
161
+ # Normal source file — set current_file and reset pending flags
162
+ current_file = Regexp.last_match(1).strip
163
+ pending_new_file = false
164
+ pending_delete_file = false
165
+ when %r{^\+\+\+ /dev/null}
166
+ # Delete file: destination is /dev/null; current_file already set by --- a/
167
+ pending_delete_file = true
168
+ when %r{^\+\+\+ b/(.*)}
169
+ current_file = Regexp.last_match(1).strip
170
+ when /^@@ -(\d+),?\d* \+(\d+),?\d* @@/
171
+ hunk = {
172
+ file: current_file,
173
+ start_line: Regexp.last_match(1).to_i,
174
+ new_start: Regexp.last_match(2).to_i,
175
+ context: [],
176
+ additions: [],
177
+ deletions: [],
178
+ lines: [],
179
+ new_file: pending_new_file,
180
+ delete_file: pending_delete_file
181
+ }
182
+ hunks << hunk
183
+ # Reset pending flags after consuming them
184
+ pending_new_file = false
185
+ pending_delete_file = false
186
+ else
187
+ hunk = hunks.last
188
+ next unless hunk
189
+
190
+ if line.start_with?("+")
191
+ hunk[:additions] << line[1..].rstrip
192
+ hunk[:lines] << { type: :add, content: line[1..].rstrip }
193
+ elsif line.start_with?("-")
194
+ hunk[:deletions] << line[1..].rstrip
195
+ hunk[:lines] << { type: :del, content: line[1..].rstrip }
196
+ elsif line.start_with?(" ")
197
+ hunk[:context] << line[1..].rstrip
198
+ hunk[:lines] << { type: :ctx, content: line[1..].rstrip }
199
+ end
200
+ end
201
+ end
202
+
203
+ hunks
204
+ end
205
+
206
+ # Returns [new_content, drift, fuzzy].
207
+ # new_content: the rewritten file content, or nil if context can't
208
+ # be found anywhere within the fuzzy search window.
209
+ # drift: signed line offset from the hunk's requested start
210
+ # (0 = exact match).
211
+ # fuzzy: true iff the match was found by find_context rather
212
+ # than at the requested line. The caller surfaces this
213
+ # so the model can see "I asked line 10, you applied
214
+ # at line 13" instead of trusting a silent fuzzy match.
215
+ def apply_hunk(content, hunk)
216
+ lines = content.lines.map(&:rstrip)
217
+ requested_ix = hunk[:start_line] - 1
218
+ start_idx = requested_ix
219
+ fuzzy = false
220
+
221
+ expected = hunk[:lines].reject { |l| l[:type] == :add }.map { |l| l[:content] }
222
+
223
+ actual = lines[start_idx, expected.size]
224
+ unless actual && actual.map(&:rstrip) == expected.map(&:rstrip)
225
+ found_idx = find_context(lines, expected, start_idx)
226
+ return [nil, 0, false] unless found_idx
227
+
228
+ start_idx = found_idx
229
+ fuzzy = (found_idx != requested_ix)
230
+ end
231
+
232
+ new_lines = lines[0...start_idx]
233
+ hunk[:lines].each do |line|
234
+ case line[:type]
235
+ when :add, :ctx
236
+ new_lines << line[:content]
237
+ when :del
238
+ # removed — skip
239
+ end
240
+ end
241
+ new_lines.concat(lines[(start_idx + expected.size)..] || [])
242
+
243
+ [new_lines.join("\n") + "\n", start_idx - requested_ix, fuzzy]
244
+ end
245
+
246
+ def find_context(lines, expected, hint_idx)
247
+ search_range = 20
248
+ start = [0, hint_idx - search_range].max
249
+ finish = [lines.size - expected.size, hint_idx + search_range].min
250
+
251
+ (start..finish).each do |idx|
252
+ actual = lines[idx, expected.size]
253
+ return idx if actual && actual.map(&:rstrip) == expected.map(&:rstrip)
254
+ end
255
+
256
+ nil
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subagent_probe"
4
+
5
+ module Rubino
6
+ module Tools
7
+ # probe — the MODEL-callable EPHEMERAL peek into one of the caller's OWN
8
+ # running children (S3). The model counterpart of the human
9
+ # `/agents <id> probe "..."`. Two paths, both read-only (they append NOTHING
10
+ # to the child's session — the EPHEMERAL invariant):
11
+ #
12
+ # live:false (DEFAULT, FREE): build the answer from the registry's
13
+ # live-progress fields ONLY (status / tool_count / last_activity + the
14
+ # bounded activity_log ring the /agents drill-in already tails). NO model
15
+ # call — unlimited.
16
+ # live:true (BILLED): run ONE side-inference over a read-only snapshot of
17
+ # the child's transcript (SubagentProbe#peek) and return the answer. This
18
+ # costs a model round-trip, so it is BUDGETED per child
19
+ # (tasks.max_live_probes_per_child, default 5). Over budget → the model is
20
+ # told to use the free snapshot.
21
+ #
22
+ # SCOPED AT CALL (the S1 correction): probe is registered for ALL agents and
23
+ # authorized by OWNERSHIP at call time — the target must be the caller's OWN
24
+ # direct child (BackgroundTasks.owned_by?). Registered normally, NOT on any
25
+ # strip list. Does NOT touch the human CLI probe path (executor.rb).
26
+ class ProbeTool < Base
27
+ # How many activity_log lines the cheap snapshot renders (matches the
28
+ # /agents drill-in's `recent:` ring).
29
+ RECENT_MAX = 6
30
+
31
+ # A probe is a snapshot at this instant: a child probed right after
32
+ # spawn has run nothing yet and honestly reports an empty/confused
33
+ # context, which reads as broken without this hint (#112).
34
+ JUST_STARTED_HINT = "(snapshot at this instant — the child just started and its " \
35
+ "context is still empty; probe again in a moment)"
36
+
37
+ def initialize(probe: nil)
38
+ # Test seam: inject a SubagentProbe (or any object responding to #peek)
39
+ # so the live path can be driven without a real model.
40
+ @probe = probe
41
+ end
42
+
43
+ def name
44
+ "probe"
45
+ end
46
+
47
+ # Gated by the same `tools.task` delegation key — probing a child is
48
+ # meaningless without the delegation substrate.
49
+ def config_key
50
+ "task"
51
+ end
52
+
53
+ def description
54
+ "Check on one of YOUR OWN running subagents WITHOUT disturbing it (this " \
55
+ "is read-only — it changes nothing about what the child does). By default " \
56
+ "(live:false) it returns a FREE instant snapshot: the child's status, how " \
57
+ "many tools it has run, its last activity, and a few recent lines — no " \
58
+ "model call. Set live:true to ask the child a specific question answered " \
59
+ "from its current context by a one-shot model peek (this costs a billed " \
60
+ "round-trip and is budgeted per child; prefer the free snapshot). You can " \
61
+ "ONLY probe subagents you started (your direct children)."
62
+ end
63
+
64
+ def input_schema
65
+ {
66
+ type: "object",
67
+ properties: {
68
+ task_id: { type: "string", description: "The id (sa_…) of YOUR subagent to probe." },
69
+ question: { type: "string",
70
+ description: "What you want to know. For a free snapshot this frames the check; for live:true it is the question the child answers from its context." },
71
+ live: {
72
+ type: "boolean",
73
+ description: "false (default) = FREE instant snapshot from the registry, no model call. " \
74
+ "true = billed one-shot model peek over the child's transcript (budgeted per child)."
75
+ }
76
+ },
77
+ required: %w[task_id question]
78
+ }
79
+ end
80
+
81
+ def risk_level
82
+ :low
83
+ end
84
+
85
+ def call(arguments)
86
+ task_id = (arguments["task_id"] || arguments[:task_id]).to_s.strip
87
+ question = (arguments["question"] || arguments[:question]).to_s.strip
88
+ live = live_arg(arguments)
89
+
90
+ caller_id = Rubino.current_subagent_id
91
+ registry = BackgroundTasks.instance
92
+ entry = task_id.empty? ? nil : registry.find(task_id)
93
+
94
+ return "Cannot probe #{task_id} — no such subagent." unless entry
95
+ return "Error: question is required" if question.empty?
96
+ unless registry.owned_by?(caller_id, task_id)
97
+ return "Error: #{task_id} is not one of your subagents — you can only probe children you started."
98
+ end
99
+ # A child parked on a BLOCKING ask_parent has no live activity to peek
100
+ # at — its pending tool_use is not in the persisted snapshot, so a
101
+ # billed live peek would honestly answer "I never called ask_parent"
102
+ # while task_result says blocked (#198). Short-circuit with the parked
103
+ # question and the one action that unblocks it (no billed peek).
104
+ return blocked_on_ask_answer(entry) if parked_on_ask?(entry)
105
+
106
+ live ? probe_live(registry, entry, question) : probe_cheap(entry)
107
+ end
108
+
109
+ private
110
+
111
+ # live defaults to FALSE (the free, unbilled snapshot). Only an explicit
112
+ # true opts into the billed model peek.
113
+ def live_arg(arguments)
114
+ raw = arguments.key?("live") ? arguments["live"] : arguments[:live]
115
+ [true, "true", 1, "1"].include?(raw)
116
+ end
117
+
118
+ def just_started?(entry)
119
+ entry.tool_count.to_i.zero?
120
+ end
121
+
122
+ # True when the child's thread is PARKED on a blocking ask_parent gate
123
+ # (a non-blocking ask keeps the blocked status on the card but the child
124
+ # keeps working, so it stays probeable).
125
+ def parked_on_ask?(entry)
126
+ entry.ask_gate && entry.ask_blocking &&
127
+ %i[blocked_on_human blocked_on_parent].include?(entry.status)
128
+ end
129
+
130
+ def blocked_on_ask_answer(entry)
131
+ "probe #{entry.id} · #{entry.subagent} · BLOCKED on ask_parent waiting for YOUR answer — " \
132
+ "question:\n#{entry.ask_question}\n" \
133
+ "Answer with answer_child(task_id: \"#{entry.id}\", answer: \"…\") to unblock it."
134
+ end
135
+
136
+ # FREE path: render the live-progress fields only. NO model call.
137
+ def probe_cheap(entry)
138
+ recent = Array(entry.activity_log).last(RECENT_MAX)
139
+ lines = recent.empty? ? "(none yet)" : recent.join("\n")
140
+ out = "probe #{entry.id} · #{entry.subagent} · #{entry.status} · " \
141
+ "#{entry.tool_count.to_i} tools · last: #{entry.last_activity || "—"}\n" \
142
+ "recent:\n#{lines}"
143
+ out += "\n#{JUST_STARTED_HINT}" if just_started?(entry)
144
+ out
145
+ end
146
+
147
+ # BILLED path: enforce the per-child budget, then run the one-shot peek.
148
+ # peek is best-effort (never raises) — a failure is reported inline.
149
+ def probe_live(registry, entry, question)
150
+ max = max_live_probes
151
+ if entry.probe_count.to_i >= max
152
+ return "Error: live-probe budget exhausted for #{entry.id} (max #{max} per child). " \
153
+ "Use live:false for a free snapshot."
154
+ end
155
+
156
+ registry.record_live_probe(entry.id)
157
+ answer = probe_engine.peek(entry: entry, question: question)
158
+ out = "probe #{entry.id} (live) ⟵ #{answer}"
159
+ out += "\n#{JUST_STARTED_HINT}" if just_started?(entry)
160
+ out
161
+ end
162
+
163
+ def probe_engine
164
+ @probe ||= SubagentProbe.new
165
+ end
166
+
167
+ def max_live_probes
168
+ cfg = Rubino.configuration if Rubino.respond_to?(:configuration)
169
+ Integer(cfg&.tasks_max_live_probes_per_child)
170
+ rescue StandardError, TypeError, ArgumentError
171
+ 5
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Tool that asks the user interactive questions with predefined options.
6
+ # Allows the agent to gather clarification or preferences from the user.
7
+ class QuestionTool < Base
8
+ def name
9
+ "question"
10
+ end
11
+
12
+ def description
13
+ "Ask the user a question with optional predefined choices. " \
14
+ "Use this when you need clarification, user preferences, or a decision. " \
15
+ "The user can select from options or type a custom answer."
16
+ end
17
+
18
+ def input_schema
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ question: {
23
+ type: "string",
24
+ description: "The question to ask the user"
25
+ },
26
+ options: {
27
+ type: "array",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ label: { type: "string", description: "Short display text for the option" },
32
+ description: { type: "string", description: "Explanation of this choice" }
33
+ },
34
+ required: %w[label]
35
+ },
36
+ description: "Available choices (optional). A 'Type your own' option is added automatically."
37
+ },
38
+ multiple: {
39
+ type: "boolean",
40
+ description: "Allow selecting multiple choices (default: false)"
41
+ }
42
+ },
43
+ required: %w[question]
44
+ }
45
+ end
46
+
47
+ def risk_level
48
+ :low
49
+ end
50
+
51
+ # Deterministic result when no user answer is available — the UI's #ask
52
+ # returned nil (non-interactive / piped session, or the user gave no
53
+ # response). Fail closed instead of reading ambient stdin or silently
54
+ # picking an option (#107): never assume a choice on the user's behalf.
55
+ NO_ANSWER = "No answer: no interactive user input available " \
56
+ "(non-interactive session, or the user gave no response). " \
57
+ "Do not assume a choice on the user's behalf; proceed with the " \
58
+ "safest option and state the assumption, or finish and report " \
59
+ "the open question."
60
+
61
+ def call(arguments)
62
+ question = arguments["question"] || arguments[:question]
63
+ options = arguments["options"] || arguments[:options]
64
+ multiple = arguments["multiple"] || arguments[:multiple] || false
65
+
66
+ ui = Rubino.ui
67
+
68
+ if options && !options.empty?
69
+ ask_with_options(ui, question, options, multiple)
70
+ else
71
+ ask_freeform(ui, question)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def ask_with_options(ui, question, options, multiple)
78
+ # Format options for display
79
+ formatted = options.map do |opt|
80
+ label = opt["label"] || opt[:label]
81
+ desc = opt["description"] || opt[:description]
82
+ desc ? "#{label} - #{desc}" : label
83
+ end
84
+
85
+ # Build a SINGLE prompt carrying the question, the numbered options, the
86
+ # multiple-select hint, and the trailing instruction. On the API path the
87
+ # whole prompt becomes the clarify.required event's `question` payload, so
88
+ # the web clarify box renders the question next to the input (instead of
89
+ # only the generic "Your choice…" line, with the question lost up top).
90
+ lines = [question]
91
+ formatted.each_with_index do |opt, i|
92
+ lines << " #{i + 1}. #{opt}"
93
+ end
94
+ lines << " (Select multiple numbers separated by commas, or type a custom answer)" if multiple
95
+ lines << "Your choice#{"(s)" if multiple} (number or custom answer):"
96
+
97
+ answer = ui.ask(lines.join("\n"))
98
+ return NO_ANSWER if answer.nil?
99
+
100
+ # Parse single or multiple numeric selections
101
+ if multiple && answer&.match?(/\A[\d,\s]+\z/)
102
+ indices = answer.scan(/\d+/).map { |n| n.to_i - 1 }
103
+ selected = indices.filter_map do |idx|
104
+ options[idx]["label"] || options[idx][:label] if idx >= 0 && idx < options.size
105
+ end
106
+ selected.empty? ? "User answered: #{answer}" : "User selected: #{selected.join(", ")}"
107
+ elsif answer&.match?(/\A\d+\z/)
108
+ idx = answer.to_i - 1
109
+ if idx >= 0 && idx < options.size
110
+ selected = options[idx]
111
+ "User selected: #{selected["label"] || selected[:label]}"
112
+ else
113
+ "User answered: #{answer}"
114
+ end
115
+ else
116
+ "User answered: #{answer}"
117
+ end
118
+ end
119
+
120
+ def ask_freeform(ui, question)
121
+ answer = ui.ask(question)
122
+ return NO_ANSWER if answer.nil?
123
+
124
+ "User answered: #{answer}"
125
+ end
126
+ end
127
+ end
128
+ end