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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../llm/content_builder"
4
+
5
+ module Rubino
6
+ module Interaction
7
+ # Parses a raw CLI input line and pulls out image attachments so they can
8
+ # be routed to the model's native vision slot (image_paths) instead of being
9
+ # sent as literal text.
10
+ #
11
+ # Three input shapes are recognised, mirroring how Claude Code lets a user
12
+ # attach an image from the terminal:
13
+ #
14
+ # 1. `@path/to/pic.png` — the composer's `@` file-picker. When the picked
15
+ # file is an image it becomes an attachment; a non-image `@file` is left
16
+ # in the text untouched (the model reads it via the `read` tool, as
17
+ # before).
18
+ # 2. A dropped / pasted file path — terminals insert an absolute path when
19
+ # a file is dragged in, often single/double-quoted or backslash-escaped
20
+ # for spaces. An image path (quoted, escaped or bare) is attached.
21
+ #
22
+ # Only paths that (a) have a recognised image extension AND (b) exist on disk
23
+ # are attached; anything else is preserved verbatim in the returned text so
24
+ # we never silently eat a word that merely looked path-ish.
25
+ #
26
+ # Every candidate attachment is then gated through the SAME secure-by-default
27
+ # attachment layer the server/run path uses (Attachments::Classify + Policy:
28
+ # lstat/realpath safety pipeline, max_file_bytes cap, magic-byte kind check)
29
+ # — the CLI used to bypass it entirely, shipping oversize/spoofed files to
30
+ # the provider and burning the retry budget on the permanent error (#98).
31
+ # A rejected candidate is consumed from the text and reported in
32
+ # Result#rejected so the caller can surface a clean one-line error.
33
+ #
34
+ # Returns a Result with the cleaned text (image tokens removed, whitespace
35
+ # collapsed), the de-duplicated, expanded absolute image paths in order, and
36
+ # any policy rejections as { path:, reason: } hashes.
37
+ module ImageInput
38
+ Result = Struct.new(:text, :image_paths, :rejected, keyword_init: true) do
39
+ def images? = !image_paths.empty?
40
+ end
41
+
42
+ # An `@token`: `@` followed by a run of non-space chars. Quoting inside an
43
+ # `@` token isn't a terminal convention, so we keep it simple.
44
+ AT_TOKEN = /(?<![^\s])@(\S+)/
45
+
46
+ # A quoted path: '...' or "..." (drag-drop on terminals that quote).
47
+ QUOTED_PATH = /(?<![^\s])(?:'([^']+)'|"([^"]+)")/
48
+
49
+ # A bare / backslash-escaped path token: a leading /, ./, ../ or ~/ then a
50
+ # run of non-space chars, allowing `\ ` escaped spaces (drag-drop default
51
+ # on iTerm/Terminal.app). Anchored at a word boundary so it doesn't bite
52
+ # into the middle of a URL or sentence.
53
+ BARE_PATH = %r{(?<![^\s])((?:~|\.{0,2})/(?:\\.|\S)+)}
54
+
55
+ module_function
56
+
57
+ # Extracts image attachments from +input+. +existing+ lets a caller carry
58
+ # forward images already attached to the pending turn (e.g. a clipboard
59
+ # paste) so a follow-up line's parse adds to them rather than replacing.
60
+ def parse(input, existing: [])
61
+ text = input.to_s
62
+ paths = []
63
+ rejected = []
64
+
65
+ text = text.gsub(AT_TOKEN) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) }
66
+ text = text.gsub(QUOTED_PATH) do
67
+ token = Regexp.last_match(1) || Regexp.last_match(2)
68
+ capture_if_image(token, Regexp.last_match(0), paths, rejected)
69
+ end
70
+ text = text.gsub(BARE_PATH) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) }
71
+
72
+ Result.new(
73
+ text: text.gsub(/[ \t]{2,}/, " ").strip,
74
+ image_paths: (Array(existing) + paths).uniq,
75
+ rejected: rejected.uniq
76
+ )
77
+ end
78
+
79
+ # If +token+ resolves to an existing image file, record its absolute path
80
+ # and drop it from the text (returns ""); otherwise leave the original
81
+ # match (+original+) untouched. +original+ is captured by the caller before
82
+ # any path work, because #expand runs its own gsub and would clobber
83
+ # Regexp.last_match here. A candidate that LOOKS like an image but fails
84
+ # the attachment policy is consumed too — never shipped, never left as a
85
+ # path the model would chase with tools — and recorded in +rejected+.
86
+ def capture_if_image(token, original, paths, rejected)
87
+ path = expand(token)
88
+ return original unless LLM::ContentBuilder.image_file?(path) && File.file?(path)
89
+
90
+ if (reason = attachment_error(path))
91
+ rejected << { path: path, reason: reason }
92
+ else
93
+ paths << path unless paths.include?(path)
94
+ end
95
+ ""
96
+ end
97
+
98
+ # Gates one candidate image through the universal attachment layer —
99
+ # Attachments::Classify (lstat/realpath safety pipeline, max_file_bytes
100
+ # cap, magic-byte classification) + Policy.allow_kind? — the SAME checks
101
+ # the server/run path applies (#98). Returns a one-line human reason when
102
+ # the file must NOT be attached, nil when it is safe to send.
103
+ def attachment_error(path)
104
+ cls = Attachments::Classify.call(path)
105
+ unless cls.safe
106
+ if cls.reason.to_s.start_with?("exceeds max_file_bytes")
107
+ return "exceeds the #{Attachments::Policy.max_file_bytes / 1_048_576} MB attachment limit"
108
+ end
109
+
110
+ return cls.reason
111
+ end
112
+ return "not a valid image (content is #{cls.mime})" unless cls.kind == :image
113
+ return "image attachments are disabled by policy (allow_kinds)" unless Attachments::Policy.allow_kind?(:image)
114
+
115
+ nil
116
+ end
117
+
118
+ # Normalises a raw token into an absolute filesystem path: strips
119
+ # backslash escapes (`\ ` → ` `) and expands `~`/relative paths.
120
+ def expand(token)
121
+ File.expand_path(token.to_s.gsub(/\\(.)/, '\1'))
122
+ rescue ArgumentError
123
+ token.to_s
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # Thread-safe hand-off of typed-while-busy input to the REPL loop.
6
+ #
7
+ # The chat REPL runs one turn synchronously while a background reader
8
+ # thread keeps accepting keystrokes from the TTY (see
9
+ # CLI::ChatCommand#run_turn). Each completed line the reader sees is
10
+ # +push+-ed here; when the turn returns, the REPL +drain+s the queue and
11
+ # the captured lines become the NEXT user turn — never injected mid-tool.
12
+ #
13
+ # Mirrors Run::ApprovalGate's idiom: a plain +::Queue+ guarded by a
14
+ # +Mutex+ for the multi-line snapshot. Two threads touch it — the reader
15
+ # (push) and the main loop (drain/pending?) — so every read of the
16
+ # backing queue happens under the lock to keep +drain+ and +pending?+
17
+ # consistent against a concurrent +push+.
18
+ class InputQueue
19
+ def initialize
20
+ # An Array (under @mutex) rather than ::Queue: B4 consumes ONE line at a
21
+ # time (FIFO) and an interrupt line must be able to JUMP ahead of items
22
+ # explicitly parked earlier in the same turn (#push_front), neither of
23
+ # which ::Queue supports. The mutex still serialises the reader (push)
24
+ # against the main loop (shift/drain/pending?).
25
+ @lines = []
26
+ # Deterministic background notices (#push_notice) are held apart from
27
+ # typed lines: #shift never returns them, so a parked notice can't fire
28
+ # a standalone model turn at the idle prompt (#13).
29
+ @notices = []
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ # Records one completed line typed during the turn. Blank/nil lines are
34
+ # dropped so a stray Enter doesn't manufacture an empty next turn.
35
+ def push(line)
36
+ text = normalize(line)
37
+ return if text.nil?
38
+
39
+ @mutex.synchronize { @lines.push(text) }
40
+ end
41
+
42
+ # Records a line at the FRONT of the queue so it is the NEXT one #shift
43
+ # returns. Used by the interrupt-by-default Enter: the just-submitted line
44
+ # runs immediately next, AHEAD of any items the user explicitly parked
45
+ # (Alt+Enter / "/queued") earlier in the same turn, which then run in
46
+ # their own order behind it.
47
+ def push_front(line)
48
+ text = normalize(line)
49
+ return if text.nil?
50
+
51
+ @mutex.synchronize { @lines.unshift(text) }
52
+ end
53
+
54
+ # Records a deterministic background notice (the `[background-task] …
55
+ # completed/failed/stopped` lines). Notices are NOT user turns: #shift
56
+ # never returns them, so at the idle prompt a notice doesn't spend a
57
+ # whole model turn just to restate itself (#13). It rides along on the
58
+ # NEXT real turn instead — #drain (mid-turn steering boundary) and
59
+ # #drain_notices (turn start) both deliver it.
60
+ def push_notice(line)
61
+ text = normalize(line)
62
+ return if text.nil?
63
+
64
+ @mutex.synchronize { @notices.push(text) }
65
+ end
66
+
67
+ # Removes and returns the OLDEST queued line (FIFO), or nil when empty.
68
+ # The REPL consumes one queued message per turn so several lines parked
69
+ # during one turn each run as their OWN turn, in submission order (B4) —
70
+ # instead of #drain coalescing them into a single newline-joined message.
71
+ # Atomic against a concurrent #push.
72
+ def shift
73
+ @mutex.synchronize { @lines.shift }
74
+ end
75
+
76
+ # Removes and returns every queued line, in arrival order — parked
77
+ # background notices first, then typed lines. Empty when nothing is
78
+ # waiting. Atomic against a concurrent #push.
79
+ def drain
80
+ @mutex.synchronize do
81
+ lines = @notices + @lines
82
+ @notices = []
83
+ @lines = []
84
+ lines
85
+ end
86
+ end
87
+
88
+ # Removes and returns only the parked background notices. The turn-START
89
+ # injection (Loop, iteration 1) folds notices into the turn the user just
90
+ # submitted without consuming their typed-ahead lines, which must keep
91
+ # running as their own turns (#13).
92
+ def drain_notices
93
+ @mutex.synchronize do
94
+ notices = @notices
95
+ @notices = []
96
+ notices
97
+ end
98
+ end
99
+
100
+ # True when at least one line or notice is waiting to be drained.
101
+ def pending?
102
+ @mutex.synchronize { !@lines.empty? || !@notices.empty? }
103
+ end
104
+
105
+ private
106
+
107
+ # Normalizes a pushed line: nil → nil; blank → nil (dropped so a stray
108
+ # Enter never manufactures an empty turn); else the stringified line.
109
+ def normalize(line)
110
+ return nil if line.nil?
111
+
112
+ text = line.to_s
113
+ text.strip.empty? ? nil : text
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # Orchestrates the full lifecycle of a single user interaction.
6
+ # Coordinates all phases from input to final response and post-turn jobs.
7
+ class Lifecycle
8
+ def initialize(session:, event_bus:, ui:, config:, ignore_rules: false,
9
+ agent_definition: nil, cancel_token: nil,
10
+ model_override: nil, provider_override: nil,
11
+ max_tool_iterations: nil)
12
+ @session = session
13
+ @event_bus = event_bus
14
+ @ui = ui
15
+ @config = config
16
+ @ignore_rules = ignore_rules
17
+ @agent_definition = agent_definition
18
+ @cancel_token = cancel_token
19
+ @model_override = model_override
20
+ @provider_override = provider_override
21
+ # Explicit per-run cap from `--max-turns` (Runner → here → IterationBudget).
22
+ # nil ⇒ use the configured agent_max_tool_iterations (#141).
23
+ @max_tool_iterations = max_tool_iterations
24
+ @state = State.new
25
+ @session_repo = Session::Repository.new
26
+ @message_store = Session::Store.new
27
+ end
28
+
29
+ # Executes the full interaction lifecycle for a user input.
30
+ # image_paths are vision-capable attachments routed natively to the
31
+ # primary model (ruby_llm `with:` slot); only consumed on the first
32
+ # iteration of the inner agent loop. Subsequent iterations carry tool
33
+ # results, not user input, and don't re-attach the images.
34
+ # +input_queue+ is the optional steering hand-off (Interaction::InputQueue)
35
+ # for mid-turn injection: when given, the inner agent loop drains any text
36
+ # the user typed while it was working and folds it into the turn at a safe
37
+ # iteration boundary. Nil for the API/server path and for nested SUBAGENT
38
+ # runs, which stay isolated — no user injection, exactly as before.
39
+ def execute(input, image_paths: [], input_queue: nil, paste_expansions: [])
40
+ @event_bus.emit(Events::INTERACTION_STARTED, input: input)
41
+ @state.transition_to!(:receiving_input, event_bus: @event_bus)
42
+
43
+ # 1. Persist user message
44
+ @state.transition_to!(:loading_session, event_bus: @event_bus)
45
+ persist_user_message(input, paste_expansions: paste_expansions)
46
+
47
+ # 2. Load memory (if enabled)
48
+ @state.transition_to!(:loading_memory, event_bus: @event_bus)
49
+ memory_context = load_memory(input)
50
+
51
+ # 3. Build prompt/context
52
+ @state.transition_to!(:building_context, event_bus: @event_bus)
53
+ messages = build_messages(input, memory_context)
54
+ tools = load_tools
55
+
56
+ # 4. Check token budget
57
+ @state.transition_to!(:checking_budget, event_bus: @event_bus)
58
+ messages = check_and_compact(messages)
59
+
60
+ # 5. Run agent loop
61
+ @state.transition_to!(:calling_model, event_bus: @event_bus)
62
+ response = run_agent_loop(messages, tools, image_paths: image_paths,
63
+ input_queue: input_queue)
64
+
65
+ # 6. Persist session state
66
+ @state.transition_to!(:persisting_session, event_bus: @event_bus)
67
+ update_session_state
68
+
69
+ # 7. Enqueue post-turn jobs
70
+ @state.transition_to!(:enqueueing_jobs, event_bus: @event_bus)
71
+ enqueue_post_turn_jobs
72
+
73
+ # 8. Finish
74
+ # Carry the final assistant text as the terminal event's authoritative
75
+ # output, regardless of streaming mode. Streaming consumers also receive
76
+ # it incrementally via MODEL_STREAM (message.delta), but the
77
+ # non-streaming path emits no deltas — so without this, a completed run
78
+ # would terminate with no final text for clients to display. This makes
79
+ # run.completed the single source of truth for the answer.
80
+ @state.transition_to!(:finished, event_bus: @event_bus)
81
+ @event_bus.emit(Events::INTERACTION_FINISHED, output: response.to_s)
82
+
83
+ response
84
+ rescue StandardError => e
85
+ @state.transition_to!(:failed, event_bus: @event_bus)
86
+ @event_bus.emit(Events::INTERACTION_FAILED, error: e.message)
87
+ raise
88
+ end
89
+
90
+ private
91
+
92
+ def persist_user_message(input, paste_expansions: [])
93
+ # Lazily insert the session row on the first real message (#144). A
94
+ # session built by the CLI stays in-memory until now, so opening `chat`
95
+ # and exiting without sending anything never persists an empty row. The
96
+ # message table has a session_id FK, so the row must exist first.
97
+ @session_repo.persist!(@session)
98
+
99
+ # Persist the user's message verbatim. Image attachments are owned by
100
+ # the image_paths pipeline (Executor -> Runner -> Loop), routed natively
101
+ # to the model; we must not strip paths out of the stored/sent text.
102
+ # +input+ keeps any compact "[Pasted text #N …]" placeholder so the
103
+ # transcript echo stays clean on resume (#213); the matching expansion
104
+ # bodies ride as metadata and are folded into the model-facing content
105
+ # by Message#to_context, so the model still sees the full paste.
106
+ attrs = { session_id: @session[:id], role: "user", content: input }
107
+ attrs[:metadata] = { paste_expansions: paste_expansions } unless paste_expansions.empty?
108
+ @message_store.create(**attrs)
109
+ @session_repo.increment_message_count!(@session[:id])
110
+ maybe_set_title(input)
111
+ end
112
+
113
+ # Auto-title a still-untitled session from its first user message (#103),
114
+ # so `/sessions` is navigable and `--resume <title>` can match. Cheap and
115
+ # deterministic (no model call). Set once: any session that already has a
116
+ # title is left alone. Title failures must never break the turn.
117
+ def maybe_set_title(input)
118
+ return if @session[:title] && !@session[:title].to_s.strip.empty?
119
+
120
+ title = Session::Repository.derive_title(input)
121
+ return unless title
122
+
123
+ @session_repo.update(@session[:id], title: title)
124
+ @session[:title] = title
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ def load_memory(query = nil)
130
+ return {} unless @config.memory_enabled?
131
+
132
+ # Route through the configured backend. `query` (the current user
133
+ # message) lets a relevance-aware backend rank recall; the default
134
+ # backend ignores it and returns "everything that fits", as before.
135
+ backend = Memory::Backends.build(config: @config)
136
+ {
137
+ user_profile: backend.user_profile,
138
+ project_context: backend.project_context,
139
+ relevant_memories: backend.retrieve(session_id: @session[:id], query: query)
140
+ }
141
+ rescue StandardError
142
+ {} # Don't fail the interaction if memory loading fails
143
+ end
144
+
145
+ def build_messages(_input, memory_context)
146
+ assembler = Context::PromptAssembler.new(
147
+ session: @session,
148
+ memory_context: memory_context,
149
+ config: @config,
150
+ agent_definition: @agent_definition,
151
+ ignore_rules: @ignore_rules
152
+ )
153
+ assembler.build
154
+ end
155
+
156
+ def load_tools
157
+ return [] if @config.agent_disabled_toolsets.include?("all")
158
+
159
+ # Honor the agent definition's tool restrictions (:all, :read_only, or
160
+ # an explicit list). Falls back to all enabled tools when no definition
161
+ # is present (e.g. one-shot CLI calls without an explicit agent).
162
+ if @agent_definition
163
+ @agent_definition.resolved_tools
164
+ else
165
+ Tools::Registry.instance.enabled_tools
166
+ end
167
+ end
168
+
169
+ def check_and_compact(messages)
170
+ budget = Context::TokenBudget.new(
171
+ model_id: @session[:model],
172
+ config: @config
173
+ )
174
+
175
+ if budget.needs_compaction?(messages)
176
+ @state.transition_to!(:compressing_context, event_bus: @event_bus)
177
+ @ui.compression_started
178
+ @event_bus.emit(Events::COMPRESSION_STARTED, session_id: @session[:id])
179
+
180
+ compressor = Context::Compressor.new(session_id: @session[:id])
181
+ result = compressor.compact!
182
+
183
+ @event_bus.emit(Events::COMPRESSION_FINISHED, **result)
184
+ @ui.compression_finished(result)
185
+
186
+ # Reload messages after compaction
187
+ assembler = Context::PromptAssembler.new(
188
+ session: @session,
189
+ memory_context: {},
190
+ config: @config,
191
+ agent_definition: @agent_definition,
192
+ ignore_rules: @ignore_rules
193
+ )
194
+ assembler.build
195
+ else
196
+ messages
197
+ end
198
+ end
199
+
200
+ def run_agent_loop(messages, tools, image_paths: [], input_queue: nil)
201
+ tool_executor = Agent::ToolExecutor.new(
202
+ registry: Tools::Registry.instance,
203
+ approval_policy: Security::ApprovalPolicy.new,
204
+ ui: @ui,
205
+ config: @config,
206
+ cancel_token: @cancel_token,
207
+ # SESSION-scoped read-before-edit tracker (#151): a read in an
208
+ # earlier turn of this session still satisfies the gate while the
209
+ # file's mtime is unchanged, so an edit in the next turn doesn't
210
+ # force a redundant re-read + a second approval round-trip. The
211
+ # gate itself still re-prompts on any on-disk change.
212
+ read_tracker: Tools::ReadTracker.for_session(@session[:id]),
213
+ event_bus: @event_bus
214
+ )
215
+
216
+ # Dispatch through AdapterFactory so a "fake/..." model id (or an
217
+ # explicit provider: "fake") short-circuits to FakeProvider; every
218
+ # other model stays on RubyLLMAdapter unchanged.
219
+ #
220
+ # Per-run model/provider overrides win over the session defaults so
221
+ # the HTTP API client can pin a specific FakeProvider scenario (e.g.
222
+ # "fake/with-approvals") on an existing session without having to
223
+ # mutate the persisted session row.
224
+ llm_adapter = LLM::AdapterFactory.build(
225
+ model_id: @model_override || @session[:model],
226
+ provider: @provider_override || @config.model_provider,
227
+ ui: @ui,
228
+ event_bus: @event_bus,
229
+ tool_executor: tool_executor,
230
+ cancel_token: @cancel_token
231
+ )
232
+
233
+ budget = Agent::IterationBudget.new(config: @config, max_tool_iterations: @max_tool_iterations)
234
+
235
+ loop_runner = Agent::Loop.new(
236
+ session: @session,
237
+ llm_adapter: llm_adapter,
238
+ tool_executor: tool_executor,
239
+ message_store: @message_store,
240
+ budget: budget,
241
+ ui: @ui,
242
+ event_bus: @event_bus,
243
+ config: @config,
244
+ cancel_token: @cancel_token,
245
+ initial_image_paths: image_paths,
246
+ input_queue: input_queue
247
+ )
248
+
249
+ # Bind the parent's steering queue as the background-subagent
250
+ # notification sink for the duration of this turn. A backgrounded `task`
251
+ # subagent pushes its completion notice onto this same queue, so the
252
+ # parent loop folds it in at its next iteration boundary
253
+ # (Loop#inject_steered_input) — correct ordering for free. Nil queue
254
+ # (API/server) ⇒ no sink; the result stays reachable via `task_result`.
255
+ Rubino.with_background_sink(input_queue) do
256
+ Rubino.with_event_bus(@event_bus) do
257
+ loop_runner.run(messages: messages, tools: tools)
258
+ end
259
+ end
260
+ end
261
+
262
+ def update_session_state
263
+ token_count = @message_store.token_sum(@session[:id])
264
+ @session_repo.update_token_count!(@session[:id], token_count)
265
+ @session_repo.increment_message_count!(@session[:id])
266
+ end
267
+
268
+ def enqueue_post_turn_jobs
269
+ queue = Jobs::Queue.new
270
+
271
+ # Extract memory if enabled
272
+ if @config.memory_auto_extract?
273
+ queue.enqueue("ExtractMemoryJob", { session_id: @session[:id] })
274
+ @event_bus.emit(Events::JOB_ENQUEUED, type: "ExtractMemoryJob")
275
+ end
276
+
277
+ # Variant B — deterministic post-turn skill distillation. Gated exactly
278
+ # like ExtractMemoryJob above: a dedicated config predicate guards the
279
+ # enqueue so this aux-spending background job only runs when explicitly
280
+ # enabled (skills.auto_distill, default true). The job then applies its
281
+ # own deterministic gate (run succeeded AND >= N tool calls AND not
282
+ # already covered) before spending one aux-model call. Handler lookup
283
+ # is load-order independent: Jobs::Registry resolves the class from
284
+ # the Handlers namespace on demand (#81).
285
+ if @config.skills_auto_distill?
286
+ queue.enqueue("DistillSkillJob", { session_id: @session[:id] })
287
+ @event_bus.emit(Events::JOB_ENQUEUED, type: "DistillSkillJob")
288
+ end
289
+
290
+ # Summarize if session is getting long
291
+ message_count = @message_store.count(@session[:id])
292
+ return unless message_count > 20
293
+
294
+ queue.enqueue("SummarizeSessionJob", { session_id: @session[:id] })
295
+ @event_bus.emit(Events::JOB_ENQUEUED, type: "SummarizeSessionJob")
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # An ephemeral, read-only side-question against the CURRENT session.
6
+ #
7
+ # `probe` is the principal-chat counterpart of the subagent-level probe:
8
+ # it answers a lateral question from the session's context-so-far and then
9
+ # VANISHES — neither the question nor the answer is written to the session
10
+ # transcript, so the next real turn proceeds exactly as if it never happened
11
+ # (Claude Code's `/btw` semantics).
12
+ #
13
+ # Reuse, not reinvention:
14
+ # - Context::PromptAssembler.build gives the SAME message array a real
15
+ # turn would send (system + summary + history snapshot). We append the
16
+ # question as a final user message and call the adapter ONCE.
17
+ # - LLM::AdapterFactory.build(...).chat(messages:, tools: nil) is the
18
+ # existing one-shot completion seam — no Loop, no tools, no persistence.
19
+ #
20
+ # Nothing here touches Session::Store, so the probe is screen-only: the only
21
+ # artifact is the dim aside the CLI renders.
22
+ class Probe
23
+ Result = Struct.new(:question, :answer, keyword_init: true)
24
+
25
+ def initialize(session:, config: Rubino.configuration, model_override: nil,
26
+ provider_override: nil)
27
+ @session = session
28
+ @config = config
29
+ @model_override = model_override
30
+ @provider_override = provider_override
31
+ end
32
+
33
+ # Runs the one-shot side-inference over a SNAPSHOT of the session and the
34
+ # question. Returns a Result(question:, answer:). Read-only: the session's
35
+ # message store is never written.
36
+ def ask(question)
37
+ messages = snapshot_messages
38
+ messages << { role: "user", content: question }
39
+
40
+ adapter = LLM::AdapterFactory.build(
41
+ model_id: @model_override || @session[:model],
42
+ provider: @provider_override || @config.model_provider,
43
+ config: @config
44
+ )
45
+ response = adapter.chat(messages: messages, tools: nil)
46
+
47
+ Result.new(question: question, answer: response.content.to_s)
48
+ end
49
+
50
+ private
51
+
52
+ # The exact message array a real turn would assemble for this session —
53
+ # system prompt + summary + the full history so far — minus the new turn.
54
+ # Memory context is left empty: a probe is a quick aside, and skipping the
55
+ # memory snapshot keeps it cheap and side-effect-free.
56
+ def snapshot_messages
57
+ Context::PromptAssembler.new(
58
+ session: @session,
59
+ memory_context: {},
60
+ config: @config
61
+ ).build
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Interaction
5
+ # Tracks the current state of an interaction.
6
+ # Implements a simple state machine with valid transitions.
7
+ class State
8
+ VALID_STATES = %i[
9
+ idle
10
+ receiving_input
11
+ loading_session
12
+ loading_memory
13
+ building_context
14
+ checking_budget
15
+ compressing_context
16
+ calling_model
17
+ persisting_session
18
+ enqueueing_jobs
19
+ finished
20
+ failed
21
+ ].freeze
22
+
23
+ attr_reader :current
24
+
25
+ def initialize
26
+ @current = :idle
27
+ end
28
+
29
+ # Transitions to a new state, emitting an event
30
+ def transition_to!(new_state, event_bus: nil)
31
+ raise Error, "Invalid state: #{new_state}" unless VALID_STATES.include?(new_state)
32
+
33
+ old_state = @current
34
+ @current = new_state
35
+
36
+ event_bus&.emit(Events::STATUS_CHANGED, from: old_state, to: new_state)
37
+ end
38
+
39
+ def idle?
40
+ @current == :idle
41
+ end
42
+
43
+ def finished?
44
+ @current == :finished
45
+ end
46
+
47
+ def failed?
48
+ @current == :failed
49
+ end
50
+
51
+ def terminal?
52
+ finished? || failed?
53
+ end
54
+ end
55
+ end
56
+ end