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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Reads and parses the byte tail of an ESC keystroke for the
6
+ # {BottomComposer}: arrows / Home / End / Delete / word-jump / Shift+Tab /
7
+ # Alt+Enter and the bracketed-paste body. PURE input: it only consumes
8
+ # bytes from the keystroke source and returns a semantic action tuple —
9
+ # the composer maps actions to its editing/menu/turn behavior, so all
10
+ # rendering and state stay on its side of the seam.
11
+ #
12
+ # Three escape families are handled:
13
+ # * CSI — ESC '[' params final (arrows, Home/End, Delete, Shift+Tab,
14
+ # xterm modified keys like ESC[1;5C for Ctrl+→, bracketed paste)
15
+ # * SS3 — ESC 'O' final (application-cursor arrows / Home/End)
16
+ # * Meta — ESC b / ESC f (Alt+b / Alt+f word-jump on many terms)
17
+ class EscapeReader
18
+ # Bracketed paste (DEC 2004) end marker tail: the terminal closes a paste
19
+ # with ESC[201~ (the opener ESC[200~ arrives as a normal CSI above).
20
+ PASTE_END = "201~"
21
+
22
+ # @param source [#call] returns the keystroke IO to read from. A callable
23
+ # rather than a captured IO so the reader always follows the composer's
24
+ # CURRENT input — the escape tail must come from the same stream the
25
+ # leading "\e" byte was read from, even if the IO is swapped (tests).
26
+ def initialize(source)
27
+ @source = source
28
+ end
29
+
30
+ # Consume the remainder of the escape sequence after a read "\e" and
31
+ # return what it MEANS, as one of:
32
+ #
33
+ # [:esc] lone ESC (no following bytes)
34
+ # [:esc_esc] two ESC bytes in ONE burst (fast double-tap)
35
+ # [:alt_enter] Alt/Meta+Enter (ESC CR / ESC LF)
36
+ # [:paste, body] bracketed paste with its raw body
37
+ # [:mode_cycle] Shift+Tab (ESC[Z)
38
+ # [:history_up] / [:history_down] ↑ / ↓
39
+ # [:move_by, ±1] bare ← / →
40
+ # [:word_left] / [:word_right] modified ←/→, Alt+b/f
41
+ # [:move_home] / [:move_end] Home / End (CSI, SS3 and tilde forms)
42
+ # [:delete_forward] Delete (ESC[3~)
43
+ # nil unrecognized sequence (a quiet no-op)
44
+ #
45
+ # Non-blocking reads so a lone ESC doesn't hang.
46
+ def read_action
47
+ case read_nonblock_char
48
+ when nil then [:esc]
49
+ when "\e" then double_escape_action
50
+ when "\r", "\n" then [:alt_enter]
51
+ when "[" then csi_action(read_csi)
52
+ when "O" then final_action(read_nonblock_char, modifier: 1)
53
+ when "b" then [:word_left]
54
+ when "f" then [:word_right]
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # Two ESC bytes arrived in ONE burst: a fast Esc-Esc double-tap lands
61
+ # both before the reader wakes, so the second ESC shows up as the tail
62
+ # of the first. With nothing after it that IS the double-tap
63
+ # ([:esc_esc] — the composer treats it as two lone Escs for the rewind
64
+ # chord); with a real sequence after it (ESC ESC [ A — a Meta-prefixed
65
+ # arrow on some terminals) the leading ESC is just the Meta prefix, so
66
+ # the inner action passes through unchanged.
67
+ def double_escape_action
68
+ inner = read_action
69
+ inner == [:esc] ? [:esc_esc] : inner
70
+ end
71
+
72
+ # Acts on a parsed CSI sequence. Bracketed paste and Shift+Tab are
73
+ # special; everything else splits into "params;…final" so a modified
74
+ # arrow (ESC[1;5C = Ctrl+→) routes to the same move as the bare arrow
75
+ # plus the modifier that promotes it to a word-jump.
76
+ def csi_action(seq)
77
+ case seq
78
+ when "200~" then return [:paste, read_paste_body]
79
+ when "Z" then return [:mode_cycle] # Shift+Tab arrives as ESC[Z
80
+ end
81
+
82
+ final = seq[-1]
83
+ params = seq[0...-1].split(";")
84
+ # The modifier param is the 2nd field for xterm "1;mod<final>" form; the
85
+ # numpad/edit keys (Home/End/Delete) carry "<n>;mod~". Default mod 1.
86
+ modifier = (params[1] || params[0] || "1").to_i
87
+ modifier = 1 if modifier.zero?
88
+ if final == "~"
89
+ tilde_action(params.first.to_i)
90
+ else
91
+ final_action(final, modifier: modifier)
92
+ end
93
+ end
94
+
95
+ # Final-byte cursor keys (and SS3 arrows). A modifier > 1 (Ctrl=5, Alt=3,
96
+ # Shift=2, etc.) promotes ←/→ to a word-jump, matching how terminals
97
+ # encode Ctrl/Alt + arrow.
98
+ def final_action(final, modifier:)
99
+ word = modifier > 1
100
+ case final
101
+ when "A" then [:history_up]
102
+ when "B" then [:history_down]
103
+ when "C" then word ? [:word_right] : [:move_by, 1]
104
+ when "D" then word ? [:word_left] : [:move_by, -1]
105
+ when "H" then [:move_home]
106
+ when "F" then [:move_end]
107
+ end
108
+ end
109
+
110
+ # Tilde-terminated edit keys: 1/7 = Home, 4/8 = End, 3 = Delete-forward.
111
+ def tilde_action(code)
112
+ case code
113
+ when 1, 7 then [:move_home]
114
+ when 4, 8 then [:move_end]
115
+ when 3 then [:delete_forward]
116
+ end
117
+ end
118
+
119
+ # Reads the remainder of a CSI sequence: params (digits + ';') up to and
120
+ # including the final byte in 0x40..0x7E. Returns the raw param/final
121
+ # string, e.g. "A", "3~", "1;5C".
122
+ def read_csi
123
+ seq = +""
124
+ loop do
125
+ c = read_nonblock_char
126
+ break if c.nil?
127
+
128
+ seq << c
129
+ break if c.ord.between?(0x40, 0x7E)
130
+ end
131
+ seq
132
+ end
133
+
134
+ # Accumulate a bracketed-paste body until the closing ESC[201~ marker.
135
+ # Blocking reads here: a paste is a contiguous burst, so we won't hang
136
+ # waiting on the user.
137
+ def read_paste_body
138
+ body = +""
139
+ until body.end_with?(PASTE_END)
140
+ c = read_paste_char
141
+ break if c.nil?
142
+
143
+ body << c
144
+ end
145
+ body = body[0...-PASTE_END.length] if body.end_with?(PASTE_END)
146
+ # Drop the ESC[ that precedes the 201~ end marker.
147
+ body.sub(/\e\[\z/, "")
148
+ end
149
+
150
+ # Blocking single-char read for the paste body (a paste arrives as one
151
+ # uninterrupted burst).
152
+ def read_paste_char
153
+ input.getc
154
+ rescue IOError, Errno::EIO # IOError covers EOFError
155
+ nil
156
+ end
157
+
158
+ def read_nonblock_char
159
+ input.read_nonblock(1)
160
+ rescue IO::WaitReadable, IOError, Errno::EIO # IOError covers EOFError
161
+ nil
162
+ end
163
+
164
+ def input
165
+ @source.call
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Output decorator that left-pads everything written through it by a fixed
6
+ # indent, so a TTY::Prompt menu renders in the SAME column as the card
7
+ # above it (P7) instead of flush-left at column 0.
8
+ #
9
+ # tty-prompt repaints its frame by moving the cursor up, clearing lines,
10
+ # and re-printing — so "start of line" is not only "after a newline": a
11
+ # carriage return or a cursor column-reset escape (`\e[<n>G`) also restart
12
+ # the line. The transform tracks that state ACROSS writes and injects the
13
+ # indent before the first visible character of every line, leaving all
14
+ # escape sequences untouched.
15
+ #
16
+ # Resolves the underlying IO from the given block on every call (default:
17
+ # the current $stdout) so a composer/proxy swap can never strand a stale
18
+ # handle. Everything else (tty?, winsize, …) delegates to that IO.
19
+ class IndentedIO
20
+ # ANSI CSI/OSC sequences pass through unindented; any other single
21
+ # character is a candidate for the line-start indent.
22
+ TOKEN_RE = /\e\[[\d;?]*[A-Za-z]|\e\][^\a\e]*(?:\a|\e\\)|./m
23
+
24
+ def initialize(indent: " ", io: nil)
25
+ @indent = indent
26
+ @resolve = io ? -> { io } : -> { $stdout }
27
+ @at_line_start = true
28
+ end
29
+
30
+ def print(*args)
31
+ io.print(*args.map { |a| transform(a.to_s) })
32
+ end
33
+
34
+ def write(*args)
35
+ io.write(*args.map { |a| transform(a.to_s) })
36
+ end
37
+
38
+ def puts(*args)
39
+ if args.empty?
40
+ @at_line_start = true
41
+ io.puts
42
+ else
43
+ print("#{args.join("\n")}\n")
44
+ end
45
+ end
46
+
47
+ def <<(text)
48
+ write(text)
49
+ self
50
+ end
51
+
52
+ def flush
53
+ io.flush
54
+ end
55
+
56
+ def method_missing(name, *, &)
57
+ io.respond_to?(name) ? io.public_send(name, *, &) : super
58
+ end
59
+
60
+ def respond_to_missing?(name, include_private = false)
61
+ io.respond_to?(name, include_private) || super
62
+ end
63
+
64
+ private
65
+
66
+ def io
67
+ @resolve.call
68
+ end
69
+
70
+ def transform(text)
71
+ text.gsub(TOKEN_RE) do |tok|
72
+ if ["\n", "\r"].include?(tok)
73
+ @at_line_start = true
74
+ tok
75
+ elsif tok.start_with?("\e")
76
+ @at_line_start = true if tok.match?(/\e\[\d*G\z/)
77
+ tok
78
+ elsif @at_line_start
79
+ @at_line_start = false
80
+ "#{@indent}#{tok}"
81
+ else
82
+ tok
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Rubino
6
+ module UI
7
+ # Prompt history for the bottom composer, backed by the SAME store the old
8
+ # Reline idle prompt used (+Reline::HISTORY+) so continuity is preserved when
9
+ # the composer becomes the single idle input path — a session's earlier
10
+ # entries (and anything Reline itself recorded) stay navigable.
11
+ #
12
+ # Navigation model mirrors a shell / Reline: ↑ walks BACK toward older
13
+ # entries, ↓ walks FORWARD toward newer ones and finally back to the live
14
+ # draft the user was typing. The in-progress draft is stashed on the first ↑
15
+ # so ↓-ing all the way down restores exactly what the user had typed, never
16
+ # losing it.
17
+ #
18
+ # Like +LineInput#remember+, consecutive duplicates are de-duped on push so a
19
+ # repeated command doesn't clutter the ring.
20
+ class InputHistory
21
+ def initialize(store: Reline::HISTORY)
22
+ @store = store
23
+ # Cursor into the history ring. nil = "on the live draft" (not navigating
24
+ # history). 0 = most recent entry, increasing = older.
25
+ @index = nil
26
+ @draft = nil
27
+ end
28
+
29
+ # Append a submitted line, de-duping a consecutive duplicate (matches
30
+ # LineInput#remember). Blank lines are not recorded. Resets navigation so
31
+ # the next ↑ starts from the newest entry again.
32
+ def remember(line)
33
+ reset!
34
+ return if line.nil?
35
+
36
+ stripped = line.strip
37
+ return if stripped.empty? || last == stripped
38
+ # Slash commands (/new, /help, …) are control input, not prompts — keep
39
+ # them out of recall so ↑ surfaces real messages, not commands (H1).
40
+ return if stripped.start_with?("/")
41
+
42
+ @store.push(stripped)
43
+ end
44
+
45
+ # Move toward OLDER entries (↑). +current+ is the buffer the user is
46
+ # editing right now; it's stashed as the draft on the first move up so ↓
47
+ # can restore it. Returns the entry to show, or nil when there's nothing
48
+ # older (caller keeps the current buffer).
49
+ def up(current)
50
+ entries = to_a
51
+ return nil if entries.empty?
52
+
53
+ if @index.nil?
54
+ # dup, not to_s: String#to_s returns self, so a later in-place
55
+ # @buffer.replace by the caller would mutate the stashed draft too.
56
+ @draft = current.to_s.dup
57
+ @index = 0
58
+ elsif @index < entries.size - 1
59
+ @index += 1
60
+ else
61
+ return nil # already on the oldest entry — clamp
62
+ end
63
+ entries[entries.size - 1 - @index]
64
+ end
65
+
66
+ # Move toward NEWER entries (↓). Returns the newer entry, or the stashed
67
+ # draft when stepping back below the newest entry, or nil when not
68
+ # currently navigating history (caller keeps the current buffer).
69
+ def down(_current = nil)
70
+ return nil if @index.nil?
71
+
72
+ entries = to_a
73
+ if @index.positive?
74
+ @index -= 1
75
+ entries[entries.size - 1 - @index]
76
+ else
77
+ # Stepped below the newest entry → back to the live draft.
78
+ @index = nil
79
+ d = @draft.to_s
80
+ @draft = nil
81
+ d
82
+ end
83
+ end
84
+
85
+ # True while the cursor is walking the history ring (not on the draft).
86
+ def navigating?
87
+ !@index.nil?
88
+ end
89
+
90
+ # Drop navigation state (called on submit / any direct edit so a fresh ↑
91
+ # starts from the newest entry and a typed edit isn't treated as history).
92
+ def reset!
93
+ @index = nil
94
+ @draft = nil
95
+ end
96
+
97
+ private
98
+
99
+ def to_a
100
+ @store.respond_to?(:to_a) ? @store.to_a : Array(@store)
101
+ end
102
+
103
+ def last
104
+ to_a.last
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Rubino
6
+ module UI
7
+ # The {BottomComposer}'s live-region renderer: the rows redrawn IN PLACE
8
+ # above the prompt every frame (subagent cards, completion menu, transient
9
+ # announce, queued indicators, streamed partial). Owns the count of rows
10
+ # currently on screen and the scroll-safe erase→commit→redraw discipline;
11
+ # the composer assembles the row list and draws the prompt row itself.
12
+ # Pure output: no state of its own beyond the row count, and it NEVER takes
13
+ # the render mutex — the composer holds it around every call.
14
+ #
15
+ # Scroll-safe strategy (mirrors prompt_toolkit / Ink): ERASE the whole live
16
+ # region first (the prompt row, plus the rows above it) so nothing stale is
17
+ # left, then print any committed output and let the terminal scroll
18
+ # NATURALLY, then redraw the live region FRESH from wherever the cursor
19
+ # lands. We never issue a post-scroll +\e[1A+ that assumes the pre-scroll
20
+ # geometry: such a relative move desyncs the instant a trailing newline
21
+ # scrolls the screen at the bottom row, which is exactly what wiped the
22
+ # typed input.
23
+ class LiveRegion
24
+ def initialize(output)
25
+ @output = output
26
+ # How many rows the live region currently occupies ABOVE the input
27
+ # block. The clear walks up exactly this many rows, so a multi-line
28
+ # block clears cleanly without a single-row \e[1A desyncing it.
29
+ @rows_above = 0
30
+ # INPUT-BLOCK geometry, relative to the row the terminal cursor is
31
+ # parked on (the caret's visual row): how many input rows sit ABOVE it
32
+ # and how many rows sit BELOW it (wrapped input rows after the caret +
33
+ # the status bar). The composer records these after every #draw_input
34
+ # via {#input_drawn}, so {#clear_input_block} can erase the whole block
35
+ # — multi-row input + status bar — before the next draw.
36
+ @input_above = 0
37
+ @input_below = 0
38
+ end
39
+
40
+ attr_reader :rows_above, :input_above, :input_below
41
+
42
+ # True when any rows are currently drawn above the input block.
43
+ def live?
44
+ @rows_above.positive?
45
+ end
46
+
47
+ # Record the input block's geometry for the frame just drawn (see
48
+ # ivar docs above). Called by the composer at the end of #draw_input.
49
+ def input_drawn(above:, below:)
50
+ @input_above = above
51
+ @input_below = below
52
+ end
53
+
54
+ # Erase the INPUT BLOCK in place (every wrapped input row + the status
55
+ # bar) and park the cursor, column 0, on the block's TOP row — where the
56
+ # next #draw_input begins. Walks DOWN from the caret row clearing the
57
+ # rows below first (status bar + wrapped rows after the caret), returns,
58
+ # clears the caret row, then walks UP clearing the rows above. All moves
59
+ # are relative and happen BEFORE any printing, so nothing has scrolled
60
+ # and the walk is valid. Leaves the above-block live rows untouched.
61
+ def clear_input_block
62
+ if @input_below.positive?
63
+ @input_below.times { @output.print("\e[1B\e[2K") }
64
+ @output.print("\e[#{@input_below}A")
65
+ end
66
+ @output.print("\r\e[2K")
67
+ @input_above.times { @output.print("\e[1A\e[2K") }
68
+ @input_above = 0
69
+ @input_below = 0
70
+ end
71
+
72
+ # Draws one atomic frame. Layout (top → bottom): the committed lines (only
73
+ # when given; they scroll into scrollback and stay there), then the live
74
+ # +rows+ redrawn in place, then the prompt row drawn by the block.
75
+ # Must be called while the composer holds its render mutex.
76
+ def frame(committed:, rows:, cols:)
77
+ clear # 1) erase prompt (+ live) rows, BEFORE any scroll
78
+ commit(committed) # 2) print committed output, scroll naturally
79
+ # 3) redraw fresh from the post-scroll cursor row
80
+ rows.each { |row| emit_row(row, cols) }
81
+ yield # the prompt row — ALWAYS last, so it survives every scroll
82
+ end
83
+
84
+ # Erase the live region IN PLACE and park the cursor on its TOP row:
85
+ # clear the input block (wrapped rows + status bar, see
86
+ # {#clear_input_block}), then walk UP and clear each of the rows above it
87
+ # in turn, leaving the cursor on the now-blank top row. This runs BEFORE
88
+ # any output is printed, so the screen has not scrolled yet and the
89
+ # relative walks are valid; afterward the cursor sits on a blank row with
90
+ # nothing stale below.
91
+ def clear
92
+ clear_input_block
93
+ @rows_above.times { @output.print("\e[1A\e[2K") }
94
+ @rows_above = 0
95
+ end
96
+
97
+ # Print ONE live row clamped to one column SHORT of the width and
98
+ # terminated with a CRLF (which scrolls naturally if we're at the bottom),
99
+ # bumping the row count so the NEXT frame's clear walks up exactly this
100
+ # many rows.
101
+ #
102
+ # The one-column-short clamp matters: a glyph in the final column arms the
103
+ # terminal's deferred auto-wrap ("pending wrap"), and the following CRLF
104
+ # can then resolve as a double scroll on some terminals — which slides the
105
+ # live region out from under the next frame's relative \e[1A walk-up and
106
+ # wipes the prompt. One spare column keeps each row scroll-deterministic.
107
+ def emit_row(row, cols)
108
+ @output.print("\r\e[2K#{self.class.clamp(row, cols - 1)}\r\n")
109
+ @rows_above += 1
110
+ end
111
+
112
+ # Commit finished output from the blank top row. It scrolls into
113
+ # scrollback NATURALLY; after the trailing CRLF the cursor sits on a fresh
114
+ # blank line at the (possibly new) bottom — the anchor the live rows are
115
+ # redrawn from. Crucially we make NO relative cursor move after this, so a
116
+ # scroll here can never desync the redraw. Each line is emitted with a
117
+ # trailing "\r\n" because OPOST is off in raw mode (a bare "\n" would not
118
+ # return the carriage and the next line would stair-step).
119
+ # An EMPTY committed line is a deliberate blank row (the P3 rhythm gaps —
120
+ # one blank before the answer block, the separator before a tool run):
121
+ # it must scroll a real row, not be dropped, or the in-turn rhythm
122
+ # differs from the between-turns one. Only nil is a no-op.
123
+ def commit(committed)
124
+ return if committed.nil?
125
+
126
+ normalized = committed.to_s.gsub("\r\n", "\n").gsub("\n", "\r\n")
127
+ @output.print(normalized)
128
+ @output.print("\r\n") unless normalized.end_with?("\r\n")
129
+ end
130
+
131
+ class << self
132
+ # Clamp a single visible line to the terminal width (one row), left-
133
+ # truncating with a leading "…" so a long line never wraps and desyncs
134
+ # the frame.
135
+ #
136
+ # Width is measured in terminal DISPLAY COLUMNS, not characters: a wide
137
+ # glyph (CJK / emoji like ✅ 🔄) occupies two columns but counts as one
138
+ # String#length char. Measuring by char count let a "clamped" line
139
+ # render WIDER than the row, so xterm wrapped it to a second physical
140
+ # line that the single-row clear (\e[1A) never erased — the residue
141
+ # accumulated downward (the streaming-table trail). Truncating by
142
+ # display width keeps each row exactly one physical line so the clear
143
+ # math stays valid.
144
+ def clamp(str, cols)
145
+ flat = str.to_s.tr("\n", " ")
146
+ # Guard a non-positive width (winsize can report 0 cols in some
147
+ # terminals/multiplexers, at startup, or a zero-height window):
148
+ # without this truncation could return an empty/over-wide line and
149
+ # desync the frame, which escaped run_turn's `rescue Interrupt` and
150
+ # killed the whole chat mid-turn.
151
+ cols = 1 if cols.nil? || cols < 1
152
+ return flat if display_width(flat) <= cols
153
+
154
+ # Leading "…" costs one column; fill the rest from the END of the line.
155
+ "…#{take_last_columns(flat, cols - 1)}"
156
+ end
157
+
158
+ # Terminal display columns for a string (wide glyphs count as 2).
159
+ def display_width(str)
160
+ Unicode::DisplayWidth.of(str.to_s)
161
+ end
162
+
163
+ # The longest SUFFIX of +str+ whose display width is <= +cols+. Walks
164
+ # from the end so a wide trailing glyph is dropped whole (never
165
+ # half-rendered) rather than cut mid-cell.
166
+ def take_last_columns(str, cols)
167
+ return "" if cols <= 0
168
+
169
+ used = 0
170
+ taken = []
171
+ str.to_s.chars.reverse_each do |ch|
172
+ w = display_width(ch)
173
+ break if used + w > cols
174
+
175
+ taken << ch
176
+ used += w
177
+ end
178
+ taken.reverse.join
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end