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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Prevents duplicate memories from being stored.
6
+ # Uses content similarity to detect duplicates.
7
+ class Deduplicator
8
+ # Similarity threshold (0.0 to 1.0) - above this is considered duplicate
9
+ SIMILARITY_THRESHOLD = 0.85
10
+
11
+ def initialize(store: nil)
12
+ @store = store || Store.new
13
+ end
14
+
15
+ # Returns true if a similar memory already exists
16
+ def duplicate?(kind:, content:)
17
+ existing = @store.by_kind(kind, limit: 100)
18
+ existing.any? { |m| similar?(m[:content], content) }
19
+ end
20
+
21
+ # Removes duplicate memories, keeping the highest confidence version
22
+ def deduplicate_all!
23
+ removed = 0
24
+ Store::VALID_KINDS.each do |kind|
25
+ removed += deduplicate_kind(kind)
26
+ end
27
+ removed
28
+ end
29
+
30
+ private
31
+
32
+ def similar?(text_a, text_b)
33
+ return true if text_a == text_b
34
+
35
+ # Simple Jaccard similarity on word sets
36
+ words_a = text_a.downcase.split(/\W+/).to_set
37
+ words_b = text_b.downcase.split(/\W+/).to_set
38
+
39
+ return false if words_a.empty? || words_b.empty?
40
+
41
+ intersection = (words_a & words_b).size
42
+ union = (words_a | words_b).size
43
+
44
+ (intersection.to_f / union) >= SIMILARITY_THRESHOLD
45
+ end
46
+
47
+ def deduplicate_kind(kind)
48
+ memories = @store.by_kind(kind, limit: 500)
49
+ to_remove = []
50
+
51
+ memories.each_with_index do |mem, i|
52
+ next if to_remove.include?(mem[:id])
53
+
54
+ memories[(i + 1)..].each do |other|
55
+ next if to_remove.include?(other[:id])
56
+
57
+ if similar?(mem[:content], other[:content])
58
+ # Keep the one with higher confidence
59
+ if mem[:confidence] >= (other[:confidence] || 0)
60
+ to_remove << other[:id]
61
+ else
62
+ to_remove << mem[:id]
63
+ break
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ to_remove.each { |id| @store.delete(id) }
70
+ to_remove.size
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Extracts potential memories from conversation history.
6
+ # Identifies facts, preferences, decisions, and other memorable items.
7
+ class Extractor
8
+ # Patterns that suggest extractable memories
9
+ PREFERENCE_PATTERNS = [
10
+ /(?:I prefer|I like|I always|I never|I usually|my preferred)/i,
11
+ /(?:please always|please never|don't ever|always use|never use)/i
12
+ ].freeze
13
+
14
+ DECISION_PATTERNS = [
15
+ /(?:we decided|the decision is|let's go with|I chose|we'll use)/i,
16
+ /(?:the approach is|the strategy is|we agreed on)/i
17
+ ].freeze
18
+
19
+ def initialize(store: nil)
20
+ @store = store || Store.new
21
+ end
22
+
23
+ # Extracts memories from a session's messages
24
+ def extract_from_session(session_id)
25
+ message_store = Session::Store.new
26
+ messages = message_store.for_session(session_id)
27
+ extracted = []
28
+
29
+ messages.each do |msg|
30
+ next unless %w[user assistant].include?(msg.role)
31
+ next if msg.content.nil? || msg.content.empty?
32
+
33
+ memories = extract_from_content(msg.content, session_id)
34
+ extracted.concat(memories)
35
+ end
36
+
37
+ extracted
38
+ end
39
+
40
+ # Extracts memories from a single content string
41
+ def extract_from_content(content, session_id = nil)
42
+ memories = []
43
+
44
+ # Check for preferences
45
+ if matches_patterns?(content, PREFERENCE_PATTERNS)
46
+ memories << save_memory(
47
+ kind: "preference",
48
+ content: content.strip[0..500],
49
+ session_id: session_id
50
+ )
51
+ end
52
+
53
+ # Check for technical decisions
54
+ if matches_patterns?(content, DECISION_PATTERNS)
55
+ memories << save_memory(
56
+ kind: "technical_decision",
57
+ content: content.strip[0..500],
58
+ session_id: session_id
59
+ )
60
+ end
61
+
62
+ memories.compact
63
+ end
64
+
65
+ private
66
+
67
+ def matches_patterns?(content, patterns)
68
+ patterns.any? { |p| content.match?(p) }
69
+ end
70
+
71
+ def save_memory(kind:, content:, session_id:)
72
+ # Check for duplicates before saving
73
+ deduplicator = Deduplicator.new(store: @store)
74
+ return nil if deduplicator.duplicate?(kind: kind, content: content)
75
+
76
+ @store.create(
77
+ kind: kind,
78
+ content: content,
79
+ source_session_id: session_id,
80
+ confidence: 0.8
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Flushes working memory to persistent storage before compaction.
6
+ # Ensures no important information is lost when context is compressed.
7
+ class Flusher
8
+ def initialize(backend: nil)
9
+ @backend = backend
10
+ end
11
+
12
+ # Flushes all pending memories for a session before compaction.
13
+ # Routes through the configured backend's extract path so compaction
14
+ # mines facts with the same backend the rest of the gem uses.
15
+ def flush_before_compaction!(session_id)
16
+ extracted = backend.extract(session_id)
17
+
18
+ {
19
+ flushed_count: extracted.size,
20
+ session_id: session_id
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def backend
27
+ @backend ||= Backends.build
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Retrieves relevant memories for inclusion in prompts.
6
+ # Handles user profile, project context, and session-relevant memories.
7
+ class Retriever
8
+ def initialize(store: nil, config: nil)
9
+ @store = store || Store.new
10
+ @config = config || Rubino.configuration
11
+ end
12
+
13
+ # Returns the user profile text (concatenated user_profile memories)
14
+ def user_profile
15
+ return nil unless @config.dig("memory", "user_profile_enabled")
16
+
17
+ char_limit = @config.memory_user_char_limit
18
+ memories = @store.by_kind("user_profile")
19
+
20
+ text = memories.map { |m| m[:content] }.join("\n")
21
+ text.length > char_limit ? text[0...char_limit] : text
22
+ end
23
+
24
+ # Returns project context memories
25
+ def project_context
26
+ return nil unless @config.dig("memory", "project_context_enabled")
27
+
28
+ memories = @store.by_kind("project_context", limit: 10)
29
+ return nil if memories.empty?
30
+
31
+ memories.map { |m| m[:content] }.join("\n")
32
+ end
33
+
34
+ # Returns memories relevant to the current session context
35
+ def relevant_for_session(_session_id)
36
+ char_limit = @config.memory_char_limit
37
+ @store.within_limit(char_limit: char_limit)
38
+ end
39
+
40
+ # Returns all memories formatted for prompt inclusion
41
+ def for_prompt
42
+ {
43
+ user_profile: user_profile,
44
+ project_context: project_context,
45
+ general: @store.within_limit(char_limit: @config.memory_char_limit)
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # The single aux-LLM extraction prompt for the Sqlite backend. Collapses
6
+ # Zep's six-step ingestion (entity/fact/temporal extraction + invalidation)
7
+ # into ONE structured call: given the latest turn and the currently-live
8
+ # facts, the model returns durable atomic facts to `add` and contradicted
9
+ # facts to `supersede`. The doctrine ("durable declarative facts, not
10
+ # imperatives, not stale artifacts") is lifted from the reference MEMORY_GUIDANCE.
11
+ module SqliteExtractionPrompt
12
+ KINDS = %w[user_profile preference project fact env].freeze
13
+
14
+ SYSTEM = <<~PROMPT
15
+ You maintain a long-term memory of durable facts about the user and their project.
16
+ You will see the latest conversation turn and the facts already in memory.
17
+
18
+ Extract only DURABLE facts worth remembering across sessions:
19
+ - user identity, preferences, and recurring corrections (highest value — they reduce future steering)
20
+ - stable project/environment conventions and tool quirks
21
+ Write each as ONE atomic declarative fact in the third person, present tense.
22
+ GOOD: "User prefers concise answers without preamble."
23
+ GOOD: "Project uses pytest with the xdist plugin."
24
+ BAD (imperative): "Always answer concisely." BAD (procedure): "Run tests with pytest -n 4."
25
+ BAD (stale artifact): "Fixed bug #4821." "Opened PR 90." "Phase 2 done."
26
+ If a fact will be stale within a week (PR/issue/commit numbers, task progress, TODO state), DO NOT save it.
27
+ Procedures and how-to workflows are NOT memory — skip them.
28
+
29
+ SUPERSEDE: if a new fact CONTRADICTS an existing one (same subject, changed value),
30
+ emit it under "supersede" with the id of the fact it replaces. Prefer the newer information.
31
+ Tag each fact with 1-4 lowercase entity keywords (people, tools, projects) for retrieval.
32
+
33
+ EDGES (optional, light): if the turn states a clear RELATIONSHIP between two of the
34
+ entities you tagged, emit it under "edges" as {"src","relation","dst"} with a short
35
+ lowercase relation (e.g. uses, deploys_to, written_in, runs_on, depends_on). Keep it to
36
+ the few obvious relations the turn actually asserts — do not invent links. These let a
37
+ later query like "what does X use for Y" reach the connected fact. Omit when unsure.
38
+
39
+ Return STRICT JSON, no prose:
40
+ { "add": [ {"text": "...", "kind": "preference|user_profile|project|fact|env",
41
+ "entities": ["..."], "valid_from": "<ISO8601 or null>"} ],
42
+ "supersede": [ {"id": "<existing fact id>", "by_text": "...", "kind": "...",
43
+ "entities": ["..."]} ],
44
+ "edges": [ {"src": "...", "relation": "...", "dst": "..."} ] }
45
+ If nothing is worth saving, return {"add": [], "supersede": [], "edges": []}.
46
+ PROMPT
47
+
48
+ module_function
49
+
50
+ # Builds the USER message: reference timestamp, the live fact set (already
51
+ # char-capped by the caller), and the latest turn rendered as a transcript.
52
+ def user_message(now:, live_facts:, turn:)
53
+ live = if live_facts.empty?
54
+ "(none)"
55
+ else
56
+ live_facts.map { |f| "#{f[:id]} | #{f[:kind]} | #{f[:text]}" }.join("\n")
57
+ end
58
+
59
+ <<~MSG
60
+ Reference timestamp: #{now}
61
+ Existing live facts:
62
+ #{live}
63
+
64
+ Latest turn:
65
+ #{turn}
66
+ MSG
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Rubino
7
+ module Memory
8
+ # Graph-lite layer for the Sqlite backend (Memory Phase 3b).
9
+ #
10
+ # A thin mixin over two tables (memory_entities + memory_edges) that turns
11
+ # the per-fact entity tags into a tiny knowledge graph and blends a bounded
12
+ # 1-hop traversal into retrieval. NOT a graph DB — just entity resolution by
13
+ # normalized name and a bounded join over edges.
14
+ #
15
+ # Edges are populated two ways, both cheap (no extra LLM call beyond the
16
+ # single extraction call the backend already makes):
17
+ # * DETERMINISTIC co-occurrence — every pair of entities tagged on the
18
+ # same fact gets a `co_occurs` edge (free, derived from `entities_json`).
19
+ # * TYPED relations — the extraction LLM optionally returns `edges:
20
+ # [{src, relation, dst}]` in the SAME structured call, so the typed
21
+ # graph costs 0 additional calls/turn.
22
+ #
23
+ # Edges are bi-temporal like facts: a contradicting relation soft-retires
24
+ # the old edge (valid_to set), it is not deleted.
25
+ module SqliteGraph
26
+ ENTITIES = :memory_entities
27
+ EDGES = :memory_edges
28
+ CO_OCCURS = "co_occurs"
29
+
30
+ # ---- node resolution ----
31
+
32
+ # Resolve (find-or-create) an entity node by normalized name, returning
33
+ # its id. Same name from different facts collapses to one node.
34
+ def resolve_entity(name, kind: nil)
35
+ norm = normalize_entity_name(name)
36
+ return nil if norm.empty?
37
+
38
+ existing = @db[ENTITIES].where(name_norm: norm).first
39
+ return existing[:id] if existing
40
+
41
+ now = Time.now.utc.iso8601
42
+ id = SecureRandom.uuid
43
+ @db[ENTITIES].insert(
44
+ id: id, name: name.to_s.strip, name_norm: norm, kind: kind,
45
+ created_at: now, updated_at: now
46
+ )
47
+ id
48
+ rescue Sequel::UniqueConstraintViolation
49
+ # Concurrent insert: re-read the winner.
50
+ @db[ENTITIES].where(name_norm: norm).get(:id)
51
+ end
52
+
53
+ def normalize_entity_name(name)
54
+ name.to_s.strip.downcase.gsub(/\s+/, " ")
55
+ end
56
+
57
+ # ---- edge population ----
58
+
59
+ # Wire the graph for a freshly-inserted fact: upsert its entity nodes,
60
+ # connect every co-occurring pair with a co_occurs edge, and add any
61
+ # typed relations the extractor emitted for this fact. Bounded and free
62
+ # of extra LLM calls. `typed` is an array of {src, relation, dst} hashes.
63
+ def index_fact_graph(fact_id, entities, typed: [])
64
+ ids = Array(entities).filter_map { |e| resolve_entity(e) }.uniq
65
+ ids.combination(2).each { |a, b| upsert_edge(a, b, CO_OCCURS, fact_id) }
66
+
67
+ Array(typed).each do |edge|
68
+ src = resolve_entity(edge["src"] || edge[:src])
69
+ dst = resolve_entity(edge["dst"] || edge[:dst])
70
+ rel = (edge["relation"] || edge[:relation]).to_s.strip.downcase
71
+ next if src.nil? || dst.nil? || src == dst || rel.empty?
72
+
73
+ # A changed typed relation between the SAME pair supersedes the old
74
+ # one (e.g. "uses postgres" -> "uses sqlite" is handled at the fact
75
+ # level; here we keep the latest relation label live).
76
+ supersede_edge(src, dst, rel)
77
+ upsert_edge(src, dst, rel, fact_id)
78
+ end
79
+ end
80
+
81
+ # Insert a live edge unless an identical live edge already exists
82
+ # (idempotent). Co_occurs edges are undirected in effect: we store the
83
+ # canonical ordering for the pair so the de-dup works both ways.
84
+ def upsert_edge(src, dst, relation, source_fact_id)
85
+ a, b = relation == CO_OCCURS ? [src, dst].minmax : [src, dst]
86
+ return if @db[EDGES].where(
87
+ src_entity_id: a, dst_entity_id: b, relation: relation, valid_to: nil
88
+ ).count.positive?
89
+
90
+ now = Time.now.utc.iso8601
91
+ @db[EDGES].insert(
92
+ id: SecureRandom.uuid, src_entity_id: a, dst_entity_id: b,
93
+ relation: relation, source_fact_id: source_fact_id,
94
+ valid_from: now, valid_to: nil, superseded_by: nil,
95
+ created_at: now, updated_at: now
96
+ )
97
+ end
98
+
99
+ # Soft-retire any live typed edge between src->dst whose relation differs,
100
+ # so a contradicting relation supersedes the old one (history kept).
101
+ def supersede_edge(src, dst, _relation)
102
+ @db[EDGES].where(src_entity_id: src, dst_entity_id: dst, valid_to: nil)
103
+ .exclude(relation: CO_OCCURS)
104
+ .update(valid_to: Time.now.utc.iso8601, updated_at: Time.now.utc.iso8601)
105
+ end
106
+
107
+ # ---- 1-hop traversal ----
108
+
109
+ # Given query text, find seed entities whose name appears in the query,
110
+ # walk LIVE edges out one hop to neighbor entities, and return the ids of
111
+ # LIVE facts tagged with any seed-or-neighbor entity. This surfaces facts
112
+ # connected through a relation that pure FTS on the probe would miss.
113
+ # Bounded: capped seeds, single hop, capped fact scan.
114
+ def graph_neighbors(query, limit)
115
+ seeds = seed_entities(query)
116
+ return [] if seeds.empty?
117
+
118
+ # 1-hop: neighbors reachable via a live edge in either direction.
119
+ neighbor_ids = @db[EDGES]
120
+ .where(valid_to: nil)
121
+ .where(Sequel.|({ src_entity_id: seeds }, { dst_entity_id: seeds }))
122
+ .select_map(%i[src_entity_id dst_entity_id])
123
+ .flatten.uniq
124
+
125
+ entity_ids = (seeds + neighbor_ids).uniq
126
+ return [] if entity_ids.empty?
127
+
128
+ names = @db[ENTITIES].where(id: entity_ids).select_map(:name_norm)
129
+ facts_tagged_with(names, limit)
130
+ end
131
+
132
+ # Entities whose normalized name (or a token of it) appears in the query.
133
+ def seed_entities(query)
134
+ tokens = query.to_s.downcase.scan(/[\p{L}\p{N}]+/).reject { |w| w.length < 2 }.uniq
135
+ return [] if tokens.empty?
136
+
137
+ @db[ENTITIES].where(name_norm: tokens).select_map(:id).first(8)
138
+ end
139
+
140
+ # Live fact ids whose entities_json contains any of the given normalized
141
+ # entity names. Bounded scan over the live set (small in practice).
142
+ def facts_tagged_with(norm_names, limit)
143
+ wanted = norm_names.to_set
144
+ return [] if wanted.empty?
145
+
146
+ live_dataset.exclude(entities_json: nil).order(Sequel.desc(:created_at))
147
+ .limit(limit * 6).all.filter_map do |row|
148
+ ents = parse_entities(row[:entities_json]).map { |e| e.to_s.downcase }
149
+ row[:id] if ents.any? { |e| wanted.include?(e) }
150
+ end.first(limit)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Memory
8
+ # Primary storage interface for persistent memories.
9
+ # Handles CRUD operations on the memories table.
10
+ class Store
11
+ VALID_KINDS = %w[
12
+ user_profile
13
+ preference
14
+ project_context
15
+ technical_decision
16
+ fact
17
+ task_state
18
+ tool_result
19
+ ].freeze
20
+
21
+ # Raised when ThreatScanner flags content destined for the store.
22
+ # Carries the threat label so callers can branch on it without
23
+ # parsing the human-facing message.
24
+ class ThreatDetectedError < Rubino::Error
25
+ attr_reader :threat
26
+
27
+ def initialize(threat)
28
+ @threat = threat
29
+ super("memory threat detected: #{threat}")
30
+ end
31
+ end
32
+
33
+ # Raised when adding `content` to `kind`'s group would push the
34
+ # group's total characters past the configured budget. Group rules
35
+ # live in Store#group_for_kind — user_profile is its own group,
36
+ # everything else shares the general "memory" budget.
37
+ class BudgetExceededError < Rubino::Error
38
+ attr_reader :group, :limit, :current, :requested
39
+
40
+ def initialize(group:, limit:, current:, requested:)
41
+ @group = group
42
+ @limit = limit
43
+ @current = current
44
+ @requested = requested
45
+ super("memory budget exceeded for #{group}: " \
46
+ "#{current}+#{requested} > #{limit}")
47
+ end
48
+ end
49
+
50
+ def initialize(db: nil, config: nil)
51
+ @db = db || Rubino.database.db
52
+ @config = config
53
+ end
54
+
55
+ # Creates a new memory entry.
56
+ #
57
+ # Two boundary checks run *before* the row is inserted:
58
+ # 1. ThreatScanner — refuses prompt-injection, exfil, invisible
59
+ # unicode, etc. Memory persists across sessions, so a tainted
60
+ # write would keep biasing every future prompt.
61
+ # 2. char-budget — refuses writes that would push the group's
62
+ # total past memory_char_limit / memory_user_char_limit. Lets
63
+ # callers (Tools::MemoryTool) surface a "delete or replace
64
+ # older entries first" message instead of silently truncating
65
+ # at read-time.
66
+ def create(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
67
+ validate_kind!(kind)
68
+ enforce_threat_scan!(content)
69
+ enforce_char_budget!(kind, content)
70
+
71
+ now = Time.now.utc.iso8601
72
+ id = SecureRandom.uuid
73
+
74
+ @db[:memories].insert(
75
+ id: id,
76
+ kind: kind,
77
+ content: content,
78
+ source_session_id: source_session_id,
79
+ confidence: confidence,
80
+ metadata_json: metadata.empty? ? nil : JSON.generate(metadata),
81
+ created_at: now,
82
+ updated_at: now
83
+ )
84
+
85
+ find(id)
86
+ end
87
+
88
+ # Finds a memory by ID (supports prefix matching)
89
+ def find(id)
90
+ @db[:memories].where(Sequel.like(:id, "#{id}%")).first
91
+ end
92
+
93
+ # Lists memories with optional filters
94
+ def list(kind: nil, limit: 20)
95
+ dataset = @db[:memories].order(Sequel.desc(:created_at)).limit(limit)
96
+ dataset = dataset.where(kind: kind) if kind
97
+ dataset.all
98
+ end
99
+
100
+ # Updates a memory's content.
101
+ #
102
+ # Same two boundary checks as create — the replace path was a hole that
103
+ # let an agent rewrite a benign entry with prompt-injection / exfil
104
+ # content without going through ThreatScanner, and let a chain of
105
+ # replaces grow a group past its char budget one byte at a time. The
106
+ # budget check subtracts the old row's length before re-adding the new,
107
+ # otherwise a same-size edit would be flagged as over budget when it
108
+ # isn't.
109
+ def update(id, content:, confidence: nil)
110
+ existing = find(id)
111
+ enforce_threat_scan!(content)
112
+ enforce_char_budget_for_update!(existing, content) if existing
113
+
114
+ attrs = { content: content, updated_at: Time.now.utc.iso8601 }
115
+ attrs[:confidence] = confidence if confidence
116
+ @db[:memories].where(id: id).update(attrs)
117
+ end
118
+
119
+ # Deletes a memory
120
+ def delete(id)
121
+ count = @db[:memories].where(Sequel.like(:id, "#{id}%")).delete
122
+ count > 0
123
+ end
124
+
125
+ # Returns memories of a specific kind
126
+ def by_kind(kind, limit: 50)
127
+ @db[:memories]
128
+ .where(kind: kind)
129
+ .order(Sequel.desc(:confidence), Sequel.desc(:created_at))
130
+ .limit(limit)
131
+ .all
132
+ end
133
+
134
+ # Returns all memories within the character limit
135
+ def within_limit(char_limit:)
136
+ memories = @db[:memories]
137
+ .order(Sequel.desc(:confidence), Sequel.desc(:updated_at))
138
+ .all
139
+
140
+ selected = []
141
+ total_chars = 0
142
+
143
+ memories.each do |m|
144
+ break if total_chars + m[:content].length > char_limit
145
+
146
+ selected << m
147
+ total_chars += m[:content].length
148
+ end
149
+
150
+ selected
151
+ end
152
+
153
+ # Returns the total count of stored memories
154
+ def count
155
+ @db[:memories].count
156
+ end
157
+
158
+ # Returns the budget group a kind belongs to:
159
+ # - "user" → user_profile (its own dedicated budget)
160
+ # - "memory" → everything else (shared general-memory budget)
161
+ def self.group_for_kind(kind)
162
+ kind == "user_profile" ? "user" : "memory"
163
+ end
164
+
165
+ # Sum of content length across every row in the given group.
166
+ def total_chars_for_group(group)
167
+ if group == "user"
168
+ @db[:memories].where(kind: "user_profile").sum(Sequel.function(:length, :content)).to_i
169
+ else
170
+ @db[:memories].exclude(kind: "user_profile").sum(Sequel.function(:length, :content)).to_i
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def validate_kind!(kind)
177
+ return if VALID_KINDS.include?(kind)
178
+
179
+ raise Error, "Invalid memory kind: #{kind}. Valid: #{VALID_KINDS.join(", ")}"
180
+ end
181
+
182
+ def enforce_threat_scan!(content)
183
+ threat = ThreatScanner.scan(content)
184
+ return unless threat
185
+
186
+ begin
187
+ Rubino.logger.warn(event: "memory.threat_detected", threat: threat)
188
+ rescue StandardError
189
+ # logging must never block the refusal path
190
+ end
191
+ raise ThreatDetectedError.new(threat)
192
+ end
193
+
194
+ def enforce_char_budget!(kind, content)
195
+ cfg = @config || Rubino.configuration
196
+ group = self.class.group_for_kind(kind)
197
+ limit = group == "user" ? cfg.memory_user_char_limit : cfg.memory_char_limit
198
+ return unless limit && limit > 0
199
+
200
+ current = total_chars_for_group(group)
201
+ requested = content.to_s.length
202
+ return if current + requested <= limit
203
+
204
+ raise BudgetExceededError.new(
205
+ group: group, limit: limit, current: current, requested: requested
206
+ )
207
+ end
208
+
209
+ # Update variant: subtract the row's current content length from the
210
+ # group total before checking the new one, so a same-size or smaller
211
+ # edit always passes even when the group is already at the limit.
212
+ def enforce_char_budget_for_update!(existing, new_content)
213
+ cfg = @config || Rubino.configuration
214
+ group = self.class.group_for_kind(existing[:kind])
215
+ limit = group == "user" ? cfg.memory_user_char_limit : cfg.memory_char_limit
216
+ return unless limit && limit > 0
217
+
218
+ current = total_chars_for_group(group) - existing[:content].to_s.length
219
+ requested = new_content.to_s.length
220
+ return if current + requested <= limit
221
+
222
+ raise BudgetExceededError.new(
223
+ group: group, limit: limit, current: current, requested: requested
224
+ )
225
+ end
226
+ end
227
+ end
228
+ end