rubik 0.1.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 (523) hide show
  1. checksums.yaml +7 -0
  2. data/ATTRIBUTION.md +139 -0
  3. data/CHANGELOG.md +236 -0
  4. data/LICENSE +21 -0
  5. data/README.md +269 -0
  6. data/bin/rubik +82 -0
  7. data/lib/rubik/adapter/base.rb +195 -0
  8. data/lib/rubik/adapter/c.rb +36 -0
  9. data/lib/rubik/adapter/crystal.rb +78 -0
  10. data/lib/rubik/adapter/generic.rb +86 -0
  11. data/lib/rubik/adapter/go.rb +36 -0
  12. data/lib/rubik/adapter/node.rb +146 -0
  13. data/lib/rubik/adapter/process.rb +78 -0
  14. data/lib/rubik/adapter/python.rb +30 -0
  15. data/lib/rubik/adapter/registry.rb +81 -0
  16. data/lib/rubik/adapter/ruby.rb +30 -0
  17. data/lib/rubik/adapter/rust.rb +36 -0
  18. data/lib/rubik/adapter/zig.rb +36 -0
  19. data/lib/rubik/adapter.rb +18 -0
  20. data/lib/rubik/ast/body_loader.rb +19 -0
  21. data/lib/rubik/ast/c_extractor.rb +114 -0
  22. data/lib/rubik/ast/cache_store.rb +191 -0
  23. data/lib/rubik/ast/extractor.rb +59 -0
  24. data/lib/rubik/ast/fallback_cleaner.rb +145 -0
  25. data/lib/rubik/ast/go_extractor.rb +148 -0
  26. data/lib/rubik/ast/graph.rb +272 -0
  27. data/lib/rubik/ast/python_extractor.rb +120 -0
  28. data/lib/rubik/ast/ruby_extractor.rb +260 -0
  29. data/lib/rubik/ast/rust_extractor.rb +127 -0
  30. data/lib/rubik/ast/symbol.rb +103 -0
  31. data/lib/rubik/ast/treesitter/base_walker.rb +109 -0
  32. data/lib/rubik/ast/treesitter/c_walker.rb +122 -0
  33. data/lib/rubik/ast/treesitter/go_walker.rb +120 -0
  34. data/lib/rubik/ast/treesitter/python_walker.rb +107 -0
  35. data/lib/rubik/ast/treesitter/rust_walker.rb +130 -0
  36. data/lib/rubik/ast/treesitter/typescript_walker.rb +182 -0
  37. data/lib/rubik/ast/treesitter/zig_walker.rb +109 -0
  38. data/lib/rubik/ast/treesitter_extractor.rb +93 -0
  39. data/lib/rubik/ast/typescript_extractor.rb +283 -0
  40. data/lib/rubik/ast/zig_extractor.rb +116 -0
  41. data/lib/rubik/ast.rb +13 -0
  42. data/lib/rubik/benchmark/runner.rb +224 -0
  43. data/lib/rubik/benchmark/synth_repo.rb +59 -0
  44. data/lib/rubik/benchmark.rb +9 -0
  45. data/lib/rubik/buffer/diff.rb +43 -0
  46. data/lib/rubik/buffer/manager.rb +427 -0
  47. data/lib/rubik/buffer/path_validator.rb +60 -0
  48. data/lib/rubik/buffer/snapshot.rb +99 -0
  49. data/lib/rubik/buffer.rb +10 -0
  50. data/lib/rubik/coeffects.rb +129 -0
  51. data/lib/rubik/concurrency/boundary.rb +74 -0
  52. data/lib/rubik/concurrency/dns_pool.rb +68 -0
  53. data/lib/rubik/concurrency/errors.rb +17 -0
  54. data/lib/rubik/concurrency/event_loop.rb +441 -0
  55. data/lib/rubik/concurrency/join.rb +55 -0
  56. data/lib/rubik/concurrency/process_waiter.rb +55 -0
  57. data/lib/rubik/concurrency/scheduler.rb +84 -0
  58. data/lib/rubik/concurrency/timers.rb +109 -0
  59. data/lib/rubik/concurrency.rb +132 -0
  60. data/lib/rubik/config/deep_ops.rb +80 -0
  61. data/lib/rubik/config/defaults.rb +248 -0
  62. data/lib/rubik/config/model_catalog.rb +93 -0
  63. data/lib/rubik/config/store.rb +155 -0
  64. data/lib/rubik/config.rb +216 -0
  65. data/lib/rubik/context/git_info.rb +84 -0
  66. data/lib/rubik/context/git_lister.rb +37 -0
  67. data/lib/rubik/context/index.rb +134 -0
  68. data/lib/rubik/context/repo_context_reader.rb +121 -0
  69. data/lib/rubik/context/scanner.rb +439 -0
  70. data/lib/rubik/context.rb +11 -0
  71. data/lib/rubik/diagnostics/collector.rb +200 -0
  72. data/lib/rubik/diagnostics/entry.rb +60 -0
  73. data/lib/rubik/diagnostics/parser.rb +107 -0
  74. data/lib/rubik/diagnostics.rb +10 -0
  75. data/lib/rubik/event_bus.rb +56 -0
  76. data/lib/rubik/kernel/dispatcher.rb +171 -0
  77. data/lib/rubik/kernel/dmesg.rb +116 -0
  78. data/lib/rubik/kernel/init.rb +177 -0
  79. data/lib/rubik/kernel/record.rb +85 -0
  80. data/lib/rubik/kernel/unit.rb +160 -0
  81. data/lib/rubik/llm/anthropic.rb +202 -0
  82. data/lib/rubik/llm/chunk_assembler.rb +285 -0
  83. data/lib/rubik/llm/client.rb +526 -0
  84. data/lib/rubik/llm/formatter.rb +450 -0
  85. data/lib/rubik/llm/message_sanitizer.rb +87 -0
  86. data/lib/rubik/llm/ollama.rb +167 -0
  87. data/lib/rubik/llm/openai.rb +21 -0
  88. data/lib/rubik/llm/openai_compatible.rb +216 -0
  89. data/lib/rubik/llm/provider.rb +125 -0
  90. data/lib/rubik/llm/registry.rb +336 -0
  91. data/lib/rubik/llm/router.rb +100 -0
  92. data/lib/rubik/llm.rb +18 -0
  93. data/lib/rubik/log.rb +38 -0
  94. data/lib/rubik/lsp/client.rb +475 -0
  95. data/lib/rubik/lsp/manager.rb +366 -0
  96. data/lib/rubik/lsp.rb +9 -0
  97. data/lib/rubik/mcp/boot_lifecycle.rb +114 -0
  98. data/lib/rubik/mcp/client.rb +169 -0
  99. data/lib/rubik/mcp/errors.rb +11 -0
  100. data/lib/rubik/mcp/manager.rb +619 -0
  101. data/lib/rubik/mcp/proxy_tool.rb +104 -0
  102. data/lib/rubik/mcp/transport/stdio.rb +255 -0
  103. data/lib/rubik/mcp.rb +12 -0
  104. data/lib/rubik/orchestrator/activity_log.rb +75 -0
  105. data/lib/rubik/orchestrator/auto_apply.rb +166 -0
  106. data/lib/rubik/orchestrator/budget_manager.rb +97 -0
  107. data/lib/rubik/orchestrator/continuation_manager.rb +161 -0
  108. data/lib/rubik/orchestrator/finish_message_selector.rb +55 -0
  109. data/lib/rubik/orchestrator/goal_ledger/drift.rb +71 -0
  110. data/lib/rubik/orchestrator/goal_ledger/ledger.rb +399 -0
  111. data/lib/rubik/orchestrator/goal_ledger/observation_projection.rb +179 -0
  112. data/lib/rubik/orchestrator/goal_ledger/patch.rb +268 -0
  113. data/lib/rubik/orchestrator/goal_ledger/schema.rb +123 -0
  114. data/lib/rubik/orchestrator/goal_ledger/validator.rb +361 -0
  115. data/lib/rubik/orchestrator/goal_ledger/verifier.rb +129 -0
  116. data/lib/rubik/orchestrator/goal_ledger/verify_spec.rb +50 -0
  117. data/lib/rubik/orchestrator/goal_ledger.rb +65 -0
  118. data/lib/rubik/orchestrator/goal_state.rb +184 -0
  119. data/lib/rubik/orchestrator/goal_wait.rb +125 -0
  120. data/lib/rubik/orchestrator/loop.rb +388 -0
  121. data/lib/rubik/orchestrator/lsp_feedback.rb +55 -0
  122. data/lib/rubik/orchestrator/maestro_gate.rb +65 -0
  123. data/lib/rubik/orchestrator/provider_recovery.rb +90 -0
  124. data/lib/rubik/orchestrator/provider_resolver.rb +69 -0
  125. data/lib/rubik/orchestrator/shell_deps.rb +85 -0
  126. data/lib/rubik/orchestrator/step_executor.rb +328 -0
  127. data/lib/rubik/orchestrator/stream_manager.rb +118 -0
  128. data/lib/rubik/orchestrator/subagent/activation.rb +65 -0
  129. data/lib/rubik/orchestrator/subagent/budget.rb +108 -0
  130. data/lib/rubik/orchestrator/subagent/completion.rb +61 -0
  131. data/lib/rubik/orchestrator/subagent/concurrency_gate.rb +37 -0
  132. data/lib/rubik/orchestrator/subagent/conversation_id.rb +67 -0
  133. data/lib/rubik/orchestrator/subagent/dependency.rb +398 -0
  134. data/lib/rubik/orchestrator/subagent/instance.rb +384 -0
  135. data/lib/rubik/orchestrator/subagent/lifecycle.rb +118 -0
  136. data/lib/rubik/orchestrator/subagent/messaging.rb +61 -0
  137. data/lib/rubik/orchestrator/subagent/narration.rb +125 -0
  138. data/lib/rubik/orchestrator/subagent/notices.rb +209 -0
  139. data/lib/rubik/orchestrator/subagent/questioner.rb +80 -0
  140. data/lib/rubik/orchestrator/subagent/reaper.rb +98 -0
  141. data/lib/rubik/orchestrator/subagent/resilience.rb +70 -0
  142. data/lib/rubik/orchestrator/subagent/reuse.rb +127 -0
  143. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +79 -0
  144. data/lib/rubik/orchestrator/subagent/step_runner.rb +240 -0
  145. data/lib/rubik/orchestrator/subagent/storage_id.rb +26 -0
  146. data/lib/rubik/orchestrator/subagent/supervisor.rb +327 -0
  147. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +141 -0
  148. data/lib/rubik/orchestrator/subagent/type_registry.rb +199 -0
  149. data/lib/rubik/orchestrator/subagent_notice_inbox.rb +80 -0
  150. data/lib/rubik/orchestrator/tool_call_id.rb +47 -0
  151. data/lib/rubik/orchestrator/tool_circuit_breaker.rb +91 -0
  152. data/lib/rubik/orchestrator/tool_history.rb +39 -0
  153. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +102 -0
  154. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +172 -0
  155. data/lib/rubik/orchestrator/turn_executor.rb +376 -0
  156. data/lib/rubik/orchestrator.rb +11 -0
  157. data/lib/rubik/platform/filesystem.rb +64 -0
  158. data/lib/rubik/platform/io_drain.rb +53 -0
  159. data/lib/rubik/platform/path.rb +210 -0
  160. data/lib/rubik/platform/process.rb +38 -0
  161. data/lib/rubik/platform/sanitize.rb +157 -0
  162. data/lib/rubik/platform.rb +113 -0
  163. data/lib/rubik/plugin/dsl.rb +51 -0
  164. data/lib/rubik/plugin/manager.rb +247 -0
  165. data/lib/rubik/plugin/storage.rb +195 -0
  166. data/lib/rubik/plugin.rb +10 -0
  167. data/lib/rubik/prompt/context_builder.rb +377 -0
  168. data/lib/rubik/prompt/context_compactor.rb +326 -0
  169. data/lib/rubik/prompt/conversation_rows.rb +74 -0
  170. data/lib/rubik/prompt/doctrine.rb +160 -0
  171. data/lib/rubik/prompt/emergency_compactor.rb +81 -0
  172. data/lib/rubik/prompt/services.rb +60 -0
  173. data/lib/rubik/prompt/token_estimator.rb +63 -0
  174. data/lib/rubik/prompt/tool_teachings.rb +78 -0
  175. data/lib/rubik/prompt.rb +13 -0
  176. data/lib/rubik/repair/loop.rb +95 -0
  177. data/lib/rubik/repair.rb +8 -0
  178. data/lib/rubik/repl/ast_warmup.rb +129 -0
  179. data/lib/rubik/repl/command_context.rb +214 -0
  180. data/lib/rubik/repl/command_parser.rb +48 -0
  181. data/lib/rubik/repl/commands/agent.rb +30 -0
  182. data/lib/rubik/repl/commands/agent_context.rb +289 -0
  183. data/lib/rubik/repl/commands/agent_execution.rb +342 -0
  184. data/lib/rubik/repl/commands/agent_models.rb +212 -0
  185. data/lib/rubik/repl/commands/agent_providers.rb +232 -0
  186. data/lib/rubik/repl/commands/base.rb +55 -0
  187. data/lib/rubik/repl/commands/buffer.rb +124 -0
  188. data/lib/rubik/repl/commands/dmesg.rb +87 -0
  189. data/lib/rubik/repl/commands/lsp.rb +235 -0
  190. data/lib/rubik/repl/commands/mcp.rb +194 -0
  191. data/lib/rubik/repl/commands/plugins.rb +208 -0
  192. data/lib/rubik/repl/commands/pruner.rb +71 -0
  193. data/lib/rubik/repl/commands/session.rb +183 -0
  194. data/lib/rubik/repl/commands/skills.rb +111 -0
  195. data/lib/rubik/repl/commands/storage.rb +85 -0
  196. data/lib/rubik/repl/commands/subagents.rb +195 -0
  197. data/lib/rubik/repl/commands/system.rb +85 -0
  198. data/lib/rubik/repl/commands/tasks.rb +120 -0
  199. data/lib/rubik/repl/commands/workspace.rb +443 -0
  200. data/lib/rubik/repl/commands.rb +44 -0
  201. data/lib/rubik/repl/composition.rb +32 -0
  202. data/lib/rubik/repl/config_wiring.rb +142 -0
  203. data/lib/rubik/repl/confirmations.rb +56 -0
  204. data/lib/rubik/repl/hooks.rb +47 -0
  205. data/lib/rubik/repl/input_execution.rb +323 -0
  206. data/lib/rubik/repl/input_loop.rb +213 -0
  207. data/lib/rubik/repl/interrupt_handler.rb +217 -0
  208. data/lib/rubik/repl/lifecycle.rb +410 -0
  209. data/lib/rubik/repl/paste_support.rb +88 -0
  210. data/lib/rubik/repl/pending_notice_queue.rb +60 -0
  211. data/lib/rubik/repl/presentation.rb +132 -0
  212. data/lib/rubik/repl/pruner/brief.rb +101 -0
  213. data/lib/rubik/repl/pruner/gate.rb +279 -0
  214. data/lib/rubik/repl/pruner/harness.rb +236 -0
  215. data/lib/rubik/repl/pruner/rules.rb +316 -0
  216. data/lib/rubik/repl/pruner/service.rb +82 -0
  217. data/lib/rubik/repl/pruner.rb +39 -0
  218. data/lib/rubik/repl/service_catalog.rb +374 -0
  219. data/lib/rubik/repl/setup_factories.rb +66 -0
  220. data/lib/rubik/repl/shell.rb +231 -0
  221. data/lib/rubik/repl/step_formatter.rb +324 -0
  222. data/lib/rubik/repl/tee_io.rb +105 -0
  223. data/lib/rubik/repl/terminal_guard.rb +157 -0
  224. data/lib/rubik/repl/turn_recorder.rb +234 -0
  225. data/lib/rubik/repl.rb +14 -0
  226. data/lib/rubik/runtime/ast_service.rb +78 -0
  227. data/lib/rubik/runtime/context.rb +335 -0
  228. data/lib/rubik/runtime/diagnostic_engine.rb +188 -0
  229. data/lib/rubik/runtime/discovery.rb +342 -0
  230. data/lib/rubik/runtime/fiber.rb +142 -0
  231. data/lib/rubik/runtime/fiber_ordering.rb +52 -0
  232. data/lib/rubik/runtime/fiber_retry_gate.rb +84 -0
  233. data/lib/rubik/runtime/index_service.rb +58 -0
  234. data/lib/rubik/runtime/loop_service.rb +73 -0
  235. data/lib/rubik/runtime/manager.rb +241 -0
  236. data/lib/rubik/runtime/overlay_storage.rb +481 -0
  237. data/lib/rubik/runtime/service_definition.rb +54 -0
  238. data/lib/rubik/runtime/service_registration.rb +33 -0
  239. data/lib/rubik/runtime/task_manager.rb +302 -0
  240. data/lib/rubik/runtime.rb +19 -0
  241. data/lib/rubik/sandbox/driver/base.rb +30 -0
  242. data/lib/rubik/sandbox/driver/bubblewrap.rb +111 -0
  243. data/lib/rubik/sandbox/driver/docker.rb +113 -0
  244. data/lib/rubik/sandbox/driver/podman.rb +98 -0
  245. data/lib/rubik/sandbox/driver/process.rb +190 -0
  246. data/lib/rubik/sandbox/driver.rb +91 -0
  247. data/lib/rubik/sandbox/runner.rb +218 -0
  248. data/lib/rubik/sandbox/shell_resolver.rb +178 -0
  249. data/lib/rubik/sandbox.rb +8 -0
  250. data/lib/rubik/security/blocked_guidance.rb +187 -0
  251. data/lib/rubik/security/classifier/classifier.rb +440 -0
  252. data/lib/rubik/security/classifier/commands/agent-browser.yaml +9 -0
  253. data/lib/rubik/security/classifier/commands/apt.yaml +25 -0
  254. data/lib/rubik/security/classifier/commands/awk.yaml +10 -0
  255. data/lib/rubik/security/classifier/commands/basename.yaml +7 -0
  256. data/lib/rubik/security/classifier/commands/bash-classify.yaml +8 -0
  257. data/lib/rubik/security/classifier/commands/bash.yaml +20 -0
  258. data/lib/rubik/security/classifier/commands/black.yaml +12 -0
  259. data/lib/rubik/security/classifier/commands/brew.yaml +31 -0
  260. data/lib/rubik/security/classifier/commands/builtin.yaml +8 -0
  261. data/lib/rubik/security/classifier/commands/cargo.yaml +114 -0
  262. data/lib/rubik/security/classifier/commands/cat.yaml +7 -0
  263. data/lib/rubik/security/classifier/commands/chgrp.yaml +10 -0
  264. data/lib/rubik/security/classifier/commands/chmod.yaml +10 -0
  265. data/lib/rubik/security/classifier/commands/chown.yaml +10 -0
  266. data/lib/rubik/security/classifier/commands/column.yaml +7 -0
  267. data/lib/rubik/security/classifier/commands/comm.yaml +7 -0
  268. data/lib/rubik/security/classifier/commands/command.yaml +12 -0
  269. data/lib/rubik/security/classifier/commands/cp.yaml +7 -0
  270. data/lib/rubik/security/classifier/commands/crystal.yaml +63 -0
  271. data/lib/rubik/security/classifier/commands/curl.yaml +30 -0
  272. data/lib/rubik/security/classifier/commands/cut.yaml +7 -0
  273. data/lib/rubik/security/classifier/commands/date.yaml +10 -0
  274. data/lib/rubik/security/classifier/commands/df.yaml +7 -0
  275. data/lib/rubik/security/classifier/commands/diff.yaml +7 -0
  276. data/lib/rubik/security/classifier/commands/dig.yaml +7 -0
  277. data/lib/rubik/security/classifier/commands/dirname.yaml +7 -0
  278. data/lib/rubik/security/classifier/commands/docker.yaml +177 -0
  279. data/lib/rubik/security/classifier/commands/du.yaml +7 -0
  280. data/lib/rubik/security/classifier/commands/echo.yaml +7 -0
  281. data/lib/rubik/security/classifier/commands/env.yaml +15 -0
  282. data/lib/rubik/security/classifier/commands/eslint.yaml +12 -0
  283. data/lib/rubik/security/classifier/commands/eval.yaml +8 -0
  284. data/lib/rubik/security/classifier/commands/exec.yaml +12 -0
  285. data/lib/rubik/security/classifier/commands/expand.yaml +7 -0
  286. data/lib/rubik/security/classifier/commands/false.yaml +7 -0
  287. data/lib/rubik/security/classifier/commands/file.yaml +7 -0
  288. data/lib/rubik/security/classifier/commands/find.yaml +25 -0
  289. data/lib/rubik/security/classifier/commands/fmt.yaml +7 -0
  290. data/lib/rubik/security/classifier/commands/fold.yaml +7 -0
  291. data/lib/rubik/security/classifier/commands/free.yaml +7 -0
  292. data/lib/rubik/security/classifier/commands/gawk.yaml +10 -0
  293. data/lib/rubik/security/classifier/commands/gcloud.yaml +979 -0
  294. data/lib/rubik/security/classifier/commands/gh.yaml +615 -0
  295. data/lib/rubik/security/classifier/commands/git.yaml +524 -0
  296. data/lib/rubik/security/classifier/commands/glab-discussion.yaml +21 -0
  297. data/lib/rubik/security/classifier/commands/glab.yaml +807 -0
  298. data/lib/rubik/security/classifier/commands/go.yaml +44 -0
  299. data/lib/rubik/security/classifier/commands/gog.yaml +983 -0
  300. data/lib/rubik/security/classifier/commands/gradle.yaml +92 -0
  301. data/lib/rubik/security/classifier/commands/gradlew.yaml +92 -0
  302. data/lib/rubik/security/classifier/commands/grep.yaml +7 -0
  303. data/lib/rubik/security/classifier/commands/groups.yaml +7 -0
  304. data/lib/rubik/security/classifier/commands/gunzip.yaml +8 -0
  305. data/lib/rubik/security/classifier/commands/gws.yaml +211 -0
  306. data/lib/rubik/security/classifier/commands/gzip.yaml +7 -0
  307. data/lib/rubik/security/classifier/commands/hadolint.yaml +9 -0
  308. data/lib/rubik/security/classifier/commands/halt.yaml +8 -0
  309. data/lib/rubik/security/classifier/commands/head.yaml +7 -0
  310. data/lib/rubik/security/classifier/commands/helm.yaml +129 -0
  311. data/lib/rubik/security/classifier/commands/hostname.yaml +7 -0
  312. data/lib/rubik/security/classifier/commands/id.yaml +7 -0
  313. data/lib/rubik/security/classifier/commands/install.yaml +7 -0
  314. data/lib/rubik/security/classifier/commands/isort.yaml +14 -0
  315. data/lib/rubik/security/classifier/commands/java.yaml +35 -0
  316. data/lib/rubik/security/classifier/commands/javac.yaml +39 -0
  317. data/lib/rubik/security/classifier/commands/join.yaml +7 -0
  318. data/lib/rubik/security/classifier/commands/journalctl.yaml +14 -0
  319. data/lib/rubik/security/classifier/commands/jq.yaml +7 -0
  320. data/lib/rubik/security/classifier/commands/kill.yaml +7 -0
  321. data/lib/rubik/security/classifier/commands/killall.yaml +7 -0
  322. data/lib/rubik/security/classifier/commands/kubectl.yaml +211 -0
  323. data/lib/rubik/security/classifier/commands/less.yaml +7 -0
  324. data/lib/rubik/security/classifier/commands/ln.yaml +7 -0
  325. data/lib/rubik/security/classifier/commands/ls.yaml +7 -0
  326. data/lib/rubik/security/classifier/commands/make.yaml +21 -0
  327. data/lib/rubik/security/classifier/commands/md5sum.yaml +7 -0
  328. data/lib/rubik/security/classifier/commands/mise.yaml +579 -0
  329. data/lib/rubik/security/classifier/commands/mkdir.yaml +8 -0
  330. data/lib/rubik/security/classifier/commands/more.yaml +7 -0
  331. data/lib/rubik/security/classifier/commands/mv.yaml +7 -0
  332. data/lib/rubik/security/classifier/commands/mvn.yaml +83 -0
  333. data/lib/rubik/security/classifier/commands/mvnw.yaml +83 -0
  334. data/lib/rubik/security/classifier/commands/mypy.yaml +9 -0
  335. data/lib/rubik/security/classifier/commands/netstat.yaml +7 -0
  336. data/lib/rubik/security/classifier/commands/nice.yaml +11 -0
  337. data/lib/rubik/security/classifier/commands/nl.yaml +7 -0
  338. data/lib/rubik/security/classifier/commands/node.yaml +20 -0
  339. data/lib/rubik/security/classifier/commands/nohup.yaml +9 -0
  340. data/lib/rubik/security/classifier/commands/npm.yaml +71 -0
  341. data/lib/rubik/security/classifier/commands/npx.yaml +42 -0
  342. data/lib/rubik/security/classifier/commands/nslookup.yaml +7 -0
  343. data/lib/rubik/security/classifier/commands/paste.yaml +7 -0
  344. data/lib/rubik/security/classifier/commands/ping.yaml +7 -0
  345. data/lib/rubik/security/classifier/commands/pip.yaml +33 -0
  346. data/lib/rubik/security/classifier/commands/pip3.yaml +33 -0
  347. data/lib/rubik/security/classifier/commands/pipx.yaml +33 -0
  348. data/lib/rubik/security/classifier/commands/pkill.yaml +7 -0
  349. data/lib/rubik/security/classifier/commands/pnpm.yaml +346 -0
  350. data/lib/rubik/security/classifier/commands/poetry.yaml +97 -0
  351. data/lib/rubik/security/classifier/commands/poweroff.yaml +8 -0
  352. data/lib/rubik/security/classifier/commands/prettier.yaml +13 -0
  353. data/lib/rubik/security/classifier/commands/printenv.yaml +7 -0
  354. data/lib/rubik/security/classifier/commands/printf.yaml +7 -0
  355. data/lib/rubik/security/classifier/commands/ps.yaml +7 -0
  356. data/lib/rubik/security/classifier/commands/pup.yaml +882 -0
  357. data/lib/rubik/security/classifier/commands/pwd.yaml +7 -0
  358. data/lib/rubik/security/classifier/commands/pytest.yaml +7 -0
  359. data/lib/rubik/security/classifier/commands/python.yaml +9 -0
  360. data/lib/rubik/security/classifier/commands/python3.yaml +9 -0
  361. data/lib/rubik/security/classifier/commands/read.yaml +7 -0
  362. data/lib/rubik/security/classifier/commands/readlink.yaml +7 -0
  363. data/lib/rubik/security/classifier/commands/realpath.yaml +7 -0
  364. data/lib/rubik/security/classifier/commands/reboot.yaml +8 -0
  365. data/lib/rubik/security/classifier/commands/rev.yaml +7 -0
  366. data/lib/rubik/security/classifier/commands/rg.yaml +7 -0
  367. data/lib/rubik/security/classifier/commands/rm.yaml +8 -0
  368. data/lib/rubik/security/classifier/commands/rsync.yaml +12 -0
  369. data/lib/rubik/security/classifier/commands/rtk.yaml +140 -0
  370. data/lib/rubik/security/classifier/commands/ruff.yaml +29 -0
  371. data/lib/rubik/security/classifier/commands/rustfmt.yaml +14 -0
  372. data/lib/rubik/security/classifier/commands/scp.yaml +7 -0
  373. data/lib/rubik/security/classifier/commands/sed.yaml +14 -0
  374. data/lib/rubik/security/classifier/commands/seq.yaml +7 -0
  375. data/lib/rubik/security/classifier/commands/sh.yaml +14 -0
  376. data/lib/rubik/security/classifier/commands/sha1sum.yaml +7 -0
  377. data/lib/rubik/security/classifier/commands/sha256sum.yaml +7 -0
  378. data/lib/rubik/security/classifier/commands/shellcheck.yaml +10 -0
  379. data/lib/rubik/security/classifier/commands/shutdown.yaml +8 -0
  380. data/lib/rubik/security/classifier/commands/slack.yaml +113 -0
  381. data/lib/rubik/security/classifier/commands/sleep.yaml +8 -0
  382. data/lib/rubik/security/classifier/commands/snip.yaml +79 -0
  383. data/lib/rubik/security/classifier/commands/sort.yaml +10 -0
  384. data/lib/rubik/security/classifier/commands/ss.yaml +7 -0
  385. data/lib/rubik/security/classifier/commands/ssh.yaml +41 -0
  386. data/lib/rubik/security/classifier/commands/stat.yaml +7 -0
  387. data/lib/rubik/security/classifier/commands/stdbuf.yaml +15 -0
  388. data/lib/rubik/security/classifier/commands/strace.yaml +20 -0
  389. data/lib/rubik/security/classifier/commands/strings.yaml +11 -0
  390. data/lib/rubik/security/classifier/commands/sudo.yaml +24 -0
  391. data/lib/rubik/security/classifier/commands/systemctl.yaml +58 -0
  392. data/lib/rubik/security/classifier/commands/systemd-run.yaml +30 -0
  393. data/lib/rubik/security/classifier/commands/tac.yaml +7 -0
  394. data/lib/rubik/security/classifier/commands/tail.yaml +7 -0
  395. data/lib/rubik/security/classifier/commands/tar.yaml +25 -0
  396. data/lib/rubik/security/classifier/commands/tee.yaml +8 -0
  397. data/lib/rubik/security/classifier/commands/terraform.yaml +70 -0
  398. data/lib/rubik/security/classifier/commands/test.yaml +7 -0
  399. data/lib/rubik/security/classifier/commands/time.yaml +8 -0
  400. data/lib/rubik/security/classifier/commands/timeout.yaml +18 -0
  401. data/lib/rubik/security/classifier/commands/top.yaml +7 -0
  402. data/lib/rubik/security/classifier/commands/touch.yaml +8 -0
  403. data/lib/rubik/security/classifier/commands/tr.yaml +7 -0
  404. data/lib/rubik/security/classifier/commands/traceroute.yaml +7 -0
  405. data/lib/rubik/security/classifier/commands/true.yaml +7 -0
  406. data/lib/rubik/security/classifier/commands/tsc.yaml +16 -0
  407. data/lib/rubik/security/classifier/commands/type.yaml +7 -0
  408. data/lib/rubik/security/classifier/commands/uname.yaml +7 -0
  409. data/lib/rubik/security/classifier/commands/unexpand.yaml +7 -0
  410. data/lib/rubik/security/classifier/commands/uniq.yaml +7 -0
  411. data/lib/rubik/security/classifier/commands/unzip.yaml +10 -0
  412. data/lib/rubik/security/classifier/commands/uptime.yaml +7 -0
  413. data/lib/rubik/security/classifier/commands/uv.yaml +156 -0
  414. data/lib/rubik/security/classifier/commands/wait.yaml +11 -0
  415. data/lib/rubik/security/classifier/commands/wc.yaml +7 -0
  416. data/lib/rubik/security/classifier/commands/wget.yaml +7 -0
  417. data/lib/rubik/security/classifier/commands/whereis.yaml +7 -0
  418. data/lib/rubik/security/classifier/commands/which.yaml +7 -0
  419. data/lib/rubik/security/classifier/commands/whoami.yaml +7 -0
  420. data/lib/rubik/security/classifier/commands/xargs.yaml +26 -0
  421. data/lib/rubik/security/classifier/commands/xmllint.yaml +7 -0
  422. data/lib/rubik/security/classifier/commands/yarn.yaml +72 -0
  423. data/lib/rubik/security/classifier/commands/yes.yaml +7 -0
  424. data/lib/rubik/security/classifier/commands/yq.yaml +10 -0
  425. data/lib/rubik/security/classifier/commands/zip.yaml +8 -0
  426. data/lib/rubik/security/classifier/commands/zsh.yaml +14 -0
  427. data/lib/rubik/security/classifier/database.rb +216 -0
  428. data/lib/rubik/security/classifier/matcher/delegation.rb +190 -0
  429. data/lib/rubik/security/classifier/matcher/options.rb +245 -0
  430. data/lib/rubik/security/classifier/matcher/subcommands.rb +103 -0
  431. data/lib/rubik/security/classifier/matcher.rb +249 -0
  432. data/lib/rubik/security/classifier/models.rb +170 -0
  433. data/lib/rubik/security/classifier/parser.rb +23 -0
  434. data/lib/rubik/security/classifier/parsers/bash.rb +280 -0
  435. data/lib/rubik/security/classifier/parsers/bash_extractors.rb +221 -0
  436. data/lib/rubik/security/classifier/parsers/powershell.rb +185 -0
  437. data/lib/rubik/security/classifier/pwsh_rules.rb +224 -0
  438. data/lib/rubik/security/classifier.rb +25 -0
  439. data/lib/rubik/security/errors.rb +39 -0
  440. data/lib/rubik/security/guard.rb +954 -0
  441. data/lib/rubik/security/redactor.rb +89 -0
  442. data/lib/rubik/security.rb +11 -0
  443. data/lib/rubik/session/manager.rb +331 -0
  444. data/lib/rubik/session/recorder.rb +149 -0
  445. data/lib/rubik/session/replayer.rb +65 -0
  446. data/lib/rubik/session.rb +10 -0
  447. data/lib/rubik/skills/frontmatter.rb +41 -0
  448. data/lib/rubik/skills/manager.rb +223 -0
  449. data/lib/rubik/skills/registry.rb +64 -0
  450. data/lib/rubik/skills/skill.rb +33 -0
  451. data/lib/rubik/skills/storage.rb +77 -0
  452. data/lib/rubik/skills/validator.rb +116 -0
  453. data/lib/rubik/skills.rb +20 -0
  454. data/lib/rubik/storage/folding.rb +23 -0
  455. data/lib/rubik/storage/layout.rb +145 -0
  456. data/lib/rubik/storage/render.rb +97 -0
  457. data/lib/rubik/storage/service.rb +42 -0
  458. data/lib/rubik/storage/store.rb +514 -0
  459. data/lib/rubik/storage/transcript_indexer.rb +116 -0
  460. data/lib/rubik/storage/uri_reader.rb +168 -0
  461. data/lib/rubik/storage/vakanuvis/annal_writer.rb +92 -0
  462. data/lib/rubik/storage/vakanuvis/distill_gate.rb +323 -0
  463. data/lib/rubik/storage/vakanuvis/gate.rb +250 -0
  464. data/lib/rubik/storage/vakanuvis/harness.rb +421 -0
  465. data/lib/rubik/storage/vakanuvis/memory_steward.rb +163 -0
  466. data/lib/rubik/storage/vakanuvis/service.rb +69 -0
  467. data/lib/rubik/storage/vakanuvis/vocabulary.rb +99 -0
  468. data/lib/rubik/storage/vakanuvis/wiki_skeleton.rb +67 -0
  469. data/lib/rubik/storage/vakanuvis.rb +77 -0
  470. data/lib/rubik/storage.rb +29 -0
  471. data/lib/rubik/tools/base.rb +107 -0
  472. data/lib/rubik/tools/builtins/dmesg.rb +74 -0
  473. data/lib/rubik/tools/builtins/exec.rb +293 -0
  474. data/lib/rubik/tools/builtins/files.rb +354 -0
  475. data/lib/rubik/tools/builtins/goal_ledger.rb +89 -0
  476. data/lib/rubik/tools/builtins/grep.rb +281 -0
  477. data/lib/rubik/tools/builtins/interactive.rb +175 -0
  478. data/lib/rubik/tools/builtins/lifecycle.rb +359 -0
  479. data/lib/rubik/tools/builtins/lsp.rb +283 -0
  480. data/lib/rubik/tools/builtins/pruner.rb +88 -0
  481. data/lib/rubik/tools/builtins/runtime.rb +120 -0
  482. data/lib/rubik/tools/builtins/send_message.rb +109 -0
  483. data/lib/rubik/tools/builtins/show_file.rb +84 -0
  484. data/lib/rubik/tools/builtins/skill.rb +72 -0
  485. data/lib/rubik/tools/builtins/storage.rb +232 -0
  486. data/lib/rubik/tools/builtins/subagent.rb +356 -0
  487. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +72 -0
  488. data/lib/rubik/tools/builtins/symbols.rb +209 -0
  489. data/lib/rubik/tools/builtins/synthesis.rb +314 -0
  490. data/lib/rubik/tools/builtins/task.rb +168 -0
  491. data/lib/rubik/tools/builtins/unsynthesize.rb +111 -0
  492. data/lib/rubik/tools/builtins/vakanuvis.rb +161 -0
  493. data/lib/rubik/tools/builtins.rb +30 -0
  494. data/lib/rubik/tools/catalog.rb +70 -0
  495. data/lib/rubik/tools/declaration.rb +90 -0
  496. data/lib/rubik/tools/diagnostic_reporter.rb +283 -0
  497. data/lib/rubik/tools/fuzzy_matcher.rb +172 -0
  498. data/lib/rubik/tools/native_search.rb +180 -0
  499. data/lib/rubik/tools/output.rb +54 -0
  500. data/lib/rubik/tools/preflight_runner.rb +209 -0
  501. data/lib/rubik/tools/registry.rb +247 -0
  502. data/lib/rubik/tools/result.rb +71 -0
  503. data/lib/rubik/tools/services.rb +96 -0
  504. data/lib/rubik/tools/truncator.rb +78 -0
  505. data/lib/rubik/tools/uri_resolver.rb +195 -0
  506. data/lib/rubik/tools.rb +14 -0
  507. data/lib/rubik/ui/approvals.rb +52 -0
  508. data/lib/rubik/ui/banner.rb +43 -0
  509. data/lib/rubik/ui/capability.rb +122 -0
  510. data/lib/rubik/ui/dialog.rb +106 -0
  511. data/lib/rubik/ui/diff_viewer.rb +78 -0
  512. data/lib/rubik/ui/frame.rb +95 -0
  513. data/lib/rubik/ui/markdown_stream.rb +107 -0
  514. data/lib/rubik/ui/panel.rb +98 -0
  515. data/lib/rubik/ui/renderer.rb +206 -0
  516. data/lib/rubik/ui/spinner.rb +120 -0
  517. data/lib/rubik/ui/statusline.rb +134 -0
  518. data/lib/rubik/ui/theme.rb +118 -0
  519. data/lib/rubik/ui.rb +57 -0
  520. data/lib/rubik/uris.rb +101 -0
  521. data/lib/rubik/version.rb +5 -0
  522. data/lib/rubik.rb +65 -0
  523. metadata +770 -0
@@ -0,0 +1,526 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+ require_relative "../platform"
8
+
9
+ module Rubik
10
+ module LLM
11
+ class ClientError < StandardError; end
12
+
13
+ # HTTP 429 (rate limited), carrying the parsed Retry-After hint when the
14
+ # server advertised one. Subclass of ClientError so every existing
15
+ # `rescue ClientError` site keeps working; the typed class lets callers
16
+ # distinguish "provider throttled us" from every other failure without
17
+ # parsing message strings.
18
+ class RateLimitError < ClientError
19
+ attr_reader :retry_after
20
+
21
+ def initialize(message, retry_after: nil)
22
+ super(message)
23
+ @retry_after = retry_after
24
+ end
25
+ end
26
+
27
+ # Buffers raw bytes from a stream and emits complete events.
28
+ # Default delimiter is the SSE blank line (\n\n); pass
29
+ # `terminator: "\n"` to parse NDJSON streams (one JSON object per line).
30
+ # One event is a Hash like:
31
+ # { type: "message", data: "json-string" }
32
+ # In SSE mode, multi-line `data:` fields are joined with \n. In NDJSON
33
+ # mode (`terminator: "\n"`), the `type` field is always "message" and
34
+ # the entire line is the `data`. Incomplete events are held in the
35
+ # internal buffer until the next feed.
36
+ class SSEParser
37
+ SSE_EVENT_SEP = /\r\n\r\n|\n\n|\r\n\n|\n\r\n|\r\r/
38
+ SSE_TERMINATOR = "\n\n"
39
+ NDJSON_TERMINATOR = "\n"
40
+
41
+ def initialize(terminator: SSE_TERMINATOR)
42
+ @terminator = terminator
43
+ @sse_mode = @terminator == SSE_TERMINATOR || @terminator == SSE_EVENT_SEP
44
+ @buffer = String.new(encoding: Encoding::UTF_8)
45
+ end
46
+
47
+ # Feed raw bytes; returns an array of complete events parsed from this
48
+ # chunk. Partial events stay in the buffer.
49
+ def feed(chunk_bytes)
50
+ incoming = chunk_bytes.to_s.dup.force_encoding(Encoding::UTF_8)
51
+ incoming = incoming.scrub unless incoming.valid_encoding?
52
+ @buffer << incoming
53
+ events = []
54
+
55
+ if @sse_mode
56
+ while (m = @buffer.match(SSE_EVENT_SEP))
57
+ # Chunk boundary \r buffering: if match ends on \r at the very end of @buffer,
58
+ # wait for next feed to see if \n follows (e.g. \r\r could become \r\r\n).
59
+ break if m.end(0) == @buffer.bytesize && @buffer.end_with?("\r")
60
+
61
+ event_block = @buffer[0...m.begin(0)]
62
+ @buffer = @buffer[m.end(0)..] || String.new(encoding: Encoding::UTF_8)
63
+ parsed = parse_event(event_block)
64
+ events << parsed if parsed
65
+ end
66
+ else
67
+ while (idx = @buffer.index(@terminator))
68
+ event_block = @buffer[0...idx]
69
+ @buffer = @buffer[(idx + @terminator.length)..] || String.new(encoding: Encoding::UTF_8)
70
+ parsed = parse_event(event_block)
71
+ events << parsed if parsed
72
+ end
73
+ end
74
+
75
+ events
76
+ end
77
+
78
+ # Flush any trailing event (no trailing terminator) and reset.
79
+ def flush
80
+ return [] if @buffer.empty?
81
+
82
+ events = []
83
+ if @sse_mode
84
+ while (m = @buffer.match(SSE_EVENT_SEP))
85
+ event_block = @buffer[0...m.begin(0)]
86
+ @buffer = @buffer[m.end(0)..] || String.new(encoding: Encoding::UTF_8)
87
+ parsed = parse_event(event_block)
88
+ events << parsed if parsed
89
+ end
90
+ else
91
+ while (idx = @buffer.index(@terminator))
92
+ event_block = @buffer[0...idx]
93
+ @buffer = @buffer[(idx + @terminator.length)..] || String.new(encoding: Encoding::UTF_8)
94
+ parsed = parse_event(event_block)
95
+ events << parsed if parsed
96
+ end
97
+ end
98
+
99
+ unless @buffer.empty?
100
+ remaining = @buffer.dup
101
+ @buffer = String.new(encoding: Encoding::UTF_8)
102
+ parsed = parse_event(remaining)
103
+ events << parsed if parsed
104
+ end
105
+
106
+ events
107
+ end
108
+
109
+ private
110
+
111
+ def parse_event(block)
112
+ return nil if block.nil?
113
+ return { type: "message", data: block.to_s.chomp } unless @sse_mode
114
+
115
+ lines = block.to_s.split(/\r\n|\r|\n/)
116
+ return nil if lines.empty?
117
+
118
+ event_type = "message"
119
+ data_lines = []
120
+ has_field = false
121
+
122
+ lines.each do |line|
123
+ next if line.empty?
124
+ next if line.start_with?(":")
125
+
126
+ if (m = line.match(/\Aevent:\s*(.+)\z/))
127
+ event_type = m[1]
128
+ has_field = true
129
+ elsif (m = line.match(/\Adata:\s?(.*)\z/))
130
+ data_lines << m[1]
131
+ has_field = true
132
+ end
133
+ end
134
+
135
+ return nil unless has_field
136
+
137
+ { type: event_type, data: data_lines.join("\n") }
138
+ end
139
+ end
140
+
141
+ class HTTPClient
142
+ # Errors that are safe to retry with an idempotent bounded backoff:
143
+ # connect-phase failures and transport resets. Net::ReadTimeout is
144
+ # deliberately NOT here: a read timeout on a non-streaming LLM call
145
+ # means the server is still generating, and re-sending the full payload
146
+ # would silently duplicate work (the original 2-minute freeze).
147
+ RETRYABLE_ERRORS = [
148
+ Net::OpenTimeout,
149
+ Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED,
150
+ Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
151
+ SocketError,
152
+ EOFError
153
+ ].freeze
154
+
155
+ # Shared hosted-model concurrency limits (429) arrive in bursts lasting
156
+ # seconds to minutes and advertise their window via `Retry-After`. The
157
+ # generic budget (max_retries: 2, ~1.2s backoff) gives up mid-burst and
158
+ # loses the turn; rate limiting gets its own, larger budget and honors
159
+ # the server's hint, capped so a pathological header can't stall an
160
+ # interactive agent turn indefinitely.
161
+ RATE_LIMIT_MAX_DELAY_S = 60
162
+
163
+ # Read a response header defensively: test doubles and exotic response
164
+ # objects may not implement the Net::HTTPHeader interface.
165
+ def self.header_value(res, name)
166
+ res.respond_to?(:key?) && res.key?(name) ? res[name] : nil
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
171
+ # Retry-After may be delay-seconds or an HTTP-date; anything else is
172
+ # unparseable -> nil (caller falls back to exponential backoff).
173
+ def self.parse_retry_after(value)
174
+ return nil if value.nil?
175
+
176
+ s = value.to_s.strip
177
+ return s.to_f if s.match?(/\A\d+(\.\d+)?\z/)
178
+
179
+ t = Time.httpdate(s)
180
+ d = (t - Time.now).ceil
181
+ d.positive? ? d : 0
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ # Delay before re-sending after a 429: the server's Retry-After hint
187
+ # when present (honored exactly — the server knows its own window);
188
+ # otherwise exponential backoff with ±20% jitter to de-synchronize
189
+ # concurrent clients hammering the same endpoint. Both capped.
190
+ def self.retry_delay_after(res, retries, backoff_base)
191
+ hint = parse_retry_after(header_value(res, "Retry-After"))
192
+ delay = hint
193
+ unless delay
194
+ delay = backoff_base * (2**(retries - 1))
195
+ delay *= (0.8 + (0.4 * rand))
196
+ end
197
+ delay.clamp(0, RATE_LIMIT_MAX_DELAY_S)
198
+ end
199
+
200
+ # `retries` is nil on paths that never entered the retry loop (streaming)
201
+ # — the message then omits the attempt count instead of falsely claiming
202
+ # "gave up after 0 retries" (fresh-eye R5).
203
+ #
204
+ # `body` overrides `res.body` for the streaming path: inside Net::HTTP's
205
+ # block form (`http.request { |response| ... }`) the accessor returns an
206
+ # unmaterialized Net::ReadAdapter sink, not the transferred bytes, so the
207
+ # caller must pass the chunks it drained (fresh-eye R7 — a live 429 paste
208
+ # showed "#<Net::ReadAdapter:0x...>" instead of the provider's error JSON).
209
+ def self.rate_limit_error(res, retries, body = nil)
210
+ hint = parse_retry_after(header_value(res, "Retry-After"))
211
+ detail = hint ? "server hinted #{hint}s" : "no Retry-After hint"
212
+ budget = retries ? " (gave up after #{retries} rate-limit retries)" : ""
213
+ RateLimitError.new(
214
+ "HTTP 429 rate limited#{budget}, #{detail}: #{Rubik::Platform.safe_utf8(body || res.body)}",
215
+ retry_after: hint
216
+ )
217
+ end
218
+
219
+ def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
220
+ max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
221
+ on_retry: nil, http_factory: nil)
222
+ begin
223
+ uri = URI.parse(url_str.to_s)
224
+ rescue URI::InvalidURIError => e
225
+ raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
226
+ end
227
+
228
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
229
+ raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
230
+ end
231
+
232
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
233
+ retries = 0
234
+ rate_limit_retries = 0
235
+
236
+ loop do
237
+ res =
238
+ if http_factory
239
+ http = http_factory.call(uri)
240
+ req = build_post_request(uri, headers, payload)
241
+ http.request(req)
242
+ else
243
+ with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
244
+ req = build_post_request(uri, headers, payload)
245
+ http.request(req)
246
+ end
247
+ end
248
+
249
+ status = res.code.to_i
250
+
251
+ if status == 429
252
+ # Rate limiting is transitory and self-healing — it gets its own
253
+ # larger budget (max_429_retries), a Retry-After-aware delay, and
254
+ # a typed error when exhausted so callers can report "provider
255
+ # throttled" distinctly. The generic 5xx budget is untouched, so
256
+ # one 429 burst cannot exhaust the transport-failure budget.
257
+ if rate_limit_retries < max_429_retries
258
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
259
+ delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
260
+ if elapsed + delay <= max_wall_clock
261
+ rate_limit_retries += 1
262
+ on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
263
+ sleep(delay)
264
+ next
265
+ end
266
+ end
267
+ raise rate_limit_error(res, rate_limit_retries)
268
+ end
269
+
270
+ if (status >= 500) && (retries < max_retries)
271
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
272
+ delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
273
+ if elapsed + delay <= max_wall_clock
274
+ retries += 1
275
+ on_retry&.call(retries, max_retries, "HTTP #{status}")
276
+ sleep(delay)
277
+ next
278
+ end
279
+ end
280
+
281
+ unless status >= 200 && status < 300
282
+ # A body without a charset arrives tagged ASCII-8BIT; embedding it
283
+ # raw makes every ClientError message binary and the UTF-8 output
284
+ # layer raises inside the REPL's rescue clause (masking the real
285
+ # HTTP error as Encoding::CompatibilityError).
286
+ raise ClientError, "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}"
287
+ end
288
+
289
+ begin
290
+ return JSON.parse(res.body)
291
+ rescue JSON::ParserError => e
292
+ raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
293
+ end
294
+ rescue ClientError
295
+ raise
296
+ rescue Net::ReadTimeout
297
+ # Server accepted the request and is still working. NEVER retry:
298
+ raise ClientError, "read timeout after #{timeout}s calling #{uri.host} (model/server still working? request not retried)"
299
+ rescue Interrupt
300
+ raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
301
+ rescue *RETRYABLE_ERRORS => e
302
+ if retries < max_retries
303
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
304
+ delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
305
+ if elapsed + delay <= max_wall_clock
306
+ retries += 1
307
+ on_retry&.call(retries, max_retries, e.class.name)
308
+ sleep(delay)
309
+ next
310
+ end
311
+ end
312
+ raise ClientError, "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})"
313
+ rescue StandardError => e
314
+ raise ClientError, "Network error calling #{uri.host}: #{e.class} #{e.message}"
315
+ end
316
+ end
317
+
318
+ # Builds the real Net::HTTP transport. open_timeout is bounded LOW and
319
+ # independent of the (long) read timeout: Net::HTTP arms a Timeout thread
320
+ # around TCP connect on Ruby 3.3; a short arming window shrinks the chance
321
+ # that a stale Net::OpenTimeout is injected into unrelated main-thread
322
+ # code after an Interrupt escapes the connect region.
323
+ def self.default_http(uri, timeout:, open_timeout:)
324
+ http = Net::HTTP.new(uri.host, uri.port)
325
+ http.use_ssl = (uri.scheme == "https")
326
+ http.open_timeout = open_timeout
327
+ http.read_timeout = timeout
328
+ http
329
+ end
330
+
331
+ # ---- Persistent connection pool -------------------------------------
332
+ #
333
+ # Opening a brand-new TCP(+TLS) connection per LLM round-trip costs
334
+ # ~100-300 ms each, and an agent turn issues many round-trips. Sessions
335
+ # here persist per (host, port, scheme); a mutex per key serializes
336
+ # concurrent use because a single Net::HTTP session is not thread-safe
337
+ # (same-endpoint calls wait for the current response to finish).
338
+ # Ruby's non-reentrant Mutex would deadlock on same-thread re-entry
339
+ # (nested provider call, Enumerator#next fibers) — those transparently
340
+ # fall back to a private single-use connection. Transport failures and
341
+ # interrupts drop the session so the next attempt dials fresh.
342
+ CONNECTION_IDLE_TIMEOUT = 300
343
+
344
+ # After any of these the socket's integrity is unknown (or the stream
345
+ # was cut mid-read by Ctrl+C): never hand the session back to the pool.
346
+ INTEGRITY_LOST_ERRORS = [
347
+ *RETRYABLE_ERRORS, Net::ReadTimeout, IOError,
348
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Interrupt
349
+ ].freeze
350
+
351
+ @pool_mutex = Mutex.new
352
+ @pool = {}
353
+ @pool_locks = {}
354
+ @pool_locks_guard = Mutex.new
355
+
356
+ def self.with_pooled_connection(uri, timeout:, open_timeout:)
357
+ key = [uri.host, uri.port, uri.scheme == "https"]
358
+ lock = @pool_locks_guard.synchronize { @pool_locks[key] ||= Mutex.new }
359
+
360
+ begin
361
+ lock.synchronize do
362
+ http = @pool_mutex.synchronize { @pool[key] }
363
+ if http&.started?
364
+ http.read_timeout = timeout
365
+ http.open_timeout = open_timeout
366
+ else
367
+ http = default_http(uri, timeout: timeout, open_timeout: open_timeout)
368
+ http.keep_alive_timeout = CONNECTION_IDLE_TIMEOUT
369
+ http.start
370
+ @pool_mutex.synchronize { @pool[key] = http }
371
+ end
372
+
373
+ begin
374
+ yield http
375
+ rescue *INTEGRITY_LOST_ERRORS
376
+ drop_pooled(key, http)
377
+ raise
378
+ end
379
+ end
380
+ rescue ThreadError
381
+ # Re-entrant call on the same thread (nested LLM call from inside a
382
+ # streaming callback, or a second Enumerator#next fiber) — the
383
+ # per-key mutex cannot be taken. Serve this call on a private,
384
+ # single-use connection instead of deadlocking.
385
+ http = default_http(uri, timeout: timeout, open_timeout: open_timeout)
386
+ http.start
387
+ begin
388
+ yield http
389
+ ensure
390
+ http.finish if http.started?
391
+ end
392
+ end
393
+ end
394
+
395
+ def self.drop_pooled(key, http)
396
+ @pool_mutex.synchronize { @pool.delete(key) if @pool[key].equal?(http) }
397
+ http.finish if http&.started?
398
+ rescue StandardError
399
+ nil
400
+ end
401
+
402
+ def self.build_post_request(uri, headers, payload, accept: nil)
403
+ req = Net::HTTP::Post.new(uri.request_uri)
404
+ headers.each { |k, v| req[k] = v }
405
+ req["Content-Type"] = "application/json"
406
+ req["Accept"] = accept if accept
407
+ req["User-Agent"] ||= "Rubik/1.0 (Windows; x64; Agentic CLI)"
408
+ req.body = Rubik::Platform.safe_json_dump(payload)
409
+ req
410
+ end
411
+
412
+ # Streaming variant of `post_json`. Yields each SSE event (as a Hash
413
+ # `{ type:, data: }`) to the block. Does NOT retry once any part of the
414
+ # response has surfaced — partial chunks are dangerous in a stream.
415
+ # Exception: a transport failure before ANY byte arrived (a stale
416
+ # pooled socket hit at connect/request time) is retried once on a
417
+ # fresh connection, since the caller has seen nothing yet.
418
+ #
419
+ # The transport uses `Net::HTTP`'s block form of `#request`, which
420
+ # gives access to streaming response bytes. We feed them to an
421
+ # `SSEParser` and yield complete events as they emerge.
422
+ #
423
+ # `open_timeout` bounds only the TCP/TLS connect phase and is therefore
424
+ # deliberately LOW and independent of the (long) read timeout — same
425
+ # rationale as in `default_http`: waiting out a 60 s open window after
426
+ # e.g. a DNS failure is exactly the multi-second freeze users see as
427
+ # "the app hangs".
428
+ #
429
+ # If the response is not an SSE event stream (no events arrive before
430
+ # EOF), the full body is still yielded as a single `{ type: "message",
431
+ # data: <body> }` event, so NDJSON-style consumers can read the
432
+ # last "chunk" via `flush`.
433
+ def self.post_stream_json(url_str, headers: {}, payload: {}, timeout: 120, open_timeout: 15,
434
+ idle_timeout: nil,
435
+ terminator: SSEParser::SSE_TERMINATOR, http_factory: nil, &)
436
+ begin
437
+ uri = URI.parse(url_str.to_s)
438
+ rescue URI::InvalidURIError => e
439
+ raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
440
+ end
441
+
442
+ raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https://" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
443
+
444
+ effective_timeout = idle_timeout || timeout
445
+ state = { surfaced: false }
446
+ req = build_post_request(uri, headers, payload, accept: "text/event-stream")
447
+ attempts = 0
448
+
449
+ begin
450
+ attempts += 1
451
+ if http_factory
452
+ http = http_factory.call(uri)
453
+ http.read_timeout = effective_timeout if http.respond_to?(:read_timeout=)
454
+ http.open_timeout = open_timeout if http.respond_to?(:open_timeout=)
455
+ stream_attempt(http, uri, req, terminator: terminator, state: state, &)
456
+ else
457
+ with_pooled_connection(uri, timeout: effective_timeout, open_timeout: open_timeout) do |http|
458
+ stream_attempt(http, uri, req, terminator: terminator, state: state, &)
459
+ end
460
+ end
461
+ rescue ClientError
462
+ raise
463
+ rescue *RETRYABLE_ERRORS, IOError => e
464
+ # Transparent fresh-socket retry ONLY while literally nothing has
465
+ # reached the caller: no headers, no events. A stale pooled idle
466
+ # socket manifests exactly like this.
467
+ clean_retry = !state[:surfaced] && attempts < 2
468
+ retry if clean_retry
469
+ raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
470
+ rescue StandardError => e
471
+ # Everything else is wrapped once without retrying. That includes
472
+ # Net::ReadTimeout — same policy as post_json: the server may
473
+ # already be generating, and re-sending would duplicate work while
474
+ # doubling the user-visible freeze — and TLS handshake failures
475
+ # raised by http.start inside the pool.
476
+ raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
477
+ end
478
+
479
+ :ok
480
+ end
481
+
482
+ # A single streaming request over an established connection. Sets
483
+ # `state[:surfaced]` once response headers arrive; from that point on
484
+ # the caller may already have consumed events.
485
+ def self.stream_attempt(http, uri, req, terminator:, state:, &callback)
486
+ parser = SSEParser.new(terminator: terminator)
487
+
488
+ begin
489
+ http.request(req) do |response|
490
+ state[:surfaced] = true
491
+ status_code = response.code.to_i
492
+
493
+ unless status_code >= 200 && status_code < 300
494
+ # Drain the body so the connection can be released, then raise.
495
+ body_chunks = []
496
+ response.read_body { |chunk| body_chunks << chunk }
497
+ # Streaming never retries a 429 (once headers surface, events
498
+ # may already be consumed) — but the failure is still typed as
499
+ # RateLimitError for callers to report distinctly. retries: nil
500
+ # because the streaming path has no retry loop (fresh-eye R5);
501
+ # the drained chunks are the real body — in block form res.body
502
+ # is a Net::ReadAdapter sink, not the bytes (fresh-eye R7).
503
+ raise rate_limit_error(response, nil, body_chunks.join) if status_code == 429
504
+
505
+ raise ClientError, "HTTP Request failed with code #{status_code}: #{Rubik::Platform.safe_utf8(body_chunks.join)}"
506
+ end
507
+
508
+ response.read_body do |chunk|
509
+ parser.feed(chunk).each(&callback)
510
+ end
511
+ end
512
+
513
+ # After the connection closes, flush any trailing event.
514
+ parser.flush.each(&callback)
515
+ rescue ClientError,
516
+ *RETRYABLE_ERRORS, IOError, Net::ReadTimeout
517
+ # Propagate untouched so the pooled layer can drop a suspect session
518
+ # and the caller decides on a transparent fresh-socket retry.
519
+ raise
520
+ rescue StandardError => e
521
+ raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end