rubik 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d73f60e02ac1d3902de40f910cb600612f5685c7e7a7ef2b7736b91567c14d8
4
- data.tar.gz: ecf9501583c3e5c480efe0ad5093ba4dc8a5d91be08b33292503ee0b6f414e2f
3
+ metadata.gz: a0ec56cafd854c2e2999c68cdfdf787322ca4d7d2a2866fac4b4e8ce9f1efac7
4
+ data.tar.gz: ec584d8ac5ee2039423d71968cce852afb65fcb072cbdbb87325ef94693e6a5d
5
5
  SHA512:
6
- metadata.gz: ccdb0699c44357cfe03c15ff0755293b38fca1e8b25daa1a7c47458f770242ce91c87ec723df61463931306a096a1d811dee35dd7404bb1033701276e2a81b8b
7
- data.tar.gz: 21ec55890b5ba261f9cbb97b78fe7a87b07a9bc998e01ff4af52e7353935407a12003efcfae499af7c44004103b792aa42c85ee46fd5f647dc09b97842585015
6
+ metadata.gz: 3a535d5c0f45c74363387e7df47da8c87b1296be897fdd09adc17e4adfcc576b48555066cb0b3618d27f1eeeba052c53fe1ccb7a670598a43cad57bb8a4cc942
7
+ data.tar.gz: d75bb114c57fb2221f17f9d89a1a0fe1811b9e36ba52289337030b822210ef5373118e7d8438c4000da9dfca65fb4dadf3b5f85bdb8ad117ee50d56469c1a782
data/ATTRIBUTION.md CHANGED
@@ -54,6 +54,15 @@ SOFTWARE.
54
54
  - **Spatial Composability:** Reactive Coeffect Dependency DAG ($\Sigma \vdash C : \tau$) implemented in [`Rubik::Runtime::ComponentFiber`](lib/rubik/runtime/fiber.rb) and [`Rubik::Runtime::Manager`](lib/rubik/runtime/manager.rb).
55
55
  - Zero-downtime hot reloading, atomic rollback on activation failure, and leak-free lifecycle management.
56
56
 
57
+ ### SKILL.state (Structured Execution State)
58
+ - **Paper:** *"SKILL.state: Scalable Long-Horizon Agent Skills"* ([arXiv:2608.26263](https://arxiv.org/abs/2608.26263))
59
+ - **Authors:** Sanket Badhe, Priyanka Tiwari, Jonghyun Chung
60
+ - **Rubik Implementation:**
61
+ - The structured goal ledger ([`orchestrator/goal_ledger/`](lib/rubik/orchestrator/goal_ledger/), 2026-10-10): the `!goal` loop replaces append-only conversation memory with an explicit, mutable, rev-guarded execution state Σ (objective, tasks, findings, decisions, open questions, environment notes) — the model sees the current state, not an ever-growing history.
62
+ - Validated JSON-patch intake (RFC 6902 subset) with the `:syntax` / `:schema` / `:conflict` rejection taxonomy and bounded-retry model feedback (`GoalLedger::Patch` / `Schema` / `Validator`).
63
+ - The paper's world-drift recovery, coded as tri-state FS re-verification: declarative `verify` assertions are re-checked at step boundaries; a failed assertion flips the task to `stale` with an internal rev bump, so the `:conflict` the model then hits IS the drift signal (`GoalLedger::Verifier` / `VerifySpec` / `DriftNote`).
64
+ - Observation projection: banked tool results compact into per-row `<absorbed_into_ledger>` tombstones while the last K turns stay raw (`GoalLedger::ObservationProjection`).
65
+
57
66
  ---
58
67
 
59
68
  ## 🌳 3. AST Parsing & Multi-Language Ecosystem
@@ -112,7 +121,7 @@ SOFTWARE.
112
121
  | **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
113
122
  | **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
114
123
  | **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
115
- | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 1,690+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,400+ unit, integration, and contract tests. |
116
125
  | **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
117
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. |
118
127
 
data/CHANGELOG.md CHANGED
@@ -8,6 +8,73 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ Nothing yet.
12
+
13
+ ## [0.1.2] - 2026-10-11
14
+
15
+ The Anthropic correctness release: the provider now speaks the modern
16
+ extended-thinking contract, so pinning any current Claude model works
17
+ instead of failing with HTTP 400.
18
+
19
+ ### Fixed
20
+
21
+ - **Anthropic provider speaks the modern thinking contract:** the
22
+ default model pin moves from the stale `claude-3-7-sonnet-20250219`
23
+ to `claude-sonnet-5-5`, and the extended-thinking payload follows the
24
+ model line ? 4.6+ and the 5.x/Fable models get the adaptive form
25
+ (`thinking: {type: "adaptive"}` with an `output_config` effort),
26
+ 3-7-sonnet through the 4.5 line keep the `budget_tokens` math, and
27
+ pre-thinking lines get nothing attached. 0.1.1 sent the legacy form
28
+ to every Anthropic model, which Claude 4.7+ rejects with HTTP 400.
29
+
30
+ ### Changed
31
+
32
+ - Extended thinking auto-enables on every thinking-capable Anthropic
33
+ model line (previously only claude-3-7-sonnet); `thinking: false`
34
+ still disables it everywhere and `thinking_budget_ratio` shapes the
35
+ legacy form only. Requests that carry thinking still force
36
+ `temperature` to 1.0.
37
+ - README quick start documents the native Anthropic provider
38
+ (`ANTHROPIC_API_KEY` + `!model anthropic/<model>`) next to the
39
+ OpenAI-compatible example.
40
+
41
+ ## [0.1.1] - 2026-10-11
42
+
43
+
44
+ The Linux surface release: CI now proves Linux on Ruby 3.4 and 4.0
45
+ (`linux-test` is a required check), and the host seams that surface
46
+ exposed are fixed. Ships the structured goal ledger, the default-on
47
+ context pruner, and the vakanüvis recall/write hardening below.
48
+
49
+ ### Fixed
50
+
51
+ - **Fiber Scheduler: Net timeouts no longer fire watchdogs into
52
+ unrelated frames** (#102): Net::HTTP implemented its open/read
53
+ deadlines with the Timeout module — an async `Thread#raise` into the
54
+ originating thread. Under the scheduler that thread hops between
55
+ parked fibers, so an expired deadline landed in whichever frame was
56
+ running (observed on Linux killing the test reporter's stdout write
57
+ and `safe_select`'s `IO.select` itself). Deadlines are now
58
+ fiber-native: EventLoop timers drive them, and the timeout never
59
+ leaves the owning fiber.
60
+ - **LSP diagnostics keys normalize by path shape, not host OS** (#103):
61
+ a windows-drive root plus `file:///C:/...` uris and backslash lookups
62
+ resolved differently on a linux host; drive semantics now follow the
63
+ path's own shape.
64
+ - **Allowlist `.exe` alias matches lexically** (#103): the windows
65
+ executable-extension rule engaged through a second host seam the
66
+ platform stub did not cover; the alias is decided inside the matcher.
67
+ - **Task manager fixtures are shell-safe and poll for readiness**
68
+ (#103): fixed sleeps raced linux process startup, so a kill arrived
69
+ after the task had already FAILED.
70
+ - **REPL wake-loop**: subagent notices stranded at turn end re-armed the
71
+ reactive wake (synthetic Enter), so a steady subagent stream chained
72
+ autonomous maestro turns forever — the prompt never returned and the cycle
73
+ survived Ctrl+C, because the interrupted turn's own ensure block re-armed
74
+ the wake. Settlement invariant: an autonomous notice turn never spawns
75
+ another one, and an interrupted turn settles the wake debt; the
76
+ model-channel no-loss delivery is unchanged.
77
+
11
78
  ### Added
12
79
 
13
80
  - **Vakanüvis recall + write hardening** (issues #99/#100): `storage put`
@@ -91,16 +158,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
91
158
  subagent reported red?"); failed instances still get a body (task + state
92
159
  line) and the upsert/dedupe/canonical-link contract is unchanged.
93
160
 
94
- ### Fixed
95
-
96
- - **REPL wake-loop**: subagent notices stranded at turn end re-armed the
97
- reactive wake (synthetic Enter), so a steady subagent stream chained
98
- autonomous maestro turns forever — the prompt never returned and the cycle
99
- survived Ctrl+C, because the interrupted turn's own ensure block re-armed
100
- the wake. Settlement invariant: an autonomous notice turn never spawns
101
- another one, and an interrupted turn settles the wake debt; the
102
- model-channel no-loss delivery is unchanged.
103
-
104
161
  ## [0.1.0] - 2026-10-09
105
162
 
106
163
  First rubygems.org release of the `rubik` gem. Linux and macOS install out of
data/README.md CHANGED
@@ -11,7 +11,7 @@ English | [Türkçe](README.tr.md)
11
11
  [![Protocol](https://img.shields.io/badge/protocol-AGENTS.md-blue.svg)](AGENTS.md)
12
12
  [![Commands Guide](https://img.shields.io/badge/docs-COMMANDS.md-yellow.svg)](COMMANDS.md)
13
13
  [![Config Guide](https://img.shields.io/badge/docs-CONFIG.md-green.svg)](CONFIG.md)
14
- [![Test Suite](https://img.shields.io/badge/tests-3202%20runs%20%7C%20100%25%20passed-brightgreen.svg)]()
14
+ [![Test Suite](https://img.shields.io/badge/tests-3403%20runs%20%7C%20100%25%20passed-brightgreen.svg)]()
15
15
 
16
16
  ---
17
17
 
@@ -31,6 +31,12 @@ Requires **Ruby 3.4+**:
31
31
  gem install rubik
32
32
  ```
33
33
 
34
+ The installer puts the `rubik` launcher into RubyGems' bin directory (already on your `PATH`), so you can start it from any project directory:
35
+
36
+ ```bash
37
+ rubik
38
+ ```
39
+
34
40
  or with Bundler:
35
41
 
36
42
  ```ruby
@@ -73,6 +79,9 @@ ln -sf $(pwd)/bin/rubik ~/.local/bin/rubik
73
79
  rubik
74
80
  ```
75
81
 
82
+ > [!NOTE]
83
+ > **The gem and a clone can coexist.** On POSIX the symlink always runs your clone: `bin/rubik` resolves `lib/` from its own real path, so a `git pull` is live on the very next launch — even while the `rubik` gem stays installed. The gem-installed `rubik` keeps serving the last released version until you run `gem update rubik`. On Windows, whichever `bin` folder comes first in `PATH` wins — `where.exe rubik` shows the winner.
84
+
76
85
  #### 🪟 Windows (PowerShell / Command Prompt)
77
86
 
78
87
  Rubik ships with built-in `.cmd`, `.bat`, and `.ps1` wrappers for Windows:
@@ -105,6 +114,12 @@ rubik > !provider add deepseek openai_compatible https://api.deepseek.com/v1/cha
105
114
  rubik > !model deepseek/deepseek-coder
106
115
  ```
107
116
 
117
+ Anthropic talks over its **native Messages API** (not an OpenAI shim): the `anthropic` provider is registered out of the box — set `ANTHROPIC_API_KEY` and pick a model:
118
+
119
+ ```bash
120
+ rubik > !model anthropic/claude-sonnet-5-5
121
+ ```
122
+
108
123
  ### First Session Example
109
124
 
110
125
  ```text
@@ -28,8 +28,21 @@ module Rubik
28
28
  # Thread#join/block), io-waiting, or timer-pending. Finished fibers leave
29
29
  # no registration, so "is anything waiting?" answers "are we done?".
30
30
  class EventLoop
31
- READABLE = 1
32
- WRITABLE = 2
31
+ # The VM's io_wait hook passes and expects the scheduler-protocol wait
32
+ # mask — the same domain as ::IO::READABLE / ::IO::WRITABLE (1 and 4;
33
+ # 2 is IO::PRIORITY). A local 1/2 pair silently drops WRITABLE
34
+ # interests (5.anybits?(2) is false): connects park forever until a
35
+ # deadline fires — the mis-aimed-watchdog crash of #102 never even
36
+ # needed the watchdog. Aliased to the real constants so a protocol
37
+ # drift surfaces as a NameError, not a hang.
38
+ READABLE = ::IO::READABLE
39
+ WRITABLE = ::IO::WRITABLE
40
+
41
+ # Timer generation slot for deadline registrations that must survive
42
+ # re-parks (Timeout.timeout spans many io waits within ONE timed
43
+ # operation). Sticky entries never go stale — they are cancelled
44
+ # explicitly by the owning fiber's ensure.
45
+ STICKY = :sticky
33
46
 
34
47
  attr_reader :spawn_errors
35
48
 
@@ -41,7 +54,11 @@ module Rubik
41
54
  # Stale timers (fiber re-parked or resumed since registration) must
42
55
  # not gate draining — @gen is bumped on every resume, so any of this
43
56
  # fiber's outstanding registrations becomes inert at that moment.
44
- @timers = Timers.new { |fiber, gen| @gen[fiber] != gen }
57
+ @timers = Timers.new do |fiber, gen|
58
+ next false if gen == STICKY
59
+
60
+ @gen[fiber] != gen
61
+ end
45
62
  @io_waiters = {} # io => { read: {fiber=>gen}, write: {fiber=>gen} }
46
63
  @gen = ::Hash.new(0) # fiber => park generation (loop thread only)
47
64
  @live = {} # fiber => true while a spawned fiber runs
@@ -212,6 +229,34 @@ module Rubik
212
229
  ::Addrinfo.getaddrinfo(hostname, nil).map(&:ip_address).uniq
213
230
  end
214
231
 
232
+ # True while this thread is inside run/run_until_drained (a boundary
233
+ # is draining). Timeout deadlines are fiber-native only in this
234
+ # window — outside it no loop consumes the timers, so the patch falls
235
+ # back to the stock Timeout watchdog.
236
+ def draining?
237
+ !@closed && !@loop_thread.nil?
238
+ end
239
+
240
+ # Fiber-native Timeout.timeout body (see concurrency/fiber_timeout.rb):
241
+ # runs the block inline and delivers the deadline exception through
242
+ # the interrupt path at the fiber's current park. The :sticky
243
+ # generation means re-parks during the timed operation never stale
244
+ # the deadline (a Net open spans many io waits); the ensure cancels
245
+ # the timer and retracts an already-fired, still-queued delivery, so
246
+ # a deadline that loses the race against block completion can never
247
+ # leak into a later operation.
248
+ def fiber_timeout(sec, exception_class, message)
249
+ fiber = ::Fiber.current
250
+ error = (exception_class || ::Timeout::Error).new(message || "execution expired")
251
+ timer = @timers.add(monotonic + sec, fiber, error, STICKY)
252
+ begin
253
+ yield
254
+ ensure
255
+ @timers.cancel(timer)
256
+ discard_queued(fiber, error)
257
+ end
258
+ end
259
+
215
260
  # ---- Reactor -------------------------------------------------------------
216
261
 
217
262
  # Runs the boundary body as a fiber and drains everything it spawned.
@@ -359,7 +404,7 @@ module Rubik
359
404
 
360
405
  def fire_due_timers
361
406
  @timers.fire_due!(monotonic) do |fiber, value, gen|
362
- next if @gen[fiber] != gen # stale: fiber moved on after registering
407
+ next if gen != STICKY && @gen[fiber] != gen # stale: fiber moved on after registering
363
408
 
364
409
  drop_io_interest(fiber)
365
410
  push(fiber, value: value)
@@ -427,6 +472,20 @@ module Rubik
427
472
  nil
428
473
  end
429
474
 
475
+ # Retract a queued resume: a deadline that fired while its fiber was
476
+ # running (mid-operation) sits in @runnable until the next pop —
477
+ # without retraction, the block's completion would leave the
478
+ # exception aimed at the fiber's NEXT park (the watchdog-leak shape,
479
+ # in miniature). Same-thread ordering makes this airtight: the entry
480
+ # can only be consumed by a resume that delivers it into the timed
481
+ # block, and then the ensure's cancel finds nothing to retract.
482
+ def discard_queued(fiber, value)
483
+ @mutex.synchronize do
484
+ @runnable.reject! { |(queued_fiber, queued_value)| queued_fiber.equal?(fiber) && queued_value.equal?(value) }
485
+ end
486
+ nil
487
+ end
488
+
430
489
  def kick
431
490
  @wake_write.write_nonblock(".", exception: false)
432
491
  rescue StandardError
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Rubik
6
+ module Concurrency
7
+ # #102: Net::HTTP (and any stdlib code) enforces deadlines with the
8
+ # Timeout module — a watchdog Thread that Thread#raises the calling
9
+ # thread when the deadline expires. Under the scheduler that thread is
10
+ # the loop thread, shared by every parked fiber: the raise lands in
11
+ # whichever frame is executing (another fiber's IO.select, the minitest
12
+ # reporter), and after an abandoned drain it fires into unrelated
13
+ # runner code.
14
+ #
15
+ # While a boundary is draining on this thread, Timeout.timeout is
16
+ # fiber-native instead: the block runs inline (no watchdog thread is
17
+ # ever created) and the deadline exception is delivered through the
18
+ # loop's interrupt path at the calling fiber's current park. Outside a
19
+ # drain there is no loop to consume timers, so the stock watchdog
20
+ # applies — a thread-raise into the thread that is genuinely blocked is
21
+ # correctly targeted there.
22
+ module TimeoutPatch
23
+ def timeout(sec = nil, exception_class = nil, message = nil, &)
24
+ scheduler = ::Fiber.scheduler
25
+ if sec.is_a?(::Numeric) && sec.positive? &&
26
+ scheduler.is_a?(::Rubik::Concurrency::Scheduler) && scheduler.native_deadlines?
27
+ return scheduler.fiber_timeout(sec, exception_class, message, &)
28
+ end
29
+
30
+ super
31
+ end
32
+ end
33
+
34
+ ::Timeout.singleton_class.prepend(TimeoutPatch)
35
+ end
36
+ end
@@ -75,6 +75,21 @@ module Rubik
75
75
  @loop.resolve_address(hostname)
76
76
  end
77
77
 
78
+ # True while the loop is actively draining on this thread — the only
79
+ # window in which Timeout deadlines can be driven by loop timers
80
+ # (outside it the stock Timeout watchdog applies).
81
+ def native_deadlines?
82
+ @loop.draining?
83
+ end
84
+
85
+ # Runs block with a fiber-native deadline: the exception arrives at
86
+ # the fiber's current park instead of a watchdog Thread#raise into
87
+ # the loop thread (#102). Loop-thread only — the Timeout patch routes
88
+ # here exclusively from scheduled fibers.
89
+ def fiber_timeout(sec, exception_class, message, &)
90
+ @loop.fiber_timeout(sec, exception_class, message, &)
91
+ end
92
+
78
93
  def scheduler_close
79
94
  @loop.shutdown
80
95
  nil
@@ -68,6 +68,24 @@ module Rubik
68
68
  fired
69
69
  end
70
70
 
71
+ # Removes a pending entry (identity comparison — entries are arrays
72
+ # that may compare structurally equal) and re-heapifies around the
73
+ # hole. Returns true when the entry was still pending. Only the loop
74
+ # thread mutates the heap and cancellation runs on the loop thread
75
+ # too, so a cancelled entry can never fire afterwards.
76
+ def cancel(entry)
77
+ index = @heap.index { |candidate| candidate.equal?(entry) }
78
+ return false unless index
79
+
80
+ last = @heap.pop
81
+ return true if index >= @heap.size
82
+
83
+ @heap[index] = last
84
+ sift_down(index)
85
+ sift_up(index)
86
+ true
87
+ end
88
+
71
89
  private
72
90
 
73
91
  def prune!
@@ -6,6 +6,7 @@ require_relative "concurrency/event_loop"
6
6
  require_relative "concurrency/scheduler"
7
7
  require_relative "concurrency/boundary"
8
8
  require_relative "concurrency/join"
9
+ require_relative "concurrency/fiber_timeout"
9
10
 
10
11
  module Rubik
11
12
  # Silent concurrency over Ruby's Fiber Scheduler: plain blocking I/O —
@@ -17,10 +18,12 @@ module Rubik
17
18
  # Envelope (documented limits, plan §9): this is I/O concurrency, not CPU
18
19
  # parallelism (the GVL still serializes computation); regular-file reads
19
20
  # never park (read_nonblock on files returns immediately — they simply run
20
- # serially, which is harmless); Timeout.timeout-backed code paths are not
21
- # adopted inside boundaries in v1 (Thread.raise cannot be aimed at a
22
- # specific fiber). Escape hatch: RUBIK_FIBER_SCHEDULER=off degrades every
23
- # boundary to plain sequential execution.
21
+ # serially, which is harmless). Timeout.timeout-backed deadlines (Net::HTTP
22
+ # open/read) are fiber-native inside boundaries: the watchdog Thread#raise
23
+ # is replaced by a loop-timer delivery into the calling fiber, so it can
24
+ # never land in another fiber's frame or in post-shutdown runner code
25
+ # (concurrency/fiber_timeout.rb). Escape hatch: RUBIK_FIBER_SCHEDULER=off
26
+ # degrades every boundary to plain sequential execution.
24
27
  module Concurrency
25
28
  module_function
26
29
 
@@ -9,7 +9,32 @@ module Rubik
9
9
  class AnthropicProvider < BaseProvider
10
10
  attr_reader :name, :model, :formatter, :headers, :api_key, :api_key_env
11
11
 
12
- def initialize(name: nil, model: "claude-3-7-sonnet-20250219", formatter: AnthropicFormatter.new, headers: {},
12
+ # Extended-thinking support by model line (Anthropic docs, 2026-10):
13
+ # :adaptive — 4.6+ and the 5.x/Fable lines: `thinking: {type: "adaptive"}`
14
+ # with an `output_config` effort; the legacy budget_tokens form is
15
+ # REJECTED with a 400 from 4.7 onward.
16
+ # :legacy — claude-3-7-sonnet through the 4.5 line: the manual
17
+ # `thinking: {type: "enabled", budget_tokens: N}` form is the only
18
+ # accepted mode.
19
+ # nil — 3.5-and-earlier lines accept no thinking at all, and
20
+ # unrecognized names (custom aliases/proxies) get no auto thinking.
21
+ def self.thinking_mode_for(model_name)
22
+ s = model_name.to_s
23
+ return :legacy if s.include?("claude-3-7-sonnet")
24
+ return nil if s.match?(/\Aclaude-[23]/) # number-first 2.x/3.x lines (3-7 handled above)
25
+
26
+ # Single-digit captures: Anthropic versions are one digit per segment,
27
+ # so a snapshot date suffix (claude-sonnet-4-20250514) can never be
28
+ # swallowed as the minor version.
29
+ version = s.match(/\Aclaude-(?:sonnet|opus|haiku|fable)-(\d)(?:-(\d))?/)
30
+ return nil unless version
31
+
32
+ major = version[1].to_i
33
+ minor = version[2].to_i
34
+ major > 4 || (major == 4 && minor >= 6) ? :adaptive : :legacy
35
+ end
36
+
37
+ def initialize(name: nil, model: "claude-sonnet-5-5", formatter: AnthropicFormatter.new, headers: {},
13
38
  api_key: nil, api_key_env: nil)
14
39
  super()
15
40
  @name = name.to_s.downcase
@@ -170,14 +195,25 @@ module Rubik
170
195
  thinking_opt = opts[:thinking]
171
196
  thinking_disabled = [false, :disabled].include?(thinking_opt)
172
197
  ratio = (opts[:thinking_budget_ratio] || 0.5).to_f
198
+ # Thinking is on for every model line that supports it unless
199
+ # explicitly disabled; thinking_budget_ratio only shapes the legacy
200
+ # budget form — adaptive models reason as they see fit.
201
+ mode = self.class.thinking_mode_for(target_model)
173
202
 
174
203
  if thinking_opt.is_a?(Hash)
175
204
  payload[:thinking] = thinking_opt
176
- elsif !thinking_disabled && (thinking_opt == true || target_model.to_s.include?("claude-3-7-sonnet"))
177
- if max_tokens > 2048
178
- budget = [(max_tokens * ratio).to_i, 1024].max
179
- budget = [budget, max_tokens - 1024].min
180
- payload[:thinking] = { type: "enabled", budget_tokens: budget } if budget >= 1024
205
+ elsif !thinking_disabled && (thinking_opt == true || mode)
206
+ if mode == :legacy
207
+ if max_tokens > 2048
208
+ budget = [(max_tokens * ratio).to_i, 1024].max
209
+ budget = [budget, max_tokens - 1024].min
210
+ payload[:thinking] = { type: "enabled", budget_tokens: budget } if budget >= 1024
211
+ end
212
+ else
213
+ # Adaptive models reject the legacy form outright, and for
214
+ # unrecognized names adaptive is the modern-generation default.
215
+ payload[:thinking] = { type: "adaptive" }
216
+ payload[:output_config] = { effort: "high" }
181
217
  end
182
218
  end
183
219
 
@@ -38,7 +38,7 @@ module Rubik
38
38
  @providers = {
39
39
  "ollama" => OllamaProvider.new(model: "qwen2.5-coder:7b", headers: global_headers),
40
40
  "openai" => OpenAIProvider.new(model: "gpt-4o", headers: global_headers),
41
- "anthropic" => AnthropicProvider.new(model: "claude-3-7-sonnet-20250219", headers: global_headers)
41
+ "anthropic" => AnthropicProvider.new(model: "claude-sonnet-5-5", headers: global_headers)
42
42
  }
43
43
  @models_catalog = {}
44
44
  load_from_config(config) if config
@@ -82,7 +82,7 @@ module Rubik
82
82
  end
83
83
  if @providers["anthropic"].instance_of?(AnthropicProvider)
84
84
  @providers["anthropic"] = AnthropicProvider.new(
85
- model: @providers["anthropic"]&.model || "claude-3-7-sonnet-20250219",
85
+ model: @providers["anthropic"]&.model || "claude-sonnet-5-5",
86
86
  headers: global_headers
87
87
  )
88
88
  end
@@ -132,7 +132,7 @@ module Rubik
132
132
  when "anthropic"
133
133
  AnthropicProvider.new(
134
134
  name: p_name,
135
- model: default_model || "claude-3-7-sonnet-20250219",
135
+ model: default_model || "claude-sonnet-5-5",
136
136
  # Honor the same api_key / api_key_env that
137
137
  # openai-compatible specs already forward;
138
138
  # otherwise Anthropic auth fails silently.
@@ -257,7 +257,7 @@ module Rubik
257
257
  when "anthropic"
258
258
  anthropic_headers = (custom.respond_to?(:headers) ? custom.headers : nil) || global_headers
259
259
  AnthropicProvider.new(
260
- model: model_name || "claude-3-7-sonnet-20250219",
260
+ model: model_name || "claude-sonnet-5-5",
261
261
  headers: anthropic_headers,
262
262
  api_key: custom.respond_to?(:api_key) ? custom.api_key : nil,
263
263
  api_key_env: custom.respond_to?(:api_key_env) ? custom.api_key_env : nil
@@ -28,7 +28,7 @@ module Rubik
28
28
 
29
29
  def initialize(language, root_dir: Dir.pwd, server_cmd: nil)
30
30
  @language = language.to_s.downcase
31
- @root_dir = File.expand_path(root_dir)
31
+ @root_dir = Rubik::Platform.expand_path(root_dir)
32
32
  cmd = server_cmd || SERVERS[@language]
33
33
  if (cmd.to_s.start_with?("typeprof") || cmd.to_s.start_with?("typescript-language-server")) && !cmd.to_s.include?("--stdio")
34
34
  cmd = "#{cmd} --stdio"
@@ -232,7 +232,7 @@ module Rubik
232
232
  def did_open(file_path, content, language_id = @language)
233
233
  return false unless alive?
234
234
 
235
- abs_path = Rubik::Platform.normalize_path_raw(File.expand_path(file_path, @root_dir))
235
+ abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
236
236
  @mutex.synchronize do
237
237
  @version_counter += 1
238
238
  @open_documents[abs_path] = @version_counter
@@ -254,7 +254,7 @@ module Rubik
254
254
  def did_change(file_path, content)
255
255
  return false unless alive?
256
256
 
257
- abs_path = Rubik::Platform.normalize_path_raw(File.expand_path(file_path, @root_dir))
257
+ abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
258
258
  version = @mutex.synchronize do
259
259
  @version_counter += 1
260
260
  @open_documents[abs_path] = @version_counter
@@ -281,7 +281,7 @@ module Rubik
281
281
  def did_close(file_path)
282
282
  return false unless @running
283
283
 
284
- abs_path = Rubik::Platform.normalize_path_raw(File.expand_path(file_path, @root_dir))
284
+ abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
285
285
  @mutex.synchronize { @open_documents.delete(abs_path) }
286
286
 
287
287
  send_notification("textDocument/didClose", {
@@ -295,14 +295,14 @@ module Rubik
295
295
  def document_open?(file_path)
296
296
  return false if file_path.nil? || file_path.to_s.strip.empty?
297
297
 
298
- abs_path = Rubik::Platform.normalize_path_raw(File.expand_path(file_path, @root_dir))
298
+ abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
299
299
  @mutex.synchronize { @open_documents.key?(abs_path) }
300
300
  rescue ArgumentError
301
301
  false
302
302
  end
303
303
 
304
304
  def diagnostics_for(file_path)
305
- abs_path = Rubik::Platform.normalize_path_raw(File.expand_path(file_path, @root_dir))
305
+ abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
306
306
  root_norm = Rubik::Platform.normalize_path_raw(@root_dir)
307
307
  rel_path = abs_path.start_with?("#{root_norm}/") ? abs_path.delete_prefix("#{root_norm}/") : file_path.to_s.tr("\\", "/")
308
308
  @mutex.synchronize do
@@ -381,7 +381,7 @@ module Rubik
381
381
  params = msg["params"] || {}
382
382
  uri = params["uri"].to_s
383
383
  raw_path = Rubik::Platform.from_file_uri(uri)
384
- abs_path = Rubik::Platform.normalize_path_raw(File.expand_path(raw_path, @root_dir))
384
+ abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(raw_path, @root_dir))
385
385
  root_norm = Rubik::Platform.normalize_path_raw(@root_dir)
386
386
  rel_path = abs_path.start_with?("#{root_norm}/") ? abs_path.delete_prefix("#{root_norm}/") : abs_path
387
387
  diags = params["diagnostics"] || []
@@ -65,7 +65,7 @@ module Rubik
65
65
  # RFC 8089 Compliant File URI (e.g. file:///C:/path/file vs file:///path/file)
66
66
  def to_file_uri(path)
67
67
  normalized = normalize_path_raw(path)
68
- if windows? && normalized =~ %r{^([a-zA-Z]):/(.*)$}
68
+ if (windows? || windows_shaped?(normalized)) && normalized =~ %r{^([a-zA-Z]):/(.*)$}
69
69
  "file:///#{::Regexp.last_match(1)}:/#{::Regexp.last_match(2)}"
70
70
  else
71
71
  normalized.start_with?("/") ? "file://#{normalized}" : "file:///#{normalized}"
@@ -73,23 +73,49 @@ module Rubik
73
73
  end
74
74
 
75
75
  def from_file_uri(uri)
76
- str = uri.to_s.sub(%r{^file://}, "")
76
+ str = uri.to_s.sub(%r{\Afile://}, "")
77
77
  str = CGI.unescape(str)
78
- if windows?
79
- # Handles file:///C:/... and file://C:/...
80
- if str =~ %r{^/?([a-zA-Z]:/.*)$}
78
+ if windows? || str.match?(%r{\A/?[a-zA-Z]:/})
79
+ # Handles file:///C:/... and file://C:/... — keyed by SHAPE, so
80
+ # the same drive URIs resolve identically on a posix host (#103).
81
+ if str =~ %r{\A/?([a-zA-Z]:/.*)\z}
81
82
  ::Regexp.last_match(1)
82
83
  else
83
84
  str
84
85
  end
86
+ elsif str.start_with?("/")
87
+ str
85
88
  else
86
- str.start_with?("/") ? str : "/#{str}"
89
+ "/#{str}"
87
90
  end
88
91
  end
89
92
 
93
+ # Windows path SHAPE: a drive-letter root (C:/ or C:\). Host OS and
94
+ # path shape are independent — a windows-shaped path can reach a
95
+ # posix host (LSP payloads, fixtures, mounted drives) and its drive
96
+ # semantics must not depend on where the process happens to run
97
+ # (#103). Only drive-rooted paths count: a backslash is a legal
98
+ # character in a posix filename, so it never triggers windows
99
+ # semantics on its own.
100
+ def windows_shaped?(path)
101
+ path.to_s.match?(%r{\A[a-zA-Z]:[/\\]})
102
+ end
103
+
104
+ # File.expand_path is host-OS-driven: on a posix host a drive-rooted
105
+ # windows path is treated as a RELATIVE name and mangled under cwd.
106
+ # A windows-shaped path carries its own root, so expansion is a no-op
107
+ # and only the separators normalize. Posix-shaped inputs and windows
108
+ # hosts take the stock path, byte-for-byte — including its TYPE
109
+ # strictness: callers' error paths depend on File.expand_path raising
110
+ # on non-strings, so never coerce here.
111
+ def expand_path(path, base = nil)
112
+ return path.tr("\\", "/") if !windows? && windows_shaped?(path)
113
+
114
+ ::File.expand_path(path, base)
115
+ end
116
+
90
117
  def normalize_path_raw(path)
91
- expanded = File.expand_path(path.to_s)
92
- expanded.gsub("\\", "/")
118
+ expand_path(path.to_s).gsub("\\", "/")
93
119
  end
94
120
 
95
121
  # Normalizes separators and casing for comparison
@@ -257,6 +257,14 @@ module Rubik
257
257
 
258
258
  def binary_allowlist_match?(arg, token)
259
259
  return true if arg == token
260
+ # Windows exe-alias fast path (#103): on a windows host "node.exe"
261
+ # IS the allowlisted "node" — a lexical alias, no filesystem probe.
262
+ # The canonical-path probe below resolves through Platform.which +
263
+ # File.file?, a second host seam the windows? stub cannot cover (a
264
+ # linux host has no node.exe on disk, so the probe fails and the
265
+ # allowlist would dead-close). On a posix host the alias is FALSE:
266
+ # node.exe is a different binary than node there.
267
+ return true if windows_executable_alias?(arg, token)
260
268
 
261
269
  c_arg = canonical_binary_path(arg)
262
270
  c_token = canonical_binary_path(token)
@@ -266,6 +274,19 @@ module Rubik
266
274
  false
267
275
  end
268
276
 
277
+ # True when arg carries an executable extension from PATHEXT and its
278
+ # stem equals the token (case-insensitive — windows path semantics).
279
+ # Bare stems only: a path-form arg ("C:/x/node.exe") keeps going
280
+ # through the canonical-path probe, which resolves both sides.
281
+ def windows_executable_alias?(arg, token)
282
+ return false unless Rubik::Platform.windows?
283
+
284
+ ext = ::File.extname(arg)
285
+ return false if ext.empty? || !Rubik::Platform.pathext.include?(ext.downcase)
286
+
287
+ arg.delete_suffix(ext).casecmp?(token)
288
+ end
289
+
269
290
  def command_matches_allowlist?(result)
270
291
  # Missing argv (e.g. empty invocation) can never be allowlisted.
271
292
  argv = result.respond_to?(:argv) ? result.argv : nil
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır
@@ -276,6 +276,7 @@ files:
276
276
  - lib/rubik/concurrency/dns_pool.rb
277
277
  - lib/rubik/concurrency/errors.rb
278
278
  - lib/rubik/concurrency/event_loop.rb
279
+ - lib/rubik/concurrency/fiber_timeout.rb
279
280
  - lib/rubik/concurrency/join.rb
280
281
  - lib/rubik/concurrency/process_waiter.rb
281
282
  - lib/rubik/concurrency/scheduler.rb