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,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module CLI
5
+ module Chat
6
+ # Builds the composer's CompletionSource: the `/command` + `@file` candidates
7
+ # plus the per-command ARGUMENT grammars (the dropdown that completes the
8
+ # argument of /skills, /agents, /mcp, /sessions, /memory, /config, … the same
9
+ # way it completes a command or a file). Extracted out of ChatCommand — a
10
+ # self-contained candidate/data-generation block (#17 collaborator pattern).
11
+ #
12
+ # Given the command loader it needs; every source is best-effort (a DB or
13
+ # registry hiccup degrades to no candidates, never a broken prompt) and read
14
+ # lazily on each dropdown open so a /model, /config or skill change is
15
+ # reflected immediately.
16
+ class CompletionBuilder
17
+ # The /agents subcommand grammar offered by the dropdown (#39): first an
18
+ # id, then what you can do to it.
19
+ AGENTS_SUBCOMMANDS = ["steer", "probe", "--stop"].freeze
20
+
21
+ # The /mcp subcommand grammar (#182): configured server names + reload
22
+ # first, then the on/off verbs for a named server.
23
+ MCP_SUBCOMMANDS = %w[on off].freeze
24
+
25
+ # The /sessions subcommand grammar (#183): verbs + recent session ids
26
+ # first (bare id resumes, verb then id shows/deletes), then ids after a
27
+ # verb. Mirrors the /agents grammar so the picker teaches the surface.
28
+ SESSIONS_SUBCOMMANDS = ["show", "delete", "--all"].freeze
29
+
30
+ # The /memory subcommand grammar (#184): verbs first, then recent fact
31
+ # ids after show/forget (short ids — the store resolves prefixes) or the
32
+ # registered backend names after backend.
33
+ MEMORY_SUBCOMMANDS = ["search", "show", "forget", "backend", "--all"].freeze
34
+
35
+ # The /skills grammar (#188): position one mixes the `✗ none` clear entry
36
+ # (CompletionSource keeps its special matching), the enable/disable verbs
37
+ # and the activate-by-name skill list; after a toggle verb, the names
38
+ # complete again. Activate-by-name and `✗ none` behave exactly as before.
39
+ SKILLS_SUBCOMMANDS = %w[enable disable].freeze
40
+
41
+ # The /config grammar (#187): verbs + the known config keys first (a
42
+ # bare key gets, key+value sets), keys again after get/set.
43
+ CONFIG_SUBCOMMANDS = %w[get set show path].freeze
44
+
45
+ def initialize(cmd_loader)
46
+ @cmd_loader = cmd_loader
47
+ end
48
+
49
+ def build
50
+ custom = begin
51
+ @cmd_loader.names
52
+ rescue StandardError
53
+ []
54
+ end
55
+ names = (::Rubino::Commands::BuiltIns::NAMES + custom).uniq
56
+ files = -> { Rubino::Workspace.primary_root }
57
+ # ARGUMENT sources: the dropdown completes the argument of these commands
58
+ # the same way it completes `/command` and `@file`.
59
+ # * /skills <partial> — a skill name (lazily re-read each open so a
60
+ # freshly-authored skill appears), TRUST-aligned with the prompt
61
+ # assembler (#63) so the picker never offers a skill that won't pin.
62
+ # * /agents (alias /tasks) — the live subagent ids, then the
63
+ # steer/probe/--stop subcommand grammar, so the comm surface is
64
+ # discoverable from the composer (#39).
65
+ # * /reply — the ids of children blocked waiting on the human.
66
+ # * /mcp — the configured server names (+ reload), then on/off for a
67
+ # named server (#182), same grammar shape as /agents.
68
+ # * /mode, /reasoning, /think — the closed enums (#185), via the
69
+ # positional shape so no `✗ none` clear entry is injected (there
70
+ # is no "clear" for a mode — see CompletionSource#initialize).
71
+ # * /model — the ruby_llm-registry model ids for the active provider
72
+ # (empty for custom backends like minimax/gateway, which aren't
73
+ # enumerable — the dropdown just shows nothing extra there).
74
+ # * /add-dir — filesystem DIRECTORY candidates from the typed
75
+ # partial (#185), via the partial-aware two-arg shape.
76
+ # * /sessions, /memory — verbs + recent ids (#183/#184), the same
77
+ # per-position grammar /agents ships.
78
+ # * /jobs — recent job ids (#187); /config — the get/set/show/path
79
+ # verbs + the known config keys flattened from the defaults tree.
80
+ # * /skills — the `✗ none` clear entry + the enable/disable verbs +
81
+ # the skill names (#188); after a toggle verb, the names again.
82
+ arg_sources = {
83
+ "skills" => ->(args) { skills_arg_candidates(args) },
84
+ "agents" => ->(args) { agents_arg_candidates(args) },
85
+ "tasks" => ->(args) { agents_arg_candidates(args) },
86
+ "reply" => ->(args) { args.empty? ? blocked_subagent_ids : [] },
87
+ "mcp" => ->(args) { mcp_arg_candidates(args) },
88
+ "mode" => ->(args) { args.empty? ? Rubino::Modes::ALL.map(&:to_s) : [] },
89
+ "model" => ->(args) { args.empty? ? model_arg_candidates : [] },
90
+ "reasoning" => ->(args) { args.empty? ? Rubino::Config::ReasoningPrefs::RENDER_MODES.map(&:to_s) : [] },
91
+ "think" => ->(args) { args.empty? ? Rubino::Config::ReasoningPrefs::EFFORTS.map(&:to_s) : [] },
92
+ "add-dir" => lambda { |args, partial|
93
+ args.empty? ? Rubino::UI::CompletionSource.directory_candidates(partial) : []
94
+ },
95
+ "sessions" => ->(args) { sessions_arg_candidates(args) },
96
+ "memory" => ->(args) { memory_arg_candidates(args) },
97
+ "jobs" => ->(args) { args.empty? ? recent_job_ids : [] },
98
+ "config" => ->(args) { config_arg_candidates(args) }
99
+ }
100
+ Rubino::UI::CompletionSource.new(commands: names, files: files,
101
+ arg_sources: arg_sources,
102
+ descriptions: completion_descriptions)
103
+ end
104
+
105
+ private
106
+
107
+ # Argument candidates per /agents position: ids → subcommands → nothing.
108
+ def agents_arg_candidates(args)
109
+ case args.length
110
+ when 0 then Tools::BackgroundTasks.instance.list.map(&:id)
111
+ when 1 then AGENTS_SUBCOMMANDS
112
+ else []
113
+ end
114
+ end
115
+
116
+ # Children parked on an ask_parent waiting for the human — the ids /reply
117
+ # answers.
118
+ def blocked_subagent_ids
119
+ Tools::BackgroundTasks.instance.awaiting_human.map(&:id)
120
+ end
121
+
122
+ # The /model candidates: the registry's model ids for the provider the
123
+ # next turn would route through. Resolved lazily on each dropdown open so
124
+ # a /model or /config provider switch is reflected immediately.
125
+ def model_arg_candidates
126
+ config = Rubino.configuration
127
+ current = config.model_default
128
+ Rubino::LLM::ModelCatalog.ids_for(
129
+ Rubino::LLM::ProviderResolver.resolve(current, explicit_provider: config.model_provider)
130
+ )
131
+ rescue StandardError
132
+ []
133
+ end
134
+
135
+ def mcp_arg_candidates(args)
136
+ case args.length
137
+ when 0 then mcp_server_names + ["reload"]
138
+ when 1 then args.first == "reload" ? [] : MCP_SUBCOMMANDS
139
+ else []
140
+ end
141
+ end
142
+
143
+ def mcp_server_names
144
+ (Rubino.configuration.dig("mcp", "servers") || {}).keys.map(&:to_s)
145
+ rescue StandardError
146
+ []
147
+ end
148
+
149
+ def sessions_arg_candidates(args)
150
+ case args.length
151
+ when 0 then SESSIONS_SUBCOMMANDS + recent_session_ids
152
+ when 1 then %w[show delete].include?(args.first) ? recent_session_ids : []
153
+ else []
154
+ end
155
+ end
156
+
157
+ # Recent session ids for the /sessions dropdown — same source the
158
+ # in-chat list reads (Session::Repository#list). Best-effort: a DB
159
+ # hiccup degrades to no id candidates, never a broken prompt.
160
+ def recent_session_ids
161
+ Rubino::Session::Repository.new.list(limit: 10).map { |s| s[:id].to_s }
162
+ rescue StandardError
163
+ []
164
+ end
165
+
166
+ def memory_arg_candidates(args)
167
+ case args.length
168
+ when 0 then MEMORY_SUBCOMMANDS
169
+ when 1
170
+ case args.first
171
+ when "show", "forget" then recent_memory_ids
172
+ when "backend" then Rubino::Memory::Backends.names
173
+ else []
174
+ end
175
+ else []
176
+ end
177
+ end
178
+
179
+ # Recent fact ids (short form) for the /memory show/forget dropdown,
180
+ # read from the ACTIVE backend — the same store /memory manages.
181
+ def recent_memory_ids
182
+ Rubino::Memory::Backends.build.list(limit: 10).map { |m| m[:id].to_s[0..7] }
183
+ rescue StandardError
184
+ []
185
+ end
186
+
187
+ def skills_arg_candidates(args)
188
+ case args.length
189
+ when 0 then [Rubino::UI::CompletionSource::NONE_ENTRY] + SKILLS_SUBCOMMANDS + skill_names
190
+ when 1 then SKILLS_SUBCOMMANDS.include?(args.first) ? skill_names : []
191
+ else []
192
+ end
193
+ end
194
+
195
+ # TRUST-aligned skill names (#63), lazily re-read each open so a
196
+ # freshly-authored skill appears. Best-effort, like the other sources.
197
+ def skill_names
198
+ Rubino::Skills::Registry.trusted.names
199
+ rescue StandardError
200
+ []
201
+ end
202
+
203
+ # Recent job ids (the short form the /jobs table renders — the queue
204
+ # resolves prefixes) for the /jobs dropdown (#187).
205
+ def recent_job_ids
206
+ Rubino::Jobs::Queue.new.list(limit: 10).map { |j| j[:id].to_s[0..7] }
207
+ rescue StandardError
208
+ []
209
+ end
210
+
211
+ def config_arg_candidates(args)
212
+ case args.length
213
+ when 0 then CONFIG_SUBCOMMANDS + config_key_candidates
214
+ when 1 then %w[get set].include?(args.first) ? config_key_candidates : []
215
+ else []
216
+ end
217
+ end
218
+
219
+ # The KNOWN config vocabulary: every leaf dot-path in the defaults tree
220
+ # (Config::Defaults.to_hash) — the same keys `config get` resolves
221
+ # against. Discovery, not validation: a key only present in the user's
222
+ # config.yml still works typed by hand.
223
+ def config_key_candidates
224
+ flatten_config_keys(Rubino::Config::Defaults.to_hash)
225
+ rescue StandardError
226
+ []
227
+ end
228
+
229
+ def flatten_config_keys(tree, prefix = nil)
230
+ tree.flat_map do |key, value|
231
+ path = [prefix, key.to_s].compact.join(".")
232
+ value.is_a?(Hash) && !value.empty? ? flatten_config_keys(value, path) : [path]
233
+ end
234
+ end
235
+
236
+ # One-line descriptions for the dropdown (#39): the SAME strings /help
237
+ # shows (BuiltIns + custom command frontmatter), plus usage hints for the
238
+ # /agents subcommand grammar. Best-effort — a loader hiccup degrades to
239
+ # built-ins only, never breaks the prompt.
240
+ def completion_descriptions
241
+ descriptions = ::Rubino::Commands::BuiltIns::DESCRIPTIONS.dup
242
+ begin
243
+ @cmd_loader.all.each do |cmd|
244
+ desc = cmd.description.to_s.strip
245
+ descriptions["/#{cmd.name}"] = desc unless desc.empty?
246
+ end
247
+ rescue StandardError
248
+ nil
249
+ end
250
+ descriptions.merge(
251
+ "steer" => "park a note the subagent folds in at its next turn",
252
+ "probe" => "ask the subagent an ephemeral question (not saved)",
253
+ "--stop" => "cancel the running subagent",
254
+ # /mcp verbs (#182). "off" is ALSO /think's zero effort (#185) —
255
+ # descriptions are keyed by candidate string, so the one line
256
+ # covers both surfaces.
257
+ "reload" => "re-read config.yml and reconnect every MCP server",
258
+ "on" => "(re)start the MCP server and register its tools",
259
+ "off" => "mcp: stop the server and its tools · think: no thinking budget",
260
+ # /sessions + /memory verbs (#183/#184). "show"/"--all" are shared
261
+ # by both grammars — and "show" by /config too (#187) — so each
262
+ # one-liner covers all its surfaces.
263
+ "show" => "show full details (sessions/memory: by id · config: the whole tree)",
264
+ "delete" => "delete a session and its messages (asks to confirm)",
265
+ "search" => "search facts by substring",
266
+ "forget" => "delete a fact by id",
267
+ "backend" => "show the active memory backend",
268
+ "--all" => "list everything (sessions: no row cap · memory: incl. retired)",
269
+ # /config verbs (#187) + /skills toggle verbs (#188).
270
+ "get" => "read one config value (dot-notation, merged over defaults)",
271
+ "set" => "write one config value (persisted to config.yml)",
272
+ "path" => "print the config file path",
273
+ "enable" => "put a skill back in the index (every session)",
274
+ "disable" => "drop a skill from the index (every session, persisted)",
275
+ # The closed enums (#185) reuse the same wording the commands print.
276
+ "default" => Rubino::Modes.description(:default),
277
+ "plan" => Rubino::Modes.description(:plan),
278
+ "yolo" => Rubino::Modes.description(:yolo),
279
+ "hidden" => "show no reasoning (Ctrl-O reveals the last)",
280
+ "collapsed" => "a dim one-line cue; Ctrl-O expands",
281
+ "full" => "the whole reasoning as a dim aside",
282
+ "low" => "small thinking-token budget",
283
+ "medium" => "medium thinking-token budget (default)",
284
+ "high" => "large thinking-token budget"
285
+ )
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module CLI
5
+ module Chat
6
+ # Hosts the collapsed background-subagent card region (F1) at the IDLE
7
+ # prompt, extracted from ChatCommand (#17): repaints the registry's live
8
+ # snapshot onto whatever BottomComposer currently owns the screen, and
9
+ # owns the low-frequency ticker thread that keeps the cards fresh in the
10
+ # quiet gaps between child events.
11
+ class IdleCardHost
12
+ # How often (seconds) the idle card region repaints on its own so the
13
+ # cards' elapsed-time field advances even when no child event fires, and so
14
+ # we promptly notice the last child finishing. Child tool start/finish
15
+ # already poke an immediate repaint via #set_subagent_cards; this tick only
16
+ # covers the quiet gaps.
17
+ IDLE_CARD_TICK = 1.0
18
+
19
+ # True when at least one background subagent (the `task` tool's default)
20
+ # is still live — running or parked on a human approval. Drives whether the
21
+ # idle prompt hosts the collapsed live cards (F1).
22
+ def children_live?
23
+ Tools::BackgroundTasks.instance.running.any?
24
+ rescue StandardError
25
+ false
26
+ end
27
+
28
+ # Repaints the idle card region from the registry's current snapshot. Mirrors
29
+ # UI::CLI#set_subagent_cards (which the child taps call), but is callable
30
+ # from the REPL's own ticker without a parent UI handle — both ultimately
31
+ # drive BottomComposer#set_cards under the render mutex.
32
+ def paint
33
+ composer = UI::BottomComposer.current
34
+ return unless composer
35
+
36
+ entries = Tools::BackgroundTasks.instance.running
37
+ composer.set_cards(cards.card_lines(entries))
38
+ rescue StandardError
39
+ nil # a card repaint is cosmetic — never break the idle prompt.
40
+ end
41
+
42
+ # A low-frequency ticker that repaints the idle card region so the elapsed
43
+ # time advances and a finished last-child is noticed even in a quiet gap
44
+ # between child events. Repaints go through the composer's render mutex, so
45
+ # they never race the keystroke handler. Exits as soon as no child is live
46
+ # (it clears the region one last time) or when killed on teardown.
47
+ def start_ticker(composer)
48
+ Thread.new do
49
+ loop do
50
+ sleep(IDLE_CARD_TICK)
51
+ break unless composer.equal?(UI::BottomComposer.current)
52
+
53
+ paint
54
+ break unless children_live?
55
+ end
56
+ rescue StandardError
57
+ nil
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def cards
64
+ @cards ||= UI::SubagentCards.new
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module CLI
5
+ module Chat
6
+ # The REPL's image-attachment inbox (attach an image from the terminal),
7
+ # extracted from ChatCommand (#17).
8
+ #
9
+ # Attachments live in #pending_image_paths between the prompt read and
10
+ # the turn; run_turn consumes + clears them via #take! so each image is
11
+ # sent once into the native vision slot (image_paths →
12
+ # Lifecycle#execute → adapter `with:`).
13
+ class ImageInbox
14
+ # Builds the [text, image_paths] pair for a one-shot turn. Pulls @image /
15
+ # dropped-path tokens out of the prompt (so they hit the vision slot, not
16
+ # the literal text) and prepends any paths given via --image. Flag paths
17
+ # are expanded the same way as in-line tokens; a flag path that isn't a
18
+ # readable image is reported and skipped rather than silently dropped.
19
+ #
20
+ # Every candidate then passes the SAME secure-by-default attachment gate
21
+ # as the server/run path (Attachments::Classify + Policy, via
22
+ # ImageInput#attachment_error) — a policy rejection is a clean one-line
23
+ # error BEFORE any network call, not five provider retries (#98).
24
+ def self.resolve_oneshot(query, flag_values)
25
+ flag_paths = Array(flag_values).map { |p| Interaction::ImageInput.expand(p) }
26
+ flag_paths.each do |p|
27
+ next if LLM::ContentBuilder.image_file?(p) && File.file?(p)
28
+
29
+ warn "rubino: ignoring --image #{p} (not a readable image file)"
30
+ end
31
+ valid_flags = flag_paths.select { |p| LLM::ContentBuilder.image_file?(p) && File.file?(p) }
32
+ valid_flags.each do |p|
33
+ reason = Interaction::ImageInput.attachment_error(p)
34
+ raise Rubino::Error, "--image #{p}: #{reason}" if reason
35
+ end
36
+
37
+ result = Interaction::ImageInput.parse(query, existing: valid_flags)
38
+ if (rejection = result.rejected.first)
39
+ raise Rubino::Error, "#{rejection[:path]}: #{rejection[:reason]}"
40
+ end
41
+
42
+ [result.text, result.image_paths]
43
+ end
44
+
45
+ def pending_image_paths
46
+ @pending_image_paths ||= []
47
+ end
48
+
49
+ # Consumes the turn's queued image attachments (the native vision slot)
50
+ # and resets so they're attached exactly once, not re-sent next turn.
51
+ def take!
52
+ paths = pending_image_paths
53
+ @pending_image_paths = []
54
+ paths
55
+ end
56
+
57
+ # Seeds the interactive pending-images inbox from --image/-i flag paths
58
+ # (#160), through the SAME attachment gate every other staging surface
59
+ # uses (Attachments::Classify + Policy via ImageInput#attachment_error).
60
+ # A bad flag path warns and is skipped — interactive startup must not die
61
+ # on it the way one-shot raises. Staged images show the usual indicator
62
+ # and are covered by /clear-images, as documented.
63
+ def stage_flag_images(flag_values, ui)
64
+ Array(flag_values).each do |raw|
65
+ path = Interaction::ImageInput.expand(raw)
66
+ unless LLM::ContentBuilder.image_file?(path) && File.file?(path)
67
+ ui.warning("not attached — #{raw}: not a readable image file")
68
+ next
69
+ end
70
+ if (reason = Interaction::ImageInput.attachment_error(path))
71
+ ui.warning("not attached — #{File.basename(path)}: #{reason}")
72
+ next
73
+ end
74
+ pending_image_paths << path unless pending_image_paths.include?(path)
75
+ end
76
+ show_image_indicator(ui, pending_image_paths) unless pending_image_paths.empty?
77
+ end
78
+
79
+ # Parses the line for image references (@image, dropped/quoted/escaped
80
+ # path), moves any into @pending_image_paths and returns the cleaned text.
81
+ # Non-image references are left in the text (current behaviour). Shows an
82
+ # in-prompt indicator for whatever is now attached. A candidate the
83
+ # attachment policy rejects (oversize / spoofed extension / unsafe) is
84
+ # dropped with a one-line warning instead of being shipped (#98).
85
+ def extract_images!(input, ui)
86
+ result = Interaction::ImageInput.parse(input, existing: pending_image_paths)
87
+ result.rejected.each do |rejection|
88
+ ui.warning("not attached — #{File.basename(rejection[:path])}: #{rejection[:reason]}")
89
+ end
90
+ newly = result.image_paths - pending_image_paths
91
+ @pending_image_paths = result.image_paths
92
+ # A line with text AND an @image sends BOTH on THIS turn (the cleaned
93
+ # text is non-empty, so the main loop submits now); an image-only line
94
+ # stages for the next message. The indicator must match that
95
+ # disposition — saying "sent with your next message" on a text+image
96
+ # line is wrong (#225).
97
+ unless newly.empty?
98
+ attached_now = !result.text.strip.empty?
99
+ show_image_indicator(ui, newly, attached_now: attached_now)
100
+ end
101
+ result.text
102
+ end
103
+
104
+ # Handles the REPL-local image commands. Returns true when it consumed the
105
+ # input (so the main loop should `next`), false otherwise.
106
+ #
107
+ # /paste — grab an image from the clipboard into image_paths
108
+ # /clear-images — drop all pending attachments
109
+ # rubocop:disable Naming/PredicateMethod -- "did I consume the line", not a pure predicate
110
+ def handle_image_command(input, ui)
111
+ case input.strip.downcase
112
+ when "/clear-images", "/clear-image"
113
+ if pending_image_paths.empty?
114
+ ui.info("No attached images to clear.")
115
+ else
116
+ ui.info("Cleared #{pending_image_paths.size} attached image(s).")
117
+ @pending_image_paths = []
118
+ end
119
+ true
120
+ when "/paste"
121
+ paste_clipboard_image(ui)
122
+ true
123
+ else
124
+ false
125
+ end
126
+ end
127
+ # rubocop:enable Naming/PredicateMethod
128
+
129
+ private
130
+
131
+ def paste_clipboard_image(ui)
132
+ path = Interaction::ClipboardImage.save_to_tempfile
133
+ unless path
134
+ ui.warning("Clipboard paste failed: #{Interaction::ClipboardImage.unavailable_reason}")
135
+ return
136
+ end
137
+
138
+ # Same universal attachment gate as @image/dropped/--image paths (#98):
139
+ # a clipboard capture that violates policy (e.g. oversize) is dropped
140
+ # with a clear warning, never shipped to the provider.
141
+ if (reason = Interaction::ImageInput.attachment_error(path))
142
+ ui.warning("not attached — #{File.basename(path)}: #{reason}")
143
+ return
144
+ end
145
+
146
+ pending_image_paths << path unless pending_image_paths.include?(path)
147
+ show_image_indicator(ui, [path])
148
+ end
149
+
150
+ # In-prompt indicator of attached image(s), Claude-Code style. When the
151
+ # image rides a line that ALSO carries text (+attached_now+), it goes out
152
+ # with THIS turn, so the indicator says so; an image-only line stages for
153
+ # the next message and keeps the "sent with your next message" wording
154
+ # (#225).
155
+ def show_image_indicator(ui, newly, attached_now: false)
156
+ newly.each { |p| ui.status("[image: #{File.basename(p)}]") }
157
+ total = pending_image_paths.size
158
+ disposition = if attached_now
159
+ "attached to this message"
160
+ else
161
+ "sent with your next message (/clear-images to drop)"
162
+ end
163
+ ui.status("#{total} image#{"s" if total != 1} attached — #{disposition}.")
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end