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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # DELETE /v1/jobs/:id
8
+ # Unschedules the cron job from the in-process scheduler before deleting
9
+ # the row, so no stray ticks fire post-delete.
10
+ #
11
+ # @return [[Integer, Hash]] 204 No Content.
12
+ # @raise [Rubino::NotFoundError] when the cron job does not exist.
13
+ class DeleteOperation
14
+ def self.call(request)
15
+ new.call(request)
16
+ end
17
+
18
+ # Accepts an alternate repository and scheduler for tests.
19
+ def initialize(repository: nil, scheduler: nil)
20
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
21
+ @scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
22
+ end
23
+
24
+ def call(request)
25
+ id = request.params.fetch("id")
26
+ raise NotFoundError.new("cron_job", id) unless @repository.find(id)
27
+
28
+ @scheduler.unschedule(id)
29
+ @repository.destroy!(id)
30
+ Responses.no_content
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ module Operations
8
+ module CronJobs
9
+ # GET /v1/jobs
10
+ # Lists cron jobs. Disabled jobs are included by default; pass
11
+ # ?include_disabled=false to hide them.
12
+ class ListOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate repository for tests.
18
+ def initialize(repository: nil)
19
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
20
+ end
21
+
22
+ def call(request)
23
+ include_disabled = request.query["include_disabled"] != "false"
24
+ jobs = @repository.list(include_disabled: include_disabled).map { |j| Serializer.call(j) }
25
+ [200, jobs]
26
+ end
27
+ end
28
+
29
+ # Shared serializer used by every CronJobs operation so the wire shape
30
+ # (and the JSON-decoded skills array) stays consistent.
31
+ module Serializer
32
+ module_function
33
+
34
+ def call(job)
35
+ {
36
+ id: job[:id],
37
+ name: job[:name],
38
+ schedule: job[:schedule],
39
+ prompt: job[:prompt],
40
+ skills: job[:skills_json] ? JSON.parse(job[:skills_json]) : [],
41
+ model: job[:model],
42
+ provider: job[:provider],
43
+ deliver: job[:deliver],
44
+ enabled: job[:enabled] == true,
45
+ last_run_at: job[:last_run_at],
46
+ last_run_id: job[:last_run_id],
47
+ created_at: job[:created_at],
48
+ updated_at: job[:updated_at]
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # POST /v1/jobs/:id/pause
8
+ # Flips enabled=false on the cron job and unschedules its tick. Idempotent.
9
+ #
10
+ # @raise [Rubino::NotFoundError] when the cron job does not exist.
11
+ class PauseOperation
12
+ def self.call(request)
13
+ new.call(request)
14
+ end
15
+
16
+ # Accepts an alternate repository and scheduler for tests.
17
+ def initialize(repository: nil, scheduler: nil)
18
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
19
+ @scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
20
+ end
21
+
22
+ def call(request)
23
+ id = request.params.fetch("id")
24
+ raise NotFoundError.new("cron_job", id) unless @repository.find(id)
25
+
26
+ updated = @repository.set_enabled(id, enabled: false)
27
+ @scheduler.unschedule(id)
28
+ [200, Serializer.call(updated)]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # POST /v1/jobs/:id/resume
8
+ # Flips enabled=true on the cron job and (re)registers its tick. Idempotent.
9
+ #
10
+ # @raise [Rubino::NotFoundError] when the cron job does not exist.
11
+ class ResumeOperation
12
+ def self.call(request)
13
+ new.call(request)
14
+ end
15
+
16
+ # Accepts an alternate repository and scheduler for tests.
17
+ def initialize(repository: nil, scheduler: nil)
18
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
19
+ @scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
20
+ end
21
+
22
+ def call(request)
23
+ id = request.params.fetch("id")
24
+ raise NotFoundError.new("cron_job", id) unless @repository.find(id)
25
+
26
+ updated = @repository.set_enabled(id, enabled: true)
27
+ @scheduler.schedule(updated)
28
+ [200, Serializer.call(updated)]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fugit"
4
+
5
+ module Rubino
6
+ module API
7
+ module Operations
8
+ module CronJobs
9
+ # Pre-flight cron validation shared by Create/Update (#164). A schedule
10
+ # Fugit cannot parse must be rejected BEFORE the row is persisted: a
11
+ # committed bad row used to 500 the request AND poison the next boot
12
+ # (Scheduler#load_all! raised on it and the server never bound).
13
+ module ScheduleValidation
14
+ private
15
+
16
+ def validate_schedule!(schedule)
17
+ return if schedule.nil? || Fugit.parse_cron(schedule)
18
+
19
+ # Same envelope shape as Request#validate! so clients see one
20
+ # canonical 422 format: error.details.errors.<field> => [messages].
21
+ raise ValidationError.new(
22
+ "invalid request body",
23
+ details: { errors: { schedule: ["is not a valid cron expression"] } }
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # GET /v1/jobs/:id
8
+ # Fetches a single cron job by id.
9
+ #
10
+ # @raise [Rubino::NotFoundError] when the cron job does not exist.
11
+ class ShowOperation
12
+ def self.call(request)
13
+ new.call(request)
14
+ end
15
+
16
+ # Accepts an alternate repository for tests.
17
+ def initialize(repository: nil)
18
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
19
+ end
20
+
21
+ def call(request)
22
+ id = request.params.fetch("id")
23
+ job = @repository.find(id)
24
+ raise NotFoundError.new("cron_job", id) unless job
25
+
26
+ [200, Serializer.call(job)]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # POST /v1/jobs/:id/trigger
8
+ # Forces an off-cycle execution of the cron job through the scheduler
9
+ # and returns the new run/session ids.
10
+ #
11
+ # @return [[Integer, Hash]] 202 + { job_id, run_id, session_id }.
12
+ # @raise [Rubino::NotFoundError] when the cron job does not exist.
13
+ # @raise [Rubino::ConflictError] when the scheduler refuses to dispatch (returns nil).
14
+ class TriggerOperation
15
+ def self.call(request)
16
+ new.call(request)
17
+ end
18
+
19
+ # Accepts an alternate repository and scheduler for tests.
20
+ def initialize(repository: nil, scheduler: nil)
21
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
22
+ @scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
23
+ end
24
+
25
+ def call(request)
26
+ id = request.params.fetch("id")
27
+ raise NotFoundError.new("cron_job", id) unless @repository.find(id)
28
+
29
+ run = @scheduler.trigger(id)
30
+ raise ConflictError, "trigger failed" if run.nil?
31
+
32
+ [202, { job_id: id, run_id: run[:id], session_id: run[:session_id] }]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # PATCH /v1/jobs/:id
8
+ # Applies a partial update and resyncs the scheduler: always unschedule,
9
+ # reschedule only when the resulting row is still enabled.
10
+ #
11
+ # @raise [Rubino::NotFoundError] when the cron job does not exist.
12
+ # @raise [Rubino::ValidationError] when the body fails Schemas::UpdateCronJob
13
+ # or carries a cron schedule Fugit cannot parse (#164).
14
+ class UpdateOperation
15
+ include ScheduleValidation
16
+
17
+ def self.call(request)
18
+ new.call(request)
19
+ end
20
+
21
+ # Accepts an alternate repository and scheduler for tests.
22
+ def initialize(repository: nil, scheduler: nil)
23
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
24
+ @scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
25
+ end
26
+
27
+ def call(request)
28
+ id = request.params.fetch("id")
29
+ raise NotFoundError.new("cron_job", id) unless @repository.find(id)
30
+
31
+ attrs = request.validate!(Schemas::UpdateCronJob)
32
+ validate_schedule!(attrs[:schedule])
33
+ updated = @repository.update(id, attrs)
34
+ @scheduler.unschedule(id)
35
+ @scheduler.schedule(updated) if updated[:enabled]
36
+ [200, Serializer.call(updated)]
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Files
7
+ # GET /v1/files?path=relative/path
8
+ # Streams raw bytes from a path inside the sandboxed workspace as
9
+ # application/octet-stream. Path traversal is enforced by the workspace.
10
+ #
11
+ # @return [[Integer, Hash, Array<String>]] 200 + octet-stream Rack triple.
12
+ # @raise [Rubino::ValidationError] when the +path+ query parameter is missing or empty.
13
+ class ReadOperation
14
+ def self.call(request)
15
+ new.call(request)
16
+ end
17
+
18
+ # Accepts an alternate workspace for tests.
19
+ #
20
+ # Roots the workspace at the SAME directory the tools sandbox to
21
+ # (terminal.cwd || Dir.pwd), not config.paths_home. Tools and
22
+ # attach_file emit absolute paths under that root, so a produced
23
+ # artifact lives there — rooting at paths_home would make every
24
+ # such path look like a traversal escape and 422 the download.
25
+ def initialize(workspace: nil)
26
+ @workspace = workspace || ::Rubino::Files::Workspace.new(root: ::Rubino::Tools::Base.workspace_root)
27
+ end
28
+
29
+ def call(request)
30
+ path = request.query["path"]
31
+ raise ValidationError, "path query parameter is required" if path.nil? || path.empty?
32
+
33
+ content = @workspace.read(path)
34
+ [200, { "content-type" => "application/octet-stream" }, [content]]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/multipart"
4
+
5
+ module Rubino
6
+ module API
7
+ module Operations
8
+ module Files
9
+ # POST /v1/files (multipart/form-data, field "file")
10
+ # Persists a single uploaded file into the sandboxed workspace and
11
+ # returns its descriptor (id, filename, size).
12
+ #
13
+ # Multipart payload is capped at api.max_upload_bytes (default 50 MiB).
14
+ # The cap is enforced twice: first against the declared Content-Length
15
+ # (cheap reject before any IO), then by wrapping rack.input so a body
16
+ # that lies about its size — or omits Content-Length entirely — still
17
+ # aborts mid-stream before Rack::Multipart can fully buffer it to disk.
18
+ #
19
+ # @return [[Integer, Hash]] 201 + descriptor payload.
20
+ # @raise [Rubino::ValidationError] when the content-type is not multipart or the "file" field is missing.
21
+ # @raise [Rubino::PayloadTooLargeError] when the body exceeds the cap.
22
+ class UploadOperation
23
+ DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024
24
+
25
+ # Wraps a Rack input stream and raises PayloadTooLargeError once the
26
+ # cumulative bytes read pass +limit+. Rack::Multipart drives the read
27
+ # loop, so raising here unwinds straight out of parse_multipart and
28
+ # the partially-written tempfile is collected by the ensure block
29
+ # in #call.
30
+ class CappedInput
31
+ def initialize(io, limit)
32
+ @io = io
33
+ @limit = limit
34
+ @read = 0
35
+ end
36
+
37
+ def read(length = nil, buffer = nil)
38
+ chunk = buffer ? @io.read(length, buffer) : @io.read(length)
39
+ return chunk if chunk.nil?
40
+
41
+ @read += chunk.bytesize
42
+ if @read > @limit
43
+ raise Rubino::PayloadTooLargeError.new(
44
+ "multipart upload exceeds #{@limit} bytes",
45
+ details: { limit_bytes: @limit }
46
+ )
47
+ end
48
+
49
+ chunk
50
+ end
51
+
52
+ # Rack::Multipart::Parser interrogates rewind/eof?/gets on the input;
53
+ # delegate so the parser is unaware it's wrapped.
54
+ def rewind
55
+ @read = 0
56
+ @io.rewind
57
+ end
58
+
59
+ def eof?
60
+ @io.eof?
61
+ end
62
+
63
+ def gets(*)
64
+ line = @io.gets(*)
65
+ return line if line.nil?
66
+
67
+ @read += line.bytesize
68
+ if @read > @limit
69
+ raise Rubino::PayloadTooLargeError.new(
70
+ "multipart upload exceeds #{@limit} bytes",
71
+ details: { limit_bytes: @limit }
72
+ )
73
+ end
74
+
75
+ line
76
+ end
77
+
78
+ def respond_to_missing?(name, include_private = false)
79
+ @io.respond_to?(name, include_private) || super
80
+ end
81
+
82
+ def method_missing(name, *, &)
83
+ return @io.send(name, *, &) if @io.respond_to?(name)
84
+
85
+ super
86
+ end
87
+ end
88
+
89
+ def self.call(request)
90
+ new.call(request)
91
+ end
92
+
93
+ # Accepts an alternate workspace for tests.
94
+ #
95
+ # Roots at the tool workspace (terminal.cwd || Dir.pwd), the same
96
+ # root tools and attach_file use, so uploaded files land where the
97
+ # agent can read them back. See ReadOperation for the rationale.
98
+ def initialize(workspace: nil)
99
+ @workspace = workspace || ::Rubino::Files::Workspace.new(root: ::Rubino::Tools::Base.workspace_root)
100
+ end
101
+
102
+ def call(request)
103
+ content_type = request.env["CONTENT_TYPE"].to_s
104
+ unless content_type.start_with?("multipart/form-data")
105
+ raise ValidationError,
106
+ "content-type must be multipart/form-data"
107
+ end
108
+
109
+ limit = max_upload_bytes
110
+ declared = request.env["CONTENT_LENGTH"].to_s
111
+ if !declared.empty? && declared.to_i > limit
112
+ raise PayloadTooLargeError.new(
113
+ "multipart upload exceeds #{limit} bytes",
114
+ details: { limit_bytes: limit }
115
+ )
116
+ end
117
+
118
+ params = parse_with_cap(request.env, limit)
119
+ upload = params["file"]
120
+ raise ValidationError, "missing 'file' field" if upload.nil? || !upload.is_a?(Hash)
121
+
122
+ descriptor = @workspace.upload(filename: upload[:filename], io: upload[:tempfile])
123
+ [201, { id: descriptor[:id], filename: descriptor[:filename], size: descriptor[:size] }]
124
+ end
125
+
126
+ private
127
+
128
+ # Wraps rack.input with CappedInput so a mid-stream overflow raises
129
+ # before Rack::Multipart fully drains the body. On overflow we also
130
+ # unlink any tempfile the parser already created for the partial
131
+ # part, so no orphan upload is left under /tmp.
132
+ def parse_with_cap(env, limit)
133
+ original = env["rack.input"]
134
+ return Rack::Multipart.parse_multipart(env) || {} if original.nil?
135
+
136
+ capped = CappedInput.new(original, limit)
137
+ env["rack.input"] = capped
138
+ begin
139
+ Rack::Multipart.parse_multipart(env) || {}
140
+ rescue PayloadTooLargeError
141
+ cleanup_partial_tempfiles(env)
142
+ raise
143
+ ensure
144
+ env["rack.input"] = original
145
+ end
146
+ end
147
+
148
+ # Rack::Multipart streams each part to a Tempfile created via
149
+ # Rack::Multipart::Parser::TEMPFILE_FACTORY. When we abort mid-read
150
+ # those tempfiles remain on disk because the parser never returned
151
+ # the descriptor to us. We do not have a handle to them either —
152
+ # but rack.tempfiles (set by Rack::Multipart::Parser since 2.2) is
153
+ # the canonical collection. Unlink everything in it.
154
+ def cleanup_partial_tempfiles(env)
155
+ tempfiles = env["rack.tempfiles"]
156
+ return unless tempfiles.is_a?(Array)
157
+
158
+ tempfiles.each do |tf|
159
+ tf.close unless tf.closed?
160
+ File.unlink(tf.path) if tf.respond_to?(:path) && tf.path && File.exist?(tf.path)
161
+ rescue StandardError
162
+ # Best-effort cleanup; we already failed the request.
163
+ end
164
+ tempfiles.clear
165
+ end
166
+
167
+ def max_upload_bytes
168
+ value = Rubino.configuration.dig("api", "max_upload_bytes")
169
+ value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_UPLOAD_BYTES
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ # GET /v1/health — readiness probe.
7
+ # No auth required (allowlisted in Middleware::Auth::SKIP_PATHS).
8
+ #
9
+ # Pings the database and reports scheduler status alongside build info.
10
+ # Returns 503 if any critical dependency is degraded; never raises.
11
+ #
12
+ # @return [[Integer, Hash]] 200 when all deps are ok, 503 otherwise.
13
+ class HealthOperation
14
+ def self.call(_request)
15
+ new.call
16
+ end
17
+
18
+ def call
19
+ deps = { db: db_status, scheduler: scheduler_status }
20
+ status = deps.values.all? { |s| s[:status] == "ok" } ? 200 : 503
21
+ [status, {
22
+ status: status == 200 ? "ok" : "degraded",
23
+ version: Rubino::VERSION,
24
+ deps: deps
25
+ }]
26
+ end
27
+
28
+ private
29
+
30
+ def db_status
31
+ Rubino.database.db.test_connection
32
+ { status: "ok" }
33
+ rescue StandardError => e
34
+ { status: "down", error: e.class.name }
35
+ end
36
+
37
+ def scheduler_status
38
+ scheduler = ::Rubino::Jobs::Scheduler.instance
39
+ { status: "ok", scheduled_jobs: scheduler.scheduled_count }
40
+ rescue StandardError => e
41
+ { status: "down", error: e.class.name }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Memory
7
+ # DELETE /v1/memory/:id
8
+ # Forgets one fact by id (id-prefix match, mirroring the CLI).
9
+ #
10
+ # @return [[Integer, Hash]] 204 No Content.
11
+ # @raise [Rubino::NotFoundError] when no fact matches the id.
12
+ class DeleteOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate backend for tests.
18
+ def initialize(backend: nil)
19
+ @backend = backend || ::Rubino::Memory::Backends.build
20
+ end
21
+
22
+ def call(request)
23
+ id = request.params.fetch("id")
24
+ raise NotFoundError.new("memory", id) unless @backend.delete(id)
25
+
26
+ Responses.no_content
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Memory
7
+ # GET /v1/memory
8
+ # Lists stored facts from the active memory backend, newest first.
9
+ #
10
+ # `?q=` filters to facts whose content matches the query (case-insensitive
11
+ # substring). The match is applied at the API layer over the backend's
12
+ # admin #list so it works identically for every backend — the backends'
13
+ # own #retrieve is session/turn-relevance scoped and char-budget capped,
14
+ # which is the wrong shape for a flat admin listing.
15
+ #
16
+ # `?limit=` / `?offset=` paginate the (optionally filtered) result.
17
+ class IndexOperation
18
+ DEFAULT_LIMIT = 50
19
+ MAX_LIMIT = 200
20
+ # Window pulled from the backend before filter+paginate. Generous
21
+ # enough to page through a normal store; the backend keeps its own
22
+ # newest-first ordering.
23
+ WINDOW = 1000
24
+
25
+ def self.call(request)
26
+ new.call(request)
27
+ end
28
+
29
+ # Accepts an alternate backend for tests.
30
+ def initialize(backend: nil)
31
+ @backend = backend || ::Rubino::Memory::Backends.build
32
+ end
33
+
34
+ def call(request)
35
+ limit = clamp(request.query["limit"], DEFAULT_LIMIT, MAX_LIMIT)
36
+ offset = [request.query["offset"].to_i, 0].max
37
+ q = request.query["q"].to_s.strip
38
+
39
+ rows = @backend.list(limit: WINDOW)
40
+ rows = filter(rows, q) unless q.empty?
41
+
42
+ page = rows.slice(offset, limit) || []
43
+ [200, { memory: page.map { |row| Serializer.call(row) } }]
44
+ end
45
+
46
+ private
47
+
48
+ def filter(rows, q)
49
+ needle = q.downcase
50
+ rows.select { |row| row[:content].to_s.downcase.include?(needle) }
51
+ end
52
+
53
+ def clamp(raw, default, max)
54
+ n = raw.to_i
55
+ return default if n <= 0
56
+
57
+ [n, max].min
58
+ end
59
+ end
60
+
61
+ # Shared serializer for the memory surface. Backends differ slightly in
62
+ # the rows they return (the sqlite backend omits :updated_at), so every
63
+ # field is read defensively and absent ones serialize to null.
64
+ module Serializer
65
+ module_function
66
+
67
+ def call(row)
68
+ {
69
+ id: row[:id],
70
+ kind: row[:kind],
71
+ content: row[:content],
72
+ created_at: row[:created_at],
73
+ updated_at: row[:updated_at]
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end