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,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Session
8
+ # Thin CRUD wrapper over the `sessions` table. All session persistence
9
+ # goes through this class; callers should not touch the dataset directly.
10
+ #
11
+ # Notes:
12
+ # - #find supports prefix matching on the UUID so short ids from the CLI
13
+ # resolve to a full session row.
14
+ # - #latest_active is used to resume the most recently touched session.
15
+ # - #destroy! cascades manually to events, tool_calls, messages,
16
+ # session_summaries and runs inside a single transaction (no FK cascade
17
+ # in schema; the runs FK would otherwise block the session delete).
18
+ class Repository
19
+ def initialize(db: nil)
20
+ @db = db || Rubino.database.db
21
+ end
22
+
23
+ # Creates a new session and returns its record
24
+ def create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil)
25
+ now = Time.now.utc.iso8601
26
+ id = generate_id
27
+
28
+ @db[:sessions].insert(
29
+ id: id,
30
+ parent_session_id: parent_session_id,
31
+ source: source,
32
+ model: model,
33
+ provider: provider,
34
+ title: title,
35
+ status: "active",
36
+ owner_pid: Process.pid,
37
+ message_count: 0,
38
+ token_count: 0,
39
+ created_at: now,
40
+ updated_at: now
41
+ )
42
+
43
+ find(id)
44
+ end
45
+
46
+ # Builds an UNSAVED session record (in-memory only) with a real id, so the
47
+ # CLI can open `chat` without persisting a row until the user actually
48
+ # sends a message (#144). The row is inserted lazily by #persist! on the
49
+ # first message; a session the user opens and immediately exits never
50
+ # touches the DB, so `/sessions` stays free of (untitled)/0-msg junk.
51
+ def build(source:, model: nil, provider: nil, title: nil, parent_session_id: nil)
52
+ now = Time.now.utc.iso8601
53
+ {
54
+ id: generate_id,
55
+ parent_session_id: parent_session_id,
56
+ source: source,
57
+ model: model,
58
+ provider: provider,
59
+ title: title,
60
+ status: "active",
61
+ message_count: 0,
62
+ token_count: 0,
63
+ created_at: now,
64
+ updated_at: now,
65
+ persisted: false
66
+ }
67
+ end
68
+
69
+ # Inserts a session row built by #build if it isn't already in the DB.
70
+ # Idempotent: a no-op once persisted (the common per-message path checks
71
+ # this first). Returns the (now persisted) session record.
72
+ def persist!(session)
73
+ return session if session[:persisted] || persisted?(session[:id])
74
+
75
+ @db[:sessions].insert(
76
+ id: session[:id],
77
+ parent_session_id: session[:parent_session_id],
78
+ source: session[:source],
79
+ model: session[:model],
80
+ provider: session[:provider],
81
+ title: session[:title],
82
+ status: session[:status] || "active",
83
+ owner_pid: Process.pid,
84
+ message_count: 0,
85
+ token_count: 0,
86
+ created_at: session[:created_at] || Time.now.utc.iso8601,
87
+ updated_at: Time.now.utc.iso8601
88
+ )
89
+ session[:persisted] = true
90
+ session
91
+ end
92
+
93
+ # True when a row with this id exists in the sessions table.
94
+ def persisted?(id)
95
+ return false if id.nil?
96
+
97
+ !@db[:sessions].where(id: id).empty?
98
+ end
99
+
100
+ # Finds a session by ID (supports prefix matching)
101
+ def find(id)
102
+ @db[:sessions].where(Sequel.like(:id, "#{id}%")).first
103
+ end
104
+
105
+ # Resolves a user-supplied query to a session: tries ID prefix first
106
+ # (handles "abc12345" style short IDs), then falls back to a case-
107
+ # insensitive substring match across the 50 most recent sessions —
108
+ # against the title AND the full first user message. The stored title
109
+ # is truncated (~60 chars), so a memorable word from the TAIL of a long
110
+ # first prompt would otherwise silently fail to resume (#70).
111
+ # Returns the session row or nil. Centralised so the CLI Runner and
112
+ # the TUI history loader agree on what `--resume <query>` accepts.
113
+ #
114
+ # Raises AmbiguousSessionError when >1 session matches, so the CLI
115
+ # can show the candidates instead of silently picking the first row
116
+ # — see issue triaged from the audit (#116).
117
+ def find_by_id_or_title(query)
118
+ return nil if query.nil? || query.to_s.empty?
119
+
120
+ id_matches = @db[:sessions].where(Sequel.like(:id, "#{query}%")).all
121
+ if id_matches.size > 1
122
+ raise AmbiguousSessionError.new(query, id_matches)
123
+ elsif id_matches.size == 1
124
+ return id_matches.first
125
+ end
126
+
127
+ needle = query.to_s.downcase
128
+ title_matches = list(limit: 50).select do |s|
129
+ s[:title]&.downcase&.include?(needle) ||
130
+ first_user_message(s[:id])&.downcase&.include?(needle)
131
+ end
132
+ if title_matches.size > 1
133
+ raise AmbiguousSessionError.new(query, title_matches)
134
+ elsif title_matches.size == 1
135
+ return title_matches.first
136
+ end
137
+
138
+ nil
139
+ end
140
+
141
+ # Lists sessions with optional filters
142
+ def list(limit: 20, status: nil, search: nil)
143
+ dataset = @db[:sessions].order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid"))).limit(limit)
144
+ dataset = dataset.where(status: status) if status
145
+ dataset = dataset.where(Sequel.like(:title, "%#{search}%")) if search && !search.empty?
146
+ dataset.all
147
+ end
148
+
149
+ # Updates a session's attributes
150
+ def update(id, **attrs)
151
+ attrs[:updated_at] = Time.now.utc.iso8601
152
+ @db[:sessions].where(id: id).update(attrs)
153
+ end
154
+
155
+ # Increments message count
156
+ def increment_message_count!(id)
157
+ @db[:sessions].where(id: id).update(
158
+ message_count: Sequel[:message_count] + 1,
159
+ updated_at: Time.now.utc.iso8601
160
+ )
161
+ end
162
+
163
+ # Updates token count
164
+ def update_token_count!(id, token_count)
165
+ @db[:sessions].where(id: id).update(
166
+ token_count: token_count,
167
+ updated_at: Time.now.utc.iso8601
168
+ )
169
+ end
170
+
171
+ # Ends a session
172
+ def end_session!(id)
173
+ now = Time.now.utc.iso8601
174
+ @db[:sessions].where(id: id).update(
175
+ status: "ended",
176
+ ended_at: now,
177
+ owner_pid: nil,
178
+ updated_at: now
179
+ )
180
+ end
181
+
182
+ # Reaps orphaned sessions: any row still "active" whose owning process is
183
+ # gone is stamped "ended" (#11). This covers the un-trappable hard kill
184
+ # (SIGKILL) and a closed terminal whose SIGHUP never reached the process,
185
+ # where neither the clean-exit path nor the signal traps ran. Rows owned
186
+ # by a live process (including the current one) and rows with no recorded
187
+ # pid (pre-#11 / future sources) are left untouched. Called lazily before
188
+ # listing/resuming sessions; best-effort, returns the number reaped.
189
+ def reap_orphaned_active!
190
+ reaped = 0
191
+ @db[:sessions]
192
+ .where(status: "active")
193
+ .exclude(owner_pid: nil)
194
+ .select(:id, :owner_pid)
195
+ .each do |row|
196
+ next if process_alive?(row[:owner_pid])
197
+
198
+ end_session!(row[:id])
199
+ reaped += 1
200
+ end
201
+ reaped
202
+ rescue StandardError
203
+ reaped
204
+ end
205
+
206
+ # Returns the most recent active session, if any
207
+ def latest_active
208
+ @db[:sessions]
209
+ .where(status: "active")
210
+ .order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid")))
211
+ .first
212
+ end
213
+
214
+ # Returns the most recent session worth resuming on a bare `chat`: the
215
+ # last session that actually has messages, regardless of status, so a
216
+ # closed terminal (status still "active") OR a cleanly ended session can
217
+ # both be continued. Empty 0-message sessions are skipped so a stray
218
+ # earlier launch never shadows the real conversation (#99). Returns nil on
219
+ # a true first run, which the CLI uses to fall back to the welcome panel.
220
+ def latest_resumable
221
+ @db[:sessions]
222
+ .where { message_count > 0 }
223
+ .order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid")))
224
+ .first
225
+ end
226
+
227
+ # A first prompt shorter than this is junk for titling purposes (#128): a
228
+ # throwaway "y"/"ok" the user immediately interrupted would otherwise
229
+ # become the session title and a useless one-char `--resume "y"` matcher.
230
+ TITLE_MIN_CHARS = 3
231
+
232
+ # Derives a short, human-readable session title from the first user
233
+ # message. Deterministic and model-free (#103): collapse whitespace, strip
234
+ # a leading slash-command word, take the first line, and truncate on a word
235
+ # boundary. Returns nil for empty/blank input — and for junk-short input
236
+ # (#128) — so the caller leaves the session untitled; the next MEANINGFUL
237
+ # prompt titles it instead (Lifecycle#maybe_set_title retries every turn
238
+ # until a title sticks), and the resume hint falls back to the session id.
239
+ def self.derive_title(text, max: 60)
240
+ cleaned = text.to_s.split("\n").first.to_s.strip.gsub(/\s+/, " ")
241
+ cleaned = cleaned.sub(%r{\A/\S+\s*}, "") # drop a leading slash command
242
+ return nil if cleaned.length < TITLE_MIN_CHARS
243
+ return cleaned if cleaned.length <= max
244
+
245
+ truncated = cleaned[0, max].sub(/\s+\S*\z/, "")
246
+ truncated = cleaned[0, max] if truncated.empty?
247
+ "#{truncated}…"
248
+ end
249
+
250
+ # Deletes a session and all related records
251
+ def destroy!(id)
252
+ @db.transaction do
253
+ @db[:events].where(session_id: id).delete
254
+ @db[:tool_calls].where(session_id: id).delete
255
+ @db[:messages].where(session_id: id).delete
256
+ @db[:session_summaries].where(session_id: id).delete
257
+ @db[:runs].where(session_id: id).delete
258
+ @db[:sessions].where(id: id).delete
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ # The full first user message of a session — what derive_title truncated
265
+ # the title from — so resume-by-title can match the whole prompt (#70).
266
+ def first_user_message(session_id)
267
+ @db[:messages]
268
+ .where(session_id: session_id, role: "user")
269
+ .order(:created_at, Sequel.lit("rowid"))
270
+ .get(:content)
271
+ end
272
+
273
+ # True when a process with this pid is currently alive and signalable by
274
+ # us. Process.kill(0, pid) is the canonical liveness probe: it sends no
275
+ # signal but raises Errno::ESRCH when the pid is gone. Errno::EPERM means
276
+ # the pid exists but is owned by another user — still alive, do not reap.
277
+ def process_alive?(pid)
278
+ return false if pid.nil?
279
+
280
+ Process.kill(0, pid)
281
+ true
282
+ rescue Errno::ESRCH
283
+ false
284
+ rescue Errno::EPERM
285
+ true
286
+ rescue StandardError
287
+ true # unknown error: be conservative and keep the session
288
+ end
289
+
290
+ def generate_id
291
+ SecureRandom.uuid
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module Session
7
+ # Persists and queries messages within a session.
8
+ #
9
+ # Ordering note: created_at is iso8601 with second precision, so multiple
10
+ # messages can share the same timestamp. Read/delete paths that need a
11
+ # strict total order break ties on the SQLite `rowid` column.
12
+ #
13
+ # #last_for_role is the entry point used by retry/undo to find the last
14
+ # user (or assistant) turn before rewinding history.
15
+ class Store
16
+ def initialize(db: nil)
17
+ @db = db || Rubino.database.db
18
+ end
19
+
20
+ # Appends a message to a session
21
+ def append(message)
22
+ raise SessionError, "Invalid message" unless message.valid?
23
+
24
+ @db[:messages].insert(message.to_row)
25
+ message
26
+ end
27
+
28
+ # Creates and appends a message from attributes
29
+ def create(session_id:, role:, content:, **attrs)
30
+ message = Message.new(
31
+ session_id: session_id,
32
+ role: role,
33
+ content: content,
34
+ **attrs
35
+ )
36
+ append(message)
37
+ end
38
+
39
+ # Copies messages into another session preserving ALL wire-significant
40
+ # fields. Assistant tool calls live in metadata[:tool_calls] (not
41
+ # tool_call_id), so dropping metadata orphans the toolUse block and 400s
42
+ # strict providers (Anthropic/Bedrock) on resume. token_count is copied
43
+ # too so the target session's budget accounting stays accurate.
44
+ def copy_into(target_session_id, messages)
45
+ messages.each do |msg|
46
+ create(
47
+ session_id: target_session_id,
48
+ role: msg.role,
49
+ content: msg.content,
50
+ tool_name: msg.tool_name,
51
+ tool_call_id: msg.tool_call_id,
52
+ token_count: msg.token_count,
53
+ metadata: msg.metadata
54
+ )
55
+ end
56
+ end
57
+
58
+ # Returns all messages for a session in chronological order.
59
+ # created_at is second-precision, so we tie-break on rowid — without
60
+ # this, an assistant preamble and a tool_result persisted in the same
61
+ # second can come back swapped, which makes the resumed transcript
62
+ # look like the tool fired before the model's preamble (or worse, like
63
+ # an empty assistant box wrapping the tool).
64
+ def for_session(session_id, limit: nil)
65
+ dataset = @db[:messages]
66
+ .where(session_id: session_id)
67
+ .order(:created_at, Sequel.lit("rowid"))
68
+ dataset = dataset.limit(limit) if limit
69
+ dataset.all.map { |row| hydrate(row) }
70
+ end
71
+
72
+ # Returns the N most recent messages for a session
73
+ def recent(session_id, count:)
74
+ @db[:messages]
75
+ .where(session_id: session_id)
76
+ .order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
77
+ .limit(count)
78
+ .all
79
+ .reverse
80
+ .map { |row| hydrate(row) }
81
+ end
82
+
83
+ # Returns total message count for a session
84
+ def count(session_id)
85
+ @db[:messages].where(session_id: session_id).count
86
+ end
87
+
88
+ # Returns estimated token sum for a session
89
+ def token_sum(session_id)
90
+ @db[:messages]
91
+ .where(session_id: session_id)
92
+ .sum(:token_count) || 0
93
+ end
94
+
95
+ # Deletes the given message and every message inserted after it.
96
+ # Used by undo/retry to rewind history.
97
+ #
98
+ # Uses tuple ordering on (created_at, rowid): rows strictly later by
99
+ # timestamp are removed, and ties on created_at are broken by rowid so
100
+ # same-second inserts are still cut at the right point.
101
+ #
102
+ # @param session_id [String]
103
+ # @param from_id [String] id of the first message to delete
104
+ # @return [Integer] number of rows removed
105
+ def delete_from_inclusive(session_id, from_id:)
106
+ msg = @db[:messages]
107
+ .where(id: from_id, session_id: session_id)
108
+ .select(:created_at, Sequel.lit("rowid AS row_id"))
109
+ .first
110
+ return 0 unless msg
111
+
112
+ @db[:messages]
113
+ .where(session_id: session_id)
114
+ .where(Sequel.lit("(created_at > ?) OR (created_at = ? AND rowid >= ?)",
115
+ msg[:created_at], msg[:created_at], msg[:row_id]))
116
+ .delete
117
+ end
118
+
119
+ # Full-text search across messages backed by the `messages_fts` FTS5
120
+ # virtual table (see migration 007). Returns hydrated rows with an
121
+ # FTS5 snippet() highlighting the match. Filters compose on top of the
122
+ # FTS MATCH so the index does the heavy lifting and SQL prunes the rest.
123
+ #
124
+ # @param query [String] FTS5 MATCH expression; sanitized via Quoting
125
+ # @param since [String, nil] iso8601 lower bound on created_at
126
+ # @param until_ [String, nil] iso8601 upper bound on created_at
127
+ # @param role [String, nil] restrict to a specific message role
128
+ # @param tool [String, nil] restrict to a specific tool_name
129
+ # @param limit [Integer] cap on rows returned (max 100)
130
+ # @return [Array<Hash>] rows: session_id, run_id (nil — not tracked on
131
+ # messages), message_id, role, snippet, created_at
132
+ def search(query:, since: nil, until_: nil, role: nil, tool: nil, limit: 20)
133
+ return [] if query.nil? || query.to_s.strip.empty?
134
+
135
+ limit = limit.to_i.clamp(1, 100)
136
+ match_query = sanitize_fts_query(query)
137
+
138
+ dataset = @db[:messages_fts]
139
+ .where(Sequel.lit("messages_fts MATCH ?", match_query))
140
+ .join(:messages, Sequel[:messages][:rowid] => Sequel[:messages_fts][:rowid])
141
+ .select(
142
+ Sequel[:messages][:id].as(:message_id),
143
+ Sequel[:messages][:session_id],
144
+ Sequel[:messages][:role],
145
+ Sequel[:messages][:created_at],
146
+ Sequel.lit("snippet(messages_fts, 0, '<mark>', '</mark>', '...', 16) AS snippet")
147
+ )
148
+
149
+ dataset = dataset.where(Sequel[:messages][:role] => role) if role
150
+ dataset = dataset.where(Sequel[:messages][:tool_name] => tool) if tool
151
+ dataset = dataset.where(Sequel.lit("messages.created_at >= ?", since)) if since
152
+ dataset = dataset.where(Sequel.lit("messages.created_at <= ?", until_)) if until_
153
+
154
+ dataset
155
+ .order(Sequel.desc(Sequel[:messages][:created_at]), Sequel.desc(Sequel.lit("messages.rowid")))
156
+ .limit(limit)
157
+ .all
158
+ .map { |row| row.merge(run_id: nil) }
159
+ end
160
+
161
+ # Returns the most recent message for `role` (e.g. "user", "assistant").
162
+ # Tie-broken on rowid like the other read paths. Used by retry/undo.
163
+ def last_for_role(session_id, role)
164
+ row = @db[:messages]
165
+ .where(session_id: session_id, role: role)
166
+ .order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
167
+ .first
168
+ row && hydrate(row)
169
+ end
170
+
171
+ private
172
+
173
+ # FTS5 MATCH treats unquoted strings as expression syntax — a stray
174
+ # double quote or a token starting with `-`/`*` raises a syntax error
175
+ # at query time. Wrap the whole query as a single quoted phrase
176
+ # (doubling any embedded quotes) so user input is always literal.
177
+ def sanitize_fts_query(query)
178
+ "\"#{query.to_s.gsub('"', '""')}\""
179
+ end
180
+
181
+ def hydrate(row)
182
+ metadata = row[:metadata_json] ? JSON.parse(row[:metadata_json], symbolize_names: true) : {}
183
+
184
+ Message.new(
185
+ id: row[:id],
186
+ session_id: row[:session_id],
187
+ role: row[:role],
188
+ content: row[:content],
189
+ tool_name: row[:tool_name],
190
+ tool_call_id: row[:tool_call_id],
191
+ token_count: row[:token_count],
192
+ metadata: metadata,
193
+ created_at: row[:created_at]
194
+ )
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Rubino
6
+ module Session
7
+ # Single owner of the `session_summaries` table.
8
+ #
9
+ # Compaction summaries used to be read and written from three places
10
+ # (Context::Compressor, Context::SummaryBuilder, Context::PromptAssembler)
11
+ # with near-identical Sequel blocks that DIVERGED: the compressor stamped
12
+ # parent_summary_id to chain lineage, the builder's own save! did not — so
13
+ # whether a summary linked to its parent depended on which code path
14
+ # happened to write it. Centralising here means the row shape and the
15
+ # parent lineage live in exactly one place.
16
+ #
17
+ # "latest" is defined as the most recent created_at for a session
18
+ # (iso8601, ordered desc) — the same ordering every former caller used.
19
+ class SummaryStore
20
+ def initialize(db: nil)
21
+ @db = db || Rubino.database.db
22
+ end
23
+
24
+ # Most recent summary record for a session (or nil).
25
+ def latest(session_id)
26
+ dataset(session_id).first
27
+ end
28
+
29
+ # Content of the most recent summary (or nil) — the read path used when
30
+ # only the text is needed (prompt assembly, previous-summary carry-over).
31
+ def latest_content(session_id)
32
+ latest(session_id)&.dig(:content)
33
+ end
34
+
35
+ # Id of the most recent summary (or nil) — used as the parent link when
36
+ # recording compaction lineage.
37
+ def latest_id(session_id)
38
+ latest(session_id)&.dig(:id)
39
+ end
40
+
41
+ # Inserts a new summary, chaining parent_summary_id to the current latest
42
+ # so lineage is always recorded regardless of caller. Returns the new id.
43
+ def insert(session_id:, content:)
44
+ id = SecureRandom.uuid
45
+ @db[:session_summaries].insert(
46
+ id: id,
47
+ session_id: session_id,
48
+ parent_summary_id: latest_id(session_id),
49
+ content: content,
50
+ token_count: (content.length / 4.0).ceil,
51
+ created_at: Time.now.utc.iso8601
52
+ )
53
+ id
54
+ end
55
+
56
+ private
57
+
58
+ def dataset(session_id)
59
+ @db[:session_summaries]
60
+ .where(session_id: session_id)
61
+ .order(Sequel.desc(:created_at))
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Skills
5
+ # Builds the "## Skills (mandatory)" block injected into the SYSTEM PROMPT.
6
+ #
7
+ # This is the load-bearing trigger for skill auto-activation: surfacing the
8
+ # skill catalogue inside the system prompt (not just the `skill` tool's
9
+ # description) is what makes the model proactively scan and load a relevant
10
+ # skill before replying. Mirrors the reference build_skills_system_prompt,
11
+ # adapted to rubino's `skill(name)`
12
+ # invocation and flat name+description catalogue.
13
+ #
14
+ # Always renders a block when the skills feature is on (the caller gates on
15
+ # that): the catalogue half is dropped when no skills exist, but the
16
+ # CREATION half is always present so even a fresh install with zero skills
17
+ # nudges the agent to distill repeatable work into a new skill. Never
18
+ # returns nil — an empty registry is a valid state that still wants the
19
+ # create nudge.
20
+ class PromptIndex
21
+ # Where a freshly authored skill should be written. Mirrors the Registry's
22
+ # project-local default path; surfaced in the create nudge so the agent
23
+ # knows the exact destination + filename contract.
24
+ DEFAULT_SKILL_DIR = ".rubino/skills"
25
+
26
+ def initialize(registry: nil)
27
+ @registry = registry || Registry.new
28
+ end
29
+
30
+ # Renders the "## Skills (mandatory)" block: the available-skills
31
+ # catalogue (when any exist) followed by the proactive-creation nudge
32
+ # (always). Never nil — see the class comment.
33
+ def render
34
+ [catalogue, creation_nudge].compact.join("\n\n")
35
+ end
36
+
37
+ private
38
+
39
+ # The load-bearing auto-LOAD trigger. Nil when no skills are discovered,
40
+ # so a fresh install shows only the create nudge instead of an empty
41
+ # <available_skills> block.
42
+ def catalogue
43
+ summaries = @registry.summaries
44
+ return nil if summaries.empty?
45
+
46
+ lines = summaries.map { |s| " - #{s}" }.join("\n")
47
+ <<~PROMPT.strip
48
+ ## Skills (mandatory)
49
+ Before replying, scan the skills below. If a skill matches or is even partially relevant to your task, you MUST load it with skill(name) and follow its instructions. Err on the side of loading — it is always better to have context you don't need than to miss critical steps, pitfalls, or established workflows. Skills contain specialized knowledge — APIs, tool-specific commands, and proven workflows that outperform general-purpose approaches — and they encode the user's preferred conventions and quality standards. Load the relevant skill even for tasks you already know how to do, because the skill defines how it should be done here.
50
+
51
+ <available_skills>
52
+ #{lines}
53
+ </available_skills>
54
+
55
+ Only proceed without loading a skill if genuinely none are relevant to the task.
56
+ PROMPT
57
+ end
58
+
59
+ # The proactive-CREATION nudge — the counterpart to the load trigger.
60
+ # Without this the agent only ever consumes skills and never authors one,
61
+ # so a completed complex/repeatable task is lost instead of distilled into
62
+ # a reusable skill (skill-bench: proactive-creation F1 = 0). Gives the
63
+ # exact path + SKILL.md format so the agent can write the file with its
64
+ # normal file-writing tool, unprompted.
65
+ #
66
+ # Heads the block with the "## Skills" header when the catalogue is absent
67
+ # (fresh install) so the header is never orphaned.
68
+ def creation_nudge
69
+ header = @registry.summaries.empty? ? "## Skills\n" : ""
70
+ <<~PROMPT.strip
71
+ #{header}### Creating skills
72
+ When you finish a task that was complex, multi-step (typically 5+ tool calls), and likely to recur — and no existing skill already covers it — proactively capture it as a new skill so the next run is faster and more reliable. Do this at the natural end of the work, without being asked, and without interrupting the user mid-task. If the work was trivial, one-off, or already covered by a loaded skill, do NOT create one.
73
+
74
+ To create a skill, call the `skill` tool with action "create":
75
+
76
+ <skill_create>
77
+ skill(action: "create", name: "<kebab-case-name>", description: "One line saying what the skill is for and WHEN it applies — this is the only text future runs see before loading it, so make it match-on-sight.", body: "# <Title>\\n\\nThe proven, step-by-step instructions, commands, and pitfalls you just worked out. Be specific and prescriptive.")
78
+ </skill_create>
79
+
80
+ This writes `#{DEFAULT_SKILL_DIR}/<name>/SKILL.md` for you with valid frontmatter — you do not need the write/edit tool for this.
81
+ PROMPT
82
+ end
83
+ end
84
+ end
85
+ end