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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Rubino
8
+ module Tools
9
+ # Tool for GitHub/GitLab operations: PRs, issues, reviews.
10
+ # Uses GitHub CLI (gh) if available, otherwise uses the API directly.
11
+ class GitHubTool < Base
12
+ def name
13
+ "github"
14
+ end
15
+
16
+ def description
17
+ "Interact with GitHub: create/list PRs, issues, reviews, check status. " \
18
+ "Requires GITHUB_TOKEN or gh CLI authenticated."
19
+ end
20
+
21
+ def input_schema
22
+ {
23
+ type: "object",
24
+ properties: {
25
+ action: {
26
+ type: "string",
27
+ enum: %w[pr_create pr_list pr_view issue_create issue_list issue_view
28
+ pr_checks pr_diff repo_view release_list],
29
+ description: "The GitHub action to perform"
30
+ },
31
+ title: {
32
+ type: "string",
33
+ description: "Title (for pr_create, issue_create)"
34
+ },
35
+ body: {
36
+ type: "string",
37
+ description: "Body/description (for pr_create, issue_create)"
38
+ },
39
+ number: {
40
+ type: "integer",
41
+ description: "PR or issue number (for view/checks/diff)"
42
+ },
43
+ repo: {
44
+ type: "string",
45
+ description: "Repository in owner/name format (optional, auto-detects from git remote)"
46
+ },
47
+ base: {
48
+ type: "string",
49
+ description: "Base branch for PR (default: main)"
50
+ },
51
+ labels: {
52
+ type: "string",
53
+ description: "Comma-separated labels"
54
+ }
55
+ },
56
+ required: %w[action]
57
+ }
58
+ end
59
+
60
+ def risk_level
61
+ :medium
62
+ end
63
+
64
+ def call(arguments)
65
+ action = arguments["action"] || arguments[:action]
66
+
67
+ if gh_available?
68
+ execute_gh(action, arguments)
69
+ else
70
+ execute_api(action, arguments)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def gh_available?
77
+ # Memoized — avoid spawning a subprocess on every call()
78
+ return @gh_available unless @gh_available.nil?
79
+
80
+ @gh_available = system("which gh > /dev/null 2>&1")
81
+ end
82
+
83
+ def execute_gh(action, args)
84
+ case action
85
+ when "pr_create"
86
+ title = args["title"] || args[:title] || "New PR"
87
+ body = args["body"] || args[:body] || ""
88
+ base = args["base"] || args[:base] || "main"
89
+ cmd = "gh pr create --title '#{escape(title)}' --body '#{escape(body)}' --base '#{base}'"
90
+ run_gh(cmd)
91
+ when "pr_list"
92
+ run_gh("gh pr list --limit 20")
93
+ when "pr_view"
94
+ number = args["number"] || args[:number]
95
+ run_gh("gh pr view #{number}")
96
+ when "pr_checks"
97
+ number = args["number"] || args[:number]
98
+ run_gh("gh pr checks #{number}")
99
+ when "pr_diff"
100
+ number = args["number"] || args[:number]
101
+ run_gh("gh pr diff #{number}")
102
+ when "issue_create"
103
+ title = args["title"] || args[:title] || "New Issue"
104
+ body = args["body"] || args[:body] || ""
105
+ labels = args["labels"] || args[:labels]
106
+ cmd = "gh issue create --title '#{escape(title)}' --body '#{escape(body)}'"
107
+ cmd += " --label '#{labels}'" if labels
108
+ run_gh(cmd)
109
+ when "issue_list"
110
+ run_gh("gh issue list --limit 20")
111
+ when "issue_view"
112
+ number = args["number"] || args[:number]
113
+ run_gh("gh issue view #{number}")
114
+ when "repo_view"
115
+ run_gh("gh repo view")
116
+ when "release_list"
117
+ run_gh("gh release list --limit 10")
118
+ else
119
+ "Unknown GitHub action: #{action}"
120
+ end
121
+ end
122
+
123
+ def execute_api(action, args)
124
+ token = ENV["GITHUB_TOKEN"] || ENV.fetch("GH_TOKEN", nil)
125
+ return "Error: No GitHub authentication. Set GITHUB_TOKEN or install gh CLI." unless token
126
+
127
+ repo = args["repo"] || args[:repo] || detect_repo
128
+
129
+ case action
130
+ when "pr_list"
131
+ api_get("/repos/#{repo}/pulls?state=open&per_page=20", token)
132
+ when "pr_view"
133
+ number = args["number"] || args[:number]
134
+ api_get("/repos/#{repo}/pulls/#{number}", token)
135
+ when "issue_list"
136
+ api_get("/repos/#{repo}/issues?state=open&per_page=20", token)
137
+ when "issue_view"
138
+ number = args["number"] || args[:number]
139
+ api_get("/repos/#{repo}/issues/#{number}", token)
140
+ when "pr_create"
141
+ title = args["title"] || args[:title]
142
+ body_text = args["body"] || args[:body] || ""
143
+ base = args["base"] || args[:base] || "main"
144
+ head = current_branch
145
+ api_post("/repos/#{repo}/pulls", token, {
146
+ title: title, body: body_text, head: head, base: base
147
+ })
148
+ when "issue_create"
149
+ title = args["title"] || args[:title]
150
+ body_text = args["body"] || args[:body] || ""
151
+ api_post("/repos/#{repo}/issues", token, {
152
+ title: title, body: body_text
153
+ })
154
+ else
155
+ "Action '#{action}' requires gh CLI"
156
+ end
157
+ end
158
+
159
+ def run_gh(cmd)
160
+ output = `#{cmd} 2>&1`
161
+ output.empty? ? "(no output)" : output
162
+ end
163
+
164
+ def api_get(path, token)
165
+ uri = URI("https://api.github.com#{path}")
166
+ http = Net::HTTP.new(uri.host, uri.port)
167
+ http.use_ssl = true
168
+ request = Net::HTTP::Get.new(uri.request_uri)
169
+ request["Authorization"] = "Bearer #{token}"
170
+ request["Accept"] = "application/vnd.github+json"
171
+
172
+ response = http.request(request)
173
+ format_api_response(response)
174
+ end
175
+
176
+ def api_post(path, token, body)
177
+ uri = URI("https://api.github.com#{path}")
178
+ http = Net::HTTP.new(uri.host, uri.port)
179
+ http.use_ssl = true
180
+ request = Net::HTTP::Post.new(uri.request_uri)
181
+ request["Authorization"] = "Bearer #{token}"
182
+ request["Accept"] = "application/vnd.github+json"
183
+ request["Content-Type"] = "application/json"
184
+ request.body = JSON.generate(body)
185
+
186
+ response = http.request(request)
187
+ format_api_response(response)
188
+ end
189
+
190
+ def format_api_response(response)
191
+ data = JSON.parse(response.body)
192
+ case data
193
+ when Array
194
+ data.first(10).map { |item| format_item(item) }.join("\n\n")
195
+ when Hash
196
+ if data["message"]
197
+ "API Error: #{data["message"]}"
198
+ else
199
+ format_item(data)
200
+ end
201
+ end
202
+ rescue StandardError
203
+ response.body[0..500]
204
+ end
205
+
206
+ def format_item(item)
207
+ parts = []
208
+ parts << "##{item["number"]} #{item["title"]}" if item["number"]
209
+ parts << "State: #{item["state"]}" if item["state"]
210
+ parts << "URL: #{item["html_url"]}" if item["html_url"]
211
+ parts << "Author: #{item.dig("user", "login")}" if item.dig("user", "login")
212
+ parts.join("\n")
213
+ end
214
+
215
+ def detect_repo
216
+ remote = `git remote get-url origin 2>/dev/null`.strip
217
+ if remote.match?(%r{github\.com[:/](.+?)(?:\.git)?$})
218
+ remote.match(%r{github\.com[:/](.+?)(?:\.git)?$})[1]
219
+ else
220
+ ""
221
+ end
222
+ end
223
+
224
+ def current_branch
225
+ `git branch --show-current 2>/dev/null`.strip
226
+ end
227
+
228
+ def escape(str)
229
+ str.gsub("'", "'\\''")
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Tool for finding files by glob patterns.
6
+ # Returns matching file paths sorted by modification time.
7
+ class GlobTool < Base
8
+ def name
9
+ "glob"
10
+ end
11
+
12
+ def description
13
+ "Find files by glob pattern (e.g., '**/*.rb', 'src/**/*.ts'). " \
14
+ "Returns matching file paths sorted by modification time."
15
+ end
16
+
17
+ def input_schema
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ pattern: {
22
+ type: "string",
23
+ description: "The glob pattern to match files against (e.g., '**/*.rb')"
24
+ },
25
+ path: {
26
+ type: "string",
27
+ description: "Base directory to search in (defaults to current directory)"
28
+ },
29
+ max_results: {
30
+ type: "integer",
31
+ description: "Maximum number of results (default: 100)"
32
+ }
33
+ },
34
+ required: %w[pattern]
35
+ }
36
+ end
37
+
38
+ def risk_level
39
+ :low
40
+ end
41
+
42
+ def call(arguments)
43
+ pattern = arguments["pattern"] || arguments[:pattern]
44
+ path = arguments["path"] || arguments[:path] || "."
45
+ max_results = arguments["max_results"] || arguments[:max_results] || 100
46
+
47
+ expanded_path = File.expand_path(path)
48
+ return "Error: Directory not found: #{path}" unless File.directory?(expanded_path)
49
+
50
+ full_pattern = File.join(expanded_path, pattern)
51
+ files = Dir.glob(full_pattern)
52
+ .select { |f| File.file?(f) }
53
+ .sort_by { |f| -File.mtime(f).to_i }
54
+ .first(max_results)
55
+
56
+ if files.empty?
57
+ "No files matched pattern: #{pattern}"
58
+ else
59
+ relative_files = files.map { |f| f.sub("#{expanded_path}/", "") }
60
+ full = "#{relative_files.size} file(s) found:\n\n#{relative_files.join("\n")}"
61
+ { output: full,
62
+ metrics: "#{relative_files.size} file#{"s" if relative_files.size != 1}",
63
+ body: Util::Output.preview(full),
64
+ body_kind: :plain }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Tool for searching file contents using regex patterns.
6
+ # Backed by ripgrep (rg) if available, falls back to Ruby grep.
7
+ class GrepTool < Base
8
+ def name
9
+ "grep"
10
+ end
11
+
12
+ def description
13
+ "Search file contents using regular expressions. " \
14
+ "Returns matching file paths and line numbers. " \
15
+ "Supports include patterns to filter by file type."
16
+ end
17
+
18
+ def input_schema
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ pattern: {
23
+ type: "string",
24
+ description: "The regex pattern to search for"
25
+ },
26
+ path: {
27
+ type: "string",
28
+ description: "Directory to search in (defaults to current directory)"
29
+ },
30
+ include: {
31
+ type: "string",
32
+ description: "File pattern to include (e.g., '*.rb', '*.{ts,tsx}')"
33
+ },
34
+ max_results: {
35
+ type: "integer",
36
+ description: "Maximum number of results to return (default: 50)"
37
+ },
38
+ before: {
39
+ type: "integer",
40
+ description: "Lines of leading context to include before each match (-B). Default 0."
41
+ },
42
+ after: {
43
+ type: "integer",
44
+ description: "Lines of trailing context to include after each match (-A). Default 0."
45
+ },
46
+ context: {
47
+ type: "integer",
48
+ description: "Symmetric context (-C): sets both before and after. Wins over before/after when given."
49
+ }
50
+ },
51
+ required: %w[pattern]
52
+ }
53
+ end
54
+
55
+ def risk_level
56
+ :low
57
+ end
58
+
59
+ def call(arguments)
60
+ pattern = arguments["pattern"] || arguments[:pattern]
61
+ path = arguments["path"] || arguments[:path] || "."
62
+ include_pattern = arguments["include"] || arguments[:include]
63
+ max_results = arguments["max_results"] || arguments[:max_results] || 50
64
+
65
+ # -A/-B/-C semantics, mirroring ripgrep: `context` (-C) overrides
66
+ # both halves; otherwise each side defaults to 0. Clamp at 50 lines
67
+ # per side so a runaway model can't ask for 10_000 lines of context
68
+ # per match and overrun the output budget.
69
+ ctx = arguments["context"] || arguments[:context]
70
+ before = (ctx || arguments["before"] || arguments[:before] || 0).to_i.clamp(0, 50)
71
+ after = (ctx || arguments["after"] || arguments[:after] || 0).to_i.clamp(0, 50)
72
+
73
+ expanded_path = File.expand_path(path)
74
+ return "Error: Path not found: #{path}" unless File.exist?(expanded_path)
75
+
76
+ if ripgrep_available?
77
+ search_with_ripgrep(pattern, expanded_path, include_pattern, max_results, before, after)
78
+ else
79
+ search_with_ruby(pattern, expanded_path, include_pattern, max_results, before, after)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def ripgrep_available?
86
+ system("which rg > /dev/null 2>&1")
87
+ end
88
+
89
+ def search_with_ripgrep(pattern, path, include_pattern, max_results, before, after)
90
+ # Build argv array and use Open3 to avoid shell injection — pattern
91
+ # and path are passed as separate arguments, never interpolated into a
92
+ # shell string.
93
+ #
94
+ # NOTE: ripgrep has NO total-count flag — `--max-total-count` is not a
95
+ # real rg option and makes rg exit non-zero ("unrecognized flag"),
96
+ # which surfaced in prod as a wasted "Error executing search" turn.
97
+ # `--max-count` (-m) is PER-FILE, so it can't bound the total either.
98
+ # We therefore let rg run and cap the TOTAL number of result lines in
99
+ # Ruby below — true total cap, and it tames a pattern that matches
100
+ # thousands of lines in one file (the prod failure mode).
101
+ argv = ["rg", "--line-number", "--no-heading", "--color=never"]
102
+ argv += ["--glob=#{include_pattern}"] if include_pattern
103
+ argv += ["-B", before.to_s] if before.positive?
104
+ argv += ["-A", after.to_s] if after.positive?
105
+ argv += [pattern, path]
106
+
107
+ output = IO.popen(argv, err: %i[child out], &:read)
108
+ status = $?.exitstatus
109
+
110
+ if status == 0
111
+ all_lines = output.lines
112
+ lines = all_lines.first(max_results)
113
+ more = all_lines.size - lines.size
114
+ header = "#{lines.size} match(es) shown" \
115
+ "#{" (#{more} more — raise max_results or narrow the pattern)" if more.positive?}"
116
+ full = "#{header}:\n\n#{lines.join}"
117
+ { output: full,
118
+ metrics: "#{lines.size} match#{"es" if lines.size != 1}#{"+" if more.positive?}",
119
+ body: Util::Output.preview(full),
120
+ body_kind: :plain }
121
+ elsif status == 1
122
+ "No matches found for pattern: #{pattern}"
123
+ else
124
+ "Error executing search: #{output}"
125
+ end
126
+ end
127
+
128
+ def search_with_ruby(pattern, path, include_pattern, max_results, before, after)
129
+ regex = Regexp.new(pattern)
130
+ results = []
131
+
132
+ # ripgrep accepts a single FILE as well as a directory; mirror that
133
+ # in the fallback. Dir.glob("<file>/**/*") yields nothing, so when
134
+ # `path` is a file we search it directly (include_pattern is moot).
135
+ files = File.file?(path) ? [path] : Dir.glob(File.join(path, "**", include_pattern || "*"))
136
+
137
+ files.each do |file|
138
+ next unless File.file?(file)
139
+ next if binary_file?(file)
140
+
141
+ begin
142
+ lines = File.readlines(file)
143
+ relative = file == path ? File.basename(file) : file.sub("#{path}/", "")
144
+ pending = 0 # lines remaining to emit after a match
145
+ last_idx = -1 # last line index already in results (to dedupe overlapping ctx)
146
+ separator_pending = false
147
+ lines.each_with_index do |line, idx|
148
+ matched = line.match?(regex)
149
+ if matched
150
+ # Emit `before` lines of context (skipping any already in results).
151
+ first_ctx = [idx - before, last_idx + 1].max
152
+ results << "--" if separator_pending && first_ctx > last_idx + 1
153
+ (first_ctx...idx).each do |ci|
154
+ results << "#{relative}:#{ci + 1}- #{lines[ci].rstrip}"
155
+ last_idx = ci
156
+ end
157
+ results << "#{relative}:#{idx + 1}: #{line.rstrip}"
158
+ last_idx = idx
159
+ pending = after
160
+ separator_pending = false
161
+ break if results.size >= max_results
162
+ elsif pending.positive?
163
+ results << "#{relative}:#{idx + 1}- #{line.rstrip}"
164
+ last_idx = idx
165
+ pending -= 1
166
+ separator_pending = pending.zero?
167
+ break if results.size >= max_results
168
+ end
169
+ end
170
+ rescue StandardError
171
+ next
172
+ end
173
+
174
+ break if results.size >= max_results
175
+ end
176
+
177
+ if results.empty?
178
+ "No matches found for pattern: #{pattern}"
179
+ else
180
+ # We stop scanning once results hits max_results, so a full cap means
181
+ # more matches may exist — flag it the same way the ripgrep path does.
182
+ capped = results.size >= max_results
183
+ match_count = results.count { |l| l.include?(":") && l !~ /:\d+- / && l != "--" }
184
+ header = "#{match_count} match(es) shown" \
185
+ "#{" (more may exist — raise max_results or narrow the pattern)" if capped}"
186
+ full = "#{header}:\n\n#{results.join("\n")}"
187
+ { output: full,
188
+ metrics: "#{match_count} match#{"es" if match_count != 1}#{"+" if capped}",
189
+ body: Util::Output.preview(full),
190
+ body_kind: :plain }
191
+ end
192
+ end
193
+
194
+ def binary_file?(path)
195
+ sample = begin
196
+ File.read(path, 512)
197
+ rescue StandardError
198
+ nil
199
+ end
200
+ return true unless sample
201
+
202
+ sample.include?("\x00")
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Tools
5
+ # Agent-callable interface to the memory store.
6
+ #
7
+ # The agent uses this to record durable facts about the user or
8
+ # project across sessions. The schema is deliberately tiny — three
9
+ # actions, two targets — because every additional knob is another
10
+ # surface a prompt-injection attempt can probe. Threat scanning and
11
+ # the char-budget run inside Memory::Store; this tool only handles
12
+ # the action/target mapping and translates Store exceptions into
13
+ # tool-protocol error strings.
14
+ class MemoryTool < Base
15
+ VALID_ACTIONS = %w[add replace remove].freeze
16
+ VALID_TARGETS = %w[memory user].freeze
17
+
18
+ # target → memory kind. "user" is the user_profile slot; "memory"
19
+ # is the catch-all "fact" kind. Other kinds (preference,
20
+ # technical_decision, …) are reserved for the auto-extractor — the
21
+ # agent does not get to write to them directly through this tool.
22
+ TARGET_TO_KIND = { "memory" => "fact", "user" => "user_profile" }.freeze
23
+
24
+ def initialize(backend: nil)
25
+ @backend = backend
26
+ end
27
+
28
+ def name
29
+ "memory"
30
+ end
31
+
32
+ def description
33
+ "Persist facts across sessions. Use action=add to record a new fact, " \
34
+ "replace to update an existing fact (substring match on old_text), " \
35
+ "or remove to delete one. target=user writes to the user profile; " \
36
+ "target=memory writes to general memory. " \
37
+ "Store ONE atomic fact per call — make separate calls for separate " \
38
+ "facts so each can be superseded or forgotten independently. " \
39
+ "Content is scanned for prompt-injection / exfiltration patterns and " \
40
+ "subject to a character budget — refusals are reported in the output."
41
+ end
42
+
43
+ def input_schema
44
+ {
45
+ type: "object",
46
+ properties: {
47
+ action: {
48
+ type: "string",
49
+ enum: VALID_ACTIONS,
50
+ description: "add, replace, or remove"
51
+ },
52
+ target: {
53
+ type: "string",
54
+ enum: VALID_TARGETS,
55
+ description: "memory (general) or user (user profile)"
56
+ },
57
+ content: {
58
+ type: "string",
59
+ description: "New content (required for add and replace)"
60
+ },
61
+ old_text: {
62
+ type: "string",
63
+ description: "Substring of existing memory to match " \
64
+ "(required for replace and remove)"
65
+ }
66
+ },
67
+ required: %w[action target]
68
+ }
69
+ end
70
+
71
+ def risk_level
72
+ # Memory store/retrieve/update is an internal, low-risk operation:
73
+ # an autonomous "scratchpad" the agent maintains, not an external
74
+ # side-effect like editing the user's files or running a shell
75
+ # command. It must not trip the approval gate. Every write is
76
+ # already threat-scanned and char-budgeted inside Memory::Store,
77
+ # and the only destructive action (remove) deletes a SINGLE entry
78
+ # by substring match — there is no full-wipe op exposed here — so
79
+ # there is nothing left for an approval prompt to guard.
80
+ # :low keeps it autonomous even under approvals.mode: manual
81
+ # (Base#risky? only flags :medium/:high), matching how todo_tool
82
+ # and other internal state-mutating tools stay unprompted.
83
+ :low
84
+ end
85
+
86
+ def call(arguments)
87
+ args = symbolize(arguments)
88
+ action = args[:action].to_s
89
+ target = args[:target].to_s
90
+
91
+ return error("invalid action '#{action}'; expected one of #{VALID_ACTIONS.join(", ")}") \
92
+ unless VALID_ACTIONS.include?(action)
93
+ return error("invalid target '#{target}'; expected one of #{VALID_TARGETS.join(", ")}") \
94
+ unless VALID_TARGETS.include?(target)
95
+
96
+ kind = TARGET_TO_KIND.fetch(target)
97
+
98
+ case action
99
+ when "add" then do_add(kind, args[:content])
100
+ when "replace" then do_replace(kind, args[:old_text], args[:content])
101
+ when "remove" then do_remove(kind, args[:old_text])
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def backend
108
+ @backend ||= Memory::Backends.build
109
+ end
110
+
111
+ def do_add(kind, content)
112
+ return error("content is required for add") if blank?(content)
113
+
114
+ memory = backend.store(kind: kind, content: content)
115
+ "Memory added (id=#{memory[:id][0, 8]}, kind=#{kind})."
116
+ rescue Memory::Store::ThreatDetectedError => e
117
+ threat_error(e)
118
+ rescue Memory::Store::BudgetExceededError => e
119
+ budget_error(e)
120
+ end
121
+
122
+ def do_replace(kind, old_text, content)
123
+ return error("old_text is required for replace") if blank?(old_text)
124
+ return error("content is required for replace") if blank?(content)
125
+
126
+ target = backend.replace(kind: kind, old_text: old_text, content: content)
127
+ return error("no #{kind} memory matched substring '#{truncate(old_text)}'") unless target
128
+
129
+ "Memory replaced (id=#{target[:id][0, 8]}, kind=#{kind})."
130
+ rescue Memory::Store::ThreatDetectedError => e
131
+ threat_error(e)
132
+ rescue Memory::Store::BudgetExceededError => e
133
+ budget_error(e)
134
+ end
135
+
136
+ def do_remove(kind, old_text)
137
+ return error("old_text is required for remove") if blank?(old_text)
138
+
139
+ target = backend.forget(kind: kind, old_text: old_text)
140
+ return error("no #{kind} memory matched substring '#{truncate(old_text)}'") unless target
141
+
142
+ "Memory removed (id=#{target[:id][0, 8]}, kind=#{kind})."
143
+ end
144
+
145
+ def threat_error(err)
146
+ {
147
+ output: "Error: refused to write memory (#{err.threat}). " \
148
+ "Memory content was rejected by the threat scanner.",
149
+ error_code: :memory_threat_detected
150
+ }
151
+ end
152
+
153
+ def budget_error(err)
154
+ {
155
+ output: "Error: memory budget exceeded; delete or replace older entries first " \
156
+ "(group=#{err.group}, used=#{err.current}, requested=#{err.requested}, " \
157
+ "limit=#{err.limit}).",
158
+ error_code: :memory_budget_exceeded
159
+ }
160
+ end
161
+
162
+ def error(msg)
163
+ "Error: #{msg}"
164
+ end
165
+
166
+ def blank?(value)
167
+ value.nil? || value.to_s.strip.empty?
168
+ end
169
+
170
+ def truncate(text, max: 40)
171
+ s = text.to_s
172
+ s.length > max ? "#{s[0, max]}..." : s
173
+ end
174
+
175
+ def symbolize(arguments)
176
+ return {} unless arguments.is_a?(Hash)
177
+
178
+ arguments.each_with_object({}) do |(k, v), acc|
179
+ acc[k.to_sym] = v
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end