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 +4 -4
- data/ATTRIBUTION.md +10 -1
- data/CHANGELOG.md +67 -10
- data/README.md +16 -1
- data/lib/rubik/concurrency/event_loop.rb +63 -4
- data/lib/rubik/concurrency/fiber_timeout.rb +36 -0
- data/lib/rubik/concurrency/scheduler.rb +15 -0
- data/lib/rubik/concurrency/timers.rb +18 -0
- data/lib/rubik/concurrency.rb +7 -4
- data/lib/rubik/llm/anthropic.rb +42 -6
- data/lib/rubik/llm/registry.rb +4 -4
- data/lib/rubik/lsp/client.rb +7 -7
- data/lib/rubik/platform/path.rb +34 -8
- data/lib/rubik/security/guard.rb +21 -0
- 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: a0ec56cafd854c2e2999c68cdfdf787322ca4d7d2a2866fac4b4e8ce9f1efac7
|
|
4
|
+
data.tar.gz: ec584d8ac5ee2039423d71968cce852afb65fcb072cbdbb87325ef94693e6a5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
[](AGENTS.md)
|
|
12
12
|
[](COMMANDS.md)
|
|
13
13
|
[](CONFIG.md)
|
|
14
|
-
[]()
|
|
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
|
-
|
|
32
|
-
WRITABLE
|
|
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
|
|
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!
|
data/lib/rubik/concurrency.rb
CHANGED
|
@@ -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)
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
data/lib/rubik/llm/anthropic.rb
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
177
|
-
if
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
data/lib/rubik/llm/registry.rb
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
data/lib/rubik/lsp/client.rb
CHANGED
|
@@ -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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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"] || []
|
data/lib/rubik/platform/path.rb
CHANGED
|
@@ -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{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
expanded.gsub("\\", "/")
|
|
118
|
+
expand_path(path.to_s).gsub("\\", "/")
|
|
93
119
|
end
|
|
94
120
|
|
|
95
121
|
# Normalizes separators and casing for comparison
|
data/lib/rubik/security/guard.rb
CHANGED
|
@@ -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
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.
|
|
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
|