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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +105 -2
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- data/lib/rubik/ast/graph.rb +20 -1
- data/lib/rubik/ast/graph_extensions.rb +89 -1
- data/lib/rubik/ast/python_extractor.rb +5 -5
- data/lib/rubik/ast/rust_extractor.rb +9 -2
- data/lib/rubik/ast/typescript_extractor.rb +13 -7
- data/lib/rubik/buffer/manager.rb +78 -66
- data/lib/rubik/config/defaults.rb +1 -0
- data/lib/rubik/context/git_lister.rb +3 -1
- data/lib/rubik/diagnostics/parser.rb +53 -1
- data/lib/rubik/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- data/lib/rubik/llm/registry.rb +81 -74
- data/lib/rubik/lsp/client.rb +320 -96
- data/lib/rubik/lsp/manager.rb +83 -12
- data/lib/rubik/lsp/server_command.rb +102 -0
- data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
- data/lib/rubik/orchestrator/step_executor.rb +138 -154
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
- data/lib/rubik/prompt/token_estimator.rb +16 -12
- data/lib/rubik/repl/commands/agent_context.rb +126 -104
- data/lib/rubik/repl/commands/agent_execution.rb +83 -68
- data/lib/rubik/repl/commands/agent_models.rb +112 -87
- data/lib/rubik/repl/commands/buffer.rb +122 -88
- data/lib/rubik/repl/commands/mcp.rb +120 -92
- data/lib/rubik/repl/commands/session.rb +90 -59
- data/lib/rubik/repl/commands/workspace.rb +110 -94
- data/lib/rubik/repl/shell.rb +10 -1
- data/lib/rubik/runtime/discovery.rb +12 -6
- data/lib/rubik/security/classifier/classifier.rb +3 -1
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
- data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
- data/lib/rubik/tools/builtins/lsp.rb +36 -8
- data/lib/rubik/tools/truncator.rb +21 -8
- data/lib/rubik/version.rb +1 -1
- metadata +2 -1
data/lib/rubik/lsp/manager.rb
CHANGED
|
@@ -29,8 +29,18 @@ module Rubik
|
|
|
29
29
|
".vue" => "vue"
|
|
30
30
|
}.freeze
|
|
31
31
|
|
|
32
|
+
DOCUMENT_LANGUAGE_ID_MAP = {
|
|
33
|
+
".cpp" => "cpp",
|
|
34
|
+
".hpp" => "cpp",
|
|
35
|
+
".tsx" => "typescriptreact",
|
|
36
|
+
".jsx" => "javascriptreact",
|
|
37
|
+
".sh" => "shellscript",
|
|
38
|
+
".bash" => "shellscript"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
32
41
|
MAX_CONSECUTIVE_CRASHES = 3
|
|
33
42
|
CIRCUIT_COOLDOWN_SECONDS = 300
|
|
43
|
+
CIRCUIT_STABILITY_SECONDS = 60
|
|
34
44
|
|
|
35
45
|
attr_reader :config, :root_dir, :clients, :crash_stats
|
|
36
46
|
attr_accessor :runtime_manager, :context
|
|
@@ -42,6 +52,7 @@ module Rubik
|
|
|
42
52
|
@context = context || runtime_manager&.context
|
|
43
53
|
@clients = {}
|
|
44
54
|
@crash_stats = {}
|
|
55
|
+
@healthy_since = {}
|
|
45
56
|
@recovery_errors = {}
|
|
46
57
|
@registered_disposable_langs = {}
|
|
47
58
|
@monitor = Monitor.new
|
|
@@ -60,12 +71,24 @@ module Rubik
|
|
|
60
71
|
EXTENSION_MAP[ext]
|
|
61
72
|
end
|
|
62
73
|
|
|
74
|
+
def document_language_id(file_path)
|
|
75
|
+
ext = File.extname(file_path).downcase
|
|
76
|
+
DOCUMENT_LANGUAGE_ID_MAP[ext] || detect_language(file_path) || "plaintext"
|
|
77
|
+
end
|
|
78
|
+
|
|
63
79
|
def circuit_open?(lang)
|
|
64
80
|
@monitor.synchronize do
|
|
65
81
|
stat = @crash_stats[lang.to_s.downcase]
|
|
82
|
+
healthy_since = @healthy_since[lang.to_s.downcase]
|
|
83
|
+
if healthy_since && monotonic_now - healthy_since >= CIRCUIT_STABILITY_SECONDS
|
|
84
|
+
@crash_stats.delete(lang.to_s.downcase)
|
|
85
|
+
@healthy_since.delete(lang.to_s.downcase)
|
|
86
|
+
return false
|
|
87
|
+
end
|
|
88
|
+
|
|
66
89
|
return false unless stat && stat[:circuit_open]
|
|
67
90
|
|
|
68
|
-
if stat[:last_crash_at] && (
|
|
91
|
+
if stat[:last_crash_at] && (monotonic_now - stat[:last_crash_at] > CIRCUIT_COOLDOWN_SECONDS)
|
|
69
92
|
stat[:circuit_open] = false
|
|
70
93
|
stat[:count] = 0
|
|
71
94
|
false
|
|
@@ -78,9 +101,11 @@ module Rubik
|
|
|
78
101
|
def record_crash(lang)
|
|
79
102
|
@monitor.synchronize do
|
|
80
103
|
lang_str = lang.to_s.downcase
|
|
104
|
+
healthy_since = @healthy_since.delete(lang_str)
|
|
105
|
+
@crash_stats.delete(lang_str) if healthy_since && monotonic_now - healthy_since >= CIRCUIT_STABILITY_SECONDS
|
|
81
106
|
stat = (@crash_stats[lang_str] ||= { count: 0, last_crash_at: nil, circuit_open: false })
|
|
82
107
|
stat[:count] += 1
|
|
83
|
-
stat[:last_crash_at] =
|
|
108
|
+
stat[:last_crash_at] = monotonic_now
|
|
84
109
|
stat[:circuit_open] = true if stat[:count] >= MAX_CONSECUTIVE_CRASHES
|
|
85
110
|
end
|
|
86
111
|
end
|
|
@@ -89,6 +114,7 @@ module Rubik
|
|
|
89
114
|
@monitor.synchronize do
|
|
90
115
|
lang_str = lang.to_s.downcase
|
|
91
116
|
@crash_stats.delete(lang_str)
|
|
117
|
+
@healthy_since[lang_str] = monotonic_now
|
|
92
118
|
end
|
|
93
119
|
end
|
|
94
120
|
|
|
@@ -125,13 +151,13 @@ module Rubik
|
|
|
125
151
|
|
|
126
152
|
unless client.available?
|
|
127
153
|
failure = Runtime::DiagnosticEngine.diagnose(
|
|
128
|
-
cmd: client.server_cmd,
|
|
154
|
+
cmd: ServerCommand.display(client.server_cmd),
|
|
129
155
|
exit_code: 127,
|
|
130
|
-
stderr: "Server binary '#{client.server_cmd}' not found in PATH"
|
|
156
|
+
stderr: "Server binary '#{ServerCommand.display(client.server_cmd)}' not found in PATH"
|
|
131
157
|
)
|
|
132
158
|
return {
|
|
133
159
|
ok: false,
|
|
134
|
-
error: "Server binary '#{client.server_cmd}' not found",
|
|
160
|
+
error: "Server binary '#{ServerCommand.display(client.server_cmd)}' not found",
|
|
135
161
|
diagnostic: failure,
|
|
136
162
|
suggestion: failure.suggestion
|
|
137
163
|
}
|
|
@@ -146,12 +172,12 @@ module Rubik
|
|
|
146
172
|
reset_circuit!(lang_str)
|
|
147
173
|
@recovery_errors.delete(lang_str)
|
|
148
174
|
register_client_unlocked(lang_str, client)
|
|
149
|
-
{ ok: true, message: "Started LSP server for '#{lang_str}' (#{client.server_cmd})" }
|
|
175
|
+
{ ok: true, message: "Started LSP server for '#{lang_str}' (#{ServerCommand.display(client.server_cmd)})" }
|
|
150
176
|
else
|
|
151
177
|
record_crash(lang_str)
|
|
152
178
|
stop_client_safely(client)
|
|
153
179
|
failure = Runtime::DiagnosticEngine.diagnose(
|
|
154
|
-
cmd: client.server_cmd,
|
|
180
|
+
cmd: ServerCommand.display(client.server_cmd),
|
|
155
181
|
exit_code: 1,
|
|
156
182
|
stderr: "Failed to initialize LSP server handshake for '#{lang_str}'"
|
|
157
183
|
)
|
|
@@ -201,10 +227,19 @@ module Rubik
|
|
|
201
227
|
if client.document_open?(file_path)
|
|
202
228
|
client.did_change(file_path, content)
|
|
203
229
|
else
|
|
204
|
-
client.did_open(file_path, content)
|
|
230
|
+
client.did_open(file_path, content, document_language_id(file_path))
|
|
205
231
|
end
|
|
206
232
|
end
|
|
207
233
|
|
|
234
|
+
def did_save_file(file_path, content = nil)
|
|
235
|
+
return false unless enabled?
|
|
236
|
+
|
|
237
|
+
client = client_for(file_path)
|
|
238
|
+
return false unless client&.running
|
|
239
|
+
|
|
240
|
+
client.did_save(file_path, content)
|
|
241
|
+
end
|
|
242
|
+
|
|
208
243
|
def sync_buffer_file_if_needed(file_path, buffer_manager)
|
|
209
244
|
return false unless enabled? && buffer_manager
|
|
210
245
|
|
|
@@ -242,6 +277,20 @@ module Rubik
|
|
|
242
277
|
client.diagnostics_for(file_path)
|
|
243
278
|
end
|
|
244
279
|
|
|
280
|
+
def diagnostics_snapshot_for(file_path)
|
|
281
|
+
client = client_for(file_path)
|
|
282
|
+
return { state: :unavailable, diagnostics: [], document_version: nil, published_version: nil } unless client&.running
|
|
283
|
+
|
|
284
|
+
client.diagnostics_snapshot_for(file_path)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def wait_for_diagnostics(file_path, timeout: 0.2)
|
|
288
|
+
client = client_for(file_path)
|
|
289
|
+
return { state: :unavailable, diagnostics: [], document_version: nil, published_version: nil } unless client&.running
|
|
290
|
+
|
|
291
|
+
client.wait_for_diagnostics(file_path, timeout: timeout)
|
|
292
|
+
end
|
|
293
|
+
|
|
245
294
|
def all_diagnostics
|
|
246
295
|
result = {}
|
|
247
296
|
@monitor.synchronize do
|
|
@@ -254,6 +303,18 @@ module Rubik
|
|
|
254
303
|
result
|
|
255
304
|
end
|
|
256
305
|
|
|
306
|
+
def all_diagnostic_snapshots
|
|
307
|
+
result = {}
|
|
308
|
+
@monitor.synchronize do
|
|
309
|
+
@clients.each_value do |client|
|
|
310
|
+
next unless client.running
|
|
311
|
+
|
|
312
|
+
result.merge!(client.all_diagnostic_snapshots)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
result
|
|
316
|
+
end
|
|
317
|
+
|
|
257
318
|
def hover(file_path, line, character)
|
|
258
319
|
client = client_for(file_path)
|
|
259
320
|
return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
|
|
@@ -285,11 +346,12 @@ module Rubik
|
|
|
285
346
|
probed = avail ? cmd : Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir)
|
|
286
347
|
|
|
287
348
|
client_statuses[lang] = {
|
|
288
|
-
server_cmd: cmd,
|
|
349
|
+
server_cmd: ServerCommand.display(cmd),
|
|
289
350
|
probed_cmd: probed,
|
|
290
351
|
available: avail || !probed.nil?,
|
|
291
352
|
running: active_c&.running || false,
|
|
292
|
-
diagnostics_count: active_c ? active_c.all_diagnostics.values.flatten.size : 0
|
|
353
|
+
diagnostics_count: active_c ? active_c.all_diagnostics.values.flatten.size : 0,
|
|
354
|
+
diagnostics_pending_count: active_c ? active_c.all_diagnostic_snapshots.values.count { |s| s[:state] == :pending } : 0
|
|
293
355
|
}
|
|
294
356
|
end
|
|
295
357
|
end
|
|
@@ -316,6 +378,7 @@ module Rubik
|
|
|
316
378
|
# Caller MUST hold @monitor. Dead-client removal and replacement startup
|
|
317
379
|
# happen in one critical section so concurrent requests share one client.
|
|
318
380
|
def client_for_unlocked(lang, preferred = nil)
|
|
381
|
+
circuit_open?(lang) # also retires crash history after sustained healthy operation
|
|
319
382
|
stored = @clients[lang]
|
|
320
383
|
client = stored || preferred
|
|
321
384
|
recovering = false
|
|
@@ -349,7 +412,7 @@ module Rubik
|
|
|
349
412
|
|
|
350
413
|
if client.available? && @config.fetch("lsp.auto_start", true) && !circuit_open?(lang)
|
|
351
414
|
if client.start
|
|
352
|
-
|
|
415
|
+
note_healthy_start(lang)
|
|
353
416
|
@recovery_errors.delete(lang)
|
|
354
417
|
register_client_unlocked(lang, client)
|
|
355
418
|
else
|
|
@@ -370,7 +433,7 @@ module Rubik
|
|
|
370
433
|
elsif !client.running && @config.fetch("lsp.auto_start", true) &&
|
|
371
434
|
client.available? && !circuit_open?(lang)
|
|
372
435
|
if client.start
|
|
373
|
-
|
|
436
|
+
note_healthy_start(lang)
|
|
374
437
|
@recovery_errors.delete(lang)
|
|
375
438
|
register_client_unlocked(lang, client)
|
|
376
439
|
else
|
|
@@ -408,6 +471,14 @@ module Rubik
|
|
|
408
471
|
nil
|
|
409
472
|
end
|
|
410
473
|
|
|
474
|
+
def monotonic_now
|
|
475
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def note_healthy_start(lang)
|
|
479
|
+
@healthy_since[lang.to_s.downcase] = monotonic_now
|
|
480
|
+
end
|
|
481
|
+
|
|
411
482
|
def inactive_client_error(file_path, client)
|
|
412
483
|
lang = client&.language || detect_language(file_path)
|
|
413
484
|
@monitor.synchronize do
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "../platform"
|
|
5
|
+
|
|
6
|
+
module Rubik
|
|
7
|
+
module LSP
|
|
8
|
+
# Converts configured LSP commands to argv without invoking a shell.
|
|
9
|
+
# Structured commands are portable; legacy strings use the native
|
|
10
|
+
# platform's command-line quoting rules.
|
|
11
|
+
module ServerCommand
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def argv(command)
|
|
15
|
+
parts = case command
|
|
16
|
+
when Hash
|
|
17
|
+
executable = command["executable"] || command[:executable]
|
|
18
|
+
args = command["args"] || command[:args] || []
|
|
19
|
+
raise ArgumentError, "LSP command executable must be a non-empty string" unless executable.is_a?(String) && !executable.strip.empty?
|
|
20
|
+
raise ArgumentError, "LSP command args must be an array" unless args.is_a?(Array)
|
|
21
|
+
raise ArgumentError, "LSP command args must contain only strings" unless args.all?(String)
|
|
22
|
+
|
|
23
|
+
[executable, *args]
|
|
24
|
+
when Array
|
|
25
|
+
raise ArgumentError, "LSP argv must contain only strings" unless command.all?(String)
|
|
26
|
+
|
|
27
|
+
command
|
|
28
|
+
when String
|
|
29
|
+
Rubik::Platform.windows? ? split_windows(command) : Shellwords.shellsplit(command)
|
|
30
|
+
else
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
parts = parts.map(&:to_s)
|
|
35
|
+
return [] if parts.empty? || parts.first.to_s.strip.empty?
|
|
36
|
+
raise ArgumentError, "LSP command arguments cannot be nil" if parts.any?(&:empty?)
|
|
37
|
+
|
|
38
|
+
parts
|
|
39
|
+
rescue ArgumentError => e
|
|
40
|
+
raise ArgumentError, "Invalid LSP server command: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def display(command)
|
|
44
|
+
argv(command).join(" ")
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
command.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Implements the Windows command-line quoting rules used by
|
|
50
|
+
# CommandLineToArgvW: backslashes only escape quotes when immediately
|
|
51
|
+
# preceding one; whitespace separates arguments outside quotes.
|
|
52
|
+
def split_windows(command)
|
|
53
|
+
args = []
|
|
54
|
+
arg = +""
|
|
55
|
+
arg_started = false
|
|
56
|
+
quote_char = nil
|
|
57
|
+
i = 0
|
|
58
|
+
|
|
59
|
+
while i < command.length
|
|
60
|
+
if command[i] == "\\" && quote_char != "'"
|
|
61
|
+
start = i
|
|
62
|
+
i += 1 while command[i] == "\\"
|
|
63
|
+
slashes = i - start
|
|
64
|
+
arg_started = true
|
|
65
|
+
if command[i] == '"'
|
|
66
|
+
arg << ("\\" * (slashes / 2))
|
|
67
|
+
if slashes.odd?
|
|
68
|
+
arg << '"'
|
|
69
|
+
else
|
|
70
|
+
quote_char = quote_char == '"' ? nil : '"'
|
|
71
|
+
end
|
|
72
|
+
i += 1
|
|
73
|
+
else
|
|
74
|
+
arg << ("\\" * slashes)
|
|
75
|
+
end
|
|
76
|
+
elsif ['"', "'"].include?(command[i]) && (quote_char.nil? || quote_char == command[i])
|
|
77
|
+
arg_started = true
|
|
78
|
+
quote_char = quote_char ? nil : command[i]
|
|
79
|
+
i += 1
|
|
80
|
+
elsif command[i] =~ /\s/ && quote_char.nil?
|
|
81
|
+
if arg_started
|
|
82
|
+
args << arg
|
|
83
|
+
arg = +""
|
|
84
|
+
arg_started = false
|
|
85
|
+
end
|
|
86
|
+
i += 1 while command[i] =~ /\s/
|
|
87
|
+
else
|
|
88
|
+
arg_started = true
|
|
89
|
+
arg << command[i]
|
|
90
|
+
i += 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
raise ArgumentError, "unmatched quote" if quote_char
|
|
95
|
+
|
|
96
|
+
args << arg if arg_started
|
|
97
|
+
args
|
|
98
|
+
end
|
|
99
|
+
private_class_method :split_windows
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -36,17 +36,33 @@ module Rubik
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def append_diagnostics(deps, written_file, tool_result)
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
snapshot = if deps.lsp_manager.respond_to?(:wait_for_diagnostics)
|
|
40
|
+
deps.lsp_manager.wait_for_diagnostics(written_file, timeout: 0.2)
|
|
41
|
+
else
|
|
42
|
+
deps.lsp_manager.diagnostics_snapshot_for(written_file)
|
|
43
|
+
end
|
|
44
|
+
return append_message(tool_result, "\n\n⏳ LSP diagnostics are pending for '#{written_file}'; no clean result is available yet.") if snapshot[:state] == :pending
|
|
45
|
+
return append_message(tool_result, "\n\nℹ️ LSP diagnostics are not available for '#{written_file}'.") if snapshot[:state] == :unavailable
|
|
46
|
+
|
|
47
|
+
diags = snapshot[:diagnostics]
|
|
41
48
|
errors = diags.select { |d| d["severity"] == 1 }
|
|
42
|
-
|
|
49
|
+
if errors.empty?
|
|
50
|
+
return tool_result if snapshot[:state] == :fresh
|
|
51
|
+
|
|
52
|
+
return append_message(tool_result, "\n\nℹ️ LSP returned no errors for '#{written_file}', but omitted the document version; freshness is unverified.")
|
|
53
|
+
end
|
|
43
54
|
|
|
44
55
|
err_lines = errors.map { |e| " • Line #{(e.dig('range', 'start', 'line') || 0) + 1}: #{e['message']}" }.join("\n")
|
|
45
56
|
lsp_msg = "\n\n⚠️ [LSP Real-Time Compiler Diagnostics - #{errors.size} error(s)]:\n#{err_lines}\n" \
|
|
46
57
|
"Please fix these compilation/type errors before proceeding."
|
|
58
|
+
lsp_msg = "\n\nℹ️ LSP omitted the document version; diagnostic freshness is unverified.#{lsp_msg}" if snapshot[:state] == :unversioned
|
|
59
|
+
append_message(tool_result, lsp_msg)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def append_message(tool_result, message)
|
|
47
63
|
Rubik::Tools::Result.new(
|
|
48
64
|
tool_name: tool_result.tool_name, call_id: tool_result.call_id, status: tool_result.status,
|
|
49
|
-
output: "#{tool_result.output}#{
|
|
65
|
+
output: "#{tool_result.output}#{message}", duration_ms: tool_result.duration_ms,
|
|
50
66
|
truncated: tool_result.truncated
|
|
51
67
|
)
|
|
52
68
|
end
|