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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Documents
5
+ # Ordered registry of document converters. Mirrors Tools::Registry's shape.
6
+ # Each converter is a class exposing instance methods:
7
+ # accepts?(mime, path) -> Boolean # by MIME first, extension as tie-break
8
+ # available? -> Boolean # its optional gem is loadable (true for pure-ruby)
9
+ # convert(path) -> String # the Markdown
10
+ #
11
+ # Order matters: more specific converters come before the plain-text
12
+ # catch-all so a .json routes to Json, not Plain. The registry never offers
13
+ # a converter whose optional gem can't load (#available?), so the caller's
14
+ # fall-through to the shell-hint is exercised when, e.g., `roo` is absent.
15
+ module Registry
16
+ module_function
17
+
18
+ # Converter classes in priority order. Trivial pure-ruby converters are
19
+ # always available; gem-backed ones (Xlsx/Docx/Pptx/Pdf) gate on their
20
+ # optional gem via #available?. Plain is the last-resort text passthrough.
21
+ def converters
22
+ [
23
+ Converters::Csv,
24
+ Converters::Json,
25
+ Converters::Xml,
26
+ Converters::Html,
27
+ Converters::Xlsx,
28
+ Converters::Docx,
29
+ Converters::Pptx,
30
+ Converters::Pdf,
31
+ Converters::Plain
32
+ ]
33
+ end
34
+
35
+ # Returns an instance of the first converter that accepts the pair AND is
36
+ # available in-process, or nil.
37
+ def for(mime: nil, path: nil)
38
+ converters.each do |klass|
39
+ conv = klass.new
40
+ return conv if conv.available? && conv.accepts?(mime, path)
41
+ end
42
+ nil
43
+ end
44
+
45
+ # The CORE format labels currently supported in-process (their gem is
46
+ # loadable). Drives the doctor / EnvironmentInspector advertising. Each
47
+ # entry is [label, available?]; pure-ruby formats are always available.
48
+ def capabilities
49
+ {
50
+ "plain/code" => Converters::Plain.new.available?,
51
+ "csv" => Converters::Csv.new.available?,
52
+ "json" => Converters::Json.new.available?,
53
+ "xml" => Converters::Xml.new.available?,
54
+ "html" => Converters::Html.new.available?,
55
+ "xlsx" => Converters::Xlsx.new.available?,
56
+ "docx" => Converters::Docx.new.available?,
57
+ "pptx" => Converters::Pptx.new.available?,
58
+ "pdf" => Converters::Pdf.new.available?
59
+ }
60
+ end
61
+
62
+ # Just the labels currently available, for a compact one-line advert.
63
+ def available_formats
64
+ capabilities.select { |_, ok| ok }.keys
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Documents
5
+ # The ONE Markdown table emitter shared by the csv and xlsx converters (the
6
+ # reuse seam the plan calls for). Takes an array of rows (each an array of
7
+ # cell values) and emits a GFM pipe table: the first row is the header, a
8
+ # `|---|` separator follows, then the body. Pipes and newlines inside cells
9
+ # are escaped so a cell value can't break the table grid. Rows are capped so
10
+ # a runaway spreadsheet can't emit a million-line table into context.
11
+ module Table
12
+ module_function
13
+
14
+ # Hard cap on emitted body rows; over the cap we truncate and note it.
15
+ MAX_ROWS = 1000
16
+
17
+ # rows: Array<Array> -- first row is the header. Returns a GFM table
18
+ # String, or "" when there are no rows.
19
+ def emit(rows, max_rows: MAX_ROWS)
20
+ rows = Array(rows).compact
21
+ return "" if rows.empty?
22
+
23
+ width = rows.map { |r| Array(r).length }.max
24
+ return "" if width.nil? || width.zero?
25
+
26
+ header = pad(rows.first, width)
27
+ body = rows.drop(1)
28
+ truncated = body.length > max_rows
29
+ body = body.first(max_rows) if truncated
30
+
31
+ lines = []
32
+ lines << row_line(header)
33
+ lines << separator(width)
34
+ body.each { |r| lines << row_line(pad(r, width)) }
35
+ out = lines.join("\n")
36
+ out += "\n\n_(#{rows.length - 1 - max_rows} more rows truncated)_" if truncated
37
+ out
38
+ end
39
+
40
+ def pad(row, width)
41
+ cells = Array(row).map { |c| cell(c) }
42
+ cells.fill("", cells.length...width)
43
+ end
44
+
45
+ def row_line(cells)
46
+ "| #{cells.join(" | ")} |"
47
+ end
48
+
49
+ def separator(width)
50
+ "|#{(["---"] * width).join("|")}|"
51
+ end
52
+
53
+ # Escapes a cell so pipes/newlines can't break the table. nil -> "".
54
+ def cell(value)
55
+ value.to_s
56
+ .gsub("\\", "\\\\\\\\")
57
+ .gsub("|", "\\|")
58
+ .gsub(/\r\n?|\n/, "<br>")
59
+ .strip
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # In-repo document-to-Markdown conversion -- a focused reimplementation of
5
+ # markitdown's CORE converters in pure Ruby (issue #6). The public surface is
6
+ # a single entry point:
7
+ #
8
+ # Rubino::Documents.to_markdown(path, mime: nil) -> String | nil
9
+ #
10
+ # Architecture (mirrors markitdown): most converters extract structure via a
11
+ # mature MIT gem, shape it into an intermediate HTML string, and let ONE
12
+ # HTML->Markdown core (Documents::Html, built on kramdown which is already a
13
+ # rubino dependency) emit the final Markdown. csv/xlsx feed ONE Markdown table
14
+ # emitter (Documents::Table). The per-format converters are therefore thin.
15
+ #
16
+ # Extraction gems (roo, docx, pdf-reader, ruby_powerpoint) are OPTIONAL: each
17
+ # converter `require`s its gem lazily inside a begin/rescue LoadError and a
18
+ # converter that can't load its gem simply reports itself unavailable. The
19
+ # module MUST load and run with NONE of the optional gems installed -- callers
20
+ # then fall back to the existing shell-extraction hint. There is never an
21
+ # external process and never a hard runtime dependency. That is the whole
22
+ # point: the original concern was "markitdown isn't installed".
23
+ module Documents
24
+ module_function
25
+
26
+ # Converts the file at +path+ to Markdown, picking the first registered
27
+ # converter that accepts the (mime, path) pair and whose optional gem is
28
+ # loadable. Returns the Markdown String, or nil when no converter can handle
29
+ # the file (unknown format, or the format's optional gem isn't installed, or
30
+ # extraction produced nothing). Never raises -- a converter failure degrades
31
+ # to nil so the caller emits the actionable shell-hint.
32
+ def to_markdown(path, mime: nil)
33
+ converter = Registry.for(mime: mime, path: path)
34
+ return nil unless converter
35
+
36
+ out = converter.convert(path)
37
+ out = out.to_s
38
+ out.strip.empty? ? nil : out
39
+ rescue LoadError, StandardError
40
+ nil
41
+ end
42
+
43
+ # True when at least one converter for the (mime, path) pair is available
44
+ # in-process (its optional gem, if any, is loadable). Drives the preamble /
45
+ # environment / doctor advertising without attempting a conversion.
46
+ def supported?(mime: nil, path: nil)
47
+ !Registry.for(mime: mime, path: path).nil?
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # HTTP-boundary error hierarchy. Each class maps to a single HTTP status
5
+ # used by the API layer to translate exceptions to responses.
6
+ #
7
+ # Rubino::Error — base class (defined in lib/rubino.rb)
8
+ # NotFoundError(resource, id) — 404
9
+ # ValidationError(message, details:) — 422
10
+ # UnauthorizedError(message) — 401
11
+ # ConflictError(message) — 409
12
+ # UpstreamError(message, service:) — 502
13
+ #
14
+ # All message-first classes accept `raise Class, "msg"` (Ruby's idiomatic
15
+ # form) without losing data. NotFoundError keeps its (resource, id) shape
16
+ # because the message format depends on both values; always use
17
+ # +raise NotFoundError.new("session", id)+, not +raise NotFoundError, "..."+.
18
+ #
19
+ # Domain errors (ConfigurationError, DatabaseError, SessionError, ToolError,
20
+ # CompactionError, JobError) also subclass Error and live in lib/rubino.rb.
21
+
22
+ # Resource not found. Maps to 404.
23
+ #
24
+ # @param resource [String, Symbol] resource type (e.g. "Session", :run)
25
+ # @param id [String, nil] identifier; when nil only the resource name is shown
26
+ #
27
+ # Footgun: `raise NotFoundError, "foo"` skips this initializer (Ruby passes
28
+ # the string straight to StandardError#initialize), so @resource/@id stay nil.
29
+ # Always use `raise NotFoundError.new("Session", id)` to capture them.
30
+ class NotFoundError < Error
31
+ def initialize(resource, id = nil)
32
+ msg = id ? "#{resource} not found: #{id}" : "#{resource} not found"
33
+ super(msg)
34
+ @resource = resource
35
+ @id = id
36
+ end
37
+ attr_reader :resource, :id
38
+ end
39
+
40
+ # Request body or params failed validation. Maps to 422.
41
+ class ValidationError < Error
42
+ def initialize(message = "validation failed", details: {})
43
+ super(message)
44
+ @details = details
45
+ end
46
+ attr_reader :details
47
+ end
48
+
49
+ # Missing or invalid credentials. Maps to 401.
50
+ class UnauthorizedError < Error
51
+ def initialize(message = "unauthorized")
52
+ super
53
+ end
54
+ end
55
+
56
+ # State conflict (duplicate, illegal transition). Maps to 409.
57
+ class ConflictError < Error
58
+ def initialize(message = "conflict")
59
+ super
60
+ end
61
+ end
62
+
63
+ # User interrupted an in-progress LLM turn (Esc / Ctrl+C in the chat TUI).
64
+ # Caught by the Loop/Lifecycle so partial content can still be persisted
65
+ # and the UI can return to a ready state cleanly.
66
+ class Interrupted < Error
67
+ def initialize(message = "interrupted by user")
68
+ super
69
+ end
70
+ end
71
+
72
+ # Request body exceeded the configured byte cap (JSON or multipart upload).
73
+ # Maps to 413. Details may carry +limit_bytes+ so clients can adapt.
74
+ class PayloadTooLargeError < Error
75
+ def initialize(message = "payload too large", details: {})
76
+ super(message)
77
+ @details = details
78
+ end
79
+ attr_reader :details
80
+ end
81
+
82
+ # Upstream dependency failed (LLM provider, OAuth provider). Maps to 502.
83
+ # Message-first so +raise UpstreamError, "timeout"+ works; pass +service:+
84
+ # to tag the failing dependency (it gets prefixed onto the message).
85
+ class UpstreamError < Error
86
+ def initialize(message = "upstream error", service: nil)
87
+ super(service ? "#{service}: #{message}" : message)
88
+ @service = service
89
+ end
90
+ attr_reader :service
91
+ end
92
+
93
+ # The LLM streaming response was cut before a clean completion: upstream closed
94
+ # the SSE connection without a terminal signal (no finish_reason / no [DONE] /
95
+ # null usage), leaving only a buffered partial with no tool call. Raised by the
96
+ # Loop so a truncated turn fails honestly (run.failed) instead of being reported
97
+ # as a successful "completed" turn carrying empty/partial output. Common trigger:
98
+ # a provider stream idle-timeout during a long time-to-first-token on a very
99
+ # large context. Maps to 502 (subclass of UpstreamError).
100
+ class StreamInterruptedError < UpstreamError
101
+ def initialize(message = "stream ended before completion", service: "llm")
102
+ super
103
+ end
104
+ end
105
+
106
+ # The model returned a degenerate turn — no text AND no tool calls — that
107
+ # survived the Loop's in-turn retries. Mirrors the reference treating an
108
+ # empty/invalid response as retryable-then-terminal (such a run is
109
+ # marked `failed: True`, not `completed`). Raised by Agent::Loop so
110
+ # the run is marked failed honestly instead of being reported as a successful
111
+ # "completed" turn carrying empty output (the silent completed-but-empty bug,
112
+ # observed on MiniMax-M2.7 / api.minimax.io/anthropic). Maps to 502.
113
+ class EmptyModelResponseError < UpstreamError
114
+ def initialize(message = "model returned an empty response (no text, no tool calls)",
115
+ service: "llm")
116
+ super
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module Rubino
8
+ module Files
9
+ # Sandboxed filesystem access for the agent. Every path coming in from
10
+ # the HTTP API (read, upload, etc.) must be resolved through #resolve so
11
+ # the result is guaranteed to live under @root.
12
+ #
13
+ # Root defaults to config.paths_home (the agent home); uploads are
14
+ # written under `<root>/uploads/`. The root is overridable in tests.
15
+ #
16
+ # Path-traversal defense:
17
+ # - Pathname#+ does not normalize: `Pathname.new("/a") + "/b"` returns
18
+ # `/b`, so an attacker-supplied absolute path would silently escape.
19
+ # - We therefore call #expand_path on the joined path and then verify it
20
+ # begins with `@root + File::SEPARATOR` (or equals @root). If not,
21
+ # we raise Workspace::PathTraversal (a ValidationError subclass).
22
+ class Workspace
23
+ class PathTraversal < ::Rubino::ValidationError
24
+ def initialize(path)
25
+ super("path escapes workspace: #{path}")
26
+ end
27
+ end
28
+
29
+ def initialize(root: nil)
30
+ path = root || ::Rubino.configuration.paths_home
31
+ expanded = File.expand_path(path)
32
+ FileUtils.mkdir_p(expanded)
33
+ # Resolve symlinks (macOS' /tmp → /private/tmp is the usual offender)
34
+ # so #resolve compares apples to apples. Tools and AttachFileTool
35
+ # both call File.expand_path on their inputs, which follows OS
36
+ # symlinks; storing the raw configured root here would then make
37
+ # every absolute path under /tmp look like an escape, even though
38
+ # it really points inside the sandbox.
39
+ @root = Pathname.new(File.realpath(expanded))
40
+ end
41
+
42
+ attr_reader :root
43
+
44
+ # Resolves a relative path against the workspace root.
45
+ # Raises PathTraversal if the resolved path escapes the root.
46
+ def resolve(relative_path)
47
+ candidate = (@root + relative_path).expand_path
48
+ # If the candidate exists on disk, run it through realpath too so
49
+ # symlink components in the leading path don't make us reject a
50
+ # path that physically lives under @root. For paths that don't
51
+ # exist yet (the upload-create case) we keep the expand_path form
52
+ # — File.realpath would raise on a missing file.
53
+ candidate = Pathname.new(File.realpath(candidate.to_s)) if candidate.exist?
54
+
55
+ unless candidate.to_s.start_with?(@root.to_s + File::SEPARATOR) || candidate == @root
56
+ raise PathTraversal, relative_path
57
+ end
58
+
59
+ candidate
60
+ end
61
+
62
+ # Reads a file from the sandbox.
63
+ #
64
+ # @param relative_path [String] path relative to the workspace root
65
+ # @return [String] binary contents of the file
66
+ # @raise [Workspace::PathTraversal] if the path escapes the sandbox
67
+ # @raise [Rubino::NotFoundError] if no regular file exists at the path
68
+ def read(relative_path)
69
+ path = resolve(relative_path)
70
+ raise ::Rubino::NotFoundError.new("file", relative_path) unless path.file?
71
+
72
+ path.binread
73
+ end
74
+
75
+ # Stores an uploaded file under `uploads/<uuid>-<basename>`.
76
+ # The original filename is reduced to its basename before joining, so
77
+ # callers cannot influence the destination directory.
78
+ #
79
+ # @param filename [String] client-supplied name (basename only is kept)
80
+ # @param io [IO] readable stream containing the upload body
81
+ # @return [Hash] descriptor with keys :id, :filename, :size, :path
82
+ def upload(filename:, io:)
83
+ uploads_dir = @root + "uploads"
84
+ FileUtils.mkdir_p(uploads_dir)
85
+ safe_name = File.basename(filename.to_s)
86
+ id = SecureRandom.uuid
87
+ target = uploads_dir + "#{id}-#{safe_name}"
88
+ size = IO.copy_stream(io, target.to_s)
89
+ { id: id, filename: safe_name, size: size, path: target.to_s }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # Thread-safe cooperative cancellation flag passed through the interaction
6
+ # stack (Runner -> Lifecycle -> Loop -> LLM adapter). The chat TUI flips
7
+ # it on Esc / second Ctrl+C, and the LLM stream callback raises
8
+ # Rubino::Interrupted at the next chunk boundary so the turn aborts
9
+ # without leaking the worker thread or losing buffered output.
10
+ #
11
+ # Cancellation is one-shot: once cancelled, it stays cancelled. Build a
12
+ # fresh token per turn rather than reusing across turns.
13
+ #
14
+ # No Mutex on purpose. The flag is written exactly once (false -> true,
15
+ # never back) and only ever read otherwise — a single-writer, monotonic
16
+ # boolean. Under MRI's GVL a lone ivar read/write is atomic, so no lock
17
+ # is needed for correctness. Critically, #cancel! runs from a SIGINT
18
+ # +Signal.trap+ block, and +Mutex#lock+ is forbidden in a trap context
19
+ # (Ruby bug #14222: "can't be called from trap context"). A mutex here
20
+ # made the chat trap raise ThreadError, the flag never flipped, and the
21
+ # turn ran on. Keep this lock-free and trap-safe.
22
+ class CancelToken
23
+ def initialize
24
+ @cancelled = false
25
+ end
26
+
27
+ def cancel!
28
+ @cancelled = true
29
+ end
30
+
31
+ def cancelled?
32
+ @cancelled
33
+ end
34
+
35
+ # Raises Interrupted if the token has been cancelled. Used as a poll
36
+ # point inside hot loops (per-chunk in streams, per-iteration in the
37
+ # agent loop).
38
+ def check!
39
+ raise Rubino::Interrupted if cancelled?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "open3"
5
+
6
+ module Rubino
7
+ module Interaction
8
+ # Grabs an image from the system clipboard and writes it to a temp PNG so it
9
+ # can be attached to a turn's image_paths (the native vision slot). Mirrors
10
+ # Claude Code's Cmd+V image paste from the terminal.
11
+ #
12
+ # Platform tools, best-effort and in priority order:
13
+ # - macOS : `pngpaste` (brew install pngpaste)
14
+ # - Wayland: `wl-paste` (wl-clipboard)
15
+ # - X11 : `xclip`
16
+ #
17
+ # Returns the temp file path on success, or nil when no tool is available or
18
+ # the clipboard holds no image. #unavailable_reason explains a nil so the CLI
19
+ # can show an actionable hint instead of a silent no-op.
20
+ module ClipboardImage
21
+ module_function
22
+
23
+ # Ordered [tool, argv-builder] candidates. The builder takes the dest path
24
+ # and returns the argv that writes a PNG of the clipboard image to it.
25
+ def commands(dest)
26
+ case RbConfig::CONFIG["host_os"]
27
+ when /darwin/
28
+ [["pngpaste", [dest]]]
29
+ when /linux/
30
+ [
31
+ ["wl-paste", ["-t", "image/png", "--no-newline"]], # writes to stdout
32
+ ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
33
+ ]
34
+ else
35
+ []
36
+ end
37
+ end
38
+
39
+ # Saves the clipboard image to a temp PNG and returns its path, or nil.
40
+ def save_to_tempfile
41
+ dest = File.join(Dir.tmpdir, "rubino_clip_#{Process.pid}_#{rand(1_000_000)}.png")
42
+ capture(dest) ? dest : nil
43
+ end
44
+
45
+ # Runs the first available tool. macOS pngpaste writes the file directly;
46
+ # the Linux tools write PNG bytes to stdout which we redirect to +dest+.
47
+ def capture(dest)
48
+ commands(dest).each do |tool, args|
49
+ next unless which(tool)
50
+
51
+ if tool == "pngpaste"
52
+ _out, = Open3.capture2e(tool, *args)
53
+ else
54
+ out, status = Open3.capture2(tool, *args)
55
+ File.binwrite(dest, out) if status.success? && !out.empty?
56
+ end
57
+ return true if File.file?(dest) && File.size(dest).positive?
58
+ end
59
+ false
60
+ rescue StandardError
61
+ false
62
+ end
63
+
64
+ # Human-readable reason a paste produced nothing, for the CLI hint.
65
+ def unavailable_reason
66
+ case RbConfig::CONFIG["host_os"]
67
+ when /darwin/
68
+ "no image on the clipboard, or `pngpaste` isn't installed (brew install pngpaste)."
69
+ when /linux/
70
+ "no image on the clipboard, or neither `wl-paste` nor `xclip` is installed."
71
+ else
72
+ "clipboard image paste isn't supported on this platform."
73
+ end
74
+ end
75
+
76
+ def which(tool)
77
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
78
+ path = File.join(dir, tool)
79
+ File.executable?(path) && !File.directory?(path)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # Simple pub/sub event bus for decoupling core logic from UI.
6
+ # Core components emit events; UI adapters and other listeners subscribe.
7
+ #
8
+ # Thread-safety: subscriptions are mutated under a mutex, and `emit`
9
+ # snapshots the listener list under the lock then invokes listeners
10
+ # OUTSIDE the lock. This keeps concurrent `on`/`off` (e.g. a parent run's
11
+ # `recorder.detach!` racing a background subagent thread emitting
12
+ # SUBAGENT_COMPLETED onto the same bus — #136) from mutating the hash
13
+ # mid-iteration, while still allowing a listener to itself emit/subscribe
14
+ # without deadlocking.
15
+ class EventBus
16
+ def initialize
17
+ @listeners = Hash.new { |h, k| h[k] = [] }
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Subscribe to an event type with a callable or block
22
+ def on(event_type, &block)
23
+ @mutex.synchronize { @listeners[event_type.to_sym] << block }
24
+ end
25
+
26
+ # Emit an event to all registered listeners
27
+ def emit(event_type, **payload)
28
+ listeners = @mutex.synchronize { @listeners[event_type.to_sym].dup }
29
+ listeners.each { |listener| listener.call(payload) }
30
+ end
31
+
32
+ # Remove all listeners for a given event type
33
+ def off(event_type)
34
+ @mutex.synchronize { @listeners.delete(event_type.to_sym) }
35
+ end
36
+
37
+ # Remove all listeners
38
+ def clear!
39
+ @mutex.synchronize { @listeners.clear }
40
+ end
41
+
42
+ # Returns the count of listeners for a given event type
43
+ def listener_count(event_type)
44
+ @mutex.synchronize { @listeners[event_type.to_sym].size }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # Defines all event types used in the system.
6
+ # Acts as documentation and provides constants for event names.
7
+ module Events
8
+ # Interaction lifecycle events
9
+ INTERACTION_STARTED = :interaction_started
10
+ INTERACTION_FINISHED = :interaction_finished
11
+ INTERACTION_FAILED = :interaction_failed
12
+ # Mid-turn steering: the user typed while the agent was working and the
13
+ # loop picked the text up at an iteration boundary, injecting it as a
14
+ # user message into the in-flight turn (Codex "Enter injects into the
15
+ # current turn"). Payload: { text:, iteration: }.
16
+ INPUT_INJECTED = :input_injected
17
+
18
+ # Status change events
19
+ STATUS_CHANGED = :status_changed
20
+
21
+ # Session events
22
+ SESSION_LOADED = :session_loaded
23
+ SESSION_CREATED = :session_created
24
+ SESSION_PERSISTED = :session_persisted
25
+
26
+ # Memory events
27
+ MEMORY_LOADED = :memory_loaded
28
+ MEMORY_EXTRACTED = :memory_extracted
29
+ MEMORY_FLUSHED = :memory_flushed
30
+
31
+ # Context events
32
+ PROMPT_ASSEMBLED = :prompt_assembled
33
+ CONTEXT_BUDGET_CHECKED = :context_budget_checked
34
+
35
+ # Compression events
36
+ COMPRESSION_STARTED = :compression_started
37
+ COMPRESSION_FINISHED = :compression_finished
38
+
39
+ # LLM events
40
+ MODEL_CALL_STARTED = :model_call_started
41
+ MODEL_CALL_FINISHED = :model_call_finished
42
+ MODEL_STREAM = :model_stream
43
+ # End of one assistant message (content block). Streamed content deltas
44
+ # carry a +message_id+; this marks that block complete so a consumer can
45
+ # group the deltas that belong together instead of splitting them around
46
+ # tool calls that interleave mid-stream. Mirrors Anthropic's
47
+ # content_block_stop / the AI SDK text-end{id}.
48
+ MESSAGE_COMPLETED = :message_completed
49
+
50
+ # Tool events
51
+ TOOL_STARTED = :tool_started
52
+ # Incremental progress from a long-running tool (e.g. summarize_file's
53
+ # per-chunk "summarizing chunk N/M" or shell stdout lines). Emitted from
54
+ # the tool's stream_chunk callback so a tool that runs for minutes
55
+ # without finishing keeps the API event stream alive — the SSE idle
56
+ # watchdog only fires when NOTHING flows, so a genuinely hung run is
57
+ # still caught while a busy-but-silent one heartbeats. Payload:
58
+ # { name:, chunk: }.
59
+ TOOL_PROGRESS = :tool_progress
60
+ TOOL_FINISHED = :tool_finished
61
+ TOOL_APPROVAL_REQUESTED = :tool_approval_requested
62
+ TOOL_APPROVAL_GRANTED = :tool_approval_granted
63
+ TOOL_APPROVAL_DENIED = :tool_approval_denied
64
+
65
+ # Job events
66
+ JOB_ENQUEUED = :job_enqueued
67
+ JOB_STARTED = :job_started
68
+ JOB_FINISHED = :job_finished
69
+ JOB_FAILED = :job_failed
70
+ JOB_RETRYING = :job_retrying
71
+
72
+ # Background subagent (the `task` tool run in the background, the default).
73
+ # SPAWNED when a backgrounded subagent starts (payload: { task_id:,
74
+ # subagent:, prompt: }); COMPLETED/FAILED when it finishes (payload:
75
+ # { task_id:, subagent:, status:, output:|error: }). These let the CLI
76
+ # surface a completion line and the web UI show in-flight subagents —
77
+ # parity with how background-shell activity surfaces.
78
+ SUBAGENT_SPAWNED = :subagent_spawned
79
+ SUBAGENT_COMPLETED = :subagent_completed
80
+ SUBAGENT_FAILED = :subagent_failed
81
+
82
+ # Skill events
83
+ # Emitted when the `skill` tool successfully loads a skill's body into
84
+ # context (the level-2 "Skill 'X' loaded" path), so skill usage is a
85
+ # first-class signal for the recorder/SSE/metrics — parity with how
86
+ # TOOL_STARTED/SUBAGENT_* surface lifecycle. Payload: { name: } — the run
87
+ # association is stamped by the Recorder (run_id), like every other event.
88
+ SKILL_LOADED = :skill_loaded
89
+
90
+ # Emitted when a skill is created inline via skill(action: "create") or by
91
+ # the post-turn distill job. Payload: { name:, file_path: }.
92
+ SKILL_CREATED = :skill_created
93
+
94
+ # Artifact events
95
+ # Fired by tools that produce a downloadable user-facing file
96
+ # (currently AttachFileTool). Payload: { path:, filename:,
97
+ # content_type:, byte_size: }.
98
+ ARTIFACT_CREATED = :artifact_created
99
+ end
100
+ end
101
+ end