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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ module Handlers
6
+ # The `/help` and `/commands` listings (and the unknown-command
7
+ # "Available:" roster), extracted from Commands::Executor (batch B). A plain
8
+ # collaborator given the command `loader` and the `ui` — it owns the
9
+ # built-in/keys/input reference text and the custom-command discovery copy.
10
+ class Help
11
+ def initialize(ui:, loader:)
12
+ @ui = ui
13
+ @loader = loader
14
+ end
15
+
16
+ # All known slash commands (built-ins + discovered custom), used for the
17
+ # "Available:" hint on an unknown command (L6 — previously listed only
18
+ # custom commands, which is usually empty).
19
+ def available_commands
20
+ custom = begin
21
+ @loader.names
22
+ rescue StandardError
23
+ []
24
+ end
25
+ (BuiltIns::NAMES + custom).uniq
26
+ end
27
+
28
+ def show_help
29
+ @ui.info("Slash commands run actions or reusable prompts. Type /<name>; /help is this list.")
30
+ @ui.blank_line
31
+ @ui.info("Built-in:")
32
+ rows = help_builtin_rows
33
+ width = rows.map { |name, _| name.length }.max
34
+ rows.each do |name, desc|
35
+ @ui.info(" #{name.ljust(width)} - #{desc}")
36
+ end
37
+ @ui.blank_line
38
+
39
+ # The `@` file-picker is a discoverable composer feature (type `@` to
40
+ # autocomplete a workspace file) but was undocumented in /help (F14).
41
+ # /paste and /clear-images already appear once under "Built-in" above,
42
+ # so they're NOT repeated here — this section is image/file INPUT only,
43
+ # no command rows (#87 de-dup).
44
+ @ui.info("Input:")
45
+ @ui.info(" ! <command> - run a shell command yourself, no approval; output joins the context")
46
+ @ui.info(" @<path> - autocomplete a workspace file into the prompt")
47
+ @ui.info(" @<image> - attach an image (png/jpg/jpeg/gif/webp/bmp) to the turn")
48
+ @ui.info(" <image path> - drop or paste an image file path to attach it")
49
+ @ui.blank_line
50
+
51
+ # The keystroke vocabulary was invisible in /help (#87): a newcomer
52
+ # couldn't learn how to cancel a turn, drive the approval menu, or that
53
+ # Tab completes. One compact reference line covers it.
54
+ @ui.info("Keys:")
55
+ @ui.info(" ↑/↓ + Enter - choose in the approval menu")
56
+ @ui.info(" Enter - send; during a turn, interrupt it and run this next")
57
+ @ui.info(" Alt-Enter - queue this to run after the current turn (or /queued <msg>)")
58
+ @ui.info(" Shift-Tab - cycle mode (default → plan → yolo)")
59
+ @ui.info(" Ctrl-O - reveal the last reasoning (collapsed or hidden)")
60
+ @ui.info(" Ctrl-C - cancel the turn (twice to exit)")
61
+ @ui.info(" Esc Esc - rewind to an earlier message (fork + edit & resend)")
62
+ @ui.info(" Tab - complete the highlighted /command or @file")
63
+ @ui.info(" / - start a command; @ attach a file/image")
64
+ @ui.blank_line
65
+
66
+ custom = @loader.all
67
+ if custom.any?
68
+ @ui.info("Custom commands (run with /<name>; add --preview to see the prompt first):")
69
+ custom.each do |cmd|
70
+ @ui.info(" /#{cmd.name}#{custom_desc(cmd)}")
71
+ end
72
+ else
73
+ @ui.info("Custom commands (none yet — run /commands to learn how to add one)")
74
+ end
75
+ end
76
+
77
+ def show_commands
78
+ commands = @loader.all
79
+ return explain_empty_commands if commands.empty?
80
+
81
+ @ui.info("Custom commands (run with /<name>; add --preview to see the prompt first):")
82
+ commands.each do |cmd|
83
+ @ui.info(" /#{cmd.name}#{custom_desc(cmd)}")
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ # The Built-in rows for /help, with synonyms collapsed so /help never
90
+ # shows two rows that say the same thing (#87): /exit and /quit share one
91
+ # "End session" row as "/exit, /quit". Everything else passes through in
92
+ # the BuiltIns order.
93
+ def help_builtin_rows
94
+ rows = []
95
+ seen = {}
96
+ BuiltIns::DESCRIPTIONS.each do |name, desc|
97
+ if (canonical = seen[desc])
98
+ rows[canonical[:index]][0] = "#{canonical[:name]}, #{name}"
99
+ else
100
+ seen[desc] = { index: rows.length, name: name }
101
+ rows << [name, desc]
102
+ end
103
+ end
104
+ rows
105
+ end
106
+
107
+ # The cryptic old empty-state ("Add .md files to .rubino/commands/")
108
+ # named a dir without ever explaining what a command IS. Now we explain
109
+ # the concept, name the REAL configured paths, and show a concrete example.
110
+ def explain_empty_commands
111
+ @ui.info("Custom commands are reusable prompts you trigger with a slash. Each is a")
112
+ @ui.info("Markdown file in a commands directory; the file body becomes the prompt")
113
+ @ui.info("($ARGUMENTS / $1..$9 expand to what you type after the command).")
114
+ @ui.blank_line
115
+ @ui.info("No custom commands found yet.")
116
+ @ui.blank_line
117
+ @ui.info("Searched: #{command_dirs.join(", ")}")
118
+ @ui.info("Create one, e.g. .rubino/commands/review.md:")
119
+ @ui.blank_line
120
+ @ui.info(" ---")
121
+ @ui.info(" description: Review the current diff for bugs")
122
+ @ui.info(" ---")
123
+ @ui.info(" Review the staged diff. Flag correctness bugs only. $ARGUMENTS")
124
+ @ui.blank_line
125
+ @ui.info("Then run: /review focus on the auth change")
126
+ end
127
+
128
+ # The directories the loader actually searches, for the empty-state copy.
129
+ # Resolves through Loader.resolve_path so the "Searched:" line reports the
130
+ # real paths (RUBINO_HOME-aware), not a literal ~/.rubino never searched.
131
+ def command_dirs
132
+ paths = Rubino.configuration.dig("commands", "paths")
133
+ paths = Rubino::Config::Defaults.to_hash.dig("commands", "paths") if paths.nil?
134
+ Array(paths).map { |dir| Loader.resolve_path(dir) }
135
+ rescue StandardError
136
+ Loader.default_command_paths
137
+ end
138
+
139
+ # " - <description>" suffix for a custom-command listing, omitted when the
140
+ # command carries no description so the line stays clean.
141
+ def custom_desc(cmd)
142
+ desc = cmd.description.to_s.strip
143
+ desc.empty? ? "" : " - #{desc}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ module Handlers
6
+ # The `/jobs` in-chat window into the PERSISTENT jobs queue (#187),
7
+ # extracted from Commands::Executor (batch B) — the queue the agent itself
8
+ # feeds mid-session (DistillSkillJob after tool-heavy turns, memory
9
+ # extraction), distinct from the in-process /agents subagents. Read-mostly:
10
+ # `process`/`worker` stay CLI-only (they are daemons, not session actions).
11
+ #
12
+ # /jobs → status counts + the recent-jobs table (the SAME
13
+ # rendering as `rubino jobs list` — JobsCommand.render_list)
14
+ # /jobs <id> → one job in full (attempts, payload, last error);
15
+ # short-id prefixes resolve, like /memory show
16
+ class Jobs
17
+ # Render order for the /jobs counts header (#187) — lifecycle order, not
18
+ # the arbitrary GROUP BY order (any unknown status is appended).
19
+ STATUS_ORDER = %w[queued running completed failed dead].freeze
20
+
21
+ def initialize(ui:)
22
+ @ui = ui
23
+ end
24
+
25
+ def handle_jobs(arguments)
26
+ id = arguments.to_s.strip.split(/\s+/).first
27
+ id.nil? ? show_jobs_list : show_job_detail(id)
28
+ end
29
+
30
+ private
31
+
32
+ def show_jobs_list
33
+ queue = Rubino::Jobs::Queue.new
34
+ counts = queue.counts
35
+ if counts.empty?
36
+ @ui.info("No jobs yet — the agent enqueues background work " \
37
+ "(skill distillation, memory extraction) as you chat.")
38
+ return
39
+ end
40
+
41
+ ordered = (STATUS_ORDER & counts.keys) + (counts.keys - STATUS_ORDER)
42
+ @ui.info(ordered.map { |status| "#{counts[status]} #{status}" }.join(" · "))
43
+ CLI::JobsCommand.render_list(queue.list, ui: @ui)
44
+ @ui.info("/jobs <id> for detail · `rubino jobs process` runs pending ones now")
45
+ end
46
+
47
+ def show_job_detail(id)
48
+ job = Rubino::Jobs::Queue.new.find(id)
49
+ if job.nil?
50
+ @ui.error("no job with id #{id}.")
51
+ @ui.info("List them with /jobs")
52
+ return
53
+ end
54
+
55
+ @ui.info("#{job[:id][0..7]} #{job[:type]} · #{job[:status]}")
56
+ @ui.info(" attempts #{job[:attempts]}/#{job[:max_attempts]}")
57
+ @ui.info(" run_at #{job[:run_at]}")
58
+ @ui.info(" created #{job[:created_at]}")
59
+ @ui.info(" payload #{truncate(job[:payload_json], 200)}")
60
+ error = job[:last_error].to_s
61
+ @ui.error(error) unless error.empty?
62
+ end
63
+
64
+ def truncate(text, max)
65
+ s = text.to_s.gsub(/\s+/, " ").strip
66
+ s.length > max ? "#{s[0, max - 1]}…" : s
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubino
6
+ module Commands
7
+ module Handlers
8
+ # The `/mcp` in-chat management of MCP servers (#182), extracted from
9
+ # Commands::Executor (batch B). Shaped like /skills:
10
+ #
11
+ # /mcp → server list: status, transport, tool count
12
+ # /mcp <server> → drill-in: transport/target, health, its tools
13
+ # /mcp <server> off → stop the client + deregister its tools (session)
14
+ # /mcp <server> on → (re)start the client + register its tools
15
+ # /mcp reload → re-read config.yml and reconnect every server
16
+ #
17
+ # List/drill-in read the LIVE booted manager (Rubino::MCP.manager) and
18
+ # never re-spawn stdio servers — doctor's start/stop dance is wrong inside
19
+ # a session that already holds clients. `off` is session-scoped, like
20
+ # /skills activation; persistent disable stays a config edit (mcp.enabled
21
+ # or removing the server).
22
+ class MCP
23
+ def initialize(ui:)
24
+ @ui = ui
25
+ end
26
+
27
+ def handle_mcp(arguments)
28
+ server, action = arguments.to_s.strip.split(/\s+/)
29
+ # reload must work BEFORE the enabled? gate: its whole point is picking
30
+ # up a config edit (e.g. a first server added mid-session).
31
+ return reload_mcp if server == "reload"
32
+
33
+ unless Rubino::MCP.enabled?
34
+ show_mcp_empty_state
35
+ return
36
+ end
37
+
38
+ server.nil? ? show_mcp_list : handle_mcp_server(server, action)
39
+ end
40
+
41
+ private
42
+
43
+ # The two empty states the issue calls out: no servers at all vs the
44
+ # mcp.enabled kill switch.
45
+ # Empty states are quiet facts, not successes/calls-to-celebrate:
46
+ # dim, never colored (P8).
47
+ def show_mcp_empty_state
48
+ if mcp_servers_config.any?
49
+ @ui.status("MCP is disabled (mcp.enabled: false in config.yml) — " \
50
+ "#{mcp_servers_config.size} server(s) defined but not started.")
51
+ else
52
+ @ui.status("No MCP servers configured.")
53
+ @ui.status("Add an mcp.servers block to config.yml (see docs/mcp.md), then /mcp reload.")
54
+ end
55
+ end
56
+
57
+ def show_mcp_list
58
+ mcp_servers_config.each do |name, server_config|
59
+ tools = mcp_tools_for(name).size
60
+ @ui.panel_line(name, "(#{server_config["transport"] || "stdio"}) " \
61
+ "#{mcp_status_icon(name)} · #{tools} tool#{"s" if tools != 1}")
62
+ end
63
+ @ui.status("/mcp <server> for its tools · /mcp <server> on|off · /mcp reload")
64
+ end
65
+
66
+ def handle_mcp_server(name, action)
67
+ unless mcp_servers_config.key?(name)
68
+ @ui.error("unknown MCP server: #{name}")
69
+ @ui.info("Configured: #{mcp_servers_config.keys.join(", ")}")
70
+ return
71
+ end
72
+
73
+ case action
74
+ when nil then show_mcp_server(name)
75
+ when "off" then mcp_server_off(name)
76
+ when "on" then mcp_server_on(name)
77
+ else
78
+ @ui.error("unknown /mcp action: #{action}")
79
+ @ui.info("Usage: /mcp #{name} [on|off]")
80
+ end
81
+ end
82
+
83
+ def show_mcp_server(name)
84
+ server_config = mcp_servers_config[name]
85
+ transport = server_config["transport"] || "stdio"
86
+ target = if transport == "stdio"
87
+ [server_config["command"], *Array(server_config["args"])].join(" ")
88
+ else
89
+ server_config["url"].to_s
90
+ end
91
+
92
+ @ui.info("#{name} #{mcp_status_icon(name)}")
93
+ @ui.panel_line("transport", "#{transport} · #{target}")
94
+ last_error = Rubino::MCP.manager&.last_errors&.dig(name)
95
+ @ui.panel_line("last error", last_error) if last_error
96
+ show_mcp_server_tools(name)
97
+ end
98
+
99
+ # The server's registered tools (prefixed names + descriptions), wrapped
100
+ # like the /skills list so long descriptions never hard-break mid-word.
101
+ def show_mcp_server_tools(name)
102
+ tools = mcp_tools_for(name)
103
+ if tools.empty?
104
+ @ui.info(" tools (none registered — /mcp #{name} on to start it)")
105
+ return
106
+ end
107
+
108
+ @ui.info(" tools #{tools.size}:")
109
+ tools.each do |tool|
110
+ wrap_skill_line(" #{tool.name} - ", tool.description.to_s).each { |line| @ui.info(line) }
111
+ end
112
+ end
113
+
114
+ # Session-scoped disable: stop the client AND drop its wrappers from the
115
+ # registry (Manager#stop_server deregisters — #182), so the model stops
116
+ # seeing tools whose client is gone.
117
+ def mcp_server_off(name)
118
+ manager = Rubino::MCP.manager
119
+ if manager.nil? || !manager.clients.key?(name)
120
+ @ui.info("MCP server #{name} is not running.")
121
+ return
122
+ end
123
+
124
+ removed = mcp_tools_for(name).size
125
+ manager.stop_server(name)
126
+ @ui.success("MCP server #{name} stopped — #{removed} tool#{"s" if removed != 1} removed " \
127
+ "for this session (/mcp #{name} on to restart; config untouched).")
128
+ end
129
+
130
+ # (Re)start one server and register its tools. With no booted manager yet
131
+ # (MCP never enabled at boot, or boot failed), boot! brings the whole
132
+ # subsystem up — which starts this server too.
133
+ def mcp_server_on(name)
134
+ manager = Rubino::MCP.manager || Rubino::MCP.boot!
135
+ unless manager
136
+ @ui.error("could not boot MCP — check mcp.servers in config.yml, or /mcp reload")
137
+ return
138
+ end
139
+
140
+ manager.stop_server(name) if manager.clients.key?(name)
141
+ # start_server already warned with the failure detail; just point at it.
142
+ return @ui.error("could not start MCP server #{name} (see warning above)") unless
143
+ manager.start_server(name, mcp_servers_config[name])
144
+
145
+ manager.register_server_tools(name)
146
+ count = mcp_tools_for(name).size
147
+ @ui.success("MCP server #{name} started — #{count} tool#{"s" if count != 1} registered.")
148
+ end
149
+
150
+ def reload_mcp
151
+ manager = Rubino::MCP.reload!
152
+ if manager.nil?
153
+ show_mcp_empty_state
154
+ return
155
+ end
156
+
157
+ @ui.success("MCP reloaded.")
158
+ show_mcp_list
159
+ end
160
+
161
+ # `<glyph> <word>` for a server's state (colored like agent_status_icon):
162
+ # green ● reachable, red ✗ down, yellow ◌ not started (no live client).
163
+ def mcp_status_icon(name)
164
+ entry = mcp_health.find { |h| h[:name] == name }
165
+ glyph, word, color =
166
+ if entry.nil? then ["◌", "not started", :yellow]
167
+ elsif entry[:alive] then ["●", "reachable", :green]
168
+ else ["✗", "down", :red]
169
+ end
170
+ "#{pastel.public_send(color, glyph)} #{word}"
171
+ end
172
+
173
+ # The configured mcp.servers block (name => config), {} when absent.
174
+ def mcp_servers_config
175
+ Rubino.configuration.dig("mcp", "servers") || {}
176
+ end
177
+
178
+ # Live reachability from the booted manager; [] when MCP never booted.
179
+ # Manager#health_check already rescues per client, so a wedged transport
180
+ # reports alive: false instead of raising.
181
+ def mcp_health
182
+ Rubino::MCP.manager&.health_check || []
183
+ end
184
+
185
+ # The registry wrappers a server contributed (prefixed tools).
186
+ def mcp_tools_for(server_name)
187
+ Tools::Registry.all.select do |tool|
188
+ tool.is_a?(Rubino::MCP::MCPToolWrapper) && tool.server_name == server_name
189
+ end
190
+ end
191
+
192
+ # Wraps "<head><description>" to the terminal width, breaking only on
193
+ # whitespace, with continuation lines indented to the description column.
194
+ def wrap_skill_line(head, description)
195
+ width = terminal_width
196
+ indent = " " * head.length
197
+ avail = [width - head.length, 20].max
198
+
199
+ lines = []
200
+ current = +""
201
+ description.split(/\s+/).each do |word|
202
+ candidate = current.empty? ? word : "#{current} #{word}"
203
+ if candidate.length > avail && !current.empty?
204
+ lines << current
205
+ current = word.dup
206
+ else
207
+ current = candidate
208
+ end
209
+ end
210
+ lines << current unless current.empty?
211
+ lines = [""] if lines.empty?
212
+
213
+ lines.each_with_index.map { |line, i| (i.zero? ? head : indent) + line }
214
+ end
215
+
216
+ def pastel
217
+ @pastel ||= Pastel.new
218
+ end
219
+
220
+ def terminal_width
221
+ cols = IO.console&.winsize&.last
222
+ cols&.positive? ? cols : 80
223
+ rescue StandardError
224
+ 80
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ module Handlers
6
+ # The `/memory` in-chat read/manage view over the *active* memory backend,
7
+ # extracted from Commands::Executor (batch B) — the same store the agent
8
+ # loop, the `rubino memory` CLI (#94) and the HTTP `/v1/memory` ops resolve
9
+ # via `Memory::Backends.build`. The agent's MemoryTool does autonomous
10
+ # writes; this is the human's window into it.
11
+ #
12
+ # /memory → backend + count + recent facts
13
+ # /memory --all → recent facts INCLUDING retired, marked (#184)
14
+ # /memory <query> → substring search over content
15
+ # /memory search <query> → same search, explicit subcommand
16
+ # /memory show <id> → one fact in full, with the temporal chain (#184)
17
+ # /memory forget <id> → delete a fact
18
+ # /memory backend → active + available backends (#184)
19
+ class Memory
20
+ def initialize(ui:)
21
+ @ui = ui
22
+ end
23
+
24
+ def handle_memory(arguments)
25
+ args = arguments.to_s.strip
26
+
27
+ if args.empty?
28
+ show_memory_summary
29
+ elsif args == "--all"
30
+ show_memory_summary(include_retired: true)
31
+ elsif args.match?(/\Ashow\b/)
32
+ id = args[/\Ashow\s+(\S+)\z/, 1]
33
+ id ? show_memory(id) : @ui.info("Usage: /memory show <id>")
34
+ elsif args.match?(/\Abackend\b/)
35
+ show_memory_backend(args[/\Abackend\s+(\S+)\z/, 1])
36
+ elsif args.match?(/\Aforget\b/)
37
+ id = args[/\Aforget\s+(\S+)\z/, 1]
38
+ id ? forget_memory(id) : @ui.info("Usage: /memory forget <id>")
39
+ elsif args.match?(/\Asearch\b/)
40
+ # `search` is a subcommand token, not a query term (#59): bare
41
+ # `/memory search` falls back to the summary instead of searching
42
+ # for the literal word "search".
43
+ query = args[/\Asearch\s+(.+)\z/, 1]
44
+ query ? search_memory(query) : show_memory_summary
45
+ else
46
+ search_memory(args)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # `/memory show <id>` (#184): a REAL id lookup (the store resolves the
53
+ # short-id prefix), not a substring search over content — an id used to
54
+ # match nothing. Rendering (incl. the temporal chain: Retired /
55
+ # Superseded by) is shared with the `rubino memory show` CLI verb.
56
+ def show_memory(id)
57
+ memory = memory_backend.find(id)
58
+ if memory.nil?
59
+ @ui.error("no fact with id #{id}.")
60
+ return
61
+ end
62
+
63
+ CLI::MemoryCommand.render(memory, ui: @ui)
64
+ end
65
+
66
+ # `/memory backend [name]` (#184): shows the active + available
67
+ # backends in-chat. SWITCHING stays CLI-only on purpose: every consumer
68
+ # (the lifecycle's retriever/flusher, the memory tool, this executor)
69
+ # memoizes its built backend, so an in-process flip would leave the live
70
+ # loop writing to the OLD store while /memory reads the new one — a
71
+ # half-applied switch. The CLI verb writes config and a restart applies
72
+ # it everywhere at once.
73
+ def show_memory_backend(name)
74
+ CLI::MemoryCommand.render_active_backend(ui: @ui)
75
+ return unless name
76
+
77
+ @ui.info("Switching is CLI-only: run `rubino memory backend #{name}` " \
78
+ "(a restart applies it to the whole agent).")
79
+ end
80
+
81
+ def show_memory_summary(include_retired: false)
82
+ store = memory_backend
83
+ backend = Rubino.configuration.dig("memory", "backend") || Rubino::Memory::Backends::DEFAULT_NAME
84
+ @ui.info("backend #{backend} · #{store.count} facts")
85
+
86
+ memories = store.list(limit: 10, include_retired: include_retired)
87
+ if memories.empty?
88
+ @ui.info("No facts stored yet — the agent records them as it learns about you.")
89
+ return
90
+ end
91
+
92
+ render_memory_table(memories)
93
+ @ui.info("/memory <query> · /memory show <id> · /memory forget <id>")
94
+ end
95
+
96
+ def search_memory(query)
97
+ needle = query.downcase
98
+ matches = memory_backend.list(limit: 200)
99
+ .select { |m| m[:content].to_s.downcase.include?(needle) }
100
+ if matches.empty?
101
+ @ui.info("No facts matching #{query.inspect}.")
102
+ return
103
+ end
104
+
105
+ shown = matches.first(20)
106
+ @ui.info(%(#{shown.length} match#{"es" if shown.length != 1} for #{query.inspect}))
107
+ # A targeted search must SHOW the matched fact in full — the list-view's
108
+ # narrow truncation hides exactly the part the user searched for (#85).
109
+ # Print each match's full content, wrapping to the terminal width.
110
+ shown.each { |m| render_memory_match(m) }
111
+ @ui.info("/memory forget <id> to delete one")
112
+ end
113
+
114
+ # One searched fact, content shown end-to-end (wrapped, never truncated).
115
+ def render_memory_match(memory)
116
+ head = "#{memory[:id].to_s[0..7]} #{memory[:kind]} "
117
+ content = memory[:content].to_s.gsub(/\s+/, " ").strip
118
+ wrap_skill_line(head, content).each { |line| @ui.info(line) }
119
+ end
120
+
121
+ def forget_memory(id)
122
+ store = memory_backend
123
+ memory = store.find(id)
124
+ if memory.nil?
125
+ @ui.error("no fact with id #{id}.")
126
+ return
127
+ end
128
+
129
+ # Destructive, irreversible delete — confirm first, default No (#218).
130
+ # A piped/Esc/EOF decline must NOT forget the fact.
131
+ confirmed = @ui.confirm_destructive(
132
+ %(Forget fact #{memory[:id][0..7]} "#{truncate(memory[:content], 60)}"? This cannot be undone.)
133
+ )
134
+ unless confirmed
135
+ @ui.info("Aborted.")
136
+ return
137
+ end
138
+
139
+ store.delete(memory[:id])
140
+ @ui.success(%(Forgot #{memory[:id][0..7]} "#{truncate(memory[:content], 60)}"))
141
+ end
142
+
143
+ # Resolve the *configured* memory backend (default: sqlite tiny-Zep), the
144
+ # same store the agent loop, the `rubino memory` CLI and the HTTP
145
+ # `/v1/memory` ops use. The old `Memory::Store.new` was hardwired to the
146
+ # legacy `:memories` table and ignored `memory.backend`, so in-chat
147
+ # `/memory` never saw the facts the agent actually persists (#106).
148
+ def memory_backend
149
+ @memory_backend ||= Rubino::Memory::Backends.build
150
+ end
151
+
152
+ # The retired tombstone marker is shared with `rubino memory list --all`
153
+ # (CLI::MemoryCommand.retired_marker) so both surfaces speak one dialect.
154
+ def render_memory_table(memories)
155
+ rows = memories.map do |m|
156
+ [m[:id].to_s[0..7], m[:kind].to_s,
157
+ "#{truncate(m[:content], 60)}#{CLI::MemoryCommand.retired_marker(m)}"]
158
+ end
159
+ @ui.table(headers: %w[ID Kind Content], rows: rows)
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 truncate(text, max)
187
+ s = text.to_s.gsub(/\s+/, " ").strip
188
+ s.length > max ? "#{s[0, max - 1]}…" : s
189
+ end
190
+
191
+ def terminal_width
192
+ cols = IO.console&.winsize&.last
193
+ cols&.positive? ? cols : 80
194
+ rescue StandardError
195
+ 80
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end