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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Rubino
6
+ module LLM
7
+ # Wraps a Rubino::Tools::Base instance into a RubyLLM::Tool subclass
8
+ # so that ruby_llm can register it, serialize its schema to the LLM, and
9
+ # dispatch tool calls through our full execution pipeline.
10
+ #
11
+ # When a ToolExecutor is provided (always the case in production), tool
12
+ # execution goes through:
13
+ # ApprovalPolicy → tool.call() → truncation → ToolCallRepository.record
14
+ #
15
+ # This ensures identical behavior regardless of LLM provider — there is
16
+ # now a single tool-execution path in the entire application.
17
+ module ToolBridge
18
+ # Returns a RubyLLM::Tool instance wrapping agent_tool.
19
+ def self.for(agent_tool, ui: nil, event_bus: nil, tool_executor: nil)
20
+ klass = bridge_class_for(agent_tool.name)
21
+ klass.new(agent_tool,
22
+ ui: ui || Rubino.ui,
23
+ event_bus: event_bus || Rubino.event_bus,
24
+ tool_executor: tool_executor)
25
+ end
26
+
27
+ def self.bridge_class_for(tool_name)
28
+ @cache ||= {}
29
+ @cache[tool_name] ||= build_class(tool_name)
30
+ end
31
+
32
+ def self.build_class(tool_name)
33
+ klass = Class.new(::RubyLLM::Tool) do
34
+ define_method(:name) { tool_name }
35
+
36
+ define_method(:initialize) do |agent_tool, ui:, event_bus:, tool_executor:|
37
+ @agent_tool = agent_tool
38
+ @ui = ui
39
+ @event_bus = event_bus
40
+ @tool_executor = tool_executor
41
+ end
42
+
43
+ define_method(:description) { @agent_tool.description }
44
+ define_method(:params_schema) { @agent_tool.input_schema }
45
+
46
+ define_method(:execute) do |**kwargs|
47
+ name = @agent_tool.name
48
+ args = kwargs.transform_keys(&:to_s)
49
+
50
+ if @tool_executor
51
+ # Full pipeline: approval check → tool.call → truncation → audit record
52
+ result = @tool_executor.execute(
53
+ name: name,
54
+ arguments: args,
55
+ call_id: nil
56
+ )
57
+ result.output
58
+ else
59
+ # Fallback: direct call (tests / one-shot mode without full Lifecycle)
60
+ @event_bus&.emit(Rubino::Interaction::Events::TOOL_STARTED, name: name)
61
+ @ui&.tool_started(name, arguments: args)
62
+
63
+ begin
64
+ output = @agent_tool.call(args)
65
+ result = Rubino::Tools::Result.success(
66
+ name: name, call_id: nil, output: output.to_s
67
+ )
68
+ @event_bus&.emit(Rubino::Interaction::Events::TOOL_FINISHED, name: name)
69
+ @ui&.tool_finished(name, result: result)
70
+ result.output
71
+ rescue StandardError => e
72
+ @event_bus&.emit(Rubino::Interaction::Events::TOOL_FINISHED, name: name)
73
+ @ui&.tool_finished(name)
74
+ "Error: #{e.message}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ const_name = "Bridge_#{tool_name.gsub(/[^a-zA-Z0-9]/, "_")}"
81
+ unless Rubino::LLM::ToolBridge.const_defined?(const_name, false)
82
+ Rubino::LLM::ToolBridge.const_set(const_name, klass)
83
+ end
84
+
85
+ klass
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+
6
+ module Rubino
7
+ # Structured JSON-line logger with built-in redaction of sensitive fields.
8
+ #
9
+ # Rubino.logger.info(event: "api.server.starting", port: 4820)
10
+ # #=> {"ts":"2026-05-31T...","level":"info","event":"api.server.starting","port":4820}
11
+ #
12
+ # Each level method (#debug, #info, #warn, #error, #fatal) takes **fields
13
+ # and emits one structured line per call.
14
+ #
15
+ # Configuration via environment:
16
+ # RUBINO_LOG_LEVEL — debug|info|warn|error|fatal (default: info)
17
+ # RUBINO_LOG_FORMAT — json|pretty (default: json)
18
+ #
19
+ # Redaction: any key whose name (case-insensitive) appears in REDACT_KEYS is
20
+ # replaced with REDACTED at any nesting depth before the line is serialized.
21
+ # This covers tokens, secrets, and raw Authorization headers passing through
22
+ # middleware logs.
23
+ class Logger
24
+ LEVELS = { debug: ::Logger::DEBUG, info: ::Logger::INFO, warn: ::Logger::WARN, error: ::Logger::ERROR,
25
+ fatal: ::Logger::FATAL }.freeze
26
+
27
+ # Keys (matched case-insensitively against String form) whose values are
28
+ # replaced with REDACTED before logging. Recursive — applies at any depth.
29
+ REDACT_KEYS = %w[
30
+ access_token refresh_token id_token
31
+ client_secret api_key password secret bearer
32
+ authorization http_authorization
33
+ ].freeze
34
+
35
+ # Replacement string written in place of redacted values.
36
+ REDACTED = "[REDACTED]"
37
+
38
+ def initialize(io: $stdout, level: ENV.fetch("RUBINO_LOG_LEVEL", "info"),
39
+ format: ENV.fetch("RUBINO_LOG_FORMAT", "json"))
40
+ @logger = ::Logger.new(io)
41
+ @logger.level = LEVELS.fetch(level.to_sym, ::Logger::INFO)
42
+ @format = format.to_sym
43
+ @logger.formatter = formatter
44
+ end
45
+
46
+ # Rebinds the underlying sink to a new IO (or path) WITHOUT replacing the
47
+ # Logger object, so existing references (and the memoized Rubino.logger)
48
+ # keep working. Level and format are preserved.
49
+ #
50
+ # The interactive CLI uses this to route structured JSON lines to a file
51
+ # instead of the terminal $stdout that the raw-mode TUI owns (#125):
52
+ # otherwise a warn/info event (e.g. a network blip during a background
53
+ # subagent) prints raw JSON into the rendered conversation and corrupts the
54
+ # bottom-composer frame. Returns the previous IO so the caller can restore
55
+ # it on exit.
56
+ def reopen(io)
57
+ previous = @logger.instance_variable_get(:@logdev)&.dev
58
+ @logger.reopen(io)
59
+ previous
60
+ end
61
+
62
+ LEVELS.each_key do |level|
63
+ define_method(level) do |**fields|
64
+ @logger.public_send(level) { self.class.redact(fields) }
65
+ end
66
+ end
67
+
68
+ # Recursively walk a value, masking entries whose key matches REDACT_KEYS.
69
+ # Hash and Array are descended; scalars pass through unchanged.
70
+ # Public because middleware and tests call it directly.
71
+ #
72
+ # @param value [Object] any value (typically Hash, Array, or scalar)
73
+ # @return [Object] same shape as input with sensitive values replaced by REDACTED
74
+ def self.redact(value)
75
+ case value
76
+ when Hash
77
+ value.each_with_object({}) do |(k, v), out|
78
+ out[k] = REDACT_KEYS.include?(k.to_s.downcase) ? REDACTED : redact(v)
79
+ end
80
+ when Array
81
+ value.map { |v| redact(v) }
82
+ else
83
+ value
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def formatter
90
+ if @format == :pretty
91
+ ->(severity, time, _progname, fields) { "[#{time.iso8601}] #{severity.downcase} #{fields.inspect}\n" }
92
+ else
93
+ lambda { |severity, time, _progname, fields|
94
+ "#{JSON.generate({ ts: time.iso8601, level: severity.downcase }.merge(fields))}\n"
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/mcp"
4
+
5
+ module Rubino
6
+ module MCP
7
+ # Manages multiple MCP client connections.
8
+ # Reads server definitions from config, starts clients,
9
+ # and registers their tools into the agent's tool registry.
10
+ class Manager
11
+ # clients: name => live RubyLLM::MCP client.
12
+ # last_errors: name => the most recent start failure message (cleared on a
13
+ # successful start) — the "why is my server missing?" answer /mcp's
14
+ # drill-in shows (#182).
15
+ attr_reader :clients, :last_errors
16
+
17
+ def initialize(config: nil)
18
+ @config = config || Rubino.configuration
19
+ @clients = {}
20
+ @last_errors = {}
21
+ route_mcp_logging!
22
+ end
23
+
24
+ # Initializes all configured MCP servers
25
+ def start_all!
26
+ server_configs = @config.dig("mcp", "servers") || {}
27
+
28
+ server_configs.each do |name, server_config|
29
+ start_server(name, server_config)
30
+ end
31
+
32
+ register_all_tools!
33
+ @clients
34
+ end
35
+
36
+ # Starts a single MCP server by name
37
+ def start_server(name, server_config)
38
+ transport = server_config["transport"] || "stdio"
39
+ client_opts = build_client_options(name, transport, server_config)
40
+
41
+ client = RubyLLM::MCP.client(**client_opts)
42
+ @clients[name.to_s] = client
43
+ @last_errors.delete(name.to_s)
44
+
45
+ Rubino.event_bus.emit(:mcp_server_started, name: name)
46
+ client
47
+ rescue StandardError => e
48
+ @last_errors[name.to_s] = e.message
49
+ Rubino.ui.warning("MCP server '#{name}' failed to start: #{e.message}")
50
+ nil
51
+ end
52
+
53
+ # Stops all MCP clients (deregistering their tools — see #stop_server).
54
+ # `keys.each`, NOT `each_key`: stop_server deletes from @clients, which
55
+ # would raise mid-iteration without the snapshot.
56
+ def stop_all!
57
+ @clients.keys.each { |name| stop_server(name) } # rubocop:disable Style/HashEachMethods
58
+ end
59
+
60
+ # Stops a specific MCP client AND deregisters its MCPToolWrapper
61
+ # instances from Tools::Registry (#182) — before, nothing ever
62
+ # unregistered them, so a stopped server left dead tools the model could
63
+ # still call.
64
+ def stop_server(name)
65
+ client = @clients.delete(name.to_s)
66
+ return nil unless client
67
+
68
+ deregister_tools(name.to_s)
69
+ begin
70
+ client.stop
71
+ rescue StandardError => e
72
+ Rubino.ui.warning("Error stopping MCP '#{name}': #{e.message}")
73
+ end
74
+ Rubino.event_bus.emit(:mcp_server_stopped, name: name)
75
+ client
76
+ end
77
+
78
+ # Registers all MCP tools into the agent's tool registry.
79
+ # Per-agent mcp_servers scoping is NOT applied here — it lives in
80
+ # Agent::Definition#resolved_tools (#173), the single seam every
81
+ # consumer of an agent's tool set goes through.
82
+ def register_all_tools!
83
+ @clients.each_key { |server_name| register_server_tools(server_name) }
84
+ end
85
+
86
+ # Registers ONE started server's tools — the `/mcp <server> on` path
87
+ # (#182) re-registers only that server instead of re-reading every
88
+ # client's tool list.
89
+ def register_server_tools(name)
90
+ client = @clients[name.to_s]
91
+ return unless client
92
+
93
+ client.tools.each do |mcp_tool|
94
+ wrapped = MCPToolWrapper.new(mcp_tool, server_name: name.to_s)
95
+ Tools::Registry.register(wrapped)
96
+ end
97
+ rescue StandardError => e
98
+ Rubino.ui.warning("Failed to load tools from '#{name}': #{e.message}")
99
+ end
100
+
101
+ # Checks health of all connected servers
102
+ def health_check
103
+ @clients.map do |name, client|
104
+ alive = begin
105
+ client.alive?
106
+ rescue StandardError
107
+ false
108
+ end
109
+ { name: name, alive: alive }
110
+ end
111
+ end
112
+
113
+ # Returns true if any MCP servers are configured
114
+ def configured?
115
+ servers = @config.dig("mcp", "servers")
116
+ servers.is_a?(Hash) && !servers.empty?
117
+ end
118
+
119
+ private
120
+
121
+ # Drops a stopped server's wrappers from the registry (keyed by the
122
+ # prefixed tool name, so only that server's entries match).
123
+ def deregister_tools(server_name)
124
+ Tools::Registry.all.each do |tool|
125
+ next unless tool.is_a?(MCPToolWrapper) && tool.server_name == server_name
126
+
127
+ Tools::Registry.unregister(tool.name)
128
+ end
129
+ end
130
+
131
+ # ruby_llm-mcp logs to $stdout by default — including every line the
132
+ # stdio server prints on ITS stderr (e.g. "Secure MCP Filesystem Server
133
+ # running on stdio"), relayed at INFO. That raw logger line pollutes
134
+ # one-shot `rubino prompt` output, doctor, tools and the chat banner
135
+ # (#174 — same class as the fixed #99). Route the gem's logger to a file
136
+ # under the resolved home, next to RUBYLLM_DEBUG's ruby_llm.log.
137
+ def route_mcp_logging!
138
+ log_path = File.join(Config::Loader.default_home_path, "logs", "mcp.log")
139
+ FileUtils.mkdir_p(File.dirname(log_path))
140
+ RubyLLM::MCP.config.logger = ::Logger.new(log_path, progname: "RubyLLM::MCP", level: ::Logger::INFO)
141
+ rescue StandardError
142
+ # Logging is never worth breaking MCP boot; worst case the gem keeps
143
+ # its default logger.
144
+ nil
145
+ end
146
+
147
+ def build_client_options(name, transport, server_config)
148
+ opts = {
149
+ name: name.to_s,
150
+ transport_type: transport.to_sym
151
+ }
152
+
153
+ case transport
154
+ when "stdio"
155
+ opts[:config] = {
156
+ command: server_config["command"],
157
+ args: server_config["args"] || [],
158
+ env: server_config["env"] || {}
159
+ }
160
+ when "sse"
161
+ opts[:config] = {
162
+ url: server_config["url"],
163
+ headers: server_config["headers"] || {}
164
+ }
165
+ when "streamable"
166
+ opts[:config] = {
167
+ url: server_config["url"],
168
+ headers: server_config["headers"] || {}
169
+ }
170
+ opts[:config][:oauth] = server_config["oauth"] if server_config["oauth"]
171
+ end
172
+
173
+ # Optional: request timeout
174
+ opts[:request_timeout] = server_config["timeout"] if server_config["timeout"]
175
+
176
+ opts
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module MCP
5
+ # Wraps an MCP tool (from ruby_llm-mcp) into the Rubino::Tools::Base interface.
6
+ # This allows MCP tools to be used seamlessly alongside built-in tools.
7
+ class MCPToolWrapper < Tools::Base
8
+ attr_reader :mcp_tool, :server_name
9
+
10
+ def initialize(mcp_tool, server_name:)
11
+ @mcp_tool = mcp_tool
12
+ @server_name = server_name
13
+ end
14
+
15
+ def name
16
+ # Prefix with server name to avoid collisions
17
+ "#{@server_name}_#{@mcp_tool.name}"
18
+ end
19
+
20
+ def description
21
+ @mcp_tool.description
22
+ end
23
+
24
+ def input_schema
25
+ # The server-advertised JSON schema lives in RubyLLM::MCP::Tool#params_schema.
26
+ # The inherited RubyLLM::Tool#parameters DSL accessor is ALWAYS empty for
27
+ # MCP tools — forwarding it sent every tool to the model with `parameters:
28
+ # {}`, so the model had to guess argument names and every call failed
29
+ # server-side validation with -32602 (#170).
30
+ schema = @mcp_tool.params_schema if @mcp_tool.respond_to?(:params_schema)
31
+ schema || { type: "object", properties: {} }
32
+ end
33
+
34
+ def risk_level
35
+ # MCP tools are external, default to medium risk
36
+ :medium
37
+ end
38
+
39
+ def call(arguments)
40
+ result = @mcp_tool.execute(**symbolize_keys(arguments))
41
+ # ruby_llm-mcp reports tool failures by RETURNING `{ error: "…" }`
42
+ # instead of raising. Map both failure paths onto the registry's
43
+ # "Error: …" convention (Tools::Result#errorish?) so an errored MCP
44
+ # call renders ✗ like any built-in tool, not "✓ done" (#172).
45
+ error = result[:error] || result["error"] if result.is_a?(Hash)
46
+ return "Error: MCP tool #{@server_name}/#{@mcp_tool.name}: #{error}" if error
47
+
48
+ result.to_s
49
+ rescue StandardError => e
50
+ "Error: MCP tool #{@server_name}/#{@mcp_tool.name}: #{e.message}"
51
+ end
52
+
53
+ # Override to provide the raw MCP tool definition for LLM
54
+ def to_tool_definition
55
+ {
56
+ name: name,
57
+ description: description,
58
+ parameters: input_schema
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def symbolize_keys(hash)
65
+ hash.transform_keys(&:to_sym)
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/rubino/mcp.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # MCP (Model Context Protocol) integration module.
5
+ # Manages connections to MCP servers and exposes their tools
6
+ # to the agent via the standard Tools::Registry.
7
+ module MCP
8
+ class << self
9
+ # The shared, booted Manager (nil until boot! succeeds).
10
+ attr_reader :manager
11
+
12
+ # MCP is opt-in by configuration (#95): a non-empty `mcp.servers`
13
+ # block enables it; an explicit `mcp.enabled: false` switches it off
14
+ # without deleting the server definitions.
15
+ def enabled?(config = Rubino.configuration)
16
+ servers = config.dig("mcp", "servers")
17
+ return false unless servers.is_a?(Hash) && !servers.empty?
18
+
19
+ config.dig("mcp", "enabled") != false
20
+ end
21
+
22
+ # Boots the shared Manager once per process: connects to every
23
+ # configured server and registers their prefixed tools in
24
+ # Tools::Registry (#91). Best-effort — MCP is an optional
25
+ # integration and must never break boot, so any failure is a
26
+ # warning, not an error.
27
+ def boot!
28
+ return @manager if @manager
29
+ return nil unless enabled?
30
+
31
+ manager = Manager.new
32
+ manager.start_all!
33
+ @manager = manager
34
+ rescue StandardError => e
35
+ Rubino.ui.warning("MCP startup failed: #{e.message}")
36
+ nil
37
+ end
38
+
39
+ # `/mcp reload` (#182): stop every server (deregistering their tools),
40
+ # drop the memoized Manager, re-read config.yml fresh and boot again —
41
+ # so a server added to config becomes usable without restarting chat.
42
+ # Returns the new Manager, or nil when the re-read config leaves MCP
43
+ # disabled (no servers / mcp.enabled: false).
44
+ def reload!
45
+ @manager&.stop_all!
46
+ @manager = nil
47
+ Rubino.reload_configuration!
48
+ boot!
49
+ end
50
+
51
+ # Clears the booted Manager (used by tests).
52
+ def reset!
53
+ @manager = nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Duck-typed contract for a pluggable memory backend.
6
+ #
7
+ # A backend owns the WRITE path (store / replace / forget / extract), the
8
+ # READ path the prompt assembler depends on (user_profile / project_context
9
+ # / retrieve), and the admin surface that powers `rubino memory ...`
10
+ # (list / find). The method set is the union of what the rest of the gem
11
+ # already calls today — extracting this interface is a mechanical refactor,
12
+ # not a rewrite.
13
+ #
14
+ # The injection-defense floor (ThreatScanner + the char-budget enforced in
15
+ # Memory::Store) lives in the shared write path, so no backend can splice
16
+ # tainted or over-budget content into a future system prompt. Concrete
17
+ # backends override only what they need; the base raises NotImplementedError
18
+ # for the operations that have no sensible default.
19
+ class Backend
20
+ # Backend registry key (e.g. "default"). Subclasses must override.
21
+ def self.backend_name
22
+ raise NotImplementedError, "#{self} must define .backend_name"
23
+ end
24
+
25
+ def initialize(config: nil)
26
+ @config = config || Rubino.configuration
27
+ end
28
+
29
+ # Deps present + configured (no network). Backends with optional
30
+ # dependencies override this; the default is always available.
31
+ def available?
32
+ true
33
+ end
34
+
35
+ # -- WRITE path --
36
+
37
+ # Persist one memory entry. Returns the stored row (Hash) or raises a
38
+ # Memory::Store::ThreatDetectedError / BudgetExceededError on refusal.
39
+ def store(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
40
+ raise NotImplementedError, "#{self.class} must implement #store"
41
+ end
42
+
43
+ # Replace the content of the first entry of `kind` whose content includes
44
+ # `old_text`. Returns the matched row, or nil if nothing matched.
45
+ def replace(kind:, old_text:, content:)
46
+ raise NotImplementedError, "#{self.class} must implement #replace"
47
+ end
48
+
49
+ # Delete the first entry of `kind` whose content includes `old_text`.
50
+ # Returns the matched row, or nil if nothing matched.
51
+ def forget(kind:, old_text:)
52
+ raise NotImplementedError, "#{self.class} must implement #forget"
53
+ end
54
+
55
+ # Mine a session's messages for durable facts and persist them.
56
+ # Returns the list of stored entries.
57
+ def extract(session_id)
58
+ raise NotImplementedError, "#{self.class} must implement #extract"
59
+ end
60
+
61
+ # -- READ path (consumed by lifecycle#load_memory -> PromptAssembler) --
62
+
63
+ # User-profile text (String) or nil.
64
+ def user_profile
65
+ raise NotImplementedError, "#{self.class} must implement #user_profile"
66
+ end
67
+
68
+ # Project-context text (String) or nil.
69
+ def project_context
70
+ raise NotImplementedError, "#{self.class} must implement #project_context"
71
+ end
72
+
73
+ # Memories relevant to the turn. `query` lets a relevance-aware backend
74
+ # rank by the last user message; the default backend ignores it and
75
+ # returns everything that fits, exactly as today. Returns an array of
76
+ # rows ([{id:, kind:, content:, ...}]).
77
+ def retrieve(session_id:, query: nil)
78
+ raise NotImplementedError, "#{self.class} must implement #retrieve"
79
+ end
80
+
81
+ # -- admin (powers `rubino memory list/show/delete`) --
82
+
83
+ # Live entries only by default; `include_retired: true` opts into the
84
+ # supersession history on backends that soft-retire (sqlite).
85
+ def list(kind: nil, limit: 20, include_retired: false)
86
+ raise NotImplementedError, "#{self.class} must implement #list"
87
+ end
88
+
89
+ def find(id)
90
+ raise NotImplementedError, "#{self.class} must implement #find"
91
+ end
92
+
93
+ def delete(id)
94
+ raise NotImplementedError, "#{self.class} must implement #delete"
95
+ end
96
+
97
+ # Total number of stored memories. Powers the CLI /status line and the
98
+ # web dashboard's memory card via GET /v1/memory/stats.
99
+ def count
100
+ raise NotImplementedError, "#{self.class} must implement #count"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ module Backends
6
+ # The default memory backend: a thin façade over the existing
7
+ # Store / Retriever / Extractor. Behavior is byte-identical to the
8
+ # pre-pluggable implementation — every call delegates to the same code
9
+ # paths (and therefore the same ThreatScanner + char-budget guards in
10
+ # Memory::Store) that the seams called directly before.
11
+ #
12
+ # Named "default" because it is SQLite-table-backed today but is the
13
+ # baseline every install gets unless `memory.backend` is changed.
14
+ class Default < Backend
15
+ def self.backend_name
16
+ "default"
17
+ end
18
+
19
+ def initialize(config: nil, store: nil, retriever: nil)
20
+ super(config: config)
21
+ @store = store || Store.new(config: @config)
22
+ @retriever = retriever || Retriever.new(store: @store, config: @config)
23
+ end
24
+
25
+ # -- WRITE path --
26
+
27
+ def store(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
28
+ @store.create(
29
+ kind: kind,
30
+ content: content,
31
+ source_session_id: source_session_id,
32
+ confidence: confidence,
33
+ metadata: metadata
34
+ )
35
+ end
36
+
37
+ def replace(kind:, old_text:, content:)
38
+ target = find_by_substring(kind, old_text)
39
+ return nil unless target
40
+
41
+ @store.update(target[:id], content: content)
42
+ target
43
+ end
44
+
45
+ def forget(kind:, old_text:)
46
+ target = find_by_substring(kind, old_text)
47
+ return nil unless target
48
+
49
+ @store.delete(target[:id])
50
+ target
51
+ end
52
+
53
+ def extract(session_id)
54
+ Extractor.new(store: @store).extract_from_session(session_id)
55
+ end
56
+
57
+ # -- READ path --
58
+
59
+ def user_profile
60
+ @retriever.user_profile
61
+ end
62
+
63
+ def project_context
64
+ @retriever.project_context
65
+ end
66
+
67
+ # `query` is accepted for contract compatibility but ignored — the
68
+ # default backend returns "everything that fits", exactly as today.
69
+ def retrieve(session_id:, query: nil)
70
+ @retriever.relevant_for_session(session_id)
71
+ end
72
+
73
+ # -- admin --
74
+
75
+ # The legacy store hard-deletes on replace — there are no retired
76
+ # rows, so `include_retired` is accepted for contract parity only.
77
+ def list(kind: nil, limit: 20, include_retired: false)
78
+ @store.list(kind: kind, limit: limit)
79
+ end
80
+
81
+ def find(id)
82
+ @store.find(id)
83
+ end
84
+
85
+ def delete(id)
86
+ @store.delete(id)
87
+ end
88
+
89
+ def count
90
+ @store.count
91
+ end
92
+
93
+ private
94
+
95
+ def find_by_substring(kind, needle)
96
+ @store.by_kind(kind, limit: 500).find { |m| m[:content].to_s.include?(needle.to_s) }
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end