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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "fileutils"
5
+
6
+ module Rubino
7
+ module Database
8
+ # Manages the SQLite database connection via Sequel.
9
+ # Handles connection creation, WAL mode setup, and provides
10
+ # access to the underlying Sequel::Database instance.
11
+ class Connection
12
+ # SQLite path values that resolve to an ephemeral, in-memory database
13
+ # rather than an on-disk file. These must skip File.expand_path
14
+ # (which would turn ":memory:" into a literal "./:memory:" file) and
15
+ # FileUtils.mkdir_p on the parent directory.
16
+ MEMORY_PATHS = [":memory:", "file::memory:"].freeze
17
+
18
+ attr_reader :db_path
19
+
20
+ def initialize(db_path)
21
+ @db_path = memory_path?(db_path) ? db_path : File.expand_path(db_path)
22
+ end
23
+
24
+ # Returns the Sequel database connection (lazy-initialized)
25
+ def db
26
+ @db ||= connect!
27
+ end
28
+
29
+ # Tests if the database is accessible
30
+ def healthy?
31
+ db.execute("SELECT 1")
32
+ true
33
+ rescue StandardError
34
+ false
35
+ end
36
+
37
+ # Closes the database connection
38
+ def close
39
+ @db&.disconnect
40
+ @db = nil
41
+ end
42
+
43
+ # True when @db_path refers to an in-memory SQLite instance.
44
+ def memory?
45
+ memory_path?(@db_path)
46
+ end
47
+
48
+ private
49
+
50
+ def memory_path?(path)
51
+ MEMORY_PATHS.any? { |p| path == p } || path.to_s.start_with?("file::memory:")
52
+ end
53
+
54
+ def connect!
55
+ existed = memory? || File.exist?(@db_path)
56
+ FileUtils.mkdir_p(File.dirname(@db_path)) unless memory?
57
+
58
+ connection = Sequel.sqlite(@db_path)
59
+
60
+ # A freshly-created database holds session content — owner-only, like
61
+ # the rest of the home's secrets (#65). Creation-only so an operator
62
+ # who deliberately re-chmods an existing file is respected.
63
+ File.chmod(0o600, @db_path) unless existed
64
+
65
+ # WAL has no meaning for :memory: and triggers a warning; only apply on disk.
66
+ unless memory?
67
+ connection.run("PRAGMA journal_mode=WAL")
68
+ connection.run("PRAGMA synchronous=NORMAL")
69
+ end
70
+ connection.run("PRAGMA foreign_keys=ON")
71
+ connection.run("PRAGMA busy_timeout=5000")
72
+
73
+ connection
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:sessions) do
6
+ String :id, primary_key: true
7
+ String :parent_session_id
8
+ String :source, null: false
9
+ String :model
10
+ String :provider
11
+ String :title
12
+ Text :summary
13
+ String :status, null: false, default: "active"
14
+ Integer :message_count, null: false, default: 0
15
+ Integer :token_count, null: false, default: 0
16
+ String :created_at, null: false
17
+ String :updated_at, null: false
18
+ String :ended_at
19
+ end
20
+
21
+ create_table(:messages) do
22
+ String :id, primary_key: true
23
+ String :session_id, null: false
24
+ String :role, null: false
25
+ Text :content
26
+ String :tool_name
27
+ String :tool_call_id
28
+ Integer :token_count, default: 0
29
+ Text :metadata_json
30
+ String :created_at, null: false
31
+
32
+ foreign_key [:session_id], :sessions, key: :id
33
+ end
34
+
35
+ add_index :messages, :session_id
36
+ add_index :messages, :created_at
37
+
38
+ create_table(:tool_calls) do
39
+ String :id, primary_key: true
40
+ String :session_id, null: false
41
+ String :message_id
42
+ String :tool_name, null: false
43
+ Text :input_json
44
+ Text :output
45
+ String :status, null: false
46
+ String :risk_level
47
+ String :started_at
48
+ String :finished_at
49
+ Text :error
50
+
51
+ foreign_key [:session_id], :sessions, key: :id
52
+ end
53
+
54
+ add_index :tool_calls, :session_id
55
+ add_index :tool_calls, :tool_name
56
+
57
+ create_table(:memories) do
58
+ String :id, primary_key: true
59
+ String :kind, null: false
60
+ Text :content, null: false
61
+ String :source_session_id
62
+ Float :confidence, default: 1.0
63
+ Text :metadata_json
64
+ String :created_at, null: false
65
+ String :updated_at, null: false
66
+ end
67
+
68
+ add_index :memories, :kind
69
+ add_index :memories, :created_at
70
+
71
+ create_table(:session_summaries) do
72
+ String :id, primary_key: true
73
+ String :session_id, null: false
74
+ String :parent_summary_id
75
+ Text :content, null: false
76
+ Integer :token_count, default: 0
77
+ String :created_at, null: false
78
+
79
+ foreign_key [:session_id], :sessions, key: :id
80
+ end
81
+
82
+ add_index :session_summaries, :session_id
83
+
84
+ create_table(:compactions) do
85
+ String :id, primary_key: true
86
+ String :source_session_id, null: false
87
+ String :target_session_id, null: false
88
+ String :previous_summary_id
89
+ String :new_summary_id
90
+ Integer :original_token_count
91
+ Integer :compacted_token_count
92
+ Integer :saved_token_count
93
+ String :created_at, null: false
94
+ end
95
+
96
+ add_index :compactions, :source_session_id
97
+
98
+ create_table(:jobs) do
99
+ String :id, primary_key: true
100
+ String :type, null: false
101
+ String :status, null: false, default: "queued"
102
+ Integer :priority, null: false, default: 100
103
+ Text :payload_json, null: false
104
+ Integer :attempts, null: false, default: 0
105
+ Integer :max_attempts, null: false, default: 3
106
+ String :run_at, null: false
107
+ String :locked_at
108
+ String :locked_by
109
+ Text :last_error
110
+ String :created_at, null: false
111
+ String :updated_at, null: false
112
+ end
113
+
114
+ add_index :jobs, :status
115
+ add_index :jobs, :run_at
116
+ add_index :jobs, %i[status run_at]
117
+
118
+ create_table(:job_runs) do
119
+ String :id, primary_key: true
120
+ String :job_id, null: false
121
+ String :status, null: false
122
+ String :started_at, null: false
123
+ String :finished_at
124
+ Text :error
125
+ Text :metadata_json
126
+
127
+ foreign_key [:job_id], :jobs, key: :id
128
+ end
129
+
130
+ add_index :job_runs, :job_id
131
+
132
+ create_table(:events) do
133
+ String :id, primary_key: true
134
+ String :session_id
135
+ String :type, null: false
136
+ Text :payload_json
137
+ String :created_at, null: false
138
+ end
139
+
140
+ add_index :events, :session_id
141
+ add_index :events, :type
142
+ add_index :events, :created_at
143
+ end
144
+
145
+ down do
146
+ drop_table(:events)
147
+ drop_table(:job_runs)
148
+ drop_table(:jobs)
149
+ drop_table(:compactions)
150
+ drop_table(:session_summaries)
151
+ drop_table(:memories)
152
+ drop_table(:tool_calls)
153
+ drop_table(:messages)
154
+ drop_table(:sessions)
155
+ end
156
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:runs) do
6
+ String :id, primary_key: true
7
+ String :session_id, null: false
8
+ String :status, null: false, default: "queued" # queued|running|completed|failed|stopped
9
+ Text :input_text
10
+ Text :attachments_json
11
+ Text :skills_json
12
+ String :model
13
+ String :provider
14
+ Integer :tokens_input, default: 0
15
+ Integer :tokens_output, default: 0
16
+ Text :error
17
+ Boolean :stop_requested, null: false, default: false
18
+ String :started_at
19
+ String :finished_at
20
+ String :created_at, null: false
21
+ String :updated_at, null: false
22
+
23
+ foreign_key [:session_id], :sessions, key: :id
24
+ end
25
+
26
+ add_index :runs, :session_id
27
+ add_index :runs, :status
28
+
29
+ alter_table(:events) do
30
+ add_column :run_id, String
31
+ add_column :seq, Integer # per-session monotonic seq for SSE Last-Event-ID
32
+ end
33
+
34
+ add_index :events, :run_id
35
+ add_index :events, %i[session_id seq]
36
+ end
37
+
38
+ down do
39
+ alter_table(:events) do
40
+ drop_column :seq
41
+ drop_column :run_id
42
+ end
43
+ drop_table(:runs)
44
+ end
45
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:skill_states) do
6
+ String :name, primary_key: true
7
+ Boolean :enabled, null: false, default: true
8
+ String :updated_at, null: false
9
+ end
10
+ end
11
+
12
+ down do
13
+ drop_table(:skill_states)
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:cron_jobs) do
6
+ String :id, primary_key: true
7
+ String :name, null: false
8
+ String :schedule, null: false # cron expression
9
+ Text :prompt, null: false
10
+ Text :skills_json
11
+ String :model
12
+ String :provider
13
+ String :deliver, null: false, default: "local" # local|webhook
14
+ Boolean :enabled, null: false, default: true
15
+ String :last_run_at
16
+ String :last_run_id
17
+ String :created_at, null: false
18
+ String :updated_at, null: false
19
+ end
20
+
21
+ add_index :cron_jobs, :enabled
22
+ add_index :cron_jobs, :name
23
+
24
+ alter_table(:runs) do
25
+ add_column :cron_job_id, String
26
+ end
27
+ add_index :runs, :cron_job_id
28
+ end
29
+
30
+ down do
31
+ alter_table(:runs) do
32
+ drop_column :cron_job_id
33
+ end
34
+ drop_table(:cron_jobs)
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:oauth_connections) do
6
+ String :id, primary_key: true
7
+ String :provider, null: false
8
+ String :account_id, null: false
9
+ String :account_email
10
+ Text :access_token, null: false # encrypted
11
+ Text :refresh_token # encrypted
12
+ String :expires_at
13
+ Text :scopes_json, null: false
14
+ Text :metadata_json
15
+ String :created_at, null: false
16
+ String :updated_at, null: false
17
+
18
+ unique %i[provider account_id]
19
+ end
20
+
21
+ add_index :oauth_connections, :provider
22
+ end
23
+
24
+ down do
25
+ drop_table(:oauth_connections)
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:webhook_deliveries) do
6
+ String :id, primary_key: true
7
+ String :job_id
8
+ String :run_id
9
+ String :target_url, null: false
10
+ # request_id (X-Rubino-Delivery-Id) is unique across delivery rows so
11
+ # a crash-then-restart cannot create two pending rows for the same logical
12
+ # attempt; the resume hook keys off this column.
13
+ String :request_id, null: false, unique: true
14
+ String :payload_sha256, null: false
15
+ Integer :attempt_count, null: false, default: 0
16
+ String :status, null: false, default: "pending" # pending|delivered|failed|dead
17
+ Text :last_error
18
+ Text :payload_json, null: false
19
+ String :scheduled_at, null: false
20
+ String :delivered_at
21
+ String :created_at, null: false
22
+ String :updated_at, null: false
23
+ end
24
+
25
+ add_index :webhook_deliveries, :status
26
+ add_index :webhook_deliveries, :scheduled_at
27
+ add_index :webhook_deliveries, :job_id
28
+ add_index :webhook_deliveries, :run_id
29
+ end
30
+
31
+ down do
32
+ drop_table(:webhook_deliveries)
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Full-text search over the `messages` table.
4
+ #
5
+ # Uses an external-content FTS5 table (content='messages') so the index never
6
+ # duplicates the body — FTS5 reaches into `messages` via rowid for each match.
7
+ # Triggers keep the index in sync on insert/update/delete; tokenizer is
8
+ # unicode61 with diacritic removal so "cafe"/"café" match.
9
+ Sequel.migration do
10
+ up do
11
+ run <<~SQL
12
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
13
+ content,
14
+ tool_name,
15
+ role,
16
+ content='messages',
17
+ content_rowid='rowid',
18
+ tokenize='unicode61 remove_diacritics 2'
19
+ );
20
+ SQL
21
+
22
+ run <<~SQL
23
+ CREATE TRIGGER messages_fts_ai AFTER INSERT ON messages BEGIN
24
+ INSERT INTO messages_fts(rowid, content, tool_name, role)
25
+ VALUES (new.rowid, new.content, new.tool_name, new.role);
26
+ END;
27
+ SQL
28
+
29
+ run <<~SQL
30
+ CREATE TRIGGER messages_fts_ad AFTER DELETE ON messages BEGIN
31
+ INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, role)
32
+ VALUES ('delete', old.rowid, old.content, old.tool_name, old.role);
33
+ END;
34
+ SQL
35
+
36
+ run <<~SQL
37
+ CREATE TRIGGER messages_fts_au AFTER UPDATE ON messages BEGIN
38
+ INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, role)
39
+ VALUES ('delete', old.rowid, old.content, old.tool_name, old.role);
40
+ INSERT INTO messages_fts(rowid, content, tool_name, role)
41
+ VALUES (new.rowid, new.content, new.tool_name, new.role);
42
+ END;
43
+ SQL
44
+
45
+ # Backfill any rows already present (no-op on a fresh DB; required when
46
+ # this migration runs against an existing install).
47
+ run <<~SQL
48
+ INSERT INTO messages_fts(rowid, content, tool_name, role)
49
+ SELECT rowid, content, tool_name, role FROM messages;
50
+ SQL
51
+ end
52
+
53
+ down do
54
+ run "DROP TRIGGER IF EXISTS messages_fts_au"
55
+ run "DROP TRIGGER IF EXISTS messages_fts_ad"
56
+ run "DROP TRIGGER IF EXISTS messages_fts_ai"
57
+ run "DROP TABLE IF EXISTS messages_fts"
58
+ end
59
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tiny-Zep memory store for Memory::Backends::Sqlite.
4
+ #
5
+ # One ATOMIC declarative fact per row, with bi-temporal validity: `valid_from`
6
+ # is when the fact became true in the world, `valid_to` is set when a later,
7
+ # contradicting fact supersedes it (Graphiti-style edge invalidation). A "live"
8
+ # fact is `valid_to IS NULL`; superseded rows are kept (historical record), just
9
+ # excluded from injection. `entities_json` carries lightweight tags so a graph
10
+ # slice can be layered on later without a schema change.
11
+ #
12
+ # A companion FTS5 virtual table mirrors `text` (+ entities) for BM25 recall,
13
+ # kept in sync by triggers — same external-content pattern as messages_fts.
14
+ Sequel.migration do
15
+ up do
16
+ create_table?(:memory_facts) do
17
+ String :id, primary_key: true
18
+ Text :text, null: false
19
+ String :kind, null: false
20
+ Text :entities_json
21
+ String :source_session_id
22
+ Float :confidence, default: 1.0
23
+ String :valid_from
24
+ String :valid_to
25
+ String :superseded_by
26
+ File :embedding
27
+ String :created_at, null: false
28
+ String :updated_at, null: false
29
+
30
+ index :kind
31
+ index :valid_to
32
+ end
33
+
34
+ run <<~SQL
35
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_facts_fts USING fts5(
36
+ text,
37
+ entities,
38
+ content='memory_facts',
39
+ content_rowid='rowid',
40
+ tokenize='porter unicode61 remove_diacritics 2'
41
+ );
42
+ SQL
43
+
44
+ run <<~SQL
45
+ CREATE TRIGGER IF NOT EXISTS memory_facts_fts_ai AFTER INSERT ON memory_facts BEGIN
46
+ INSERT INTO memory_facts_fts(rowid, text, entities)
47
+ VALUES (new.rowid, new.text, new.entities_json);
48
+ END;
49
+ SQL
50
+
51
+ run <<~SQL
52
+ CREATE TRIGGER IF NOT EXISTS memory_facts_fts_ad AFTER DELETE ON memory_facts BEGIN
53
+ INSERT INTO memory_facts_fts(memory_facts_fts, rowid, text, entities)
54
+ VALUES ('delete', old.rowid, old.text, old.entities_json);
55
+ END;
56
+ SQL
57
+
58
+ run <<~SQL
59
+ CREATE TRIGGER IF NOT EXISTS memory_facts_fts_au AFTER UPDATE ON memory_facts BEGIN
60
+ INSERT INTO memory_facts_fts(memory_facts_fts, rowid, text, entities)
61
+ VALUES ('delete', old.rowid, old.text, old.entities_json);
62
+ INSERT INTO memory_facts_fts(rowid, text, entities)
63
+ VALUES (new.rowid, new.text, new.entities_json);
64
+ END;
65
+ SQL
66
+ end
67
+
68
+ down do
69
+ run "DROP TRIGGER IF EXISTS memory_facts_fts_au"
70
+ run "DROP TRIGGER IF EXISTS memory_facts_fts_ad"
71
+ run "DROP TRIGGER IF EXISTS memory_facts_fts_ai"
72
+ run "DROP TABLE IF EXISTS memory_facts_fts"
73
+ drop_table?(:memory_facts)
74
+ end
75
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Graph-lite layer for Memory::Backends::Sqlite (Memory Phase 3b).
4
+ #
5
+ # Layers a tiny entity/relationship graph on top of the atomic-fact store so
6
+ # RELATIONAL queries ("what does X use for Y") surface facts connected via a
7
+ # 1-hop edge that pure FTS on the probe would miss. This is deliberately NOT a
8
+ # graph DB: two ordinary tables + bounded/recursive SQL.
9
+ #
10
+ # memory_entities — resolved nodes (people, tools, projects). `name_norm` is
11
+ # the lowercased key used for resolution/lookup so the same
12
+ # entity from different facts collapses to one node.
13
+ # memory_edges — typed relationships between two entities, each carrying
14
+ # the `source_fact_id` it was derived from and bi-temporal
15
+ # validity (`valid_from`/`valid_to`) exactly like facts:
16
+ # a contradicted relation is soft-retired, not deleted.
17
+ #
18
+ # An edge is "live" when `valid_to IS NULL`, matching the fact convention.
19
+ Sequel.migration do
20
+ up do
21
+ create_table?(:memory_entities) do
22
+ String :id, primary_key: true
23
+ String :name, null: false # display form, first-seen casing
24
+ String :name_norm, null: false # lowercased resolution key
25
+ String :kind # person | tool | project | ... (best-effort)
26
+ String :created_at, null: false
27
+ String :updated_at, null: false
28
+
29
+ index :name_norm, unique: true
30
+ end
31
+
32
+ create_table?(:memory_edges) do
33
+ String :id, primary_key: true
34
+ String :src_entity_id, null: false
35
+ String :dst_entity_id, null: false
36
+ String :relation, null: false # lowercased relation label (uses, deploys_to, ...)
37
+ String :source_fact_id # the fact this edge was derived from
38
+ String :valid_from
39
+ String :valid_to # set when superseded; live edge = NULL
40
+ String :superseded_by # id of the edge that invalidated this one
41
+ String :created_at, null: false
42
+ String :updated_at, null: false
43
+
44
+ index :src_entity_id
45
+ index :dst_entity_id
46
+ index :valid_to
47
+ index %i[src_entity_id dst_entity_id relation]
48
+ end
49
+ end
50
+
51
+ down do
52
+ drop_table?(:memory_edges)
53
+ drop_table?(:memory_entities)
54
+ end
55
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Records the OS process that owns a live (status="active") session so an
4
+ # orphaned session — one whose process died without ending it (e.g. a hard
5
+ # terminal kill / SIGKILL that no trap can catch, #11) — can be reaped to
6
+ # "ended" the next time sessions are listed or resumed. NULL for ended
7
+ # sessions and for rows created before this migration.
8
+ Sequel.migration do
9
+ up do
10
+ alter_table(:sessions) do
11
+ add_column :owner_pid, Integer
12
+ end
13
+ end
14
+
15
+ down do
16
+ alter_table(:sessions) do
17
+ drop_column :owner_pid
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "sequel/extensions/migration"
5
+
6
+ module Rubino
7
+ module Database
8
+ # Handles database schema migrations in order.
9
+ # Migrations are stored as numbered Sequel migration files.
10
+ class Migrator
11
+ MIGRATIONS_PATH = File.expand_path("migrations", __dir__)
12
+
13
+ def initialize(connection)
14
+ @connection = connection
15
+ end
16
+
17
+ # Runs all pending migrations
18
+ def migrate!
19
+ Sequel::Migrator.run(@connection.db, MIGRATIONS_PATH)
20
+ end
21
+
22
+ # Returns current migration version
23
+ def current_version
24
+ Sequel::Migrator.get_current_migration_version(@connection.db)
25
+ rescue StandardError
26
+ 0
27
+ end
28
+
29
+ # Returns true if there are unapplied migrations.
30
+ #
31
+ # Intentionally does NOT rescue: a connection/schema error here is a real
32
+ # health problem and must propagate so callers (e.g. doctor) can report a
33
+ # failure instead of silently treating an unreachable DB as "up to date".
34
+ def pending?
35
+ !Sequel::Migrator.is_current?(@connection.db, MIGRATIONS_PATH)
36
+ end
37
+
38
+ # Returns list of pending migration files
39
+ def pending_migrations
40
+ Sequel::Migrator.migrator_class(MIGRATIONS_PATH)
41
+ .new(@connection.db, MIGRATIONS_PATH)
42
+ .files
43
+ rescue StandardError
44
+ []
45
+ end
46
+ end
47
+ end
48
+ end