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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Rubino
6
+ module Commands
7
+ module Handlers
8
+ # The `/sessions` list/show/delete/picker surface plus the `/probe` and
9
+ # `/branch` REPL signals, extracted from Commands::Executor (batch B).
10
+ #
11
+ # No-arg = list recent + how to resume; arg = resolve and resume in place.
12
+ # Resuming returns a {resume_session_id:} signal the REPL acts on by
13
+ # rebuilding its runner on that session (history replays). Reuses
14
+ # Session::Repository#list and #find_by_id_or_title (which already raises
15
+ # AmbiguousSessionError on >1 match).
16
+ #
17
+ # The management verbs (#183) reuse the CLI subcommands' logic
18
+ # (CLI::SessionCommand.render / .destroy_with_confirm — ONE rendering and
19
+ # ONE delete flow for both surfaces):
20
+ #
21
+ # /sessions → list (picker on a TTY) + resume
22
+ # /sessions --all → list without the row cap
23
+ # /sessions show <id> → details, without switching into it
24
+ # /sessions delete <id> → delete (asks to confirm)
25
+ # /sessions <id|title> → resume
26
+ class Sessions
27
+ def initialize(ui:, runner:)
28
+ @ui = ui
29
+ @runner = runner
30
+ end
31
+
32
+ def handle_sessions(arguments)
33
+ tokens = arguments.to_s.strip.split(/\s+/)
34
+ all = tokens.delete("--all") ? true : false
35
+ return list_sessions(all: all) if tokens.empty?
36
+
37
+ case tokens.first
38
+ when "show" then session_verb(tokens[1..].join(" "), "show") { |s| CLI::SessionCommand.render(s, ui: @ui) }
39
+ when "delete" then session_verb(tokens[1..].join(" "), "delete") { |s| delete_session(s) }
40
+ else resume_session(tokens.join(" "))
41
+ end
42
+ end
43
+
44
+ # `/probe <text>` — the discoverable alias for the `? ` prefix. Bare
45
+ # `/probe` only teaches the prefix (the one-keystroke common case); with
46
+ # text, signal the REPL to run the ephemeral side-inference and discard.
47
+ def handle_probe(arguments)
48
+ text = arguments.to_s.strip
49
+ if text.empty?
50
+ @ui.info("Ask an ephemeral side-question that is NOT saved to this session.")
51
+ @ui.info("Tip: just start a line with '? ' — e.g. ? is this lib MIT or GPL?")
52
+ return :handled
53
+ end
54
+
55
+ { probe: text }
56
+ end
57
+
58
+ # `/branch [name]` — fork the current session here into a NEW saved one
59
+ # and switch into it. The REPL holds the runner/session, so we just pass
60
+ # the optional title along on the branch signal.
61
+ def handle_branch(arguments)
62
+ title = arguments.to_s.strip
63
+ { branch: true, title: title.empty? ? nil : title }
64
+ end
65
+
66
+ private
67
+
68
+ # Resolves the id/title for a /sessions verb (same matcher resume uses,
69
+ # so short ids and title substrings work) and yields the session row;
70
+ # prints the usage/not-found/ambiguous error otherwise. Always :handled —
71
+ # the verbs never fall through to the unknown-command path (#34).
72
+ def session_verb(query, verb)
73
+ if query.nil? || query.empty?
74
+ @ui.info("Usage: /sessions #{verb} <id>")
75
+ return :handled
76
+ end
77
+
78
+ session = Session::Repository.new.find_by_id_or_title(query)
79
+ if session.nil?
80
+ @ui.error("no session matching #{query.inspect}.")
81
+ @ui.info("List them with /sessions")
82
+ else
83
+ yield session
84
+ end
85
+ :handled
86
+ rescue Rubino::AmbiguousSessionError => e
87
+ @ui.error(e.message)
88
+ :handled
89
+ end
90
+
91
+ # Deletes a session in-chat via the SAME confirm-and-destroy flow the
92
+ # `rubino sessions delete` CLI verb runs (#183). The session the live
93
+ # runner sits on is refused — deleting the history under the active
94
+ # runner would corrupt the running conversation; /new first.
95
+ def delete_session(session)
96
+ if @runner&.session&.dig(:id) == session[:id]
97
+ @ui.error("that is the ACTIVE session — start a new one first (/new), then delete it.")
98
+ return
99
+ end
100
+
101
+ CLI::SessionCommand.destroy_with_confirm(session, repo: Session::Repository.new, ui: @ui)
102
+ end
103
+
104
+ def list_sessions(all: false)
105
+ sessions = Session::Repository.new.list(limit: all ? nil : sessions_list_limit)
106
+ if sessions.empty?
107
+ @ui.info("No past sessions yet.")
108
+ return :handled
109
+ end
110
+
111
+ # ONE surface, not two (#40): on a real terminal the arrow-key picker
112
+ # IS the list (Enter resumes, Esc cancels — #73, letters filter), with
113
+ # Created/Status folded into each row, so the same sessions are never
114
+ # rendered twice (static table + picker). Off a TTY the static table +
115
+ # typed-shortcut fallback renders instead.
116
+ return sessions_table_fallback(sessions) unless interactive_terminal?
117
+
118
+ choices = sessions.map { |s| [session_choice_label(s), s[:id]] }
119
+ chosen = @ui.select("Resume which session? (Esc to cancel)", choices)
120
+ if chosen
121
+ session = sessions.find { |s| s[:id] == chosen }
122
+ @ui.success(%(Resuming #{chosen[0..7]} "#{session_title(session)}")) if session
123
+ return { resume_session_id: chosen }
124
+ end
125
+
126
+ @ui.info("Resume: /sessions <id|title> · /sessions show|delete <id>")
127
+ :handled
128
+ end
129
+
130
+ # Static fallback for non-interactive callers (pipes / Null UI): the
131
+ # bordered table the picker replaces on a TTY. Leads with the identifying
132
+ # fields (ID, Title, Created) so a narrow-term card fallback scans well —
133
+ # the key field first, not buried (#84).
134
+ def sessions_table_fallback(sessions)
135
+ rows = sessions.map do |s|
136
+ [s[:id].to_s[0..7], session_title(s), s[:created_at].to_s, s[:status].to_s, s[:message_count].to_s]
137
+ end
138
+ @ui.table(headers: %w[ID Title Created Status Msgs], rows: rows)
139
+ @ui.info("Resume: /sessions <id|title> · /sessions show|delete <id>")
140
+ :handled
141
+ end
142
+
143
+ # One picker row: short id + title + message count + recency (and status
144
+ # when not yet ended), so the highlighted entry is identifiable at a
145
+ # glance and the picker is a clean superset of the old static table (#40).
146
+ def session_choice_label(session)
147
+ id = session[:id].to_s[0..7]
148
+ title = session_title(session)
149
+ msgs = session[:message_count]
150
+ meta = [
151
+ ("#{msgs} msg#{"s" if msgs != 1}" if msgs),
152
+ session_age(session),
153
+ (session[:status].to_s unless ["", "ended"].include?(session[:status].to_s))
154
+ ].compact.join(" · ")
155
+ meta.empty? ? "#{id} #{title}" : "#{id} #{title} (#{meta})"
156
+ end
157
+
158
+ # "Created" humanized for the picker row — "5m ago" scans better than a
159
+ # raw ISO timestamp in a recency-ordered list (#40). nil when unparseable.
160
+ def session_age(session)
161
+ created = session[:created_at]
162
+ created = Time.parse(created.to_s) unless created.is_a?(Time)
163
+ "#{Rubino::Util::Duration.human_duration(Time.now - created)} ago"
164
+ rescue StandardError
165
+ nil
166
+ end
167
+
168
+ def resume_session(query)
169
+ session = Session::Repository.new.find_by_id_or_title(query)
170
+ if session.nil?
171
+ @ui.error("no session matching #{query.inspect}.")
172
+ @ui.info("List them with /sessions")
173
+ return :handled
174
+ end
175
+
176
+ @ui.success(%(Resuming #{session[:id][0..7]} "#{session_title(session)}"))
177
+ { resume_session_id: session[:id] }
178
+ rescue Rubino::AmbiguousSessionError => e
179
+ @ui.error(e.message)
180
+ :handled
181
+ end
182
+
183
+ def session_title(session)
184
+ title = session[:title].to_s.strip
185
+ title.empty? ? "(untitled)" : title
186
+ end
187
+
188
+ # The bare-list row cap (#183): configurable (`sessions.list_limit`) and
189
+ # liftable per call with `/sessions --all` — no longer hardwired to 10.
190
+ def sessions_list_limit
191
+ limit = Rubino.configuration.dig("sessions", "list_limit").to_i
192
+ limit.positive? ? limit : 10
193
+ rescue StandardError
194
+ 10
195
+ end
196
+
197
+ # True when the REPL owns a real interactive terminal (so the arrow-key
198
+ # picker makes sense). Off a TTY we render the static table fallback.
199
+ def interactive_terminal?
200
+ $stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty?
201
+ rescue StandardError
202
+ false
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ module Handlers
6
+ # The `/skills` list/activate/enable/disable surface, extracted from
7
+ # Commands::Executor (batch B).
8
+ #
9
+ # `/skills` → list (unchanged behavior).
10
+ # `/skills <name>` → ACTIVATE that skill for the session (sticky).
11
+ # The name is validated against the registry; an
12
+ # unknown OR DISABLED name errors and leaves the
13
+ # active skill unchanged.
14
+ # `/skills none` → CLEAR the active skill (also the `✗ none`
15
+ # picker entry, whose spliced label is
16
+ # normalized here).
17
+ # `/skills enable <name>` → persistently re-enable a skill (#188) — the
18
+ # `/skills disable <name>` same StateRepository write the HTTP API
19
+ # toggle and the `rubino skills` CLI verbs run
20
+ # (Skills::Toggle), affecting EVERY session,
21
+ # unlike the session-scoped activation.
22
+ #
23
+ # The active skill is stored in Rubino::ActiveSkill (a process-level slot,
24
+ # mirroring Rubino::Modes) so it survives across turns and is force-loaded
25
+ # into the system prompt each turn (Context::PromptAssembler).
26
+ class Skills
27
+ # The /skills toggle verbs (#188) — the same registry-validated
28
+ # StateRepository write the HTTP API and `rubino skills` CLI run.
29
+ TOGGLE_VERBS = %w[enable disable].freeze
30
+
31
+ def initialize(ui:)
32
+ @ui = ui
33
+ end
34
+
35
+ def handle_skills(arguments)
36
+ tokens = arguments.to_s.strip.split(/\s+/)
37
+ if TOGGLE_VERBS.include?(tokens.first.to_s.downcase)
38
+ toggle_skill(tokens[1], enabled: tokens.first.casecmp?("enable"))
39
+ return
40
+ end
41
+
42
+ arg = normalize_skill_arg(arguments)
43
+
44
+ return show_skills if arg.nil?
45
+
46
+ if clear_skill_arg?(arg)
47
+ previous = Rubino::ActiveSkill.current
48
+ Rubino::ActiveSkill.clear
49
+ if previous
50
+ @ui.success("Cleared active skill (was: #{previous}).")
51
+ else
52
+ @ui.info("No active skill.")
53
+ end
54
+ return
55
+ end
56
+
57
+ # Trust-aligned discovery (#63): activate only skills the assembler
58
+ # will actually pin — in an untrusted cwd a project-local skill is
59
+ # refused (with a reason) instead of chip-active-but-not-injected.
60
+ registry = Rubino::Skills::Registry.trusted
61
+ skill = registry.find(arg)
62
+ unless skill
63
+ if Rubino::Skills::Registry.new.find(arg)
64
+ @ui.error("skill #{arg} is in this directory's .rubino/skills, but the directory " \
65
+ "isn't trusted — its SKILL.md would not be loaded, so it can't be activated")
66
+ else
67
+ @ui.error("unknown skill: #{arg}")
68
+ available = registry.names
69
+ @ui.info("Available: #{available.join(", ")}") unless available.empty?
70
+ end
71
+ return
72
+ end
73
+
74
+ # A disabled skill is EXCLUDED from activation (#188): the assembler
75
+ # refuses to inject it (active_skill_block checks enabled?), so pinning
76
+ # it would show an active chip with no effect.
77
+ unless registry.enabled?(skill.name)
78
+ @ui.error("skill #{skill.name} is disabled — /skills enable #{skill.name} to use it")
79
+ return
80
+ end
81
+
82
+ Rubino::ActiveSkill.set(skill.name)
83
+ @ui.success("Active skill: #{skill.name} (loaded into context for this session).")
84
+ end
85
+
86
+ private
87
+
88
+ # `/skills enable|disable <name>` (#188) — the missing human surface for
89
+ # the StateRepository toggle (previously HTTP-API-only). Persisted, so it
90
+ # affects the Level-1 index of every session until toggled back.
91
+ def toggle_skill(name, enabled:)
92
+ verb = enabled ? "enable" : "disable"
93
+ if name.to_s.strip.empty?
94
+ @ui.info("Usage: /skills #{verb} <name>")
95
+ return
96
+ end
97
+
98
+ registry = Rubino::Skills::Registry.trusted
99
+ unless Rubino::Skills::Toggle.set(name, enabled: enabled, registry: registry)
100
+ @ui.error("unknown skill: #{name}")
101
+ available = registry.names
102
+ @ui.info("Available: #{available.join(", ")}") unless available.empty?
103
+ return
104
+ end
105
+
106
+ if enabled
107
+ @ui.success("Enabled skill: #{name} (back in the skills index for every session).")
108
+ else
109
+ clear_disabled_active_skill(name)
110
+ @ui.success("Disabled skill: #{name} (out of the index for every session; " \
111
+ "/skills enable #{name} to restore).")
112
+ end
113
+ end
114
+
115
+ # Disabling the skill that is currently PINNED active would leave a lying
116
+ # chip — the assembler silently drops a disabled active skill — so the
117
+ # pin is cleared with a note instead.
118
+ def clear_disabled_active_skill(name)
119
+ return unless Rubino::ActiveSkill.current == name
120
+
121
+ Rubino::ActiveSkill.clear
122
+ @ui.info("(it was the active skill — pin cleared)")
123
+ end
124
+
125
+ # The single argument to `/skills`, trimmed; nil when no argument was
126
+ # given (bare `/skills` → list). The picker splices the `✗ none` label, so
127
+ # the leading `✗ ` marker is stripped here to recover the bare token.
128
+ def normalize_skill_arg(arguments)
129
+ raw = arguments.to_s.strip.sub(/\A✗\s+/, "")
130
+ # Only the FIRST token is the skill name (skill names are single tokens).
131
+ token = raw.split(/\s+/).first
132
+ token unless token.nil? || token.empty?
133
+ end
134
+
135
+ # True when the argument means "clear the active skill" (the `none`
136
+ # sentinel, case-insensitive — the `✗ ` marker was already stripped).
137
+ def clear_skill_arg?(arg)
138
+ arg.casecmp?(Rubino::ActiveSkill::NONE)
139
+ end
140
+
141
+ def show_skills
142
+ registry = Rubino::Skills::Registry.trusted
143
+ skills = registry.all
144
+ if skills.empty?
145
+ @ui.info("No skills found.")
146
+ @ui.info("Add .md files to .rubino/skills/ to create skills.")
147
+ else
148
+ active = Rubino::ActiveSkill.current
149
+ skills.each do |skill|
150
+ status = registry.enabled?(skill.name) ? "" : " (disabled)"
151
+ status += " (active)" if active && active == skill.name
152
+ head = " #{skill.name}#{status} - "
153
+ # Word-wrap the description so a long one breaks on spaces instead of
154
+ # being hard-wrapped mid-word by the terminal at the right edge
155
+ # (B8 — "officia\nl"). Continuation lines hang-indent under the
156
+ # description so the list stays readable.
157
+ wrap_skill_line(head, skill.description.to_s).each { |line| @ui.info(line) }
158
+ end
159
+ end
160
+ end
161
+
162
+ # Wraps "<head><description>" to the terminal width, breaking only on
163
+ # whitespace, with continuation lines indented to the description column.
164
+ def wrap_skill_line(head, description)
165
+ width = terminal_width
166
+ indent = " " * head.length
167
+ avail = [width - head.length, 20].max
168
+
169
+ lines = []
170
+ current = +""
171
+ description.split(/\s+/).each do |word|
172
+ candidate = current.empty? ? word : "#{current} #{word}"
173
+ if candidate.length > avail && !current.empty?
174
+ lines << current
175
+ current = word.dup
176
+ else
177
+ current = candidate
178
+ end
179
+ end
180
+ lines << current unless current.empty?
181
+ lines = [""] if lines.empty?
182
+
183
+ lines.each_with_index.map { |line, i| (i.zero? ? head : indent) + line }
184
+ end
185
+
186
+ def terminal_width
187
+ cols = IO.console&.winsize&.last
188
+ cols&.positive? ? cols : 80
189
+ rescue StandardError
190
+ 80
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ module Handlers
6
+ # The `/status` at-a-glance state panel, extracted from Commands::Executor
7
+ # (batch B). Assembles the model/mode/session lines plus approval policy,
8
+ # provider/connection, and the tool/mcp/memory/skills rosters over the same
9
+ # services (Modes, Session::Repository, Memory backend, BackgroundTasks,
10
+ # Skills::Registry). A plain collaborator given the live `ui`/`runner`.
11
+ class Status
12
+ def initialize(ui:, runner:)
13
+ @ui = ui
14
+ @runner = runner
15
+ end
16
+
17
+ # Labels dim, values plain, cyan only on the actionable pointers (P8).
18
+ def show_status
19
+ @ui.separator
20
+ @ui.panel_line("model", status_model)
21
+ @ui.panel_line("provider", status_provider_line)
22
+ @ui.panel_line("mode", "#{Rubino::Modes.current} — #{Rubino::Modes.description}")
23
+ @ui.panel_line("display", status_display_line, pointer: "(use /reasoning · /think)")
24
+ @ui.panel_line("approvals", status_approvals_line)
25
+ @ui.panel_line("session", status_session_line)
26
+ @ui.panel_line("tools", status_tools_line)
27
+ # MCP only when servers are configured (#182/#186) — a non-MCP user's
28
+ # /status stays exactly as before, and MCP tools stop being invisibly
29
+ # mixed into the truncated tools line as the only trace of MCP.
30
+ @ui.panel_line("mcp", status_mcp_line, pointer: "(use /mcp)") if Rubino::MCP.enabled?
31
+ if (dirs = status_dirs_line)
32
+ @ui.panel_line("dirs", dirs, pointer: "(use /dirs)")
33
+ end
34
+ @ui.panel_line("memory", status_memory_line, pointer: "(use /memory)")
35
+ @ui.panel_line("skills", status_skills_line, pointer: "(use /skills)")
36
+ @ui.panel_line("background", status_background_line, pointer: "(use /agents)")
37
+ if (jobs = status_jobs_line)
38
+ @ui.panel_line("jobs", jobs, pointer: "(use /jobs)")
39
+ end
40
+ @ui.separator
41
+ end
42
+
43
+ private
44
+
45
+ # The persisted display prefs (#186): /reasoning and /think write config
46
+ # but were invisible — not in the chip, not in /status.
47
+ def status_display_line
48
+ mode = Rubino::Config::ReasoningPrefs.mode(Rubino.configuration)
49
+ effort = Rubino::Config::ReasoningPrefs.effort(Rubino.configuration) ||
50
+ Rubino::Config::ReasoningPrefs::DEFAULT_EFFORT
51
+ "reasoning: #{mode} · effort: #{effort}"
52
+ rescue StandardError
53
+ "(unavailable)"
54
+ end
55
+
56
+ # Workspace roots + trust (#186) — trust is the #1 "why are my
57
+ # skills/AGENTS.md not loading" confusion. Only earns a line when there
58
+ # is something to say (>1 root or any untrusted); nil otherwise.
59
+ def status_dirs_line
60
+ roots = Rubino::Workspace.canonical_roots
61
+ untrusted = roots.count { |d| !Rubino::Trust.trusted?(d) }
62
+ return nil if roots.size <= 1 && untrusted.zero?
63
+
64
+ line = "#{roots.size} root#{"s" if roots.size != 1}"
65
+ untrusted.positive? ? "#{line} · #{untrusted} untrusted (context/skills withheld)" : line
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ # The persistent jobs queue (#186) — distinct from the in-process
71
+ # `background` subagents line. Only earns a line when nonzero; nil (no
72
+ # line) when the queue is empty or unreadable.
73
+ def status_jobs_line
74
+ queue = Rubino::Jobs::Queue.new
75
+ pending = queue.pending_count
76
+ failed = queue.failed_count
77
+ return nil unless pending.positive? || failed.positive?
78
+
79
+ [("#{pending} pending" if pending.positive?),
80
+ ("#{failed} failed" if failed.positive?)].compact.join(" · ")
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def status_model
86
+ @runner&.session&.dig(:model) ||
87
+ (@runner.respond_to?(:model_id) ? @runner.model_id : nil) ||
88
+ Rubino.configuration.model_default
89
+ end
90
+
91
+ # The configured provider — the "what am I talking to" line a status
92
+ # check wants. We report the configured target, not a live probe (a
93
+ # health round-trip would make /status slow and flaky).
94
+ def status_provider_line
95
+ Rubino.configuration.model_provider || "(default)"
96
+ rescue StandardError
97
+ "(unavailable)"
98
+ end
99
+
100
+ # One-line approval-policy summary so a newcomer knows what will prompt.
101
+ # Mode is authoritative: yolo skips every approval, plan filters mutating
102
+ # tools out entirely; otherwise approvals come from config.
103
+ def status_approvals_line
104
+ case Rubino::Modes.current
105
+ when :yolo then "skipped (yolo mode — nothing prompts)"
106
+ when :plan then "read-only mode — no edits/shell to approve"
107
+ else "from config (mutating commands prompt)"
108
+ end
109
+ end
110
+
111
+ # A compact roster of the tools the agent can actually use right now
112
+ # (mode filters the registry), so /status answers "what can it DO".
113
+ def status_tools_line
114
+ names = Tools::Registry.instance.enabled_tools.map(&:name).sort
115
+ return "(none)" if names.empty?
116
+
117
+ truncate(names.join(", "), 64)
118
+ rescue StandardError
119
+ "(unavailable)"
120
+ end
121
+
122
+ # `2 servers · 1 reachable · 14 tools` — reads the LIVE booted manager
123
+ # (no client → 0 reachable), never re-spawns servers.
124
+ def status_mcp_line
125
+ servers = mcp_servers_config.size
126
+ reachable = mcp_health.count { |h| h[:alive] }
127
+ tools = Tools::Registry.all.count { |t| t.is_a?(Rubino::MCP::MCPToolWrapper) }
128
+ "#{servers} server#{"s" if servers != 1} · #{reachable} reachable · #{tools} tool#{"s" if tools != 1}"
129
+ rescue StandardError
130
+ "(unavailable)"
131
+ end
132
+
133
+ def status_session_line
134
+ session = @runner&.session
135
+ return "(none)" unless session
136
+
137
+ id = session[:id].to_s[0..7]
138
+ title = session[:title].to_s.strip
139
+ title = title.empty? ? "(untitled)" : %("#{title}")
140
+ msgs = status_message_count(session)
141
+ "#{id} #{title}#{" · #{msgs} msgs" if msgs}"
142
+ end
143
+
144
+ # The session's message count, read LIVE from the message store. The
145
+ # in-memory session hash's :message_count is a boot-time snapshot the
146
+ # streaming path never refreshes, so /status reported a permanent
147
+ # "0 msgs" while the DB had every turn (#159). Counting the persisted
148
+ # rows also matches the "Loaded N prior messages" resume banner.
149
+ def status_message_count(session)
150
+ Session::Store.new.count(session[:id])
151
+ rescue StandardError
152
+ session[:message_count]
153
+ end
154
+
155
+ # /status must count facts on the ACTIVE backend — the same store /memory
156
+ # and the `rubino memory` CLI read via Memory::Backends.build — not the
157
+ # legacy `:memories` table Memory::Store is hardwired to (#83).
158
+ def status_memory_line
159
+ backend = Rubino.configuration.dig("memory", "backend") || Rubino::Memory::Backends::DEFAULT_NAME
160
+ "backend: #{backend} · #{memory_backend.count} facts"
161
+ rescue StandardError
162
+ "(unavailable)"
163
+ end
164
+
165
+ def status_skills_line
166
+ registry = Rubino::Skills::Registry.trusted
167
+ all = registry.all
168
+ enabled = all.count { |s| registry.enabled?(s.name) }
169
+ line = "#{all.size} available, #{enabled} enabled"
170
+ # WHICH skill is pinned (#186) — the chip shows it but the canonical
171
+ # state dump omitted it.
172
+ active = Rubino::ActiveSkill.current
173
+ active ? "#{line} · active: #{active}" : line
174
+ rescue StandardError
175
+ "(unavailable)"
176
+ end
177
+
178
+ def status_background_line
179
+ entries = Tools::BackgroundTasks.instance.list
180
+ running = entries.count { |e| e.status == :running }
181
+ ids = entries.first(3).map(&:id).join(", ")
182
+ line = "#{running} running · #{entries.size} total"
183
+ ids.empty? ? line : "#{line} (#{ids})"
184
+ rescue StandardError
185
+ "(unavailable)"
186
+ end
187
+
188
+ # Resolve the *configured* memory backend (default: sqlite tiny-Zep) for
189
+ # the fact count — the same store the agent loop and /memory read.
190
+ def memory_backend
191
+ @memory_backend ||= Rubino::Memory::Backends.build
192
+ end
193
+
194
+ # The configured mcp.servers block (name => config), {} when absent.
195
+ def mcp_servers_config
196
+ Rubino.configuration.dig("mcp", "servers") || {}
197
+ end
198
+
199
+ # Live reachability from the booted manager; [] when MCP never booted.
200
+ def mcp_health
201
+ Rubino::MCP.manager&.health_check || []
202
+ end
203
+
204
+ def truncate(text, max)
205
+ s = text.to_s.gsub(/\s+/, " ").strip
206
+ s.length > max ? "#{s[0, max - 1]}…" : s
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end