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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Rubino
6
+ module CLI
7
+ # Subcommands for managing persistent memories
8
+ class MemoryCommand < Thor
9
+ # Clean `tree`/help label instead of the underscored class-name default (F12).
10
+ namespace "rubino memory"
11
+
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ desc "list", "List stored memories (live facts only; --all includes superseded)"
17
+ option :kind, type: :string, desc: "Filter by memory kind"
18
+ option :limit, type: :numeric, default: 20, desc: "Max results"
19
+ option :all, type: :boolean, default: false,
20
+ desc: "Include superseded (soft-retired) facts"
21
+ def list
22
+ Rubino.ensure_database_ready!
23
+ memories = backend_store.list(kind: options[:kind], limit: options[:limit],
24
+ include_retired: options[:all])
25
+
26
+ if memories.empty?
27
+ Rubino.ui.info("No memories found.")
28
+ return
29
+ end
30
+
31
+ rows = memories.map do |m|
32
+ [m[:id][0..7], m[:kind], "#{m[:content][0..60]}#{self.class.retired_marker(m)}", m[:created_at]]
33
+ end
34
+
35
+ Rubino.ui.table(
36
+ headers: %w[ID Kind Content Created],
37
+ rows: rows
38
+ )
39
+ end
40
+
41
+ desc "show ID", "Show a specific memory"
42
+ def show(id)
43
+ memory = backend_store.find(id)
44
+
45
+ if memory.nil?
46
+ Rubino.ui.error("memory not found: #{id}")
47
+ return
48
+ end
49
+
50
+ self.class.render(memory, ui: Rubino.ui)
51
+ end
52
+
53
+ # ONE fact-details rendering for both surfaces (#184): the CLI verb
54
+ # above and the in-chat `/memory show <id>` (Commands::Executor).
55
+ def self.render(memory, ui:)
56
+ ui.info("ID: #{memory[:id]}")
57
+ ui.info("Kind: #{memory[:kind]}")
58
+ ui.info("Confidence: #{memory[:confidence]}")
59
+ ui.info("Created: #{memory[:created_at]}")
60
+ # The temporal chain (#88): a soft-retired fact shows when it stopped
61
+ # being true and which fact replaced it.
62
+ if memory[:valid_to]
63
+ ui.info("Retired: #{memory[:valid_to]}")
64
+ ui.info("Superseded by: #{memory[:superseded_by]}") if memory[:superseded_by]
65
+ end
66
+ ui.separator
67
+ ui.info(memory[:content])
68
+ end
69
+
70
+ desc "delete ID", "Delete a specific memory"
71
+ def delete(id)
72
+ if backend_store.delete(id)
73
+ Rubino.ui.success("Memory deleted: #{id}")
74
+ else
75
+ Rubino.ui.error("memory not found: #{id}")
76
+ end
77
+ end
78
+
79
+ desc "backend [NAME]", "Show the active memory backend, or switch to NAME"
80
+ def backend(name = nil)
81
+ return show_backend if name.nil?
82
+
83
+ unless Memory::Backends.registered?(name)
84
+ Rubino.ui.error(
85
+ "Unknown memory backend: #{name}. Available: #{Memory::Backends.names.join(", ")}"
86
+ )
87
+ return
88
+ end
89
+
90
+ Config::Writer.new(config_path: config_path).set("memory.backend", name)
91
+ Rubino.ui.success("memory.backend = #{name}")
92
+ end
93
+
94
+ # `--all` surfaces soft-retired rows next to live ones; without a flag
95
+ # they were indistinguishable and the supersession chain needed a `show`
96
+ # per id (#161). Marks a tombstone with its retirement date and, when
97
+ # known, the short id of the fact that replaced it. A class method so the
98
+ # in-chat `/memory --all` table (#184) speaks the same dialect.
99
+ def self.retired_marker(memory)
100
+ return "" unless memory[:valid_to]
101
+
102
+ marker = " (retired #{memory[:valid_to][0..9]}"
103
+ marker += " → #{memory[:superseded_by][0..7]}" if memory[:superseded_by]
104
+ "#{marker})"
105
+ end
106
+
107
+ # ONE backend summary for both surfaces (#184): the CLI `memory backend`
108
+ # verb and the in-chat `/memory backend`.
109
+ def self.render_active_backend(ui:)
110
+ active = Rubino.configuration.dig("memory", "backend") || Memory::Backends::DEFAULT_NAME
111
+ ui.info("Active backend: #{active}")
112
+ ui.info("Available: #{Memory::Backends.names.join(", ")}")
113
+ end
114
+
115
+ private
116
+
117
+ # Resolve the *configured* memory backend (default: sqlite tiny-Zep), the
118
+ # same store the agent loop, the in-chat `/memory` view and the HTTP
119
+ # `/v1/memory` ops use. The old `Memory::Store.new` was hardwired to the
120
+ # legacy `:memories` table and ignored `memory.backend`, so list/show/delete
121
+ # never saw the facts the agent actually persists (#94).
122
+ def backend_store
123
+ @backend_store ||= Memory::Backends.build
124
+ end
125
+
126
+ def show_backend
127
+ self.class.render_active_backend(ui: Rubino.ui)
128
+ end
129
+
130
+ def config_path
131
+ Config::Loader.new.config_path
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubino
6
+ module CLI
7
+ # First-run onboarding (#93). A small, skippable interactive wizard that
8
+ # takes a brand-new user from an empty home to a working model: pick a
9
+ # provider/model, paste the key (written to .env, never echoed back), and
10
+ # persist the matching model.default / model.provider / providers.<name>
11
+ # block to config.yml. The catalog mirrors DOCS-BLUEPRINT models-and-keys.
12
+ #
13
+ # It is only invoked when no usable credential is configured AND we are on a
14
+ # real TTY (ChatCommand#ensure_model_configured!); non-interactive contexts
15
+ # get the actionable guidance instead. #run returns true on a completed
16
+ # setup, false if the user skipped — the caller re-checks usability either
17
+ # way, so a partial/declined run safely falls through to the guidance+exit.
18
+ class OnboardingWizard
19
+ # Each provider: the model.provider to write, a default model id, the .env
20
+ # key var, and any providers.<name> config block to persist. Ordered so the
21
+ # recommended default comes first and matches the seeded config default
22
+ # (config/defaults.rb model.default => openai/gpt-4.1), keeping the from-zero
23
+ # experience consistent between the wizard and the non-interactive
24
+ # fail-fast guidance.
25
+ PROVIDERS = [
26
+ {
27
+ key: "openai",
28
+ label: "OpenAI (GPT) — recommended default",
29
+ provider: "openai",
30
+ model: "gpt-4.1",
31
+ env_var: "OPENAI_API_KEY",
32
+ config: {}
33
+ },
34
+ {
35
+ key: "minimax",
36
+ label: "MiniMax (Anthropic-compatible)",
37
+ provider: "minimax",
38
+ model: "MiniMax-M2.7",
39
+ env_var: "MINIMAX_API_KEY",
40
+ config: {
41
+ "anthropic_compatible" => true,
42
+ "base_url" => "https://api.minimax.io/anthropic",
43
+ "api_key" => "${MINIMAX_API_KEY}"
44
+ }
45
+ },
46
+ {
47
+ key: "anthropic",
48
+ label: "Anthropic (Claude)",
49
+ provider: "anthropic",
50
+ model: "claude-sonnet-4-5",
51
+ env_var: "ANTHROPIC_API_KEY",
52
+ config: {}
53
+ },
54
+ {
55
+ key: "gemini",
56
+ label: "Google (Gemini)",
57
+ provider: "google",
58
+ model: "gemini-2.5-pro",
59
+ env_var: "GEMINI_API_KEY",
60
+ config: {}
61
+ },
62
+ {
63
+ key: "gateway",
64
+ label: "OpenAI-compatible gateway",
65
+ provider: "gateway",
66
+ model: "auto",
67
+ env_var: "OPENAI_API_KEY",
68
+ config: {
69
+ "openai_compatible" => true,
70
+ "assume_model_exists" => true,
71
+ "base_url" => nil # filled in interactively
72
+ }
73
+ }
74
+ ].freeze
75
+
76
+ def initialize(ui: Rubino.ui, input: $stdin, output: $stdout)
77
+ @ui = ui
78
+ @input = input
79
+ @output = output
80
+ end
81
+
82
+ # Drives the wizard. Returns true when a provider was configured, false
83
+ # when the user skipped (empty/`s`/`skip` at the provider prompt).
84
+ def run
85
+ @ui.blank_line
86
+ @ui.info("Welcome to rubino — let's get you connected to a model.")
87
+ @ui.status("No API key is configured yet. Pick a provider (or press Enter to skip).")
88
+ @ui.blank_line
89
+
90
+ choice = ask_provider
91
+ return false unless choice
92
+
93
+ api_key = ask_api_key(choice)
94
+ return false if api_key.nil? || api_key.empty?
95
+
96
+ base_url = ask_base_url(choice)
97
+
98
+ persist!(choice, api_key, base_url)
99
+ Rubino.reload_configuration!
100
+
101
+ @ui.blank_line
102
+ @ui.success("Configured #{choice[:label]} with model #{choice[:model]}.")
103
+ @ui.status("Saved to #{config_loader.config_path} and #{config_loader.env_path}.")
104
+ @ui.blank_line
105
+ true
106
+ end
107
+
108
+ private
109
+
110
+ def ask_provider
111
+ PROVIDERS.each_with_index do |p, i|
112
+ @output.puts " #{i + 1}) #{p[:label]}"
113
+ end
114
+
115
+ # Re-prompt on an invalid choice instead of abandoning the wizard on
116
+ # the first typo (#31). Only an explicit skip (empty / `s` / `skip`) or
117
+ # EOF leaves the loop with nil; an out-of-range number just asks again.
118
+ loop do
119
+ @output.print "Choose a provider [1-#{PROVIDERS.size}, Enter to skip]: "
120
+ @output.flush
121
+ raw = read_line
122
+ return nil if raw.nil? || raw.strip.empty? || %w[s skip].include?(raw.strip.downcase)
123
+
124
+ idx = raw.strip.to_i
125
+ return PROVIDERS[idx - 1] if idx.between?(1, PROVIDERS.size)
126
+
127
+ @ui.warning("Not a valid choice — please pick 1-#{PROVIDERS.size}, or press Enter to skip.")
128
+ end
129
+ end
130
+
131
+ def ask_api_key(choice)
132
+ @output.print "Paste your #{choice[:env_var]} (input hidden; Enter to skip): "
133
+ @output.flush
134
+ read_secret.to_s.strip
135
+ end
136
+
137
+ # The proxy provider needs a base_url; everyone else uses the upstream
138
+ # default, so we only ask when the catalog entry left base_url nil.
139
+ def ask_base_url(choice)
140
+ return nil unless choice[:config].key?("base_url") && choice[:config]["base_url"].nil?
141
+
142
+ @output.print "Enter the gateway base URL (e.g. https://host/v1): "
143
+ @output.flush
144
+ read_line.to_s.strip
145
+ end
146
+
147
+ def persist!(choice, api_key, base_url)
148
+ Rubino.ensure_directories!
149
+ loader = config_loader
150
+ # Seed config.yml from defaults the first time so the wizard's keys land
151
+ # in a complete, hand-editable file rather than a 3-line stub.
152
+ loader.create_default_config! unless loader.config_exists?
153
+
154
+ writer = Config::Writer.new(config_path: loader.config_path)
155
+ writer.set("model.default", choice[:model])
156
+ writer.set("model.provider", choice[:provider])
157
+
158
+ choice[:config].each do |k, v|
159
+ value = k == "base_url" && (v.nil? || v.empty?) ? base_url : v
160
+ next if value.nil?
161
+
162
+ writer.set("providers.#{choice[:provider]}.#{k}", value)
163
+ end
164
+
165
+ write_env_key!(loader.env_path, choice[:env_var], api_key)
166
+ end
167
+
168
+ # Appends/updates KEY=value in .env (0600). Does not echo the value. An
169
+ # existing line for the same key is replaced so re-running setup updates it.
170
+ def write_env_key!(env_path, var, value)
171
+ lines = File.exist?(env_path) ? File.readlines(env_path, chomp: true) : []
172
+ lines.reject! { |l| l =~ /\A#{Regexp.escape(var)}=/ }
173
+ lines << "#{var}=#{value}"
174
+ File.write(env_path, lines.join("\n") + "\n")
175
+ File.chmod(0o600, env_path)
176
+ # Make the key visible to THIS process too, so the immediate usability
177
+ # re-check and any subsequent model call in this run can see it.
178
+ ENV[var] = value
179
+ end
180
+
181
+ def config_loader
182
+ @config_loader ||= Config::Loader.new
183
+ end
184
+
185
+ def read_line
186
+ @input.gets
187
+ rescue StandardError
188
+ nil
189
+ end
190
+
191
+ # Hidden input for the key. Falls back to a plain read when the terminal
192
+ # can't toggle echo (piped input in tests).
193
+ def read_secret
194
+ if @input.respond_to?(:noecho) && @input.tty?
195
+ begin
196
+ secret = @input.noecho(&:gets)
197
+ @output.puts
198
+ return secret
199
+ rescue StandardError
200
+ # fall through to plain read
201
+ end
202
+ end
203
+ read_line
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module CLI
5
+ # Starts the HTTP API server (Rack + Puma).
6
+ class ServerCommand
7
+ def initialize(options = {})
8
+ @options = options
9
+ end
10
+
11
+ def execute
12
+ # Fail fast: a missing/malformed encryption key blows up on the first
13
+ # OAuth hit otherwise, with the listener already accepting traffic.
14
+ Boot::EncryptionKey.validate!
15
+
16
+ # The fake LLM provider is dev-only — it replays canned YAML scenarios
17
+ # instead of talking to a real LLM, so booting the API with it on by
18
+ # accident would silently serve fake answers to real clients. Refuse
19
+ # to start unless the operator explicitly opted in.
20
+ guard_fake_provider!
21
+
22
+ port = (@options[:port] || ENV.fetch("RUBINO_API_PORT", 4820)).to_i
23
+ # Loopback by default (#69); a routable bind is an explicit opt-in.
24
+ host = @options[:host] || ENV.fetch("RUBINO_API_HOST", "127.0.0.1")
25
+ api_key = @options[:api_key] || ENV.fetch("RUBINO_API_KEY", nil)
26
+
27
+ # When TLS is enabled (RUBINO_TLS=1 or a cert already exists), make
28
+ # sure a self-signed cert+key exist under RUBINO_HOME and serve over
29
+ # HTTPS. The web client pins this cert. Local dev / fake leave the
30
+ # toggle unset and no cert, so the listener stays plain HTTP.
31
+ tls_cert = tls_key = nil
32
+ if API::TLS.enabled?
33
+ API::TLS.ensure_cert!(host: host)
34
+ tls_cert = API::TLS.cert_path
35
+ tls_key = API::TLS.key_path
36
+ end
37
+
38
+ register_metric_descriptions!
39
+
40
+ # Without this the tool registry stays empty, Lifecycle#load_tools
41
+ # returns [], no `tools: [...]` is sent on the wire, and the model
42
+ # has no choice but to roleplay tools in markdown. The CLI path
43
+ # (ChatCommand#ensure_setup!) registers tools the same way; both
44
+ # entry points need the same line.
45
+ Rubino::Tools::Registry.register_defaults! if Rubino::Tools::Registry.all.empty?
46
+
47
+ # Instantiate the shared agent registry at boot so the `task` tool can
48
+ # resolve subagents (explore/general) over /v1 — the API path uses the
49
+ # same delegation flow as the CLI. Memoized on Rubino.agent_registry.
50
+ Rubino.agent_registry
51
+
52
+ router = API::Router.new
53
+ router.get "/v1/health", to: API::Operations::HealthOperation
54
+ router.get "/v1/metrics", to: API::Operations::MetricsOperation
55
+ router.get "/v1/sessions", to: API::Operations::Sessions::IndexOperation
56
+ router.post "/v1/sessions", to: API::Operations::Sessions::CreateOperation
57
+ router.get "/v1/sessions/:id", to: API::Operations::Sessions::ShowOperation
58
+ router.delete "/v1/sessions/:id", to: API::Operations::Sessions::DeleteOperation
59
+ router.post "/v1/sessions/:id/runs", to: API::Operations::Runs::CreateOperation
60
+ router.get "/v1/runs/:id/events", to: API::Operations::Runs::EventsOperation
61
+ router.post "/v1/runs/:id/stop", to: API::Operations::Runs::StopOperation
62
+ router.post "/v1/sessions/:id/retry", to: API::Operations::Sessions::RetryOperation
63
+ router.post "/v1/sessions/:id/undo", to: API::Operations::Sessions::UndoOperation
64
+ router.post "/v1/runs/:run_id/approvals/:approval_id", to: API::Operations::Approvals::DecideOperation
65
+ router.post "/v1/runs/:run_id/clarifications/:clarify_id", to: API::Operations::Clarifications::DecideOperation
66
+ router.get "/v1/skills", to: API::Operations::Skills::ListOperation
67
+ router.put "/v1/skills/:name", to: API::Operations::Skills::ToggleOperation
68
+ router.get "/v1/mode", to: API::Operations::Mode::ShowOperation
69
+ router.put "/v1/mode", to: API::Operations::Mode::UpdateOperation
70
+ router.get "/v1/models", to: API::Operations::Models::ListOperation
71
+ router.get "/v1/files", to: API::Operations::Files::ReadOperation
72
+ router.post "/v1/files", to: API::Operations::Files::UploadOperation
73
+ router.get "/v1/jobs", to: API::Operations::CronJobs::ListOperation
74
+ router.post "/v1/jobs", to: API::Operations::CronJobs::CreateOperation
75
+ router.get "/v1/jobs/:id", to: API::Operations::CronJobs::ShowOperation
76
+ router.patch "/v1/jobs/:id", to: API::Operations::CronJobs::UpdateOperation
77
+ router.delete "/v1/jobs/:id", to: API::Operations::CronJobs::DeleteOperation
78
+ router.post "/v1/jobs/:id/pause", to: API::Operations::CronJobs::PauseOperation
79
+ router.post "/v1/jobs/:id/resume", to: API::Operations::CronJobs::ResumeOperation
80
+ router.post "/v1/jobs/:id/trigger", to: API::Operations::CronJobs::TriggerOperation
81
+ router.get "/v1/memory", to: API::Operations::Memory::IndexOperation
82
+ router.get "/v1/memory/stats", to: API::Operations::Memory::StatsOperation
83
+ router.delete "/v1/memory/:id", to: API::Operations::Memory::DeleteOperation
84
+ router.get "/v1/tasks", to: API::Operations::Tasks::IndexOperation
85
+ router.get "/v1/tasks/:id", to: API::Operations::Tasks::ShowOperation
86
+ router.post "/v1/tasks/:id/stop", to: API::Operations::Tasks::StopOperation
87
+ router.get "/v1/oauth/providers", to: API::Operations::OAuth::Providers::ListOperation
88
+ router.post "/v1/oauth/providers/:id/connect", to: API::Operations::OAuth::Providers::ConnectOperation
89
+ router.post "/v1/oauth/providers/:id/callback", to: API::Operations::OAuth::Providers::CallbackOperation
90
+ router.get "/v1/oauth/connections", to: API::Operations::OAuth::Connections::ListOperation
91
+ router.delete "/v1/oauth/connections/:id", to: API::Operations::OAuth::Connections::DisconnectOperation
92
+
93
+ ::Rubino::OAuth::Registry.load_from_config!
94
+ Jobs::Scheduler.instance.load_all!
95
+ # Drains any webhook delivery that was persisted as pending before a
96
+ # prior crash/restart. See Jobs::WebhookDelivery#resume_pending!.
97
+ Jobs::Scheduler.instance.resume_pending_webhooks!
98
+
99
+ Rubino::API::Server.new(
100
+ port: port,
101
+ host: host,
102
+ api_key: api_key,
103
+ router: router,
104
+ tls_cert: tls_cert,
105
+ tls_key: tls_key
106
+ ).start!
107
+ end
108
+
109
+ private
110
+
111
+ def guard_fake_provider!
112
+ provider = Rubino.configuration.model_provider
113
+ return unless provider.to_s == "fake"
114
+ return if ENV["RUBINO_ALLOW_FAKE"] == "1"
115
+
116
+ warn "fake provider is dev-only — set RUBINO_ALLOW_FAKE=1 to opt in."
117
+ exit(1)
118
+ end
119
+
120
+ # HELP text is looked up by Metrics.counter/.histogram at first-touch, so
121
+ # this must run before any metric is incremented (i.e. before the Rack
122
+ # stack is built). When adding a new Metrics.counter/.histogram anywhere
123
+ # in the codebase, add its HELP line here — the metrics_help_spec asserts
124
+ # every registered metric carries a description.
125
+ def register_metric_descriptions!
126
+ Metrics.describe(:http_requests_total, "Total HTTP requests handled, labelled by method/path/status.")
127
+ Metrics.describe(:http_request_duration_seconds, "HTTP request duration in seconds.")
128
+ Metrics.describe(:cron_fires_total, "Number of cron jobs fired, labelled by job and outcome.")
129
+ Metrics.describe(:webhook_deliveries_total, "Webhook deliveries attempted, labelled by outcome.")
130
+ Metrics.describe(:oauth_token_exchanges_total, "OAuth token exchanges, labelled by provider and outcome.")
131
+ Metrics.describe(:runs_total, "Runs started, labelled by source.")
132
+ Metrics.describe(:runs_completed_total, "Total number of runs that have completed (success+failure+cancelled).")
133
+ Metrics.describe(:skills_loaded_total, "Number of times a skill was successfully loaded via the `skill` tool.")
134
+ Metrics.describe(:skills_created_total,
135
+ "Number of new skills observed by the registry on a re-scan (disk-diff signal; no creation tool exists).")
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Rubino
6
+ module CLI
7
+ # Subcommands for managing chat sessions
8
+ class SessionCommand < Thor
9
+ # Clean `tree`/help label instead of the underscored class-name default (F12).
10
+ namespace "rubino sessions"
11
+
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ desc "list", "List recent sessions"
17
+ option :limit, type: :numeric, default: 20, desc: "Max results"
18
+ option :status, type: :string, desc: "Filter by status"
19
+ option :search, type: :string, desc: "Filter by title (substring match)"
20
+ def list
21
+ Rubino.ensure_database_ready!
22
+ repo = Session::Repository.new
23
+ # Reap sessions left "active" by a process that died without ending them
24
+ # (hard terminal kill / SIGKILL, #11) so the list never shows a stale
25
+ # "active" for a window that is actually gone.
26
+ repo.reap_orphaned_active!
27
+ sessions = repo.list(limit: options[:limit], status: options[:status],
28
+ search: options[:search])
29
+
30
+ if sessions.empty?
31
+ Rubino.ui.info("No sessions found.")
32
+ return
33
+ end
34
+
35
+ rows = sessions.map do |s|
36
+ [s[:id][0..7], s[:title] || "(untitled)", s[:status],
37
+ s[:message_count].to_s, s[:created_at]]
38
+ end
39
+
40
+ Rubino.ui.table(
41
+ headers: %w[ID Title Status Messages Created],
42
+ rows: rows
43
+ )
44
+ end
45
+
46
+ desc "show ID", "Show session details"
47
+ def show(id)
48
+ Rubino.ensure_database_ready!
49
+ repo = Session::Repository.new
50
+ session = repo.find(id)
51
+
52
+ # One error, one style (#20): Thor already prints the Thor::Error message
53
+ # to stderr and exits non-zero (exit_on_failure?), so the extra styled
54
+ # ui.error line was the same failure repeated in a second format.
55
+ raise Thor::Error, "session not found: #{id}" if session.nil?
56
+
57
+ self.class.render(session, ui: Rubino.ui)
58
+ end
59
+
60
+ # ONE session-details rendering for both surfaces (#183): the CLI verb
61
+ # above and the in-chat `/sessions show <id>` (Commands::Executor).
62
+ def self.render(session, ui:)
63
+ ui.info("Session: #{session[:id]}")
64
+ ui.info("Title: #{session[:title] || "(untitled)"}")
65
+ ui.info("Status: #{session[:status]}")
66
+ ui.info("Model: #{session[:model]}")
67
+ ui.info("Messages: #{session[:message_count]}")
68
+ ui.info("Tokens: #{session[:token_count]}")
69
+ ui.info("Created: #{session[:created_at]}")
70
+ ui.info("Updated: #{session[:updated_at]}")
71
+
72
+ return unless session[:parent_session_id]
73
+
74
+ ui.info("Parent: #{session[:parent_session_id]}")
75
+ end
76
+
77
+ desc "delete ID", "Permanently delete a session and all its messages/events"
78
+ option :force, type: :boolean, default: false, aliases: "-f",
79
+ desc: "Skip the confirmation prompt"
80
+ def delete(id)
81
+ Rubino.ensure_database_ready!
82
+ repo = Session::Repository.new
83
+ session = repo.find(id)
84
+
85
+ # Single-styled not-found error (#20), as in #show above.
86
+ raise Thor::Error, "session not found: #{id}" if session.nil?
87
+
88
+ self.class.destroy_with_confirm(session, repo: repo, ui: Rubino.ui, force: options[:force])
89
+ end
90
+
91
+ # ONE confirm-and-destroy flow for both surfaces (#183): the CLI verb
92
+ # above and the in-chat `/sessions delete <id>`.
93
+ def self.destroy_with_confirm(session, repo:, ui:, force: false)
94
+ unless force
95
+ confirmed = ui.confirm_destructive(
96
+ "Delete session #{session[:id][0..7]} '#{session[:title] || "(untitled)"}'? " \
97
+ "This will also remove its messages, events, and tool calls."
98
+ )
99
+ unless confirmed
100
+ ui.info("Aborted.")
101
+ return
102
+ end
103
+ end
104
+
105
+ repo.destroy!(session[:id])
106
+ ui.success("Deleted session #{session[:id][0..7]}.")
107
+ end
108
+
109
+ desc "compact ID", "Manually trigger compaction on a session"
110
+ def compact(id)
111
+ Rubino.ensure_database_ready!
112
+ repo = Session::Repository.new
113
+ session = repo.find(id)
114
+
115
+ # Single-styled not-found error (#20), as in #show above.
116
+ raise Thor::Error, "session not found: #{id}" if session.nil?
117
+
118
+ Rubino.ui.info("Compacting session #{id}...")
119
+ compressor = Context::Compressor.new(session_id: id)
120
+ result = compressor.compact!
121
+ Rubino.ui.compression_finished(result)
122
+ end
123
+ end
124
+ end
125
+ end