rubino-agent 0.4.0 → 0.5.1

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 (322) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/.rubocop_todo.yml +12 -2
  4. data/AGENTS.md +1 -1
  5. data/CHANGELOG.md +454 -1
  6. data/CONTRIBUTING.md +10 -1
  7. data/README.md +69 -11
  8. data/Rakefile +48 -0
  9. data/docs/agents.md +82 -48
  10. data/docs/architecture.md +4 -11
  11. data/docs/commands.md +46 -7
  12. data/docs/configuration.md +174 -30
  13. data/docs/getting-started.md +5 -3
  14. data/docs/mcp.md +3 -3
  15. data/docs/memory.md +3 -3
  16. data/docs/security.md +17 -6
  17. data/docs/tools.md +45 -49
  18. data/docs/troubleshooting.md +1 -1
  19. data/exe/rubino +16 -2
  20. data/ext/landlock/extconf.rb +78 -0
  21. data/ext/landlock/landlock.c +253 -0
  22. data/install.sh +715 -54
  23. data/lib/rubino/active_agent.rb +73 -0
  24. data/lib/rubino/agent/action_claim_guard.rb +913 -0
  25. data/lib/rubino/agent/agent_registry.rb +5 -2
  26. data/lib/rubino/agent/definition.rb +4 -28
  27. data/lib/rubino/agent/fallback_chain.rb +0 -6
  28. data/lib/rubino/agent/iteration_budget.rb +109 -3
  29. data/lib/rubino/agent/loop.rb +664 -42
  30. data/lib/rubino/agent/model_call_runner.rb +81 -3
  31. data/lib/rubino/agent/prompts/build.txt +55 -7
  32. data/lib/rubino/agent/prompts/general.txt +8 -3
  33. data/lib/rubino/agent/response_validator.rb +8 -0
  34. data/lib/rubino/agent/runner.rb +307 -13
  35. data/lib/rubino/agent/tool_executor.rb +368 -31
  36. data/lib/rubino/agent/truncation_continuation.rb +11 -5
  37. data/lib/rubino/api/operations/approvals/decide_operation.rb +0 -4
  38. data/lib/rubino/api/operations/clarifications/decide_operation.rb +0 -4
  39. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +0 -4
  40. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +0 -4
  41. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +0 -4
  42. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +1 -5
  43. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +1 -5
  44. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +0 -4
  45. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +0 -4
  46. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +0 -4
  47. data/lib/rubino/api/operations/files/read_operation.rb +1 -5
  48. data/lib/rubino/api/operations/files/upload_operation.rb +0 -4
  49. data/lib/rubino/api/operations/health_operation.rb +1 -5
  50. data/lib/rubino/api/operations/memory/delete_operation.rb +0 -4
  51. data/lib/rubino/api/operations/memory/index_operation.rb +0 -4
  52. data/lib/rubino/api/operations/memory/stats_operation.rb +0 -4
  53. data/lib/rubino/api/operations/metrics_operation.rb +1 -1
  54. data/lib/rubino/api/operations/mode/show_operation.rb +0 -4
  55. data/lib/rubino/api/operations/mode/update_operation.rb +0 -4
  56. data/lib/rubino/api/operations/models/list_operation.rb +0 -4
  57. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +0 -4
  58. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +0 -4
  59. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +0 -4
  60. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +0 -4
  61. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +0 -4
  62. data/lib/rubino/api/operations/runs/create_operation.rb +0 -4
  63. data/lib/rubino/api/operations/runs/events_operation.rb +0 -4
  64. data/lib/rubino/api/operations/runs/stop_operation.rb +0 -4
  65. data/lib/rubino/api/operations/sessions/create_operation.rb +0 -4
  66. data/lib/rubino/api/operations/sessions/delete_operation.rb +0 -4
  67. data/lib/rubino/api/operations/sessions/index_operation.rb +0 -4
  68. data/lib/rubino/api/operations/sessions/retry_operation.rb +0 -4
  69. data/lib/rubino/api/operations/sessions/show_operation.rb +0 -4
  70. data/lib/rubino/api/operations/sessions/undo_operation.rb +0 -4
  71. data/lib/rubino/api/operations/skills/list_operation.rb +0 -4
  72. data/lib/rubino/api/operations/skills/toggle_operation.rb +0 -4
  73. data/lib/rubino/api/operations/tasks/index_operation.rb +0 -4
  74. data/lib/rubino/api/operations/tasks/show_operation.rb +0 -4
  75. data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -4
  76. data/lib/rubino/api/router.rb +2 -2
  77. data/lib/rubino/api/server.rb +19 -0
  78. data/lib/rubino/attachments/policy.rb +8 -0
  79. data/lib/rubino/attachments/preamble.rb +16 -8
  80. data/lib/rubino/boot/config_guard.rb +71 -0
  81. data/lib/rubino/cli/chat/completion_builder.rb +44 -8
  82. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  83. data/lib/rubino/cli/chat/session_resolver.rb +186 -50
  84. data/lib/rubino/cli/chat_command.rb +1724 -91
  85. data/lib/rubino/cli/commands.rb +373 -1
  86. data/lib/rubino/cli/config_command.rb +118 -11
  87. data/lib/rubino/cli/doctor_command.rb +268 -23
  88. data/lib/rubino/cli/jobs_command.rb +42 -3
  89. data/lib/rubino/cli/memory_command.rb +76 -23
  90. data/lib/rubino/cli/onboarding_wizard.rb +85 -7
  91. data/lib/rubino/cli/server_command.rb +43 -1
  92. data/lib/rubino/cli/session_command.rb +272 -18
  93. data/lib/rubino/cli/setup_command.rb +293 -8
  94. data/lib/rubino/cli/skills_command.rb +88 -20
  95. data/lib/rubino/cli/trust_gate.rb +16 -7
  96. data/lib/rubino/commands/built_ins.rb +4 -2
  97. data/lib/rubino/commands/command.rb +12 -2
  98. data/lib/rubino/commands/executor.rb +161 -19
  99. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  100. data/lib/rubino/commands/handlers/agents.rb +324 -60
  101. data/lib/rubino/commands/handlers/config.rb +8 -1
  102. data/lib/rubino/commands/handlers/display.rb +50 -0
  103. data/lib/rubino/commands/handlers/help.rb +106 -14
  104. data/lib/rubino/commands/handlers/mcp.rb +7 -32
  105. data/lib/rubino/commands/handlers/memory.rb +23 -38
  106. data/lib/rubino/commands/handlers/sessions.rb +70 -33
  107. data/lib/rubino/commands/handlers/skills.rb +47 -28
  108. data/lib/rubino/commands/handlers/status.rb +65 -10
  109. data/lib/rubino/commands/loader.rb +12 -0
  110. data/lib/rubino/compression/compression_result.rb +35 -0
  111. data/lib/rubino/compression/compressor.rb +109 -0
  112. data/lib/rubino/compression/content_router.rb +240 -0
  113. data/lib/rubino/compression/diff_compressor.rb +252 -0
  114. data/lib/rubino/compression/javascript_code_skeleton.rb +15 -0
  115. data/lib/rubino/compression/json_compressor.rb +274 -0
  116. data/lib/rubino/compression/line_skeleton.rb +92 -0
  117. data/lib/rubino/compression/log_compressor.rb +299 -0
  118. data/lib/rubino/compression/python_code_skeleton.rb +122 -0
  119. data/lib/rubino/compression/ruby_code_skeleton.rb +80 -0
  120. data/lib/rubino/compression/tree_sitter_code_skeleton.rb +118 -0
  121. data/lib/rubino/compression/tsx_code_skeleton.rb +15 -0
  122. data/lib/rubino/compression/typescript_code_skeleton.rb +15 -0
  123. data/lib/rubino/config/configuration.rb +151 -105
  124. data/lib/rubino/config/defaults.rb +369 -41
  125. data/lib/rubino/config/loader.rb +71 -13
  126. data/lib/rubino/config/reasoning_prefs.rb +23 -0
  127. data/lib/rubino/config/validator.rb +384 -0
  128. data/lib/rubino/config/writer.rb +123 -31
  129. data/lib/rubino/context/compressor.rb +185 -23
  130. data/lib/rubino/context/file_discovery.rb +0 -8
  131. data/lib/rubino/context/message_boundary.rb +26 -5
  132. data/lib/rubino/context/project_languages.rb +83 -0
  133. data/lib/rubino/context/prompt_assembler.rb +110 -22
  134. data/lib/rubino/context/summary_builder.rb +77 -27
  135. data/lib/rubino/context/token_budget.rb +38 -13
  136. data/lib/rubino/context/token_estimate.rb +45 -0
  137. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  138. data/lib/rubino/database/connection.rb +154 -3
  139. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  140. data/lib/rubino/database/migrator.rb +81 -14
  141. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  142. data/lib/rubino/documents/converters/csv.rb +4 -3
  143. data/lib/rubino/documents/converters/docx.rb +29 -5
  144. data/lib/rubino/documents/converters/html.rb +5 -1
  145. data/lib/rubino/documents/converters/json.rb +2 -1
  146. data/lib/rubino/documents/converters/pdf.rb +11 -2
  147. data/lib/rubino/documents/converters/plain.rb +2 -1
  148. data/lib/rubino/documents/converters/pptx.rb +11 -2
  149. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  150. data/lib/rubino/documents/converters/xml.rb +2 -1
  151. data/lib/rubino/documents/limits.rb +210 -0
  152. data/lib/rubino/documents.rb +10 -3
  153. data/lib/rubino/errors.rb +36 -5
  154. data/lib/rubino/files/workspace.rb +2 -2
  155. data/lib/rubino/interaction/cancel_token.rb +19 -3
  156. data/lib/rubino/interaction/events.rb +13 -3
  157. data/lib/rubino/interaction/input_queue.rb +11 -0
  158. data/lib/rubino/interaction/lifecycle.rb +238 -33
  159. data/lib/rubino/interaction/polishing.rb +184 -0
  160. data/lib/rubino/interaction/probe.rb +1 -1
  161. data/lib/rubino/jobs/cron_job_repository.rb +5 -12
  162. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  163. data/lib/rubino/jobs/handlers/distill_skill_job.rb +67 -21
  164. data/lib/rubino/jobs/queue.rb +133 -13
  165. data/lib/rubino/jobs/runner.rb +24 -6
  166. data/lib/rubino/jobs/worker.rb +1 -5
  167. data/lib/rubino/llm/adapter_factory.rb +1 -1
  168. data/lib/rubino/llm/adapter_response.rb +47 -4
  169. data/lib/rubino/llm/auxiliary_client.rb +63 -3
  170. data/lib/rubino/llm/cache_breakpoint_middleware.rb +194 -0
  171. data/lib/rubino/llm/credential_check.rb +76 -20
  172. data/lib/rubino/llm/error_classifier.rb +186 -77
  173. data/lib/rubino/llm/fake_provider.rb +3 -3
  174. data/lib/rubino/llm/inline_think_filter.rb +103 -15
  175. data/lib/rubino/llm/reasoning_manager.rb +3 -26
  176. data/lib/rubino/llm/request.rb +26 -15
  177. data/lib/rubino/llm/ruby_llm_adapter.rb +623 -67
  178. data/lib/rubino/llm/scenario_loader.rb +10 -17
  179. data/lib/rubino/llm/scenarios/glued-table-prose.yml +36 -0
  180. data/lib/rubino/llm/scenarios/growing-table.yml +49 -0
  181. data/lib/rubino/llm/scenarios/narrow-terminal-table.yml +47 -0
  182. data/lib/rubino/llm/scenarios/streamed-table.yml +55 -0
  183. data/lib/rubino/llm/scenarios/table-then-prose.yml +34 -0
  184. data/lib/rubino/llm/scenarios/too-wide-table.yml +47 -0
  185. data/lib/rubino/llm/scenarios/wide-table.yml +1 -1
  186. data/lib/rubino/llm/thinking_support.rb +17 -12
  187. data/lib/rubino/llm/tool_bridge.rb +200 -32
  188. data/lib/rubino/mcp/manager.rb +71 -10
  189. data/lib/rubino/mcp/mcp_tool_wrapper.rb +38 -3
  190. data/lib/rubino/memory/aux_retry.rb +107 -0
  191. data/lib/rubino/memory/backends/sqlite.rb +104 -67
  192. data/lib/rubino/memory/backends.rb +26 -10
  193. data/lib/rubino/memory/deduplicator.rb +22 -0
  194. data/lib/rubino/memory/flusher.rb +35 -1
  195. data/lib/rubino/memory/salience_gate.rb +129 -0
  196. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  197. data/lib/rubino/memory/sqlite_extraction_prompt.rb +16 -1
  198. data/lib/rubino/memory/store.rb +48 -20
  199. data/lib/rubino/memory/threat_scanner.rb +60 -0
  200. data/lib/rubino/memory.rb +47 -0
  201. data/lib/rubino/oauth/provider.rb +0 -5
  202. data/lib/rubino/output/cost.rb +52 -0
  203. data/lib/rubino/output/headless_block_latch.rb +53 -0
  204. data/lib/rubino/output/result_serializer.rb +222 -0
  205. data/lib/rubino/output/turn_recorder.rb +77 -0
  206. data/lib/rubino/run/event_store.rb +1 -6
  207. data/lib/rubino/run/repository.rb +0 -14
  208. data/lib/rubino/security/approval_policy.rb +314 -33
  209. data/lib/rubino/security/command_allowlist.rb +79 -4
  210. data/lib/rubino/security/command_normalizer.rb +36 -0
  211. data/lib/rubino/security/dangerous_patterns.rb +17 -4
  212. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  213. data/lib/rubino/security/hardline_guard.rb +190 -16
  214. data/lib/rubino/security/pattern_matcher.rb +28 -5
  215. data/lib/rubino/security/prefix_deriver.rb +25 -6
  216. data/lib/rubino/security/readonly_commands.rb +442 -18
  217. data/lib/rubino/security/redactor.rb +272 -0
  218. data/lib/rubino/security/sandbox.rb +460 -0
  219. data/lib/rubino/security/secret_detector.rb +110 -0
  220. data/lib/rubino/security/secret_path.rb +263 -0
  221. data/lib/rubino/security/url_safety.rb +255 -0
  222. data/lib/rubino/session/lock.rb +91 -0
  223. data/lib/rubino/session/message.rb +38 -3
  224. data/lib/rubino/session/picker.rb +95 -0
  225. data/lib/rubino/session/repository.rb +249 -31
  226. data/lib/rubino/session/store.rb +135 -21
  227. data/lib/rubino/skills/installer.rb +116 -32
  228. data/lib/rubino/skills/prompt_index.rb +2 -2
  229. data/lib/rubino/skills/registry.rb +56 -6
  230. data/lib/rubino/skills/skill.rb +94 -12
  231. data/lib/rubino/skills/skill_tool.rb +21 -25
  232. data/lib/rubino/skills/state_repository.rb +0 -4
  233. data/lib/rubino/tools/background_tasks.rb +299 -47
  234. data/lib/rubino/tools/base.rb +219 -4
  235. data/lib/rubino/tools/edit_tool.rb +116 -31
  236. data/lib/rubino/tools/fuzzy_match.rb +212 -0
  237. data/lib/rubino/tools/glob_tool.rb +52 -9
  238. data/lib/rubino/tools/grep_tool.rb +71 -11
  239. data/lib/rubino/tools/multi_edit_tool.rb +88 -20
  240. data/lib/rubino/tools/patch_tool.rb +56 -10
  241. data/lib/rubino/tools/probe_tool.rb +0 -20
  242. data/lib/rubino/tools/question_tool.rb +54 -2
  243. data/lib/rubino/tools/read_attachment_tool.rb +24 -12
  244. data/lib/rubino/tools/read_tool.rb +159 -35
  245. data/lib/rubino/tools/read_tracker.rb +189 -35
  246. data/lib/rubino/tools/registry.rb +151 -31
  247. data/lib/rubino/tools/result.rb +48 -9
  248. data/lib/rubino/tools/retrieve_output_tool.rb +70 -0
  249. data/lib/rubino/tools/ruby_tool.rb +0 -0
  250. data/lib/rubino/tools/shell_kill_tool.rb +6 -2
  251. data/lib/rubino/tools/shell_output_tool.rb +7 -1
  252. data/lib/rubino/tools/shell_registry.rb +229 -5
  253. data/lib/rubino/tools/shell_tail_tool.rb +6 -1
  254. data/lib/rubino/tools/shell_tool.rb +523 -54
  255. data/lib/rubino/tools/steer_tool.rb +2 -21
  256. data/lib/rubino/tools/subagent_probe.rb +1 -1
  257. data/lib/rubino/tools/summarize_file_tool.rb +12 -0
  258. data/lib/rubino/tools/task_result_tool.rb +8 -2
  259. data/lib/rubino/tools/task_stop_tool.rb +15 -22
  260. data/lib/rubino/tools/task_tool.rb +229 -104
  261. data/lib/rubino/tools/vision_tool.rb +37 -4
  262. data/lib/rubino/tools/webfetch_tool.rb +184 -7
  263. data/lib/rubino/tools/websearch_tool.rb +92 -30
  264. data/lib/rubino/tools/write_tool.rb +24 -5
  265. data/lib/rubino/ui/agent_menu.rb +179 -0
  266. data/lib/rubino/ui/api.rb +12 -3
  267. data/lib/rubino/ui/base.rb +13 -2
  268. data/lib/rubino/ui/bottom_composer.rb +1483 -203
  269. data/lib/rubino/ui/cli.rb +1340 -272
  270. data/lib/rubino/ui/completion_menu.rb +35 -50
  271. data/lib/rubino/ui/composer/input_line.rb +131 -0
  272. data/lib/rubino/ui/composer/subagent_panel.rb +35 -0
  273. data/lib/rubino/ui/headless_trace.rb +63 -0
  274. data/lib/rubino/ui/input_history.rb +90 -5
  275. data/lib/rubino/ui/live_region.rb +82 -7
  276. data/lib/rubino/ui/markdown_renderer.rb +214 -17
  277. data/lib/rubino/ui/menu_view.rb +117 -0
  278. data/lib/rubino/ui/notifier.rb +0 -2
  279. data/lib/rubino/ui/null.rb +53 -6
  280. data/lib/rubino/ui/paste_store.rb +49 -3
  281. data/lib/rubino/ui/printer_base.rb +135 -8
  282. data/lib/rubino/ui/queued_indicators.rb +6 -1
  283. data/lib/rubino/ui/status_bar.rb +61 -7
  284. data/lib/rubino/ui/streaming_markdown.rb +148 -6
  285. data/lib/rubino/ui/subagent_cards.rb +126 -25
  286. data/lib/rubino/ui/tool_label.rb +52 -0
  287. data/lib/rubino/update_check.rb +39 -4
  288. data/lib/rubino/util/atomic_file.rb +129 -0
  289. data/lib/rubino/util/duration.rb +8 -5
  290. data/lib/rubino/util/ignore_rules.rb +120 -0
  291. data/lib/rubino/util/output.rb +275 -13
  292. data/lib/rubino/util/secrets_mask.rb +70 -7
  293. data/lib/rubino/util/spill_store.rb +153 -0
  294. data/lib/rubino/version.rb +7 -1
  295. data/lib/rubino/workspace.rb +74 -3
  296. data/lib/rubino.rb +216 -25
  297. data/rubino-agent.gemspec +28 -1
  298. data/skills/ruby-expert/SKILL.md +1 -0
  299. metadata +116 -29
  300. data/docs/plugins.md +0 -195
  301. data/lib/rubino/agent/router.rb +0 -65
  302. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  303. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  304. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  305. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  306. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  307. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  308. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  309. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  310. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
  311. data/lib/rubino/interaction/state.rb +0 -56
  312. data/lib/rubino/memory/backends/default.rb +0 -101
  313. data/lib/rubino/memory/extractor.rb +0 -85
  314. data/lib/rubino/memory/retriever.rb +0 -50
  315. data/lib/rubino/plugins/registry.rb +0 -75
  316. data/lib/rubino/plugins.rb +0 -86
  317. data/lib/rubino/tools/answer_child_tool.rb +0 -83
  318. data/lib/rubino/tools/ask_parent_tool.rb +0 -232
  319. data/lib/rubino/tools/git_tool.rb +0 -71
  320. data/lib/rubino/tools/github_tool.rb +0 -233
  321. data/lib/rubino/tools/test_tool.rb +0 -454
  322. data/lib/rubino/ui/subagent_view.rb +0 -266
@@ -18,16 +18,37 @@ module Rubino
18
18
  @config = config || Rubino.configuration
19
19
  @clients = {}
20
20
  @last_errors = {}
21
+ # Guards @clients / @last_errors writes during the PARALLEL connect phase
22
+ # (start_all!). The single-server path (start_server) takes it too, so the
23
+ # invariant "shared-state mutations are serialized" holds on every caller.
24
+ @state_mutex = Mutex.new
21
25
  route_mcp_logging!
22
26
  end
23
27
 
24
- # Initializes all configured MCP servers
28
+ # Initializes all configured MCP servers.
29
+ #
30
+ # The connect handshake is the slow part: each RubyLLM::MCP.client(**opts)
31
+ # blocks up to the per-server request_timeout (default 8 s) while it spawns
32
+ # the child / opens the socket and waits for `initialize`. Done SERIALLY, N
33
+ # hanging servers cost the SUM of their timeouts (#576 measured 17.6 s with
34
+ # two stalling servers). So we connect every server CONCURRENTLY — one
35
+ # thread each (the count is small and these threads are I/O-bound) — which
36
+ # bounds total connect time to roughly the slowest SINGLE server.
37
+ #
38
+ # Thread-safety: each thread only does the network/subprocess connect and
39
+ # writes its result into @clients/@last_errors UNDER @state_mutex (plain
40
+ # Hashes are not thread-safe). Tool registration is deferred to the MAIN
41
+ # thread (register_all_tools! below, after every join) because
42
+ # Tools::Registry is a process-wide singleton over a plain Hash and is NOT
43
+ # thread-safe. Best-effort semantics are preserved: start_server rescues
44
+ # per-server, so one server raising/stalling never aborts the others.
25
45
  def start_all!
26
46
  server_configs = @config.dig("mcp", "servers") || {}
27
47
 
28
- server_configs.each do |name, server_config|
29
- start_server(name, server_config)
48
+ threads = server_configs.map do |name, server_config|
49
+ Thread.new { start_server(name, server_config) }
30
50
  end
51
+ threads.each(&:join)
31
52
 
32
53
  register_all_tools!
33
54
  @clients
@@ -38,14 +59,19 @@ module Rubino
38
59
  transport = server_config["transport"] || "stdio"
39
60
  client_opts = build_client_options(name, transport, server_config)
40
61
 
62
+ # The slow, blocking connect runs OUTSIDE the lock so concurrent
63
+ # start_all! threads actually overlap; only the shared-Hash writes are
64
+ # serialized under @state_mutex.
41
65
  client = RubyLLM::MCP.client(**client_opts)
42
- @clients[name.to_s] = client
43
- @last_errors.delete(name.to_s)
66
+ @state_mutex.synchronize do
67
+ @clients[name.to_s] = client
68
+ @last_errors.delete(name.to_s)
69
+ end
44
70
 
45
71
  Rubino.event_bus.emit(:mcp_server_started, name: name)
46
72
  client
47
73
  rescue StandardError => e
48
- @last_errors[name.to_s] = e.message
74
+ @state_mutex.synchronize { @last_errors[name.to_s] = e.message }
49
75
  Rubino.ui.warning("MCP server '#{name}' failed to start: #{e.message}")
50
76
  nil
51
77
  end
@@ -79,8 +105,13 @@ module Rubino
79
105
  # Per-agent mcp_servers scoping is NOT applied here — it lives in
80
106
  # Agent::Definition#resolved_tools (#173), the single seam every
81
107
  # consumer of an agent's tool set goes through.
108
+ # Registers in a STABLE order (sorted by server name) rather than @clients'
109
+ # insertion order — under the parallel start_all! @clients is populated in
110
+ # connect-COMPLETION order, which is nondeterministic. Sorting keeps the
111
+ # resulting tool-registration order (and anything downstream that reads it)
112
+ # deterministic across boots.
82
113
  def register_all_tools!
83
- @clients.each_key { |server_name| register_server_tools(server_name) }
114
+ @clients.keys.sort.each { |server_name| register_server_tools(server_name) }
84
115
  end
85
116
 
86
117
  # Registers ONE started server's tools — the `/mcp <server> on` path
@@ -94,11 +125,24 @@ module Rubino
94
125
  wrapped = MCPToolWrapper.new(mcp_tool, server_name: name.to_s)
95
126
  Tools::Registry.register(wrapped)
96
127
  end
128
+ # A clean tools/list clears any prior failure so a recovered server
129
+ # stops showing degraded (mirrors start_server clearing on success).
130
+ @last_errors.delete(name.to_s)
97
131
  rescue StandardError => e
132
+ # Record the failure so /mcp's drill-in (and the degraded glyph below)
133
+ # can explain a connected-but-toolless server (#575) — start_server
134
+ # records start failures the same way; a swallowed warning alone left
135
+ # the broken state invisible.
136
+ @last_errors[name.to_s] = e.message
98
137
  Rubino.ui.warning("Failed to load tools from '#{name}': #{e.message}")
99
138
  end
100
139
 
101
- # Checks health of all connected servers
140
+ # Checks health of all connected servers. `alive` is process-liveness
141
+ # (the child is up); `degraded` is protocol-liveness (#575): the process
142
+ # is alive but tools/list/registration failed, so a recorded last_error
143
+ # exists despite a live client. Callers render degraded distinctly from
144
+ # plain reachable — an alive server that legitimately exposes zero tools
145
+ # has NO last_error and is NOT degraded.
102
146
  def health_check
103
147
  @clients.map do |name, client|
104
148
  alive = begin
@@ -106,7 +150,7 @@ module Rubino
106
150
  rescue StandardError
107
151
  false
108
152
  end
109
- { name: name, alive: alive }
153
+ { name: name, alive: alive, degraded: alive && @last_errors.key?(name.to_s) }
110
154
  end
111
155
  end
112
156
 
@@ -154,7 +198,7 @@ module Rubino
154
198
  when "stdio"
155
199
  opts[:config] = {
156
200
  command: server_config["command"],
157
- args: server_config["args"] || [],
201
+ args: validate_stdio_args!(name, server_config["args"]),
158
202
  env: server_config["env"] || {}
159
203
  }
160
204
  when "sse"
@@ -175,6 +219,23 @@ module Rubino
175
219
 
176
220
  opts
177
221
  end
222
+
223
+ # stdio `args` MUST be a list (YAML sequence). A STRING — the natural typo,
224
+ # `args: "--root /data"` instead of `args: ["--root", "/data"]` — used to be
225
+ # passed straight to ruby_llm-mcp, which iterated the string into single
226
+ # characters, spawned a broken process, and surfaced ~8s later as a
227
+ # misleading "timed out" (the spawn never spoke MCP). Reject it HERE so
228
+ # start_server's rescue turns it into an immediate, clear config error
229
+ # instead of a hang. nil ⇒ no args (default []).
230
+ def validate_stdio_args!(name, args)
231
+ return [] if args.nil?
232
+ return args if args.is_a?(Array)
233
+
234
+ raise ArgumentError,
235
+ "MCP server '#{name}': `args` must be a list, got #{args.class} " \
236
+ "(#{args.inspect}). Use a YAML sequence, e.g. " \
237
+ "args: [\"--root\", \"/data\"] (not a single string)."
238
+ end
178
239
  end
179
240
  end
180
241
  end
@@ -7,14 +7,21 @@ module Rubino
7
7
  class MCPToolWrapper < Tools::Base
8
8
  attr_reader :mcp_tool, :server_name
9
9
 
10
+ # Cap on the (prefixed) tool name length. A misbehaving MCP server can
11
+ # advertise an absurdly long tool name (e.g. 20k chars), which would be
12
+ # registered uncapped, blow up the `tools` table, and 400 at the provider.
13
+ MAX_NAME_LENGTH = 64
14
+
10
15
  def initialize(mcp_tool, server_name:)
11
16
  @mcp_tool = mcp_tool
12
17
  @server_name = server_name
13
18
  end
14
19
 
15
20
  def name
16
- # Prefix with server name to avoid collisions
17
- "#{@server_name}_#{@mcp_tool.name}"
21
+ # Prefix with server name to avoid collisions. Cap the length so a
22
+ # hostile/buggy server can't register a giant name that breaks the
23
+ # `tools` table or 400s the provider (S1-MCP-2).
24
+ "#{@server_name}_#{@mcp_tool.name}"[0, MAX_NAME_LENGTH]
18
25
  end
19
26
 
20
27
  def description
@@ -28,7 +35,11 @@ module Rubino
28
35
  # {}`, so the model had to guess argument names and every call failed
29
36
  # server-side validation with -32602 (#170).
30
37
  schema = @mcp_tool.params_schema if @mcp_tool.respond_to?(:params_schema)
31
- schema || { type: "object", properties: {} }
38
+ # Coerce anything that isn't a Hash (nil, or a truthy non-Hash like a
39
+ # string) to a valid empty object schema. A server advertising a
40
+ # non-Hash `inputSchema` would otherwise poison the whole wire tool
41
+ # list and 400 every subsequent model call (S1-MCP-1).
42
+ schema.is_a?(Hash) ? schema : { type: "object", properties: {} }
32
43
  end
33
44
 
34
45
  def risk_level
@@ -36,6 +47,30 @@ module Rubino
36
47
  :medium
37
48
  end
38
49
 
50
+ # True: this tool's code runs on an external MCP server. The display layer
51
+ # reads this (NOT the name shape) to mark the call/approval card.
52
+ def mcp?
53
+ true
54
+ end
55
+
56
+ # The MCP server this tool is provided by, used in the display marker.
57
+ def mcp_server
58
+ @server_name
59
+ end
60
+
61
+ # The original, UNPREFIXED tool name the server advertised (#name returns
62
+ # the collision-safe `<server>_<tool>` registry/model-facing name).
63
+ def bare_name
64
+ @mcp_tool.name
65
+ end
66
+
67
+ # The display label for the live tool card and approval card:
68
+ # `"<bare_tool> (mcp:<server>)"` — e.g. `echo (mcp:chaos)`. The
69
+ # model-facing #name (`chaos_echo`) is unchanged; this is display-only.
70
+ def display_name
71
+ "#{bare_name} (mcp:#{@server_name})"
72
+ end
73
+
39
74
  def call(arguments)
40
75
  result = @mcp_tool.execute(**symbolize_keys(arguments))
41
76
  # ruby_llm-mcp reports tool failures by RETURNING `{ error: "…" }`
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Bounded retry/backoff for the aux memory-extraction call (r5 C-2).
6
+ #
7
+ # The aux client calls the adapter directly and so — unlike the main
8
+ # conversation loop, whose Agent::ModelCallRunner owns retry/backoff — got NO
9
+ # retry: under concurrent load a single RubyLLM::RateLimitError (429) was
10
+ # caught at the call site, logged `memory.sqlite.skip`, and the extracted
11
+ # fact was DROPPED for good. This mixin wraps the aux call in the SAME
12
+ # jittered-backoff policy the main loop uses, retrying retryable errors
13
+ # (429/overloaded/5xx/transport, per LLM::ErrorClassifier) up to a small
14
+ # budget and honouring Retry-After on a rate-limit. After the budget is
15
+ # exhausted (or on a non-retryable error) it re-raises to the caller, which
16
+ # leaves the per-session cursor put so the turn is re-fed next time rather
17
+ # than silently lost.
18
+ #
19
+ # Host requirements: `@config` (a Config::Configuration answering #dig) and a
20
+ # `DEFAULT_EXTRACT_MAX_RETRIES` constant on the including class.
21
+ module AuxRetry
22
+ # Run `block` (the aux call), retrying transient errors. Re-raises the last
23
+ # error once the budget is exhausted or the error is non-retryable.
24
+ def with_aux_retry
25
+ attempts = 0
26
+ begin
27
+ # Honour a detached-polishing cancel (Esc to skip): the background
28
+ # housekeeping thread binds Rubino.aux_cancel_token, so an Esc that
29
+ # cancelled it must abort BEFORE spending another aux-LLM call rather
30
+ # than running to completion off-screen (#319).
31
+ aux_check_cancelled!
32
+ yield
33
+ rescue Rubino::Interrupted
34
+ # Cancellation is terminal — re-raise straight through so the detached
35
+ # polishing thread unwinds and leaves the cursor put (re-runs next turn).
36
+ raise
37
+ rescue StandardError => e
38
+ classified = LLM::ErrorClassifier.classify(e)
39
+ raise unless classified.retryable && attempts < extract_max_retries
40
+
41
+ attempts += 1
42
+ wait = aux_backoff.wait_seconds(
43
+ attempts,
44
+ base: Agent::BackoffPolicy::ERROR_PATH[:base],
45
+ max: Agent::BackoffPolicy::ERROR_PATH[:max],
46
+ retry_after: aux_rate_limit_retry_after(classified, e)
47
+ )
48
+ log_aux_retry(e, attempts, wait)
49
+ # Sleep in short slices so an Esc during the (possibly long, Retry-After
50
+ # honouring) backoff wait aborts within ~100ms instead of holding the
51
+ # detached worker for the full window (#319). On the foreground/API
52
+ # path no token is bound, so this is one uninterrupted sleep as before.
53
+ aux_cancellable_sleep(wait)
54
+ retry
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # Raise Interrupted when the detached-polishing cancel token (if bound for
61
+ # this thread) has been flipped. No-op when no token is bound.
62
+ def aux_check_cancelled!
63
+ Rubino.aux_cancel_token&.check!
64
+ end
65
+
66
+ # Sleep +seconds+ in small slices, polling the aux cancel token between
67
+ # slices so a cancel aborts the wait promptly. Falls back to a single
68
+ # sleep when no token is bound (no detached polishing).
69
+ def aux_cancellable_sleep(seconds)
70
+ token = Rubino.aux_cancel_token
71
+ return aux_backoff.sleep(seconds) unless token
72
+
73
+ remaining = seconds.to_f
74
+ while remaining.positive?
75
+ token.check!
76
+ slice = [remaining, 0.1].min
77
+ aux_backoff.sleep(slice)
78
+ remaining -= slice
79
+ end
80
+ token.check!
81
+ end
82
+
83
+ def extract_max_retries
84
+ @config.dig("memory", "extract_max_retries") ||
85
+ self.class::DEFAULT_EXTRACT_MAX_RETRIES
86
+ end
87
+
88
+ def aux_backoff
89
+ @aux_backoff ||= Agent::BackoffPolicy.new
90
+ end
91
+
92
+ # Honour Retry-After only on a rate-limit, exactly as the main loop does.
93
+ def aux_rate_limit_retry_after(classified, error)
94
+ return unless classified.reason == LLM::FailoverReason::RATE_LIMIT
95
+
96
+ aux_backoff.parse_retry_after(error)
97
+ end
98
+
99
+ def log_aux_retry(error, attempt, wait)
100
+ Rubino.logger.warn(event: "memory.sqlite.extract_retry",
101
+ attempt: attempt, sleep: wait, error: error.class.name)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ end
106
+ end
107
+ end
@@ -7,10 +7,10 @@ require "time"
7
7
  module Rubino
8
8
  module Memory
9
9
  module Backends
10
- # "Tiny-Zep" memory backend on embedded SQLite (Zep/Graphiti-inspired,
11
- # minus the graph DB, the server, and the six-LLM-call pipeline).
10
+ # LLM-extracted, bi-temporal fact store on embedded SQLite with hybrid
11
+ # recall — minus a graph DB, a server, or a multi-LLM-call pipeline.
12
12
  #
13
- # Three ideas are kept from Zep:
13
+ # Three ideas drive the design:
14
14
  # * ATOMIC LLM-extracted facts (one declarative fact per row), via a
15
15
  # single aux-LLM call per turn that both ADDs new facts and SUPERSEDES
16
16
  # contradicted ones (Graphiti edge-invalidation, collapsed to 1 call).
@@ -28,12 +28,22 @@ module Rubino
28
28
  # tainted or over-budget content into a future system prompt.
29
29
  class Sqlite < Backend
30
30
  include SqliteGraph
31
+ include SqliteExtraction
32
+ include SalienceGate
33
+ include AuxRetry
31
34
 
32
35
  TABLE = :memory_facts
33
36
  FTS = :memory_facts_fts
34
37
  RRF_K = 60
35
38
  DEFAULT_K = 20
36
39
 
40
+ # Bounded retry budget for the aux extraction call on a transient error
41
+ # (429/overloaded/5xx). Small by design: extraction is best-effort
42
+ # background work, and the per-session cursor re-feeds an exhausted turn
43
+ # next time, so we ride out a brief rate-limit window without piling up
44
+ # background backoff. Overridable via `memory.extract_max_retries`.
45
+ DEFAULT_EXTRACT_MAX_RETRIES = 3
46
+
37
47
  # Weighted-RRF list weights for the DIRECT relevance signals (FTS/BM25 and
38
48
  # vector KNN). Graph (1-hop) and recency are no longer fused here — they
39
49
  # are tail supplements (see #rank) so they can never outrank a direct
@@ -84,9 +94,17 @@ module Rubino
84
94
  # -- WRITE path --
85
95
 
86
96
  def store(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
97
+ k = normalize_kind(kind)
98
+ # Exact/normalized-verbatim dedup at the direct write seam (#Y4):
99
+ # MemoryTool#add bypasses the extraction near-dup gate, so the same fact
100
+ # saved twice used to mint two identical rows. Idempotent — a verbatim
101
+ # repeat (or whitespace/case variant) returns the existing row.
102
+ existing = verbatim_duplicate(k, content)
103
+ return present(existing) if existing
104
+
87
105
  insert_fact(
88
106
  text: content,
89
- kind: normalize_kind(kind),
107
+ kind: k,
90
108
  entities: Array(metadata[:entities]),
91
109
  source_session_id: source_session_id,
92
110
  confidence: confidence,
@@ -122,17 +140,40 @@ module Rubino
122
140
  target
123
141
  end
124
142
 
125
- # ONE aux-LLM call over the recent turn(s): returns {add, supersede}.
143
+ # ONE aux-LLM call over the turn's NEW messages: returns {add, supersede}.
126
144
  # Apply is pure Ruby — insert adds (deduped + guarded), retire
127
145
  # superseded rows and insert their replacement.
146
+ #
147
+ # Per-session cursor (#249): only messages newer than the session's
148
+ # `memory_extracted_msg_id` watermark are fed, so each turn's extraction
149
+ # is bounded to that turn's new messages instead of an overlapping
150
+ # recency window. When a turn added nothing new past the cursor, we skip
151
+ # the aux-LLM call entirely (no redundant duplicate extraction pass), and
152
+ # advance the cursor only once the apply has landed.
128
153
  def extract(session_id)
129
- turn = recent_turn_text(session_id)
154
+ new_messages = unextracted_messages(session_id)
155
+ turn = turn_text(new_messages)
130
156
  return [] if turn.strip.empty?
131
157
 
158
+ # Salience gate (r5 F5/F6/F7): a greeting, a one-word "help", or any
159
+ # turn whose USER text asserts nothing durable is a NOOP — skip the aux
160
+ # call AND advance the cursor so it never mints a fact nor gets re-fed.
161
+ unless salient?(turn)
162
+ advance_extraction_cursor(session_id, new_messages)
163
+ return []
164
+ end
165
+
132
166
  result = call_llm(session_id: session_id, turn: turn)
167
+ # A nil result means the aux call failed/parsed to nothing — leave the
168
+ # cursor put so this turn's messages are retried next time rather than
169
+ # silently dropped. A parsed result (even an empty {add,supersede})
170
+ # means these messages WERE processed: advance the watermark so they're
171
+ # never re-fed, which is the overlapping-window re-work #249 removes.
133
172
  return [] unless result
134
173
 
135
- apply(result, session_id)
174
+ stored = apply(result, session_id)
175
+ advance_extraction_cursor(session_id, new_messages)
176
+ stored
136
177
  end
137
178
 
138
179
  # -- READ path --
@@ -144,7 +185,7 @@ module Rubino
144
185
  return nil if rows.empty?
145
186
 
146
187
  text = rows.map { |r| r[:text] }.join("\n")
147
- limit = @config.memory_user_char_limit
188
+ limit = @config.dig("memory", "user_char_limit")
148
189
  text.length > limit ? text[0...limit] : text
149
190
  end
150
191
 
@@ -164,7 +205,7 @@ module Rubino
164
205
  # ({id:, kind:, content:, ...}) so the prompt assembler is unchanged.
165
206
  def retrieve(session_id:, query: nil, k: DEFAULT_K)
166
207
  ranked = rank(query: query, k: k)
167
- budget = @config.memory_char_limit
208
+ budget = @config.dig("memory", "memory_char_limit")
168
209
  selected = []
169
210
  total = 0
170
211
  ranked.each do |row|
@@ -191,12 +232,19 @@ module Rubino
191
232
  end
192
233
 
193
234
  def find(id)
194
- row = @db[TABLE].where(Sequel.like(:id, "#{id}%")).first
235
+ row = resolve_row(id)
195
236
  row && present(row)
196
237
  end
197
238
 
198
239
  def delete(id)
199
- @db[TABLE].where(Sequel.like(:id, "#{id}%")).delete.positive?
240
+ row = resolve_row(id)
241
+ row ? @db[TABLE].where(id: row[:id]).delete.positive? : false
242
+ end
243
+
244
+ # Resolve a caller-supplied id to AT MOST ONE row (shared with Store,
245
+ # parameterized by this backend's dataset).
246
+ def resolve_row(id)
247
+ Memory.resolve_row(@db[TABLE], id)
200
248
  end
201
249
 
202
250
  # Count only LIVE facts (valid_to IS NULL) — retired/superseded rows are
@@ -375,6 +423,12 @@ module Rubino
375
423
  # default extractor, which silently skips dups).
376
424
  def guarded_insert(text:, kind:, entities:, session_id:, valid_from:, id: nil)
377
425
  return nil if text.to_s.strip.empty?
426
+ # NOOP error-derived tool-limitation claims (#69): after a transient
427
+ # tool failure the aux model can mint a durable-looking "the tool can't
428
+ # edit non-ASCII files" — a meta claim that is often wrong and primes
429
+ # future refusals. Drop it here, the single insert choke point shared by
430
+ # add[] and supersede[], so neither path can persist one.
431
+ return nil if tool_limitation_claim?(text)
378
432
 
379
433
  insert_fact(
380
434
  text: text, kind: normalize_kind(kind), entities: Array(entities),
@@ -463,6 +517,16 @@ module Rubino
463
517
  str.to_s.downcase.split(/\W+/).reject(&:empty?).to_set
464
518
  end
465
519
 
520
+ # First LIVE fact of `kind` whose normalized-verbatim form equals the
521
+ # candidate's (trim/collapse-whitespace + case-fold, #Y4), or nil.
522
+ def verbatim_duplicate(kind, content)
523
+ target = Deduplicator.normalize_verbatim(content)
524
+ return nil if target.empty?
525
+
526
+ live_dataset.where(kind: kind).all
527
+ .find { |row| Deduplicator.normalize_verbatim(row[:text]) == target }
528
+ end
529
+
466
530
  # ---- guards (ThreatScanner + char-budget, same floor as Store) ----
467
531
 
468
532
  def enforce_guards!(kind, text)
@@ -474,21 +538,18 @@ module Rubino
474
538
 
475
539
  def enforce_char_budget!(kind, text)
476
540
  group = kind == USER_KIND ? "user" : "memory"
477
- # INGEST cap, decoupled from the injection budget. `memory_char_limit`
541
+ # INGEST cap, decoupled from the injection budget. memory.memory_char_limit
478
542
  # bounds only what `retrieve` packs into the prompt; storing facts is
479
- # gated by `memory_ingest_char_limit` (nil => unbounded) so long
543
+ # gated by memory.ingest_char_limit (nil => unbounded) so long
480
544
  # multi-session conversations don't stall once the injection budget
481
545
  # fills. User facts keep their own (small) profile budget.
482
- limit = group == "user" ? @config.memory_user_char_limit : @config.memory_ingest_char_limit
546
+ key = group == "user" ? "user_char_limit" : "ingest_char_limit"
547
+ limit = @config.dig("memory", key)
483
548
  return unless limit&.positive?
484
549
 
485
- current = current_chars(group)
486
- requested = text.to_s.length
487
- return if current + requested <= limit
488
-
489
- raise Store::BudgetExceededError.new(
490
- group: group, limit: limit, current: current, requested: requested
491
- )
550
+ Memory.enforce_budget!(group: group, limit: limit,
551
+ current: current_chars(group),
552
+ requested: text.to_s.length)
492
553
  end
493
554
 
494
555
  # Budget is metered over LIVE facts only — superseded rows don't count
@@ -501,55 +562,35 @@ module Rubino
501
562
 
502
563
  # ---- LLM ----
503
564
 
565
+ # ONE aux-LLM extraction call, retried on a transient error via AuxRetry
566
+ # (r5 C-2): a 429/overloaded/5xx backs off (honouring Retry-After) and
567
+ # retries up to `memory.extract_max_retries` instead of dropping the fact
568
+ # on the first RateLimitError. Only after the budget is exhausted (or on a
569
+ # non-retryable error) do we rescue and return nil — and the caller leaves
570
+ # the cursor put on nil, so even an exhausted turn is re-fed next time
571
+ # rather than silently lost.
504
572
  def call_llm(session_id:, turn:)
505
- response = aux_client.call(
506
- task: :compression,
507
- messages: [
508
- { role: "system", content: SqliteExtractionPrompt::SYSTEM },
509
- { role: "user", content: SqliteExtractionPrompt.user_message(
510
- now: Time.now.utc.iso8601, live_facts: live_facts_for_prompt, turn: turn
511
- ) }
512
- ]
513
- )
514
- parse_json(response&.content)
573
+ with_aux_retry do
574
+ response = aux_client.call(
575
+ task: :compression,
576
+ messages: [
577
+ { role: "system", content: SqliteExtractionPrompt::SYSTEM },
578
+ { role: "user", content: SqliteExtractionPrompt.user_message(
579
+ now: Time.now.utc.iso8601, live_facts: live_facts_for_prompt, turn: turn
580
+ ) }
581
+ ]
582
+ )
583
+ parse_json(response&.content)
584
+ end
515
585
  rescue StandardError => e
516
586
  log_skip(e)
517
587
  nil
518
588
  end
519
589
 
520
- def live_facts_for_prompt
521
- live_dataset.order(Sequel.desc(:created_at)).limit(60).all.map do |r|
522
- { id: r[:id][0, 8], kind: r[:kind], text: r[:text] }
523
- end
524
- end
525
-
526
- # The aux model may wrap JSON in prose or a fenced block; extract the
527
- # outermost object and parse leniently.
528
- def parse_json(content)
529
- return nil if content.to_s.strip.empty?
530
-
531
- str = content[/\{.*\}/m] || content
532
- JSON.parse(str)
533
- rescue JSON::ParserError
534
- nil
535
- end
536
-
537
590
  def aux_client
538
591
  @aux_client ||= LLM::AuxiliaryClient.new(config: @config)
539
592
  end
540
593
 
541
- def recent_turn_text(session_id)
542
- msgs = Session::Store.new(db: @db).recent(session_id, count: 6)
543
- msgs.filter_map do |m|
544
- next if m.content.nil? || m.content.to_s.empty?
545
- next unless %w[user assistant].include?(m.role)
546
-
547
- "#{m.role.upcase}: #{m.content}"
548
- end.join("\n")
549
- rescue StandardError
550
- ""
551
- end
552
-
553
594
  # ---- embeddings (best-effort) ----
554
595
 
555
596
  # Vector mode is opt-in (`memory.sqlite.vector: true`) AND requires
@@ -585,13 +626,9 @@ module Rubino
585
626
  nil
586
627
  end
587
628
 
588
- def encode_embedding(vec)
589
- vec.pack("e*")
590
- end
629
+ def encode_embedding(vec) = vec.pack("e*")
591
630
 
592
- def decode_embedding(blob)
593
- blob && blob.to_s.unpack("e*")
594
- end
631
+ def decode_embedding(blob) = blob && blob.to_s.unpack("e*")
595
632
 
596
633
  def cosine(a, b)
597
634
  return 0.0 if a.empty? || b.empty? || a.size != b.size
@@ -608,7 +645,7 @@ module Rubino
608
645
  k = kind.to_s
609
646
  return USER_KIND if k.empty?
610
647
 
611
- # Map legacy/default-backend kinds onto the tiny-Zep vocabulary so the
648
+ # Map legacy/default-backend kinds onto the fact-store vocabulary so the
612
649
  # backend tolerates store() calls from the existing MemoryTool/job.
613
650
  case k
614
651
  when "user_profile", "preference", "fact", "env" then k
@@ -4,9 +4,10 @@ module Rubino
4
4
  module Memory
5
5
  # Registry of pluggable memory backends, mirroring Tools::Registry: a
6
6
  # name => class map with register/build. The active backend is selected by
7
- # the `memory.backend` config key (default "sqlite" — the tiny-Zep FTS5/
8
- # graph-lite backend). DEFAULT_NAME below is the registry fallback used only
9
- # when the configured name is blank/unknown.
7
+ # the `memory.backend` config key (default "sqlite" — the FTS5/
8
+ # graph-lite SQLite backend). DEFAULT_NAME below is the registry fallback used only
9
+ # when the configured name is BLANK/unset. An explicitly-set UNKNOWN name is
10
+ # a misconfiguration (a typo silently degrading memory) → rejected.
10
11
  module Backends
11
12
  @registry = {}
12
13
 
@@ -29,25 +30,40 @@ module Rubino
29
30
  @registry[name.to_s]
30
31
  end
31
32
 
32
- # Builds the configured backend instance. Falls back to the default
33
- # backend when `memory.backend` is unset or names an unknown backend,
34
- # so a stale config never breaks the interaction.
33
+ # Builds the configured backend instance. A BLANK/unset `memory.backend`
34
+ # falls back to the default backend (so a fresh config just works); an
35
+ # explicitly-set name that names NO registered backend is a typo that
36
+ # would otherwise silently degrade to the default — reject it with a
37
+ # clear error listing the known backends instead.
35
38
  def build(config: nil)
36
39
  cfg = config || Rubino.configuration
37
- name = cfg.dig("memory", "backend").to_s
38
- klass = @registry[name] || @registry[DEFAULT_NAME]
39
- raise Error, "no memory backend registered (looked for #{name.inspect})" unless klass
40
+ name = cfg.dig("memory", "backend").to_s.strip
41
+ klass = name.empty? ? @registry[DEFAULT_NAME] : @registry[name]
42
+
43
+ unless klass
44
+ raise Error, unknown_backend_message(name) unless name.empty?
45
+
46
+ raise Error, "no memory backend registered (looked for #{DEFAULT_NAME.inspect})"
47
+ end
40
48
 
41
49
  klass.new(config: cfg)
42
50
  end
43
51
 
52
+ # A clear, actionable rejection for an unknown `memory.backend` name,
53
+ # listing the registered backends so the user can fix the typo.
54
+ def unknown_backend_message(name)
55
+ known = names.sort.join(", ")
56
+ "unknown memory backend #{name.inspect}: set memory.backend to one of " \
57
+ "[#{known}] (or leave it unset for the default)."
58
+ end
59
+
44
60
  # For tests.
45
61
  def reset!
46
62
  @registry = {}
47
63
  end
48
64
  end
49
65
 
50
- DEFAULT_NAME = "default"
66
+ DEFAULT_NAME = "sqlite"
51
67
  end
52
68
  end
53
69
  end