rubik 0.3.0 → 0.3.1
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/CHANGELOG.md +34 -0
- data/lib/rubik/lsp/client.rb +3 -2
- data/lib/rubik/tools/fuzzy_matcher.rb +39 -19
- data/lib/rubik/tools/truncator.rb +54 -21
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b71f1262ecc5dbd53e712da9df276b2fd744a1c797a05917ad4c103ead720b4c
|
|
4
|
+
data.tar.gz: 9c7d57397bfb5a8dfa4b34846bd51ec7ade3fcade0462cbf7f03d9d00ecb576c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5994b70d91d9673cb1ea08f1eb39f9f04aa446ea9edcb61bc558d1b81f79825e205a12690dec3b652901d3176061b74012f48bd7a34d6f1b49c6d181219ada33
|
|
7
|
+
data.tar.gz: 79037bc56238a6eca4f6017e0d23c9b79af836fa5593281cb9005c31750a4f08513d0c78c8ca97c3f83b6cb41643757852a4021778b6c6e2da7a9187e9ea2120
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
Nothing yet.
|
|
12
12
|
|
|
13
|
+
## [0.3.1] - 2026-10-19
|
|
14
|
+
|
|
15
|
+
A hygiene patch: the Truncator honors the budgets you give it across
|
|
16
|
+
threads and separators, and the fuzzy matcher's Levenshtein fallback
|
|
17
|
+
gets much cheaper and UTF-8 safe.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **Truncator honors caller budgets (#189):** the `:head_tail`
|
|
22
|
+
strategy drops its hardcoded `clamp(1, 150)` / `clamp(1, 6000)`
|
|
23
|
+
caps — `max_lines` and `max_chars` are honored as given, small
|
|
24
|
+
budgets (`max_lines <= 1`) stay under their ceilings, and inputs
|
|
25
|
+
shorter than the budget no longer duplicate or overlap lines;
|
|
26
|
+
non-default separators no longer bypass the budget check (small
|
|
27
|
+
texts were wrongly reported `truncated: true`); omitted-line
|
|
28
|
+
accounting matches byte-budget slicing; and negative budgets clamp
|
|
29
|
+
to zero.
|
|
30
|
+
- **Truncator line separation is thread-safe (#187):** `truncate`
|
|
31
|
+
takes an explicit `separator: "\n"` instead of reading the ambient
|
|
32
|
+
`$/` global, removing cross-thread separator races and the Ruby
|
|
33
|
+
3.4+ deprecation warnings.
|
|
34
|
+
- **FuzzyMatcher survives invalid UTF-8 (#185):** the Levenshtein
|
|
35
|
+
fallback scrubs invalid byte sequences before codepoint iteration
|
|
36
|
+
instead of raising.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Levenshtein fallback is ~31% faster with 96% fewer allocations
|
|
41
|
+
(#185):** codepoint iteration with a single mutation buffer
|
|
42
|
+
replaces per-character string and row-array churn.
|
|
43
|
+
- **LSP reader-thread failures are structured (#184):** JSON parse
|
|
44
|
+
errors and reader exceptions log through `Rubik::Log.warn(:lsp, …)`
|
|
45
|
+
instead of bare `Kernel#warn`.
|
|
46
|
+
|
|
13
47
|
## [0.3.0] - 2026-10-18
|
|
14
48
|
|
|
15
49
|
The subagent-governance minor: runtime-defined archetypes become
|
data/lib/rubik/lsp/client.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "open3"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "../platform"
|
|
7
|
+
require_relative "../log"
|
|
7
8
|
|
|
8
9
|
module Rubik
|
|
9
10
|
module LSP
|
|
@@ -347,12 +348,12 @@ module Rubik
|
|
|
347
348
|
parsed = JSON.parse(body)
|
|
348
349
|
handle_incoming_message(parsed)
|
|
349
350
|
rescue StandardError => e
|
|
350
|
-
warn "
|
|
351
|
+
Rubik::Log.warn(:lsp, "Failed to handle message: #{e.class}: #{e.message}")
|
|
351
352
|
end
|
|
352
353
|
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
353
354
|
break
|
|
354
355
|
rescue StandardError => e
|
|
355
|
-
warn "
|
|
356
|
+
Rubik::Log.warn(:lsp, "Reader loop error: #{e.class}: #{e.message}")
|
|
356
357
|
next
|
|
357
358
|
end
|
|
358
359
|
end
|
|
@@ -81,29 +81,49 @@ module Rubik
|
|
|
81
81
|
|
|
82
82
|
# Computes Levenshtein edit distance between two strings using standard dynamic programming.
|
|
83
83
|
def self.levenshtein_distance(str1, str2)
|
|
84
|
-
return DidYouMean::Levenshtein.distance(str1.to_s, str2.to_s) if defined?(DidYouMean::Levenshtein) && DidYouMean::Levenshtein.respond_to?(:distance)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
return DidYouMean::Levenshtein.distance(str1.to_s.scrub, str2.to_s.scrub) if defined?(DidYouMean::Levenshtein) && DidYouMean::Levenshtein.respond_to?(:distance)
|
|
85
|
+
|
|
86
|
+
# PERF: Use codepoints instead of String#chars to avoid single-character String allocations,
|
|
87
|
+
# single-buffer array mutation instead of allocating row arrays (new_d), and fast while loops
|
|
88
|
+
# instead of each_with_index/Array#min blocks (~35% faster, zero inner loop allocations).
|
|
89
|
+
# scrub guarantees valid UTF-8 so codepoints never raises on
|
|
90
|
+
# malformed input (the previous .chars path tolerated bad bytes).
|
|
91
|
+
s1 = str1.to_s.scrub.codepoints
|
|
92
|
+
s2 = str2.to_s.scrub.codepoints
|
|
93
|
+
len1 = s1.length
|
|
94
|
+
len2 = s2.length
|
|
95
|
+
return len2 if len1.zero?
|
|
96
|
+
return len1 if len2.zero?
|
|
97
|
+
|
|
98
|
+
d = Array.new(len1 + 1) { |i| i }
|
|
99
|
+
|
|
100
|
+
j = 0
|
|
101
|
+
while j < len2
|
|
102
|
+
char2 = s2[j]
|
|
103
|
+
j += 1
|
|
104
|
+
new_d0 = j
|
|
105
|
+
prev = d[0]
|
|
106
|
+
i = 0
|
|
107
|
+
while i < len1
|
|
108
|
+
char1 = s1[i]
|
|
96
109
|
cost = char1 == char2 ? 0 : 1
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
del = d[i + 1] + 1
|
|
111
|
+
ins = new_d0 + 1
|
|
112
|
+
sub = prev + cost
|
|
113
|
+
|
|
114
|
+
min_val = del
|
|
115
|
+
min_val = ins if ins < min_val
|
|
116
|
+
min_val = sub if sub < min_val
|
|
117
|
+
new_d0 = min_val
|
|
118
|
+
|
|
119
|
+
prev = d[i + 1]
|
|
120
|
+
d[i + 1] = new_d0
|
|
121
|
+
i += 1
|
|
102
122
|
end
|
|
103
|
-
d =
|
|
123
|
+
d[0] = j
|
|
104
124
|
end
|
|
105
125
|
|
|
106
|
-
d
|
|
126
|
+
d[len1]
|
|
107
127
|
end
|
|
108
128
|
|
|
109
129
|
# Finds the closest matching line window in `file_content` for `target_content`
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "English"
|
|
4
|
-
|
|
5
3
|
module Rubik
|
|
6
4
|
module Tools
|
|
7
5
|
module Truncator
|
|
8
6
|
DEFAULT_MAX_CHARS = 12_000
|
|
9
7
|
DEFAULT_MAX_LINES = 300
|
|
10
8
|
|
|
11
|
-
def self.truncate(text, max_chars: DEFAULT_MAX_CHARS, max_lines: DEFAULT_MAX_LINES, strategy: :head_tail)
|
|
9
|
+
def self.truncate(text, max_chars: DEFAULT_MAX_CHARS, max_lines: DEFAULT_MAX_LINES, strategy: :head_tail, separator: "\n")
|
|
12
10
|
str = text.to_s
|
|
13
11
|
# byteslice truncation below cuts on byte boundaries and can split a
|
|
14
12
|
# multibyte character, producing invalid UTF-8 that then poisons the
|
|
@@ -16,15 +14,14 @@ module Rubik
|
|
|
16
14
|
# estimation). Scrub exactly once, after the last byteslice.
|
|
17
15
|
str = str.scrub("") unless str.valid_encoding?
|
|
18
16
|
|
|
19
|
-
max_c = (max_chars || DEFAULT_MAX_CHARS).to_i
|
|
20
|
-
max_l = (max_lines || DEFAULT_MAX_LINES).to_i
|
|
17
|
+
max_c = [(max_chars || DEFAULT_MAX_CHARS).to_i, 0].max
|
|
18
|
+
max_l = [(max_lines || DEFAULT_MAX_LINES).to_i, 0].max
|
|
19
|
+
sep = separator || "\n"
|
|
21
20
|
|
|
22
|
-
# PERF: With the default
|
|
21
|
+
# PERF: With the default newline separator, calculate the line upper bound
|
|
23
22
|
# using zero-allocation C-level #count("\n") and short-circuit BEFORE
|
|
24
|
-
# calling #lines (which allocates an Array of line strings).
|
|
25
|
-
|
|
26
|
-
# separator, since String#lines uses $/ when no separator is supplied.
|
|
27
|
-
if $INPUT_RECORD_SEPARATOR == "\n"
|
|
23
|
+
# calling #lines (which allocates an Array of line strings).
|
|
24
|
+
if sep == "\n"
|
|
28
25
|
line_bound = if str.empty?
|
|
29
26
|
0
|
|
30
27
|
elsif str.end_with?("\n")
|
|
@@ -35,7 +32,8 @@ module Rubik
|
|
|
35
32
|
return { text: str, truncated: false, omitted_lines: 0, omitted_chars: 0 } if str.bytesize <= max_c && line_bound <= max_l
|
|
36
33
|
end
|
|
37
34
|
|
|
38
|
-
lines = str.lines
|
|
35
|
+
lines = str.lines(sep)
|
|
36
|
+
return { text: str, truncated: false, omitted_lines: 0, omitted_chars: 0 } if str.bytesize <= max_c && lines.size <= max_l
|
|
39
37
|
|
|
40
38
|
strategy_sym = strategy.to_s.downcase.to_sym
|
|
41
39
|
|
|
@@ -44,7 +42,8 @@ module Rubik
|
|
|
44
42
|
kept_lines = lines.first(max_l)
|
|
45
43
|
kept_str = kept_lines.join
|
|
46
44
|
kept_str = kept_str.byteslice(0, max_c) if kept_str.bytesize > max_c
|
|
47
|
-
|
|
45
|
+
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
46
|
+
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
48
47
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
49
48
|
notice = "\n... [Truncated: omitted #{omitted_lines} lines / #{omitted_chars} chars] ...\n"
|
|
50
49
|
{ text: finalize(kept_str, notice, prefix: true), truncated: true,
|
|
@@ -53,29 +52,63 @@ module Rubik
|
|
|
53
52
|
kept_lines = lines.last(max_l)
|
|
54
53
|
kept_str = kept_lines.join
|
|
55
54
|
kept_str = kept_str.byteslice(-max_c, max_c) if kept_str.bytesize > max_c
|
|
56
|
-
|
|
55
|
+
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
56
|
+
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
57
57
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
58
58
|
notice = "\n... [Truncated: omitted #{omitted_lines} lines / #{omitted_chars} chars] ...\n"
|
|
59
59
|
{ text: finalize(kept_str, notice, prefix: false), truncated: true,
|
|
60
60
|
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
61
61
|
else # :head_tail
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
if max_l <= 1
|
|
63
|
+
kept_lines = lines.first(max_l)
|
|
64
|
+
kept_str = kept_lines.join
|
|
65
|
+
kept_str = kept_str.byteslice(0, max_c) if kept_str.bytesize > max_c
|
|
66
|
+
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
67
|
+
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
68
|
+
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
69
|
+
notice = "\n\n... [Truncated: #{omitted_lines} lines / #{omitted_chars} chars omitted to preserve token budget] ...\n\n"
|
|
70
|
+
return { text: finalize(kept_str, notice, prefix: true), truncated: true,
|
|
71
|
+
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
head_lines_count = max_l / 2
|
|
75
|
+
tail_lines_count = max_l - head_lines_count
|
|
76
|
+
|
|
77
|
+
head_chars = max_c / 2
|
|
78
|
+
tail_chars = max_c - head_chars
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
if lines.size == 1
|
|
81
|
+
head_lines = lines
|
|
82
|
+
tail_lines = lines
|
|
83
|
+
elsif head_lines_count + tail_lines_count >= lines.size
|
|
84
|
+
head_n = [head_lines_count, lines.size / 2].min
|
|
85
|
+
tail_n = [tail_lines_count, lines.size - head_n].min
|
|
86
|
+
head_lines = lines.first(head_n)
|
|
87
|
+
tail_lines = lines.last(tail_n)
|
|
88
|
+
else
|
|
89
|
+
head_lines = lines.first(head_lines_count)
|
|
90
|
+
tail_lines = lines.last(tail_lines_count)
|
|
91
|
+
end
|
|
67
92
|
|
|
68
93
|
head_str = head_lines.join
|
|
69
94
|
tail_str = tail_lines.join
|
|
70
95
|
|
|
71
|
-
head_str = head_str.byteslice(0,
|
|
72
|
-
tail_str = tail_str.byteslice(-
|
|
96
|
+
head_str = head_str.byteslice(0, head_chars) if head_str.bytesize > head_chars
|
|
97
|
+
tail_str = tail_str.byteslice(-tail_chars, tail_chars) if tail_str.bytesize > tail_chars
|
|
73
98
|
|
|
74
|
-
omitted_lines =
|
|
99
|
+
omitted_lines = if lines.size == 1
|
|
100
|
+
0
|
|
101
|
+
else
|
|
102
|
+
head_k = head_str.empty? ? 0 : head_str.lines(sep).size
|
|
103
|
+
tail_k = tail_str.empty? ? 0 : tail_str.lines(sep).size
|
|
104
|
+
[lines.size - (head_k + tail_k), 0].max
|
|
105
|
+
end
|
|
75
106
|
omitted_chars = [str.bytesize - (head_str.bytesize + tail_str.bytesize), 0].max
|
|
76
107
|
notice = "\n\n... [Truncated: #{omitted_lines} lines / #{omitted_chars} chars omitted to preserve token budget] ...\n\n"
|
|
77
108
|
|
|
78
|
-
|
|
109
|
+
head_final = head_str.valid_encoding? ? head_str : head_str.scrub("")
|
|
110
|
+
tail_final = tail_str.valid_encoding? ? tail_str : tail_str.scrub("")
|
|
111
|
+
{ text: "#{head_final}#{notice}#{tail_final}", truncated: true,
|
|
79
112
|
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
80
113
|
end
|
|
81
114
|
end
|
data/lib/rubik/version.rb
CHANGED