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,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../platform"
5
+
6
+ module Rubik
7
+ module LLM
8
+ class BaseFormatter
9
+ def self.format_messages(messages)
10
+ new.format_messages(messages)
11
+ end
12
+
13
+ def self.format_tools(tools)
14
+ new.format_tools(tools)
15
+ end
16
+
17
+ # Parse one streaming event into a `Chunk`. The `event` argument is the
18
+ # `{ type:, data: }` Hash produced by `SSEParser` (SSE providers) or
19
+ # `{ raw: <json-string> }` (NDJSON providers). Returns nil to indicate
20
+ # the event should be skipped (e.g. ping, malformed JSON, comment).
21
+ def self.parse_stream_event(event)
22
+ new.parse_stream_event(event)
23
+ end
24
+
25
+ def format_messages(messages)
26
+ messages
27
+ end
28
+
29
+ def format_tools(tools)
30
+ tools
31
+ end
32
+
33
+ # Default: no streaming. Subclasses override.
34
+ def parse_stream_event(_event)
35
+ nil
36
+ end
37
+
38
+ protected
39
+
40
+ def extract_role(message)
41
+ (message[:role] || message["role"]).to_s
42
+ end
43
+
44
+ def extract_content(message)
45
+ message[:content] || message["content"] || ""
46
+ end
47
+
48
+ def extract_tool_calls(message)
49
+ message[:tool_calls] || message["tool_calls"]
50
+ end
51
+
52
+ def extract_call_id(item, default = "call_default")
53
+ (item[:tool_call_id] || item["tool_call_id"] || item[:call_id] || item["call_id"] || item[:id] || item["id"] || default).to_s
54
+ end
55
+ end
56
+
57
+ class OpenAIFormatter < BaseFormatter
58
+ # Plan 85 D-5 (opsiyonel): tool-native olmayan OpenAI-compatible modeller için
59
+ # prefix'li user mesajı üretmeyi aç/kapat. Varsayılan true → mevcut davranış.
60
+ attr_accessor :tool_native
61
+
62
+ def initialize(tool_native: true)
63
+ super()
64
+ @tool_native = tool_native
65
+ end
66
+
67
+ def format_messages(messages)
68
+ return [] if messages.nil?
69
+
70
+ formatted = []
71
+ messages.each_with_index do |m, idx|
72
+ role = extract_role(m)
73
+ content = extract_content(m)
74
+ tcs = extract_tool_calls(m)
75
+
76
+ if role == "assistant" && tcs
77
+ formatted_tcs = tcs.map do |tc|
78
+ fn_args = tc["args"] || tc[:args] || {}
79
+ args_str = fn_args.is_a?(String) ? Rubik::Platform.clean_utf8(fn_args) : Rubik::Platform.safe_json_generate(fn_args)
80
+ call_id = extract_call_id(tc, "call_#{rand(1000..9999)}")
81
+ {
82
+ "id" => call_id,
83
+ "type" => "function",
84
+ "function" => {
85
+ "name" => tc["tool"] || tc[:tool],
86
+ "arguments" => args_str
87
+ }
88
+ }
89
+ end
90
+ msg = { "role" => "assistant", "tool_calls" => formatted_tcs }
91
+ msg["content"] = content if content && !content.empty?
92
+ formatted << msg
93
+ elsif role == "tool"
94
+ if @tool_native
95
+ # Standart OpenAI tool result formatı (mevcut davranış)
96
+ call_id = extract_call_id(m, "call_default")
97
+ formatted << {
98
+ "role" => "tool",
99
+ "tool_call_id" => call_id,
100
+ "content" => content
101
+ }
102
+ else
103
+ # Tool-native olmayan modeller için prefix'li user mesajı (Plan 85 D-5)
104
+ tool_name = (m[:tool_name] || m["tool_name"]).to_s
105
+ label = tool_name.empty? ? "Tool" : tool_name
106
+ formatted << { "role" => "user", "content" => "[Tool result: #{label}]\n#{content}" }
107
+ end
108
+ elsif role == "system"
109
+ formatted << if idx.zero?
110
+ { "role" => "system", "content" => content }
111
+ else
112
+ # Strict endpoints like DeepSeek/Groq reject mid-conversation system messages.
113
+ { "role" => "user", "content" => "[System Notification]\n#{content}" }
114
+ end
115
+ else
116
+ formatted << {
117
+ "role" => role,
118
+ "content" => content
119
+ }
120
+ end
121
+ end
122
+ formatted
123
+ end
124
+
125
+ def format_tools(tools)
126
+ return [] if tools.nil?
127
+
128
+ tools.map do |tool|
129
+ if tool.key?(:function) || tool.key?("function")
130
+ tool
131
+ else
132
+ fn_name = tool[:name] || tool["name"]
133
+ fn_desc = tool[:description] || tool["description"]
134
+ fn_params = tool[:params_schema] || tool["params_schema"] || tool[:parameters] || tool["parameters"] || { type: "object", properties: {} }
135
+
136
+ {
137
+ type: "function",
138
+ function: {
139
+ name: fn_name,
140
+ description: fn_desc,
141
+ parameters: fn_params
142
+ }
143
+ }
144
+ end
145
+ end
146
+ end
147
+
148
+ # OpenAI SSE streaming event → Chunk.
149
+ # OpenAI emits `data: {json}` lines for each delta and a
150
+ # terminator `data: [DONE]` line. Each event Hash has
151
+ # { type: "message", data: <json or "[DONE]"> }.
152
+ def parse_stream_event(event)
153
+ data = event[:data].to_s
154
+ return nil if data.empty?
155
+ return Chunk.new(done: true) if data.strip == "[DONE]"
156
+
157
+ parsed = JSON.parse(data)
158
+ choices = parsed["choices"] || []
159
+
160
+ usage = nil
161
+ done = false
162
+ if parsed["usage"].is_a?(Hash)
163
+ u = parsed["usage"].dup
164
+ u[:input_tokens] = u.delete("prompt_tokens") || u[:input_tokens] || 0
165
+ u[:output_tokens] = u.delete("completion_tokens") || u[:output_tokens] || 0
166
+ usage = u
167
+ done = true
168
+ end
169
+
170
+ if choices.empty?
171
+ return usage ? Chunk.new(usage: usage, done: done, raw: parsed) : nil
172
+ end
173
+
174
+ choice = choices[0] || {}
175
+ delta = choice["delta"] || {}
176
+ text = delta["content"] && !delta["content"].to_s.empty? ? delta["content"].to_s : nil
177
+
178
+ reasoning = delta["reasoning_content"] || delta["reasoning"] || delta["thought"]
179
+ reasoning_text = reasoning && !reasoning.to_s.empty? ? reasoning.to_s : nil
180
+
181
+ tool_deltas = nil
182
+ if delta["tool_calls"] && !delta["tool_calls"].empty?
183
+ tool_deltas = delta["tool_calls"].map do |tc|
184
+ {
185
+ "index" => tc["index"],
186
+ "id" => tc["id"],
187
+ "name" => tc.dig("function", "name"),
188
+ "arguments_delta" => tc.dig("function", "arguments").to_s
189
+ }
190
+ end
191
+ end
192
+
193
+ done = true if choice["finish_reason"]
194
+
195
+ Chunk.new(
196
+ text_delta: text,
197
+ tool_use_delta: tool_deltas,
198
+ usage: usage,
199
+ done: done,
200
+ raw: parsed,
201
+ reasoning_delta: reasoning_text
202
+ )
203
+ rescue JSON::ParserError
204
+ nil
205
+ end
206
+ end
207
+
208
+ class AnthropicFormatter < BaseFormatter
209
+ def format_messages(messages)
210
+ return [] if messages.nil?
211
+
212
+ formatted = []
213
+ messages.each do |m|
214
+ role = extract_role(m)
215
+ content = extract_content(m)
216
+ tcs = extract_tool_calls(m)
217
+
218
+ if role == "assistant" && tcs
219
+ content_blocks = []
220
+ content_blocks << { "type" => "text", "text" => content } if content && !content.empty?
221
+ (tcs || []).each do |tc|
222
+ call_id = extract_call_id(tc, "call_#{rand(1000..9999)}")
223
+ content_blocks << {
224
+ "type" => "tool_use",
225
+ "id" => call_id,
226
+ "name" => tc["tool"] || tc[:tool],
227
+ "input" => tc["args"] || tc[:args] || {}
228
+ }
229
+ end
230
+ formatted << { "role" => "assistant", "content" => content_blocks }
231
+ elsif role == "tool"
232
+ call_id = extract_call_id(m, "call_default")
233
+ block = { "type" => "tool_result", "tool_use_id" => call_id, "content" => content }
234
+ prev = formatted.last
235
+ # Plan 61 D-6: parallel tool results must land in ONE user
236
+ # message — the API rejects consecutive user turns.
237
+ if prev.is_a?(Hash) && prev["role"] == "user" && prev["content"].is_a?(Array) &&
238
+ !prev["content"].empty? && prev["content"].first.is_a?(Hash) &&
239
+ prev["content"].first["type"] == "tool_result"
240
+ prev["content"] << block
241
+ else
242
+ formatted << { "role" => "user", "content" => [block] }
243
+ end
244
+ else
245
+ prev = formatted.last
246
+ # Consecutive plain same-role turns (compaction summaries,
247
+ # restored histories) are merged to keep strict role alternation.
248
+ if prev.is_a?(Hash) && prev["role"] == role && !role.to_s.empty? && role != "system"
249
+ if prev["content"].is_a?(String)
250
+ prev["content"] = "#{prev['content']}\n\n#{content}"
251
+ elsif !content.to_s.empty?
252
+ # A tool_result block array must never be stringified —
253
+ # fold the plain text in as an extra content block.
254
+ prev["content"] << { "type" => "text", "text" => content }
255
+ end
256
+ else
257
+ formatted << { "role" => role, "content" => content }
258
+ end
259
+ end
260
+ end
261
+ formatted
262
+ end
263
+
264
+ def format_tools(tools)
265
+ return [] if tools.nil?
266
+
267
+ tools.map do |tool|
268
+ if tool.key?(:input_schema) || tool.key?("input_schema")
269
+ tool
270
+ else
271
+ fn = tool[:function] || tool["function"]
272
+ name = fn ? (fn[:name] || fn["name"]) : (tool[:name] || tool["name"])
273
+ desc = fn ? (fn[:description] || fn["description"]) : (tool[:description] || tool["description"])
274
+ schema = if fn
275
+ fn[:parameters] || fn["parameters"]
276
+ else
277
+ tool[:params_schema] || tool["params_schema"] || tool[:parameters] || tool["parameters"] || {}
278
+ end
279
+ {
280
+ name: name,
281
+ description: desc,
282
+ input_schema: schema
283
+ }
284
+ end
285
+ end
286
+ end
287
+
288
+ # Anthropic SSE streaming event → Chunk.
289
+ # Event types: message_start, content_block_start, content_block_delta,
290
+ # content_block_stop, message_delta, message_stop, ping, error
291
+ def parse_stream_event(event)
292
+ type = event[:type].to_s
293
+ data = event[:data].to_s
294
+ return nil if type == "ping"
295
+
296
+ if type == "error"
297
+ parsed_err = begin
298
+ JSON.parse(data)
299
+ rescue JSON::ParserError
300
+ { "error" => { "message" => data } }
301
+ end
302
+ msg = parsed_err.dig("error", "message") || parsed_err["message"] || "Anthropic streaming error"
303
+ raise ClientError, msg
304
+ end
305
+
306
+ parsed = JSON.parse(data)
307
+ chunk = nil
308
+
309
+ case type
310
+ when "content_block_delta"
311
+ delta = parsed["delta"] || {}
312
+ if delta["type"] == "text_delta" && delta["text"]
313
+ chunk = Chunk.new(text_delta: delta["text"].to_s)
314
+ elsif delta["type"] == "thinking_delta" && delta["thinking"]
315
+ chunk = Chunk.new(reasoning_delta: delta["thinking"].to_s)
316
+ elsif delta["type"] == "input_json_delta"
317
+ chunk = Chunk.new(
318
+ tool_use_delta: {
319
+ "index" => parsed["index"],
320
+ "arguments_delta" => delta["partial_json"].to_s
321
+ }
322
+ )
323
+ end
324
+ when "message_delta"
325
+ usage = parsed["usage"] || parsed.dig("delta", "usage")
326
+ chunk = Chunk.new(usage: usage, raw: parsed)
327
+ when "message_stop"
328
+ chunk = Chunk.new(done: true, raw: parsed)
329
+ when "message_start", "content_block_start", "content_block_stop"
330
+ chunk = Chunk.new(raw: parsed)
331
+ end
332
+
333
+ chunk || Chunk.new(raw: parsed)
334
+ rescue JSON::ParserError
335
+ nil
336
+ end
337
+ end
338
+
339
+ class OllamaFormatter < BaseFormatter
340
+ def format_messages(messages)
341
+ return [] if messages.nil?
342
+
343
+ messages.map do |m|
344
+ role = extract_role(m)
345
+ content = extract_content(m)
346
+ tcs = extract_tool_calls(m)
347
+
348
+ if role == "tool"
349
+ tool_name = (m[:tool_name] || m["tool_name"]).to_s
350
+ label = tool_name.empty? ? "Tool" : tool_name
351
+ # Prefix ile modelin tool sonucunu kullanıcı mesajıyla karıştırmaması
352
+ # sağlanır. Ollama native tool calling'i olmayan modeller için açık
353
+ # etiket kritik (Plan 85 D-1).
354
+ prefixed_content = "[Tool result: #{label}]\n#{content}"
355
+ {
356
+ "role" => "user",
357
+ "content" => prefixed_content
358
+ }
359
+ elsif role == "assistant" && tcs
360
+ formatted_tcs = tcs.map do |tc|
361
+ fn_args = tc["args"] || tc[:args] || {}
362
+ {
363
+ "function" => {
364
+ "name" => tc["tool"] || tc[:tool],
365
+ "arguments" => fn_args
366
+ }
367
+ }
368
+ end
369
+ msg = { "role" => "assistant", "tool_calls" => formatted_tcs }
370
+ msg["content"] = content if content && !content.empty?
371
+ msg
372
+ else
373
+ {
374
+ "role" => role,
375
+ "content" => content
376
+ }
377
+ end
378
+ end
379
+ end
380
+
381
+ def format_tools(tools)
382
+ return [] if tools.nil?
383
+
384
+ tools.map do |tool|
385
+ if tool.key?(:function) || tool.key?("function")
386
+ tool
387
+ else
388
+ {
389
+ type: "function",
390
+ function: {
391
+ name: tool[:name] || tool["name"],
392
+ description: tool[:description] || tool["description"],
393
+ parameters: tool[:params_schema] || tool["params_schema"] || tool[:parameters] || tool["parameters"]
394
+ }
395
+ }
396
+ end
397
+ end
398
+ end
399
+
400
+ # Ollama NDJSON streaming line → Chunk.
401
+ # `/api/chat` lines carry { "message": { "role", "content",
402
+ # "tool_calls"? } } while legacy `/api/generate` lines carry a
403
+ # top-level "response" — both shapes are supported (Plan 61 B15/B4:
404
+ # the chat shape used to be dropped entirely, losing streamed text
405
+ # AND native tool calls). The final line has "done": true plus
406
+ # eval_count / prompt_eval_count.
407
+ def parse_stream_event(event)
408
+ data = event[:data].to_s
409
+ return nil if data.empty?
410
+
411
+ parsed = JSON.parse(data)
412
+ chat_msg = parsed["message"].is_a?(Hash) ? parsed["message"] : {}
413
+
414
+ text = chat_msg["content"]
415
+ text = parsed["response"] if text.to_s.empty?
416
+ text = nil if text.to_s.empty?
417
+
418
+ tool_deltas = nil
419
+ if chat_msg["tool_calls"].is_a?(Array) && !chat_msg["tool_calls"].empty?
420
+ tool_deltas = chat_msg["tool_calls"].map.with_index do |tc, idx|
421
+ fn = tc["function"] || {}
422
+ delta = { "index" => idx, "id" => tc["id"], "name" => fn["name"] }
423
+ # Ollama sends complete argument objects, not partial JSON.
424
+ args = fn["arguments"]
425
+ delta["args"] = args if args.is_a?(Hash) || args.is_a?(String)
426
+ delta
427
+ end
428
+ end
429
+
430
+ done = parsed["done"] == true
431
+ usage = nil
432
+ if done
433
+ usage = {
434
+ input_tokens: parsed["prompt_eval_count"] || 0,
435
+ output_tokens: parsed["eval_count"] || 0
436
+ }
437
+ end
438
+ Chunk.new(
439
+ text_delta: text,
440
+ tool_use_delta: tool_deltas,
441
+ usage: usage,
442
+ done: done,
443
+ raw: parsed
444
+ )
445
+ rescue JSON::ParserError
446
+ nil
447
+ end
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../prompt/conversation_rows"
4
+
5
+ module Rubik
6
+ module LLM
7
+ # Normalizes internal message histories into provider-safe shapes
8
+ # (Plan 61 D-6). Strict endpoints (Anthropic Messages API, some
9
+ # OpenAI-compatible gateways) reject histories with consecutive
10
+ # same-role turns — which this runtime can produce through compaction
11
+ # summaries, notification messages and restored sessions.
12
+ #
13
+ # Rules:
14
+ # * Consecutive plain same-role messages are folded together with a
15
+ # blank-line join (user+user, assistant+assistant, system+system).
16
+ # * An assistant message carrying tool_calls always starts a new unit
17
+ # (it pairs with the following role:"tool" results).
18
+ # * role:"tool" messages are never merged — each carries its own
19
+ # tool_call_id; strict providers coalesce them at format time.
20
+ module MessageSanitizer
21
+ module_function
22
+
23
+ def normalize(messages)
24
+ return [] if messages.nil?
25
+
26
+ merged = []
27
+ messages.each do |msg|
28
+ unless Prompt::ConversationRows.row?(msg)
29
+ Rubik::Platform.debug_warn("MessageSanitizer: dropping non-hash message #{msg.inspect}")
30
+ next
31
+ end
32
+
33
+ role = Prompt::ConversationRows.role_of(msg)
34
+ prev = merged.last
35
+ if mergeable?(prev, role, msg)
36
+ merge_into!(prev, msg)
37
+ else
38
+ merged << msg.dup
39
+ end
40
+ end
41
+ merged
42
+ end
43
+
44
+ def mergeable?(prev, role, msg)
45
+ return false if prev.nil? || prev.empty?
46
+ return false if Prompt::ConversationRows.role_of(prev) != role
47
+ return false if role == "tool" || role.empty?
48
+
49
+ # An assistant message carrying tool_calls always opens a fresh
50
+ # atomic unit — folding it into the previous turn would break the
51
+ # tool_calls → tool results pairing.
52
+ current_tcs = msg[:tool_calls] || msg["tool_calls"]
53
+ return false if role == "assistant" && current_tcs.is_a?(Array) && !current_tcs.empty?
54
+
55
+ true
56
+ end
57
+
58
+ def merge_into!(prev, msg)
59
+ content_key = Prompt::ConversationRows.content_key_of(prev)
60
+ prev[content_key] = join_content(prev[content_key], Prompt::ConversationRows.content_of(msg))
61
+
62
+ tcs = msg[:tool_calls] || msg["tool_calls"]
63
+ return if tcs.nil? || (tcs.is_a?(Array) && tcs.empty?)
64
+
65
+ tc_key = Prompt::ConversationRows.resolve_key(prev, :tool_calls)
66
+ prev[tc_key] = Array(prev[tc_key] || []) + Array(tcs)
67
+ rescue StandardError => e
68
+ # Warn once per process: normalize runs on every provider call, so an
69
+ # unguarded warn would spam one line per message for a poisoned history.
70
+ unless @merge_failure_warned
71
+ @merge_failure_warned = true
72
+ Rubik::Log.warn(:llm, "message merge failed (#{e.class}: #{e.message}); tool_calls/content may be dropped")
73
+ end
74
+ nil
75
+ end
76
+
77
+ def join_content(first, second)
78
+ first_str = first.to_s
79
+ second_str = second.to_s
80
+ return second_str if first_str.strip.empty?
81
+ return first_str if second_str.strip.empty?
82
+
83
+ "#{first_str}\n\n#{second_str}"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "provider"
4
+ require_relative "client"
5
+ require_relative "formatter"
6
+ require "json"
7
+
8
+ module Rubik
9
+ module LLM
10
+ class OllamaProvider < BaseProvider
11
+ attr_reader :name, :endpoint, :model, :num_ctx, :formatter, :headers
12
+ alias host endpoint
13
+
14
+ def initialize(name: nil, endpoint: nil, host: nil, model: "llama3", num_ctx: nil, formatter: OllamaFormatter.new, headers: {})
15
+ super()
16
+ @name = name.to_s.downcase
17
+ @endpoint = endpoint || host || "http://localhost:11434"
18
+ @model = model
19
+ @num_ctx = num_ctx
20
+ @formatter = formatter || OllamaFormatter.new
21
+ @headers = headers || {}
22
+ end
23
+
24
+ def complete(messages, opts = {})
25
+ target_model = opts[:model] || @model
26
+ chat_endpoint = @endpoint.end_with?("/api/chat") ? @endpoint : "#{@endpoint}/api/chat"
27
+
28
+ req_headers = {}
29
+ @headers.each { |k, v| req_headers[k.to_s] = v.to_s }
30
+ req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
31
+
32
+ payload = {
33
+ model: target_model,
34
+ messages: format_messages(messages),
35
+ stream: false
36
+ }
37
+ apply_num_ctx!(payload, opts)
38
+
39
+ payload[:tools] = format_tools(opts[:tools]) if opts[:tools] && !opts[:tools].empty?
40
+
41
+ timeout = opts[:timeout] || opts[:stream_timeout] || 120
42
+ open_timeout = opts[:open_timeout] || 15
43
+
44
+ res_json = HTTPClient.post_json(
45
+ chat_endpoint,
46
+ headers: req_headers,
47
+ payload: payload,
48
+ timeout: timeout,
49
+ open_timeout: open_timeout,
50
+ on_retry: opts[:on_retry]
51
+ )
52
+
53
+ msg_content = res_json.dig("message", "content") || ""
54
+ in_tokens = res_json["prompt_eval_count"] || estimate_tokens(messages.map { |m| m[:content] || m["content"] }.join)
55
+ out_tokens = res_json["eval_count"] || estimate_tokens(msg_content)
56
+
57
+ raw_tool_calls = res_json.dig("message", "tool_calls") || []
58
+ parsed_tool_calls = raw_tool_calls.map do |tc|
59
+ fn = tc["function"] || {}
60
+ args = fn["arguments"]
61
+ malformed = nil
62
+ if args.is_a?(String)
63
+ begin
64
+ parsed = JSON.parse(args)
65
+ if parsed.is_a?(Hash)
66
+ args = parsed
67
+ else
68
+ malformed = args
69
+ args = {}
70
+ end
71
+ rescue JSON::ParserError
72
+ # Issue #55 3.4: the degrade carries the rejected payload so
73
+ # the executor can echo it back to the model in the error row.
74
+ malformed = args
75
+ args = {}
76
+ end
77
+ elsif !args.is_a?(Hash) && !args.nil?
78
+ malformed = args.to_s
79
+ args = {}
80
+ end
81
+ row = {
82
+ "id" => tc["id"] || "call_#{rand(1000..9999)}",
83
+ "tool" => fn["name"],
84
+ "args" => args || {}
85
+ }
86
+ row["malformed_args"] = malformed if malformed
87
+ row
88
+ end
89
+
90
+ Response.new(
91
+ text: msg_content,
92
+ usage: { input_tokens: in_tokens, output_tokens: out_tokens },
93
+ raw: res_json,
94
+ tool_calls: parsed_tool_calls
95
+ )
96
+ end
97
+
98
+ # Streaming variant. Ollama uses NDJSON — one JSON object per line.
99
+ # The provider's HTTPClient call already speaks NDJSON (we just don't
100
+ # frame events into \n\n blocks on the client side); each line is fed
101
+ # to the formatter via the same `{ type:, data: }` event shape, which
102
+ # the OllamaFormatter normalizes into a Chunk.
103
+ def stream_chat(messages, opts = {}, &block)
104
+ target_model = opts[:model] || @model
105
+ chat_endpoint = @endpoint.end_with?("/api/chat") ? @endpoint : "#{@endpoint}/api/chat"
106
+
107
+ payload = {
108
+ model: target_model,
109
+ messages: format_messages(messages),
110
+ stream: true
111
+ }
112
+ apply_num_ctx!(payload, opts)
113
+ payload[:tools] = format_tools(opts[:tools]) if opts[:tools] && !opts[:tools].empty?
114
+
115
+ return to_enum(:stream_chat, messages, opts) unless block
116
+
117
+ req_headers = {}
118
+ @headers.each { |k, v| req_headers[k.to_s] = v.to_s }
119
+ req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
120
+
121
+ timeout = opts[:timeout] || opts[:stream_timeout] || 120
122
+ open_timeout = opts[:open_timeout] || 15
123
+
124
+ HTTPClient.post_stream_json(
125
+ chat_endpoint,
126
+ headers: req_headers,
127
+ payload: payload,
128
+ timeout: timeout,
129
+ open_timeout: open_timeout,
130
+ terminator: SSEParser::NDJSON_TERMINATOR
131
+ ) do |event|
132
+ # Ollama's NDJSON may not include event types. The `data` field
133
+ # carries the full JSON line, and `type` is empty. Forward both
134
+ # shapes — the formatter ignores `type` and parses `data`.
135
+ chunk = @formatter.parse_stream_event(event)
136
+ next if chunk.nil?
137
+
138
+ block.call(chunk)
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ # Plan 61 D-1: without num_ctx the Ollama server silently clips the
145
+ # prompt head to its own default window (~2-4k tokens) — the model
146
+ # never sees the system prompt or earlier turns. Precedence: explicit
147
+ # request opt > provider-level config (providers.<name>.num_ctx) >
148
+ # the orchestrator's compaction target (opts[:context_limit]).
149
+ def apply_num_ctx!(payload, opts)
150
+ num_ctx = opts[:num_ctx] || @num_ctx || opts[:context_limit]
151
+ max_tokens = opts[:max_tokens] || opts[:max_output_tokens]
152
+ opts_hash = {}
153
+ opts_hash[:num_ctx] = num_ctx.to_i if num_ctx&.to_i&.positive?
154
+ opts_hash[:num_predict] = max_tokens.to_i if max_tokens&.to_i&.positive?
155
+ payload[:options] = opts_hash unless opts_hash.empty?
156
+ end
157
+
158
+ def format_messages(messages)
159
+ @formatter.format_messages(messages)
160
+ end
161
+
162
+ def format_tools(tools)
163
+ @formatter.format_tools(tools)
164
+ end
165
+ end
166
+ end
167
+ end