rubik 0.3.4 → 0.4.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +105 -2
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +9 -2
  10. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  11. data/lib/rubik/buffer/manager.rb +78 -66
  12. data/lib/rubik/config/defaults.rb +1 -0
  13. data/lib/rubik/context/git_lister.rb +3 -1
  14. data/lib/rubik/diagnostics/parser.rb +53 -1
  15. data/lib/rubik/llm/anthropic.rb +5 -2
  16. data/lib/rubik/llm/openai_compatible.rb +83 -68
  17. data/lib/rubik/llm/registry.rb +81 -74
  18. data/lib/rubik/lsp/client.rb +320 -96
  19. data/lib/rubik/lsp/manager.rb +83 -12
  20. data/lib/rubik/lsp/server_command.rb +102 -0
  21. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  22. data/lib/rubik/orchestrator/step_executor.rb +138 -154
  23. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  24. data/lib/rubik/prompt/token_estimator.rb +16 -12
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/buffer.rb +122 -88
  29. data/lib/rubik/repl/commands/mcp.rb +120 -92
  30. data/lib/rubik/repl/commands/session.rb +90 -59
  31. data/lib/rubik/repl/commands/workspace.rb +110 -94
  32. data/lib/rubik/repl/shell.rb +10 -1
  33. data/lib/rubik/runtime/discovery.rb +12 -6
  34. data/lib/rubik/security/classifier/classifier.rb +3 -1
  35. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  36. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  37. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  38. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  39. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  40. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  41. data/lib/rubik/tools/truncator.rb +21 -8
  42. data/lib/rubik/version.rb +1 -1
  43. metadata +2 -1
@@ -21,8 +21,12 @@ module Rubik
21
21
  parse_go(text)
22
22
  when :rust
23
23
  parse_rust(text)
24
- when :zig, :c
24
+ when :zig, :c, :cpp, :"c++", :cxx
25
25
  parse_zig_c(text)
26
+ when :python, :py
27
+ parse_python(text)
28
+ when :typescript, :javascript, :ts, :js
29
+ parse_ts_js(text)
26
30
  else
27
31
  parse_generic(text)
28
32
  end
@@ -93,6 +97,54 @@ module Rubik
93
97
  entries
94
98
  end
95
99
 
100
+ def parse_python(text)
101
+ entries = []
102
+ current_file = nil
103
+ current_line = nil
104
+
105
+ text.each_line do |line|
106
+ if (m = line.match(/File "([^"]+)", line (\d+)(?:, in .*)?/))
107
+ current_file = m[1]
108
+ current_line = m[2].to_i
109
+ elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception):\s*(.*)$/))
110
+ cat = m[1] =~ /\A(?:Syntax|Indentation|Tab)Error\z/ ? :syntax : :type
111
+ entries << Entry.new(
112
+ file: current_file,
113
+ line: current_line,
114
+ message: "#{m[1]}: #{m[2]}",
115
+ category: cat,
116
+ raw_text: line
117
+ )
118
+ current_file = nil
119
+ current_line = nil
120
+ end
121
+ end
122
+ entries.empty? ? parse_generic(text) : entries
123
+ end
124
+
125
+ def parse_ts_js(text)
126
+ entries = []
127
+ text.each_line do |line|
128
+ next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+)):?\s*-?\s*error\s*(TS\d+)?:?\s*(.*)$}i))
129
+
130
+ file = m[1]
131
+ line_num = (m[2] || m[4]).to_i
132
+ col_num = (m[3] || m[5]).to_i
133
+ code = m[6]
134
+ msg = m[7]
135
+ entries << Entry.new(
136
+ file: file,
137
+ line: line_num,
138
+ col: col_num,
139
+ code: code,
140
+ message: msg,
141
+ category: :syntax,
142
+ raw_text: line
143
+ )
144
+ end
145
+ entries.empty? ? parse_generic(text) : entries
146
+ end
147
+
96
148
  def parse_generic(text)
97
149
  entries = []
98
150
  text.each_line do |line|
@@ -56,8 +56,11 @@ module Rubik
56
56
  return opts[:api_key] if opts.key?(:api_key) && !opts[:api_key].nil?
57
57
  return @api_key if @api_key && !@api_key.to_s.empty?
58
58
 
59
- env_name = opts[:api_key_env] || @api_key_env || "ANTHROPIC_API_KEY"
60
- ENV.fetch(env_name, nil)
59
+ env_name = opts[:api_key_env]
60
+ env_name = @api_key_env if env_name.nil? || env_name.to_s.empty?
61
+ env_name = "ANTHROPIC_API_KEY" if env_name.nil? || env_name.to_s.empty?
62
+
63
+ ENV.fetch(env_name.to_s, nil)
61
64
  end
62
65
 
63
66
  def complete(messages, opts = {})
@@ -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)
@@ -98,87 +98,15 @@ module Rubik
98
98
  end
99
99
 
100
100
  def register_provider_spec(p_name, p_spec)
101
- type = p_spec["type"].to_s.downcase
102
101
  endpoint = p_spec["endpoint"] || p_spec["host"] || p_spec["url"]
103
- api_key_env = p_spec["api_key_env"]
104
- api_key = p_spec["api_key"]
105
102
  global_headers = extract_global_headers(@config)
106
103
  spec_headers = p_spec["headers"] || {}
107
104
  headers = global_headers.merge(spec_headers)
108
105
  models = p_spec["models"] || []
109
106
  default_model = p_spec["default_model"] || (models.is_a?(Array) && models.first.is_a?(Hash) ? models.first["name"] : models.first)
110
107
 
111
- provider_instance = case type
112
- when "openai_compatible", "openai"
113
- OpenAICompatibleProvider.new(
114
- name: p_name,
115
- endpoint: endpoint || "https://api.openai.com/v1/chat/completions",
116
- api_key: api_key,
117
- api_key_env: api_key_env,
118
- headers: headers,
119
- model: default_model || "gpt-4o",
120
- default_context_limit: spec_limit(p_spec, :context),
121
- default_max_output_tokens: spec_limit(p_spec, :output),
122
- tool_native: p_spec.fetch("tool_native", true)
123
- )
124
- when "ollama"
125
- OllamaProvider.new(
126
- name: p_name,
127
- endpoint: endpoint || "http://localhost:11434",
128
- model: default_model || "qwen2.5-coder:7b",
129
- num_ctx: p_spec["num_ctx"],
130
- headers: headers
131
- )
132
- when "anthropic"
133
- AnthropicProvider.new(
134
- name: p_name,
135
- model: default_model || "claude-sonnet-5-5",
136
- # Honor the same api_key / api_key_env that
137
- # openai-compatible specs already forward;
138
- # otherwise Anthropic auth fails silently.
139
- api_key: api_key,
140
- api_key_env: api_key_env,
141
- headers: headers
142
- )
143
- else
144
- # Default fallback to openai_compatible
145
- OpenAICompatibleProvider.new(
146
- name: p_name,
147
- endpoint: endpoint || "http://localhost:8000/v1/chat/completions",
148
- api_key: api_key,
149
- api_key_env: api_key_env,
150
- headers: headers,
151
- model: default_model || "default",
152
- default_context_limit: spec_limit(p_spec, :context),
153
- default_max_output_tokens: spec_limit(p_spec, :output),
154
- tool_native: p_spec.fetch("tool_native", true)
155
- )
156
- end
157
-
158
- @providers[p_name] = provider_instance
159
-
160
- # Register models in catalog
161
- return unless models.is_a?(Array)
162
-
163
- models.each do |m_entry|
164
- if m_entry.is_a?(Hash)
165
- m_name = m_entry["name"]
166
- c_limit = m_entry["context_limit"] || m_entry["context_window"] || 32_768
167
- @models_catalog["#{p_name}/#{m_name}"] = {
168
- provider: p_name,
169
- name: m_name,
170
- context_limit: c_limit.to_i,
171
- endpoint: endpoint
172
- }
173
- elsif m_entry.is_a?(String)
174
- @models_catalog["#{p_name}/#{m_entry}"] = {
175
- provider: p_name,
176
- name: m_entry,
177
- context_limit: 32_768,
178
- endpoint: endpoint
179
- }
180
- end
181
- end
108
+ @providers[p_name] = build_provider_instance(p_name, p_spec, headers, default_model, endpoint)
109
+ register_models_catalog(p_name, models, endpoint)
182
110
  end
183
111
 
184
112
  def register(provider_name, provider_instance)
@@ -331,6 +259,85 @@ module Rubik
331
259
  end
332
260
  result
333
261
  end
262
+
263
+ private
264
+
265
+ def build_provider_instance(p_name, p_spec, headers, default_model, endpoint)
266
+ type = p_spec["type"].to_s.downcase
267
+ api_key_env = p_spec["api_key_env"]
268
+ api_key = p_spec["api_key"]
269
+
270
+ case type
271
+ when "openai_compatible", "openai"
272
+ OpenAICompatibleProvider.new(
273
+ name: p_name,
274
+ endpoint: endpoint || "https://api.openai.com/v1/chat/completions",
275
+ api_key: api_key,
276
+ api_key_env: api_key_env,
277
+ headers: headers,
278
+ model: default_model || "gpt-4o",
279
+ default_context_limit: spec_limit(p_spec, :context),
280
+ default_max_output_tokens: spec_limit(p_spec, :output),
281
+ tool_native: p_spec.fetch("tool_native", true)
282
+ )
283
+ when "ollama"
284
+ OllamaProvider.new(
285
+ name: p_name,
286
+ endpoint: endpoint || "http://localhost:11434",
287
+ model: default_model || "qwen2.5-coder:7b",
288
+ num_ctx: p_spec["num_ctx"],
289
+ headers: headers
290
+ )
291
+ when "anthropic"
292
+ AnthropicProvider.new(
293
+ name: p_name,
294
+ model: default_model || "claude-sonnet-5-5",
295
+ # Honor the same api_key / api_key_env that
296
+ # openai-compatible specs already forward;
297
+ # otherwise Anthropic auth fails silently.
298
+ api_key: api_key,
299
+ api_key_env: api_key_env,
300
+ headers: headers
301
+ )
302
+ else
303
+ # Default fallback to openai_compatible
304
+ OpenAICompatibleProvider.new(
305
+ name: p_name,
306
+ endpoint: endpoint || "http://localhost:8000/v1/chat/completions",
307
+ api_key: api_key,
308
+ api_key_env: api_key_env,
309
+ headers: headers,
310
+ model: default_model || "default",
311
+ default_context_limit: spec_limit(p_spec, :context),
312
+ default_max_output_tokens: spec_limit(p_spec, :output),
313
+ tool_native: p_spec.fetch("tool_native", true)
314
+ )
315
+ end
316
+ end
317
+
318
+ def register_models_catalog(p_name, models, endpoint)
319
+ return unless models.is_a?(Array)
320
+
321
+ models.each do |m_entry|
322
+ if m_entry.is_a?(Hash)
323
+ m_name = m_entry["name"]
324
+ c_limit = m_entry["context_limit"] || m_entry["context_window"] || 32_768
325
+ @models_catalog["#{p_name}/#{m_name}"] = {
326
+ provider: p_name,
327
+ name: m_name,
328
+ context_limit: c_limit.to_i,
329
+ endpoint: endpoint
330
+ }
331
+ elsif m_entry.is_a?(String)
332
+ @models_catalog["#{p_name}/#{m_entry}"] = {
333
+ provider: p_name,
334
+ name: m_entry,
335
+ context_limit: 32_768,
336
+ endpoint: endpoint
337
+ }
338
+ end
339
+ end
340
+ end
334
341
  end
335
342
  end
336
343
  end