rubik 0.3.4 → 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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +54 -0
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- 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/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- 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/subagent/spawn_transaction.rb +60 -31
- 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/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/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8a3ecbfb98ba136f13800cc4a2d9ab68703b30c0086a3297c005abe6991d060
|
|
4
|
+
data.tar.gz: 72e1fff12c37f318f5e1b817d0ef88601110a83099f05edbd10c3d294564027c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a17907eee5524e8053a9f73eb946ebe8766001a64ca17db048240837c1f154f7d2ba3650ce765e8ab830dc6da8840a1ea15e147490a03af1f5203d33b678294
|
|
7
|
+
data.tar.gz: 54d72614c2c6fd31a0ff482144c7953148d780516ea748f248027a6943d0b91c40fdfd64d1867036109f8b666bc3ce9ecb58516f0418f3c775335f249fead2b5
|
data/ATTRIBUTION.md
CHANGED
|
@@ -121,7 +121,7 @@ SOFTWARE.
|
|
|
121
121
|
| **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
|
|
122
122
|
| **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
|
|
123
123
|
| **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
|
|
124
|
-
| **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,
|
|
124
|
+
| **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,850+ unit, integration, and contract tests. |
|
|
125
125
|
| **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
|
|
126
126
|
| **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
|
|
127
127
|
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,60 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
Nothing yet.
|
|
12
12
|
|
|
13
|
+
## [0.3.5] - 2026-10-24
|
|
14
|
+
|
|
15
|
+
A vakanuvis-durability patch: the chronicler's digest pipeline stops
|
|
16
|
+
losing in-flight work and serving stale reads, LSP diagnostics and
|
|
17
|
+
document sync get hardened across the board, and AST call
|
|
18
|
+
extraction runs measurably cheaper.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **vakanuvis recall and digest are durable (#266, #267, #268,
|
|
23
|
+
#270, #271, #272):** in-place memory and wiki edits now register
|
|
24
|
+
as durable content updates for digest dirty tracking; superseded
|
|
25
|
+
records no longer surface as exact recall hits or pass smoke
|
|
26
|
+
checks; annal write failures and harness shutdown no longer
|
|
27
|
+
strand targeted digest jobs or events enqueued after the STOP
|
|
28
|
+
marker; the pending digest queue is bounded when the distiller is
|
|
29
|
+
unavailable; and the rogue-memory sweep preserves canonical
|
|
30
|
+
`mem.*` aliases, superseding raw duplicates in the canonical
|
|
31
|
+
line's favor.
|
|
32
|
+
- **LSP diagnostics and sync hardened (#252, closes #245–#251):**
|
|
33
|
+
diagnostic freshness is tracked per canonical document — stale
|
|
34
|
+
versioned publications are rejected and pending or unversioned
|
|
35
|
+
analysis is reported accurately; document state and LSP frame
|
|
36
|
+
writes are serialized, and transport failures propagate to wake
|
|
37
|
+
pending requests; text-document synchronization capabilities are
|
|
38
|
+
honored (unsupported dynamic registrations rejected, save
|
|
39
|
+
notifications sent when supported); server selection is separated
|
|
40
|
+
from document language IDs, with crash history retained across
|
|
41
|
+
short-lived restarts; and structured executable/argv server
|
|
42
|
+
configuration is supported through discovery, repair and client
|
|
43
|
+
startup without invoking a shell.
|
|
44
|
+
- **Anthropic API key resolution hardened (#261):** the reported
|
|
45
|
+
hardcoded-secret finding is confirmed a false positive (the
|
|
46
|
+
literal env-var name in error messages); `resolve_api_key` now
|
|
47
|
+
falls back to `ANTHROPIC_API_KEY` defensively when the configured
|
|
48
|
+
environment-variable name is blank or empty.
|
|
49
|
+
|
|
50
|
+
### Performance
|
|
51
|
+
|
|
52
|
+
- **AST call extraction is cheaper (#277):** the Go, Rust and
|
|
53
|
+
TypeScript extractors use zero-capture lookahead regexes and
|
|
54
|
+
frozen keyword-exclusion Sets — 35–80% fewer allocations and
|
|
55
|
+
~25–27% faster in hot call-extraction paths, verified
|
|
56
|
+
output-identical.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **No-behavior-change decompositions (#254–#265):** oversized
|
|
61
|
+
methods across the OpenAI-compatible provider, goal-turn driving,
|
|
62
|
+
the MCP/workspace/session/model/context command registrations,
|
|
63
|
+
buffer registration and apply, subagent spawn and adapter process
|
|
64
|
+
management were split into helpers — same behavior, reviewable
|
|
65
|
+
units.
|
|
66
|
+
|
|
13
67
|
## [0.3.4] - 2026-10-23
|
|
14
68
|
|
|
15
69
|
A security-hardening patch: three injection/traversal findings in
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "timeout"
|
|
5
|
+
require_relative "../log"
|
|
5
6
|
|
|
6
7
|
module Rubik
|
|
7
8
|
module Adapter
|
|
@@ -19,17 +20,19 @@ module Rubik
|
|
|
19
20
|
pid = wait_thr.pid
|
|
20
21
|
begin
|
|
21
22
|
stdin.close
|
|
22
|
-
rescue
|
|
23
|
-
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Rubik::Log.debug(:adapter, "stdin close failed during process execution: #{e.class}: #{e.message}")
|
|
24
25
|
end
|
|
25
26
|
t_out = Thread.new do
|
|
26
27
|
stdout.read
|
|
27
|
-
rescue
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Rubik::Log.debug(:adapter, "stdout read failed during process execution: #{e.class}: #{e.message}")
|
|
28
30
|
""
|
|
29
31
|
end
|
|
30
32
|
t_err = Thread.new do
|
|
31
33
|
stderr.read
|
|
32
|
-
rescue
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Rubik::Log.debug(:adapter, "stderr read failed during process execution: #{e.class}: #{e.message}")
|
|
33
36
|
""
|
|
34
37
|
end
|
|
35
38
|
begin
|
|
@@ -40,13 +43,13 @@ module Rubik
|
|
|
40
43
|
ensure
|
|
41
44
|
begin
|
|
42
45
|
t_out.kill
|
|
43
|
-
rescue
|
|
44
|
-
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
Rubik::Log.debug(:adapter, "t_out kill failed: #{e.class}: #{e.message}")
|
|
45
48
|
end
|
|
46
49
|
begin
|
|
47
50
|
t_err.kill
|
|
48
|
-
rescue
|
|
49
|
-
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Rubik::Log.debug(:adapter, "t_err kill failed: #{e.class}: #{e.message}")
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
end
|
|
@@ -8,6 +8,10 @@ module Rubik
|
|
|
8
8
|
class GoExtractor
|
|
9
9
|
include BodyLoader
|
|
10
10
|
|
|
11
|
+
EXCLUDED_KEYWORDS = Set.new(
|
|
12
|
+
["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"]
|
|
13
|
+
).freeze
|
|
14
|
+
|
|
11
15
|
def self.extract(file_path, content = nil)
|
|
12
16
|
new(file_path, content).extract
|
|
13
17
|
end
|
|
@@ -105,9 +109,12 @@ module Rubik
|
|
|
105
109
|
)
|
|
106
110
|
end
|
|
107
111
|
|
|
112
|
+
# PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
|
|
113
|
+
# intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~25% speedup, ~80% fewer allocations).
|
|
108
114
|
def extract_calls(body, self_name)
|
|
109
|
-
calls = body.scan(/\b
|
|
110
|
-
calls.
|
|
115
|
+
calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
|
|
116
|
+
calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
|
|
117
|
+
calls
|
|
111
118
|
end
|
|
112
119
|
|
|
113
120
|
def find_closing_brace(start_idx)
|
|
@@ -8,6 +8,10 @@ module Rubik
|
|
|
8
8
|
class RustExtractor
|
|
9
9
|
include BodyLoader
|
|
10
10
|
|
|
11
|
+
EXCLUDED_KEYWORDS = Set.new(
|
|
12
|
+
["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"]
|
|
13
|
+
).freeze
|
|
14
|
+
|
|
11
15
|
def self.extract(file_path, content = nil)
|
|
12
16
|
new(file_path, content).extract
|
|
13
17
|
end
|
|
@@ -91,9 +95,12 @@ module Rubik
|
|
|
91
95
|
|
|
92
96
|
private
|
|
93
97
|
|
|
98
|
+
# PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
|
|
99
|
+
# intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~80% fewer allocations).
|
|
94
100
|
def extract_calls(body, self_name)
|
|
95
|
-
calls = body.scan(/\b
|
|
96
|
-
calls.
|
|
101
|
+
calls = body.scan(/\b[A-Za-z0-9_]+(?=!|\s*\()/).uniq
|
|
102
|
+
calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
|
|
103
|
+
calls
|
|
97
104
|
end
|
|
98
105
|
|
|
99
106
|
def find_closing_brace(start_idx)
|
|
@@ -8,6 +8,14 @@ module Rubik
|
|
|
8
8
|
class TypeScriptExtractor
|
|
9
9
|
include BodyLoader
|
|
10
10
|
|
|
11
|
+
EXCLUDED_KEYWORDS = Set.new(
|
|
12
|
+
[
|
|
13
|
+
"if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
|
|
14
|
+
"require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
|
|
15
|
+
"await", "super", "class", "interface", "type", "const", "let", "var"
|
|
16
|
+
]
|
|
17
|
+
).freeze
|
|
18
|
+
|
|
11
19
|
def self.extract(file_path, content = nil)
|
|
12
20
|
new(file_path, content).extract
|
|
13
21
|
end
|
|
@@ -227,14 +235,12 @@ module Rubik
|
|
|
227
235
|
[sym, next_i]
|
|
228
236
|
end
|
|
229
237
|
|
|
238
|
+
# PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
|
|
239
|
+
# intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~35% fewer allocations).
|
|
230
240
|
def extract_calls(body, self_name)
|
|
231
|
-
calls = body.scan(/\b
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
|
|
235
|
-
"await", "super", "class", "interface", "type", "const", "let", "var"
|
|
236
|
-
]
|
|
237
|
-
calls.reject { |c| c == self_name || keywords.include?(c) }
|
|
241
|
+
calls = body.scan(/\b[A-Za-z0-9_$]+(?=\s*\()/).uniq
|
|
242
|
+
calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
|
|
243
|
+
calls
|
|
238
244
|
end
|
|
239
245
|
|
|
240
246
|
def find_closing_brace(start_idx)
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -317,82 +317,20 @@ module Rubik
|
|
|
317
317
|
@monitor.synchronize do
|
|
318
318
|
applied_paths = []
|
|
319
319
|
conflicts = []
|
|
320
|
-
keys_to_apply =
|
|
321
|
-
Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
|
|
322
|
-
else
|
|
323
|
-
@dirty_files.keys.dup
|
|
324
|
-
end
|
|
325
|
-
|
|
320
|
+
keys_to_apply = determine_keys_to_apply(target_paths)
|
|
326
321
|
effective_policy = (policy || external_modification_policy).to_s.downcase
|
|
327
322
|
|
|
328
323
|
keys_to_apply.each_with_index do |rel_path, index|
|
|
329
324
|
next unless @dirty_files.key?(rel_path)
|
|
325
|
+
next unless validate_and_clean_garbage(rel_path)
|
|
330
326
|
|
|
331
|
-
|
|
332
|
-
@dirty_files.delete(rel_path)
|
|
333
|
-
@buffer.delete(rel_path)
|
|
334
|
-
@original_hashes.delete(rel_path)
|
|
335
|
-
@event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
|
|
336
|
-
next
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
baseline = @original_hashes[rel_path]
|
|
340
|
-
current_disk = disk_hash(rel_path)
|
|
341
|
-
|
|
342
|
-
conflict = if baseline
|
|
343
|
-
current_disk != baseline
|
|
344
|
-
else
|
|
345
|
-
!current_disk.nil?
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
if conflict && effective_policy != "overwrite"
|
|
327
|
+
if external_conflict?(rel_path) && effective_policy != "overwrite"
|
|
349
328
|
conflicts << rel_path
|
|
350
329
|
@event_bus&.emit(:buffer_apply_conflict, paths: conflicts, path: rel_path)
|
|
351
330
|
next
|
|
352
331
|
end
|
|
353
332
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
# TOCTOU close: re-verify containment immediately before the
|
|
357
|
-
# atomic write — a symlink planted between write-time sanitize
|
|
358
|
-
# and apply! must not redirect the write outside the workspace.
|
|
359
|
-
# Fail loud: an untrustworthy path is an attack signal or a
|
|
360
|
-
# mid-flight root misconfiguration, not a content problem; the
|
|
361
|
-
# entry stays dirty for a retry after remediation.
|
|
362
|
-
containment_kind, = containment_root(full_path)
|
|
363
|
-
unless containment_kind
|
|
364
|
-
@event_bus&.emit(:buffer_containment_blocked, path: rel_path)
|
|
365
|
-
# Commit what this run has seen so "apply! rewrites
|
|
366
|
-
# last_conflicts on every run" holds for readers (AutoApply's
|
|
367
|
-
# report_conflicts) even on the failure path.
|
|
368
|
-
@last_conflicts = conflicts
|
|
369
|
-
raise apply_failure(
|
|
370
|
-
"Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
|
|
371
|
-
"(possible symlink swap since buffering). Re-verify the workspace and retry.",
|
|
372
|
-
kind: :containment_blocked, applied: applied_paths, failed: rel_path,
|
|
373
|
-
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
374
|
-
)
|
|
375
|
-
end
|
|
376
|
-
|
|
377
|
-
content = @buffer[rel_path]
|
|
378
|
-
|
|
379
|
-
begin
|
|
380
|
-
Rubik::Platform.atomic_write(full_path, content)
|
|
381
|
-
rescue StandardError => e
|
|
382
|
-
@last_conflicts = conflicts
|
|
383
|
-
raise apply_failure(
|
|
384
|
-
"Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
|
|
385
|
-
"#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
|
|
386
|
-
"#{pending_after(keys_to_apply, index).size} file(s) still dirty",
|
|
387
|
-
kind: :write_failed, applied: applied_paths, failed: rel_path,
|
|
388
|
-
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
389
|
-
), cause: e
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
@original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
|
|
393
|
-
@dirty_files.delete(rel_path)
|
|
394
|
-
@buffer.delete(rel_path)
|
|
395
|
-
applied_paths << rel_path
|
|
333
|
+
apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply, applied_paths: applied_paths, conflicts: conflicts)
|
|
396
334
|
end
|
|
397
335
|
|
|
398
336
|
@last_conflicts = conflicts
|
|
@@ -443,6 +381,80 @@ module Rubik
|
|
|
443
381
|
|
|
444
382
|
private
|
|
445
383
|
|
|
384
|
+
def determine_keys_to_apply(target_paths)
|
|
385
|
+
if target_paths
|
|
386
|
+
Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
|
|
387
|
+
else
|
|
388
|
+
@dirty_files.keys.dup
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def validate_and_clean_garbage(rel_path)
|
|
393
|
+
return true if PathValidator.valid?(rel_path)
|
|
394
|
+
|
|
395
|
+
@dirty_files.delete(rel_path)
|
|
396
|
+
@buffer.delete(rel_path)
|
|
397
|
+
@original_hashes.delete(rel_path)
|
|
398
|
+
@event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
|
|
399
|
+
false
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def external_conflict?(rel_path)
|
|
403
|
+
baseline = @original_hashes[rel_path]
|
|
404
|
+
current_disk = disk_hash(rel_path)
|
|
405
|
+
|
|
406
|
+
if baseline
|
|
407
|
+
current_disk != baseline
|
|
408
|
+
else
|
|
409
|
+
!current_disk.nil?
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
|
|
414
|
+
full_path = File.expand_path(rel_path, @root_dir)
|
|
415
|
+
|
|
416
|
+
# TOCTOU close: re-verify containment immediately before the
|
|
417
|
+
# atomic write — a symlink planted between write-time sanitize
|
|
418
|
+
# and apply! must not redirect the write outside the workspace.
|
|
419
|
+
# Fail loud: an untrustworthy path is an attack signal or a
|
|
420
|
+
# mid-flight root misconfiguration, not a content problem; the
|
|
421
|
+
# entry stays dirty for a retry after remediation.
|
|
422
|
+
containment_kind, = containment_root(full_path)
|
|
423
|
+
unless containment_kind
|
|
424
|
+
@event_bus&.emit(:buffer_containment_blocked, path: rel_path)
|
|
425
|
+
# Commit what this run has seen so "apply! rewrites
|
|
426
|
+
# last_conflicts on every run" holds for readers (AutoApply's
|
|
427
|
+
# report_conflicts) even on the failure path.
|
|
428
|
+
@last_conflicts = conflicts
|
|
429
|
+
raise apply_failure(
|
|
430
|
+
"Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
|
|
431
|
+
"(possible symlink swap since buffering). Re-verify the workspace and retry.",
|
|
432
|
+
kind: :containment_blocked, applied: applied_paths, failed: rel_path,
|
|
433
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
434
|
+
)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
content = @buffer[rel_path]
|
|
438
|
+
|
|
439
|
+
begin
|
|
440
|
+
Rubik::Platform.atomic_write(full_path, content)
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
@last_conflicts = conflicts
|
|
443
|
+
raise apply_failure(
|
|
444
|
+
"Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
|
|
445
|
+
"#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
|
|
446
|
+
"#{pending_after(keys_to_apply, index).size} file(s) still dirty",
|
|
447
|
+
kind: :write_failed, applied: applied_paths, failed: rel_path,
|
|
448
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
449
|
+
), cause: e
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
@original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
|
|
453
|
+
@dirty_files.delete(rel_path)
|
|
454
|
+
@buffer.delete(rel_path)
|
|
455
|
+
applied_paths << rel_path
|
|
456
|
+
end
|
|
457
|
+
|
|
446
458
|
# Single source of boundary containment for both the sanitize_path
|
|
447
459
|
# entry check and the apply! re-verification. Returns [kind, dir] with
|
|
448
460
|
# kind :root / :external (dir is the LEXICAL matched root/external
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require_relative "../log"
|
|
4
5
|
|
|
5
6
|
module Rubik
|
|
6
7
|
module Context
|
|
@@ -27,7 +28,8 @@ module Rubik
|
|
|
27
28
|
res.map! { |p| p.tr("\\", "/") } if out.include?("\\")
|
|
28
29
|
res.uniq!
|
|
29
30
|
res
|
|
30
|
-
rescue StandardError
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
Rubik::Log.debug(:context, "git ls-files execution failed for #{root_dir}: #{e.class}: #{e.message}")
|
|
31
33
|
nil
|
|
32
34
|
end
|
|
33
35
|
|
data/lib/rubik/llm/anthropic.rb
CHANGED
|
@@ -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]
|
|
60
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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)
|