rubik 0.3.3 → 0.3.5

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +102 -0
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/rust_extractor.rb +9 -2
  7. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  8. data/lib/rubik/buffer/manager.rb +99 -69
  9. data/lib/rubik/config/defaults.rb +17 -20
  10. data/lib/rubik/config.rb +0 -6
  11. data/lib/rubik/context/git_lister.rb +3 -1
  12. data/lib/rubik/llm/anthropic.rb +5 -2
  13. data/lib/rubik/llm/client.rb +102 -72
  14. data/lib/rubik/llm/openai_compatible.rb +83 -68
  15. data/lib/rubik/lsp/client.rb +320 -96
  16. data/lib/rubik/lsp/manager.rb +83 -12
  17. data/lib/rubik/lsp/server_command.rb +102 -0
  18. data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
  19. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
  21. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  22. data/lib/rubik/platform/process.rb +6 -6
  23. data/lib/rubik/platform/sanitize.rb +37 -16
  24. data/lib/rubik/prompt/doctrine.rb +4 -0
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +183 -155
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/agent_providers.rb +190 -156
  29. data/lib/rubik/repl/commands/buffer.rb +122 -88
  30. data/lib/rubik/repl/commands/lsp.rb +212 -179
  31. data/lib/rubik/repl/commands/mcp.rb +120 -92
  32. data/lib/rubik/repl/commands/session.rb +90 -59
  33. data/lib/rubik/repl/commands/workspace.rb +266 -246
  34. data/lib/rubik/repl/shell.rb +10 -1
  35. data/lib/rubik/runtime/discovery.rb +12 -6
  36. data/lib/rubik/runtime/overlay_storage.rb +59 -2
  37. data/lib/rubik/sandbox/driver/docker.rb +2 -2
  38. data/lib/rubik/session/replayer.rb +1 -2
  39. data/lib/rubik/storage/store.rb +3 -2
  40. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  41. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  42. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  43. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  44. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  45. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  46. data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
  47. data/lib/rubik/version.rb +1 -1
  48. metadata +2 -1
@@ -234,87 +234,118 @@ module Rubik
234
234
  )
235
235
  end
236
236
 
237
- def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
238
- max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
239
- on_retry: nil, http_factory: nil)
240
- begin
241
- uri = URI.parse(url_str.to_s)
242
- rescue URI::InvalidURIError => e
243
- raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
244
- end
245
-
237
+ def self.parse_and_validate_uri(url_str)
238
+ uri = URI.parse(url_str.to_s)
246
239
  unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
247
240
  raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
248
241
  end
249
242
 
243
+ uri
244
+ rescue URI::InvalidURIError => e
245
+ raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
246
+ end
247
+
248
+ def self.perform_post_request(uri, headers, payload, timeout:, open_timeout:, http_factory:)
249
+ if http_factory
250
+ http = http_factory.call(uri)
251
+ req = build_post_request(uri, headers, payload)
252
+ http.request(req)
253
+ else
254
+ with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
255
+ req = build_post_request(uri, headers, payload)
256
+ http.request(req)
257
+ end
258
+ end
259
+ end
260
+
261
+ # Rate limiting is transitory and self-healing — it gets its own
262
+ # larger budget (max_429_retries), a Retry-After-aware delay, and
263
+ # a typed error when exhausted so callers can report "provider
264
+ # throttled" distinctly. The generic 5xx budget is untouched, so
265
+ # one 429 burst cannot exhaust the transport-failure budget.
266
+ def self.handle_429_retry(res, rate_limit_retries:, max_429_retries:, start_time:, backoff_base:, max_wall_clock:, on_retry:)
267
+ if rate_limit_retries < max_429_retries
268
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
269
+ delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
270
+ if elapsed + delay <= max_wall_clock
271
+ rate_limit_retries += 1
272
+ on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
273
+ sleep(delay)
274
+ return rate_limit_retries
275
+ end
276
+ end
277
+ raise rate_limit_error(res, rate_limit_retries)
278
+ end
279
+
280
+ def self.attempt_backoff_retry(retries:, max_retries:, start_time:, backoff_base:, max_wall_clock:, reason:, on_retry:)
281
+ return nil unless retries < max_retries
282
+
283
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
284
+ delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
285
+ return nil if elapsed + delay > max_wall_clock
286
+
287
+ retries += 1
288
+ on_retry&.call(retries, max_retries, reason)
289
+ sleep(delay)
290
+ retries
291
+ end
292
+
293
+ # A body without a charset arrives tagged ASCII-8BIT; embedding it
294
+ # raw makes every ClientError message binary and the UTF-8 output
295
+ # layer raises inside the REPL's rescue clause (masking the real
296
+ # HTTP error as Encoding::CompatibilityError).
297
+ # Exhausted 5xx is transport-class (provider outage) → transient;
298
+ # a 4xx is content/auth/moderation → permanent. One raise point
299
+ # for both, the flag decided by status (issue #106).
300
+ def self.parse_json_response(res, uri:, status:)
301
+ unless status >= 200 && status < 300
302
+ raise ClientError.new(
303
+ "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
304
+ transient: status >= 500
305
+ )
306
+ end
307
+
308
+ begin
309
+ JSON.parse(res.body)
310
+ rescue JSON::ParserError => e
311
+ raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
312
+ end
313
+ end
314
+
315
+ def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
316
+ max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
317
+ on_retry: nil, http_factory: nil)
318
+ uri = parse_and_validate_uri(url_str)
250
319
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
320
  retries = 0
252
321
  rate_limit_retries = 0
253
322
 
254
323
  loop do
255
- res =
256
- if http_factory
257
- http = http_factory.call(uri)
258
- req = build_post_request(uri, headers, payload)
259
- http.request(req)
260
- else
261
- with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
262
- req = build_post_request(uri, headers, payload)
263
- http.request(req)
264
- end
265
- end
266
-
324
+ res = perform_post_request(uri, headers, payload, timeout: timeout, open_timeout: open_timeout, http_factory: http_factory)
267
325
  status = res.code.to_i
268
326
 
269
327
  if status == 429
270
- # Rate limiting is transitory and self-healing — it gets its own
271
- # larger budget (max_429_retries), a Retry-After-aware delay, and
272
- # a typed error when exhausted so callers can report "provider
273
- # throttled" distinctly. The generic 5xx budget is untouched, so
274
- # one 429 burst cannot exhaust the transport-failure budget.
275
- if rate_limit_retries < max_429_retries
276
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
277
- delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
278
- if elapsed + delay <= max_wall_clock
279
- rate_limit_retries += 1
280
- on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
281
- sleep(delay)
282
- next
283
- end
284
- end
285
- raise rate_limit_error(res, rate_limit_retries)
328
+ rate_limit_retries = handle_429_retry(
329
+ res, rate_limit_retries: rate_limit_retries, max_429_retries: max_429_retries,
330
+ start_time: start_time, backoff_base: backoff_base, max_wall_clock: max_wall_clock,
331
+ on_retry: on_retry
332
+ )
333
+ next
286
334
  end
287
335
 
288
- if (status >= 500) && (retries < max_retries)
289
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
290
- delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
291
- if elapsed + delay <= max_wall_clock
292
- retries += 1
293
- on_retry&.call(retries, max_retries, "HTTP #{status}")
294
- sleep(delay)
336
+ if status >= 500
337
+ new_retries = attempt_backoff_retry(
338
+ retries: retries, max_retries: max_retries, start_time: start_time,
339
+ backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: "HTTP #{status}",
340
+ on_retry: on_retry
341
+ )
342
+ if new_retries
343
+ retries = new_retries
295
344
  next
296
345
  end
297
346
  end
298
347
 
299
- unless status >= 200 && status < 300
300
- # A body without a charset arrives tagged ASCII-8BIT; embedding it
301
- # raw makes every ClientError message binary and the UTF-8 output
302
- # layer raises inside the REPL's rescue clause (masking the real
303
- # HTTP error as Encoding::CompatibilityError).
304
- # Exhausted 5xx is transport-class (provider outage) → transient;
305
- # a 4xx is content/auth/moderation → permanent. One raise point
306
- # for both, the flag decided by status (issue #106).
307
- raise ClientError.new(
308
- "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
309
- transient: status >= 500
310
- )
311
- end
312
-
313
- begin
314
- return JSON.parse(res.body)
315
- rescue JSON::ParserError => e
316
- raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
317
- end
348
+ return parse_json_response(res, uri: uri, status: status)
318
349
  rescue ClientError
319
350
  raise
320
351
  rescue Net::ReadTimeout
@@ -323,15 +354,14 @@ module Rubik
323
354
  rescue Interrupt
324
355
  raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
325
356
  rescue *RETRYABLE_ERRORS => e
326
- if retries < max_retries
327
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
328
- delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
329
- if elapsed + delay <= max_wall_clock
330
- retries += 1
331
- on_retry&.call(retries, max_retries, e.class.name)
332
- sleep(delay)
333
- next
334
- end
357
+ new_retries = attempt_backoff_retry(
358
+ retries: retries, max_retries: max_retries, start_time: start_time,
359
+ backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: e.class.name,
360
+ on_retry: on_retry
361
+ )
362
+ if new_retries
363
+ retries = new_retries
364
+ next
335
365
  end
336
366
  raise ClientError.new(
337
367
  "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})",
@@ -50,20 +50,11 @@ module Rubik
50
50
  end
51
51
 
52
52
  def complete(messages, opts = {})
53
- resolved_key = opts[:api_key] || @api_key || (ENV.fetch(@api_key_env, nil) if @api_key_env)
54
53
  target_model = opts[:model] || @model
55
54
  target_endpoint = self.class.normalize_endpoint(opts[:endpoint] || @endpoint)
56
55
  messages = Rubik::LLM::MessageSanitizer.normalize(messages)
57
56
 
58
- req_headers = { "Content-Type" => "application/json" }
59
- req_headers["Authorization"] = "Bearer #{resolved_key}" if resolved_key && !resolved_key.empty?
60
-
61
- # Merge custom headers (e.g. OpenRouter HTTP-Referer, X-Title)
62
- @headers.each do |k, v|
63
- req_headers[k.to_s] = v.to_s
64
- end
65
- req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
66
-
57
+ req_headers = build_headers(opts)
67
58
  formatted_msgs = format_messages(messages)
68
59
  payload = build_payload(target_model, formatted_msgs, opts, stream: false)
69
60
 
@@ -79,73 +70,17 @@ module Rubik
79
70
  on_retry: opts[:on_retry]
80
71
  )
81
72
 
82
- root_json = res_json["data"].is_a?(Hash) && res_json["data"]["choices"] ? res_json["data"] : res_json
83
- msg = root_json.dig("choices", 0, "message") || {}
84
- text = msg["content"] || ""
85
- thought = msg["reasoning_content"] || msg["reasoning"] || msg["thought"]
86
- thought = nil if thought.to_s.empty?
87
- usage = root_json["usage"] || res_json["usage"] || {}
88
-
89
- raw_tool_calls = msg["tool_calls"] || []
90
- parsed_tool_calls = raw_tool_calls.map do |tc|
91
- fn = tc["function"] || {}
92
- args = fn["arguments"]
93
- malformed = nil
94
- if args.is_a?(String)
95
- begin
96
- parsed = JSON.parse(args)
97
- if parsed.is_a?(Hash)
98
- args = parsed
99
- else
100
- malformed = args
101
- args = {}
102
- end
103
- rescue JSON::ParserError
104
- # Issue #55 3.4: the degrade carries the rejected payload so
105
- # the executor can echo it back to the model in the error row.
106
- malformed = args
107
- args = {}
108
- end
109
- elsif !args.is_a?(Hash) && !args.nil?
110
- malformed = args.to_s
111
- args = {}
112
- end
113
- row = {
114
- "id" => tc["id"] || "call_#{rand(1000..9999)}",
115
- "tool" => fn["name"],
116
- "args" => args || {}
117
- }
118
- row["malformed_args"] = malformed if malformed
119
- row
120
- end
121
-
122
- in_tokens = usage["prompt_tokens"] || usage["input_tokens"] || estimate_tokens(messages.map { |m| m[:content] || m["content"] }.join)
123
- out_tokens = usage["completion_tokens"] || usage["output_tokens"] || estimate_tokens(text)
124
-
125
- Response.new(
126
- text: text,
127
- usage: {
128
- input_tokens: in_tokens,
129
- output_tokens: out_tokens
130
- },
131
- raw: res_json,
132
- tool_calls: parsed_tool_calls,
133
- thought: thought
134
- )
73
+ build_response(res_json, messages)
135
74
  end
136
75
 
137
76
  # Streaming variant. Yields Chunks. Without a block, returns an
138
77
  # Enumerator (via `to_enum`) so the caller can chain `.each`.
139
78
  def stream_chat(messages, opts = {}, &block)
140
- resolved_key = opts[:api_key] || @api_key || (ENV.fetch(@api_key_env, nil) if @api_key_env)
141
79
  target_model = opts[:model] || @model
142
80
  target_endpoint = self.class.normalize_endpoint(opts[:endpoint] || @endpoint)
143
81
  messages = Rubik::LLM::MessageSanitizer.normalize(messages)
144
82
 
145
- req_headers = { "Content-Type" => "application/json" }
146
- req_headers["Authorization"] = "Bearer #{resolved_key}" if resolved_key && !resolved_key.empty?
147
- @headers.each { |k, v| req_headers[k.to_s] = v.to_s }
148
- req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
83
+ req_headers = build_headers(opts)
149
84
 
150
85
  formatted_msgs = format_messages(messages)
151
86
  payload = build_payload(target_model, formatted_msgs, opts, stream: true)
@@ -172,6 +107,86 @@ module Rubik
172
107
 
173
108
  private
174
109
 
110
+ def build_headers(opts = {})
111
+ resolved_key = opts[:api_key] || @api_key || (ENV.fetch(@api_key_env, nil) if @api_key_env)
112
+ req_headers = { "Content-Type" => "application/json" }
113
+ req_headers["Authorization"] = "Bearer #{resolved_key}" if resolved_key && !resolved_key.empty?
114
+
115
+ # Merge custom headers (e.g. OpenRouter HTTP-Referer, X-Title)
116
+ @headers.each do |k, v|
117
+ req_headers[k.to_s] = v.to_s
118
+ end
119
+ req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
120
+ req_headers
121
+ end
122
+
123
+ def build_response(res_json, messages)
124
+ root_json = res_json["data"].is_a?(Hash) && res_json["data"]["choices"] ? res_json["data"] : res_json
125
+ msg = root_json.dig("choices", 0, "message") || {}
126
+ text = msg["content"] || ""
127
+ thought = msg["reasoning_content"] || msg["reasoning"] || msg["thought"]
128
+ thought = nil if thought.to_s.empty?
129
+ usage = root_json["usage"] || res_json["usage"] || {}
130
+
131
+ parsed_tool_calls = parse_tool_calls(msg["tool_calls"] || [])
132
+ in_tokens, out_tokens = calculate_usage(usage, messages, text)
133
+
134
+ Response.new(
135
+ text: text,
136
+ usage: {
137
+ input_tokens: in_tokens,
138
+ output_tokens: out_tokens
139
+ },
140
+ raw: res_json,
141
+ tool_calls: parsed_tool_calls,
142
+ thought: thought
143
+ )
144
+ end
145
+
146
+ def parse_tool_calls(raw_tool_calls)
147
+ raw_tool_calls.map do |tc|
148
+ fn = tc["function"] || {}
149
+ args, malformed = parse_tool_args(fn["arguments"])
150
+ row = {
151
+ "id" => tc["id"] || "call_#{rand(1000..9999)}",
152
+ "tool" => fn["name"],
153
+ "args" => args || {}
154
+ }
155
+ row["malformed_args"] = malformed if malformed
156
+ row
157
+ end
158
+ end
159
+
160
+ def parse_tool_args(args)
161
+ malformed = nil
162
+ if args.is_a?(String)
163
+ begin
164
+ parsed = JSON.parse(args)
165
+ if parsed.is_a?(Hash)
166
+ args = parsed
167
+ else
168
+ malformed = args
169
+ args = {}
170
+ end
171
+ rescue JSON::ParserError
172
+ # Issue #55 3.4: the degrade carries the rejected payload so
173
+ # the executor can echo it back to the model in the error row.
174
+ malformed = args
175
+ args = {}
176
+ end
177
+ elsif !args.is_a?(Hash) && !args.nil?
178
+ malformed = args.to_s
179
+ args = {}
180
+ end
181
+ [args, malformed]
182
+ end
183
+
184
+ def calculate_usage(usage, messages, text)
185
+ in_tokens = usage["prompt_tokens"] || usage["input_tokens"] || estimate_tokens(messages.map { |m| m[:content] || m["content"] }.join)
186
+ out_tokens = usage["completion_tokens"] || usage["output_tokens"] || estimate_tokens(text)
187
+ [in_tokens, out_tokens]
188
+ end
189
+
175
190
  def reasoning_model?(model_name, opts)
176
191
  return true if opts.key?(:reasoning_effort)
177
192
  return true if model_name.to_s.match?(/\A(o[134](-[a-z0-9]+)?|o1|o3|o4)\b/i)