openclacky 0.9.11 → 0.9.12

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: 5db22957073a294578efa60af05e69277891f976bb24d6d9397d5abbefeaf6b8
4
- data.tar.gz: 3103ec5cf881eb0ebe16f79a5f136c01ef890255ebeda9757d4aa5f592071470
3
+ metadata.gz: 791fa59b1013c559e294b373e1a7ba671545eef76ae7037ed3c604ed9f269b45
4
+ data.tar.gz: 8ffae20491125d886e36387b811f9ea8c2e2fcc76d76d10e08224bcc06a93972
5
5
  SHA512:
6
- metadata.gz: 55fe30f89017da76ac2a9348fea1a1f4d701006d4c882878e09e915600ac80dcf72131d546b7e786c3588c1c848e47db4628478ce018e619a2d7781bd0cdbfe3
7
- data.tar.gz: a6923ec51d5a5d60c2a5bd07b7fa269a76087456ea5adf4d7b33a9dc1c8a43f043570a6d4c6f6309b24dc2ad60850dd4d993cc9444fd22c76445e3749952ca09
6
+ metadata.gz: 6895348aeef5d8ed713273a2d386e390a018e5b4991c37a7fd20b7fdbb4782fc945779b45556f31cb2a0894f78df72efc80ad4661b3b95d89cd8ab0c333b65a0
7
+ data.tar.gz: 01c35bb0a3377b81c4e3f89892d58b10472f9611347ed9b0478872e76e3c236def6b499475df32d6020c56fe94dcd33c11495512ee94a5a44a1c81aefbaaad3e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.12] - 2026-03-27
11
+
12
+ ### Added
13
+ - **Improved Anthropic prompt cache hit rate (2-point caching)**: the last 2 eligible messages are now marked for caching instead of 1, so Turn N's cached prefix is still a hit in Turn N+1 — significantly reducing API costs for long sessions
14
+
15
+ ### Improved
16
+ - **Ruby 2.6+ and macOS system Ruby compatibility**: the gem now works with the macOS built-in Ruby (2.6) and LibreSSL — includes polyfills for `filter_map`, `File.absolute_path?`, `URI.encode_uri_component`, and a pure-Ruby AES-256-GCM fallback for LibreSSL environments where native OpenSSL GCM is unavailable
17
+ - **Install script streamlined for China**: the installer is now significantly simplified and more reliable for users in China — direct Alibaba Cloud mirror for RubyGems, plus a dedicated CN-optimized install path
18
+ - **Compression no longer crashes when system prompt is frozen**: fixed a bug where message compression would raise `FrozenError` by mutating the shared system prompt object — it now safely duplicates the string before modification
19
+
20
+ ### Fixed
21
+ - **Compression crash on frozen system prompt**: `MessageCompressor` now calls `.dup` on the system prompt before injecting the compression instruction, preventing `FrozenError` in long sessions
22
+
10
23
  ## [0.9.11] - 2026-03-25
11
24
 
12
25
  ### Added
@@ -0,0 +1,89 @@
1
+ # Install Script Simplification Plan
2
+
3
+ ## Background
4
+
5
+ We now support Ruby >= 2.6.0 (down from 3.1.0). This opens the door to significantly
6
+ simplifying the install script by leveraging the system Ruby that ships with older macOS versions.
7
+
8
+ ## Current Flow (Heavy)
9
+
10
+ ```
11
+ install.sh
12
+ → detect_network_region
13
+ → install_macos_dependencies
14
+ → Homebrew (+ openssl@3, libyaml, gmp build deps)
15
+ → mise (Ruby version manager)
16
+ → Ruby 3 (via mise)
17
+ → Node 22 (via mise, for chrome-devtools-mcp)
18
+ → gem install openclacky
19
+ → install_chrome_devtools_mcp (npm install -g)
20
+ ```
21
+
22
+ ## Target Flow (Simplified)
23
+
24
+ ```
25
+ check_ruby >= 2.6?
26
+ ├── YES → gem install openclacky ✅ (done, zero extra deps)
27
+ └── NO → check CLT exists (xcode-select -p)
28
+ ├── YES → mise install ruby → gem install ✅
29
+ └── NO → print clear instructions, exit:
30
+ Option 1: xcode-select --install (then re-run installer)
31
+ Option 2: brew install ruby (if brew already exists)
32
+ ```
33
+
34
+ ## Key Decisions
35
+
36
+ ### Ruby version threshold: 3.1 → 2.6
37
+ - macOS 10.15 Catalina: Ruby 2.6.3 ✅
38
+ - macOS 11 Big Sur: Ruby 2.6.8 ✅
39
+ - macOS 12+ Monterey and above: no system Ruby (Apple removed it)
40
+
41
+ ### Homebrew: removed from happy path
42
+ - Only Homebrew's build deps (openssl@3, libyaml, gmp) were needed to compile Ruby via mise
43
+ - If system Ruby exists, none of these are needed
44
+ - Homebrew itself is NOT installed by the script anymore
45
+
46
+ ### mise: fallback only
47
+ - Only invoked when system Ruby is absent (macOS 12+)
48
+ - Requires Xcode CLT to be present first (for compiling Ruby)
49
+ - No longer used to install Node
50
+
51
+ ### Node / chrome-devtools-mcp: removed from install.sh
52
+ - Browser automation is an optional feature
53
+ - Move Node installation guidance into the `browser-setup` skill
54
+ - `clacky browser setup` handles Node + chrome-devtools-mcp installation
55
+
56
+ ## Xcode CLT Handling (Deferred)
57
+
58
+ Brew uses a clever trick to install CLT silently (no GUI popup):
59
+
60
+ ```bash
61
+ # Creates a placeholder file that triggers softwareupdate to list CLT
62
+ touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
63
+
64
+ # Find the CLT package label
65
+ clt_label=$(softwareupdate -l | grep "Command Line Tools" | ...)
66
+
67
+ # Silent install — no GUI!
68
+ sudo softwareupdate -i "${clt_label}"
69
+ ```
70
+
71
+ However this still requires `sudo` (root password). Given that:
72
+ - macOS 12+ users who lack CLT are rare (any git/Xcode/Homebrew usage installs it)
73
+ - Silent CLT install downloads hundreds of MB
74
+
75
+ The current plan is to **not auto-install CLT**, and instead print clear instructions
76
+ asking the user to run `xcode-select --install` and re-run the installer.
77
+
78
+ We can revisit adopting Homebrew's `softwareupdate` approach in the future if
79
+ the "no CLT" case proves common enough.
80
+
81
+ ## Files to Change
82
+
83
+ | File | Change |
84
+ |------|--------|
85
+ | `scripts/install.sh` | Ruby threshold 3.1 → 2.6; remove Homebrew install; remove Node/npm install; simplify macOS deps function |
86
+ | `README.md` | Ruby badge and requirements: >= 3.1.0 → >= 2.6.0 |
87
+ | `docs/HOW-TO-USE.md` | Requirements: Ruby >= 3.1 → >= 2.6 |
88
+ | `docs/HOW-TO-USE-CN.md` | Same as above |
89
+ | `homebrew/openclacky.rb` | `depends_on "ruby@3.3"` → remove or update |
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module Clacky
7
+ # Pure-Ruby AES-256-GCM implementation.
8
+ #
9
+ # Why this exists:
10
+ # macOS ships Ruby 2.6 linked against LibreSSL 3.3.x which has a known
11
+ # bug: AES-GCM encrypt/decrypt raises CipherError even for valid inputs.
12
+ # This implementation uses AES-256-ECB (which LibreSSL supports correctly)
13
+ # as the single block-cipher primitive and builds GCM on top:
14
+ #
15
+ # - CTR mode → keystream for encryption / decryption
16
+ # - GHASH → authentication tag
17
+ #
18
+ # The output is 100% compatible with OpenSSL / standard AES-256-GCM:
19
+ # ciphertext, iv, and auth_tag produced here can be decrypted by OpenSSL
20
+ # and vice-versa.
21
+ #
22
+ # Reference: NIST SP 800-38D
23
+ #
24
+ # Usage:
25
+ # ct, tag = AesGcm.encrypt(key, iv, plaintext, aad)
26
+ # pt = AesGcm.decrypt(key, iv, ciphertext, tag, aad)
27
+ module AesGcm
28
+ BLOCK_SIZE = 16
29
+ TAG_LENGTH = 16
30
+
31
+ # Encrypt plaintext with AES-256-GCM.
32
+ #
33
+ # @param key [String] 32-byte binary key
34
+ # @param iv [String] 12-byte binary IV (recommended for GCM)
35
+ # @param plaintext [String] binary or UTF-8 plaintext
36
+ # @param aad [String] additional authenticated data (may be empty)
37
+ # @return [Array<String, String>] [ciphertext, auth_tag] both binary strings
38
+ def self.encrypt(key, iv, plaintext, aad = "")
39
+ aes = aes_ecb(key)
40
+ h = aes.call("\x00" * BLOCK_SIZE) # H = E(K, 0^128)
41
+ j0 = build_j0(iv, h)
42
+ ct = ctr_crypt(aes, inc32(j0), plaintext.b)
43
+ tag = compute_tag(aes, h, j0, ct, aad.b)
44
+ [ct, tag]
45
+ end
46
+
47
+ # Decrypt ciphertext with AES-256-GCM and verify auth tag.
48
+ #
49
+ # @param key [String] 32-byte binary key
50
+ # @param iv [String] 12-byte binary IV
51
+ # @param ciphertext [String] binary ciphertext
52
+ # @param tag [String] 16-byte binary auth tag
53
+ # @param aad [String] additional authenticated data (may be empty)
54
+ # @return [String] plaintext (UTF-8)
55
+ # @raise [OpenSSL::Cipher::CipherError] on authentication failure
56
+ def self.decrypt(key, iv, ciphertext, tag, aad = "")
57
+ aes = aes_ecb(key)
58
+ h = aes.call("\x00" * BLOCK_SIZE)
59
+ j0 = build_j0(iv, h)
60
+ exp_tag = compute_tag(aes, h, j0, ciphertext, aad.b)
61
+
62
+ unless secure_compare(exp_tag, tag)
63
+ raise OpenSSL::Cipher::CipherError, "bad decrypt (authentication tag mismatch)"
64
+ end
65
+
66
+ ctr_crypt(aes, inc32(j0), ciphertext).force_encoding("UTF-8")
67
+ end
68
+
69
+ # ── Private helpers ──────────────────────────────────────────────────────
70
+
71
+ # Return a lambda: block(16 bytes) → encrypted block(16 bytes)
72
+ private_class_method def self.aes_ecb(key)
73
+ lambda do |block|
74
+ c = OpenSSL::Cipher.new("aes-256-ecb")
75
+ c.encrypt
76
+ c.padding = 0
77
+ c.key = key
78
+ c.update(block) + c.final
79
+ end
80
+ end
81
+
82
+ # Build J0 counter block.
83
+ # For 12-byte IVs (standard): J0 = IV || 0x00000001
84
+ # For other lengths: J0 = GHASH(H, {}, IV)
85
+ private_class_method def self.build_j0(iv, h)
86
+ if iv.bytesize == 12
87
+ iv.b + "\x00\x00\x00\x01"
88
+ else
89
+ ghash(h, "", iv.b)
90
+ end
91
+ end
92
+
93
+ # CTR-mode encryption/decryption (symmetric — same operation).
94
+ # Starting counter block is `ctr0` (already incremented to J0+1 by caller).
95
+ private_class_method def self.ctr_crypt(aes, ctr0, data)
96
+ return "".b if data.empty?
97
+
98
+ out = "".b
99
+ ctr = ctr0.dup
100
+ pos = 0
101
+
102
+ while pos < data.bytesize
103
+ keystream = aes.call(ctr)
104
+ chunk = data.byteslice(pos, BLOCK_SIZE)
105
+ out << xor_blocks(keystream, chunk)
106
+ ctr = inc32(ctr)
107
+ pos += BLOCK_SIZE
108
+ end
109
+
110
+ out
111
+ end
112
+
113
+ # Compute GCM auth tag.
114
+ # tag = E(K, J0) XOR GHASH(H, aad, ciphertext)
115
+ private_class_method def self.compute_tag(aes, h, j0, ciphertext, aad)
116
+ s = ghash(h, aad, ciphertext)
117
+ ej0 = aes.call(j0)
118
+ xor_blocks(ej0, s)
119
+ end
120
+
121
+ # GHASH: polynomial hashing over GF(2^128)
122
+ # ghash = Σ (Xi * H^i) where Xi are 128-bit blocks of padded aad + ciphertext + lengths
123
+ private_class_method def self.ghash(h, aad, ciphertext)
124
+ h_int = bytes_to_int(h)
125
+ x = 0
126
+
127
+ # Process AAD blocks
128
+ each_block(aad) { |blk| x = gf128_mul(bytes_to_int(blk) ^ x, h_int) }
129
+
130
+ # Process ciphertext blocks
131
+ each_block(ciphertext) { |blk| x = gf128_mul(bytes_to_int(blk) ^ x, h_int) }
132
+
133
+ # Final block: len(aad) || len(ciphertext) in bits, each as 64-bit big-endian
134
+ len_block = [aad.bytesize * 8].pack("Q>") + [ciphertext.bytesize * 8].pack("Q>")
135
+ x = gf128_mul(bytes_to_int(len_block) ^ x, h_int)
136
+
137
+ int_to_bytes(x)
138
+ end
139
+
140
+ # Iterate over 16-byte zero-padded blocks of data, yielding each block.
141
+ private_class_method def self.each_block(data, &block)
142
+ return if data.empty?
143
+
144
+ i = 0
145
+ while i < data.bytesize
146
+ chunk = data.byteslice(i, BLOCK_SIZE)
147
+ chunk = chunk.ljust(BLOCK_SIZE, "\x00") if chunk.bytesize < BLOCK_SIZE
148
+ block.call(chunk)
149
+ i += BLOCK_SIZE
150
+ end
151
+ end
152
+
153
+ # Galois Field GF(2^128) multiplication.
154
+ # Reduction polynomial: x^128 + x^7 + x^2 + x + 1
155
+ # Uses the reflected bit order per GCM spec.
156
+ R = 0xe1000000000000000000000000000000
157
+ private_class_method def self.gf128_mul(x, y)
158
+ z = 0
159
+ v = x
160
+ 128.times do
161
+ z ^= v if y & (1 << 127) != 0
162
+ lsb = v & 1
163
+ v >>= 1
164
+ v ^= R if lsb == 1
165
+ y <<= 1
166
+ y &= (1 << 128) - 1
167
+ end
168
+ z
169
+ end
170
+
171
+ # Increment the rightmost 32 bits of a 16-byte counter block (big-endian).
172
+ private_class_method def self.inc32(block)
173
+ prefix = block.byteslice(0, 12)
174
+ counter = block.byteslice(12, 4).unpack1("N")
175
+ prefix + [(counter + 1) & 0xFFFFFFFF].pack("N")
176
+ end
177
+
178
+ # XOR two binary strings, truncated to the shorter length.
179
+ private_class_method def self.xor_blocks(a, b)
180
+ len = [a.bytesize, b.bytesize].min
181
+ len.times.map { |i| (a.getbyte(i) ^ b.getbyte(i)).chr }.join.b
182
+ end
183
+
184
+ # Convert a binary string to an unsigned big-endian integer.
185
+ private_class_method def self.bytes_to_int(str)
186
+ str.bytes.inject(0) { |acc, b| (acc << 8) | b }
187
+ end
188
+
189
+ # Convert an unsigned integer to a 16-byte big-endian binary string.
190
+ private_class_method def self.int_to_bytes(n)
191
+ bytes = []
192
+ 16.times { bytes.unshift(n & 0xFF); n >>= 8 }
193
+ bytes.pack("C*")
194
+ end
195
+
196
+ # Constant-time string comparison to prevent timing attacks.
197
+ private_class_method def self.secure_compare(a, b)
198
+ return false if a.bytesize != b.bytesize
199
+
200
+ result = 0
201
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
202
+ result == 0
203
+ end
204
+ end
205
+ end
@@ -99,8 +99,13 @@ module Clacky
99
99
  raise "LLM compression failed: unable to parse compressed messages"
100
100
  end
101
101
 
102
- # Return system message + compressed messages + recent messages
103
- [system_msg, *parsed_messages, *recent_messages].compact
102
+ # Return system message + compressed messages + recent messages.
103
+ # Strip any system messages from recent_messages as a safety net —
104
+ # get_recent_messages_with_tool_pairs already excludes them, but this
105
+ # guard ensures we never end up with duplicate system prompts even if
106
+ # the caller passes an unfiltered list.
107
+ safe_recent = recent_messages.reject { |m| m[:role] == "system" }
108
+ [system_msg, *parsed_messages, *safe_recent].compact
104
109
  end
105
110
 
106
111
  private
@@ -135,6 +135,14 @@ module Clacky
135
135
  chunk_path: chunk_path
136
136
  ))
137
137
 
138
+ # Reset to the estimated size of the rebuilt (small) history.
139
+ # The compression call_llm reported the OLD large token count, so
140
+ # @previous_total_tokens would still be above COMPRESSION_THRESHOLD —
141
+ # without this reset the very next think() would re-trigger compression
142
+ # immediately, causing an infinite loop (especially after image uploads
143
+ # where base64 data inflates token counts dramatically).
144
+ @previous_total_tokens = @history.estimate_tokens
145
+
138
146
  # Track this compression
139
147
  @compressed_summaries << {
140
148
  level: compression_context[:compression_level],
@@ -167,6 +175,14 @@ module Clacky
167
175
  while i >= 0 && messages_collected < count
168
176
  msg = messages[i]
169
177
 
178
+ # Never include the system message — it is always prepended separately
179
+ # by rebuild_with_compression. Including it here would cause it to appear
180
+ # twice in the rebuilt history, inflating token counts on every compression.
181
+ if msg[:role] == "system"
182
+ i -= 1
183
+ next
184
+ end
185
+
170
186
  if messages_to_include.include?(i)
171
187
  i -= 1
172
188
  next
data/lib/clacky/agent.rb CHANGED
@@ -38,7 +38,9 @@ module Clacky
38
38
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
39
39
  :status, :error, :updated_at, :source
40
40
 
41
- def permission_mode = @config&.permission_mode&.to_s || ""
41
+ def permission_mode
42
+ @config&.permission_mode&.to_s || ""
43
+ end
42
44
 
43
45
  def initialize(client, config, working_dir:, ui:, profile:, session_id:, source:)
44
46
  @client = client # Client for current model
@@ -1070,11 +1070,24 @@ module Clacky
1070
1070
  # @raise [RuntimeError] on decryption failure (wrong key, tampered data)
1071
1071
  private def aes_gcm_decrypt(key, ciphertext, iv_b64, tag_b64)
1072
1072
  require "base64"
1073
- cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
1074
- cipher.key = key
1075
- cipher.iv = Base64.strict_decode64(iv_b64)
1076
- cipher.auth_tag = Base64.strict_decode64(tag_b64)
1077
- (cipher.update(ciphertext) + cipher.final).force_encoding("UTF-8")
1073
+ require_relative "aes_gcm"
1074
+
1075
+ iv = Base64.strict_decode64(iv_b64)
1076
+ tag = Base64.strict_decode64(tag_b64)
1077
+
1078
+ # Try native OpenSSL AES-GCM first (fastest path; works on real OpenSSL).
1079
+ # LibreSSL 3.3.x has a known bug where AES-GCM raises CipherError even
1080
+ # for valid inputs, so we fall back to the pure-Ruby implementation.
1081
+ begin
1082
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
1083
+ cipher.key = key
1084
+ cipher.iv = iv
1085
+ cipher.auth_tag = tag
1086
+ (cipher.update(ciphertext) + cipher.final).force_encoding("UTF-8")
1087
+ rescue OpenSSL::Cipher::CipherError
1088
+ # Native GCM failed — use pure-Ruby fallback (LibreSSL-safe)
1089
+ Clacky::AesGcm.decrypt(key, iv, ciphertext, tag)
1090
+ end
1078
1091
  rescue OpenSSL::Cipher::CipherError => e
1079
1092
  raise "Decryption failed: #{e.message}. " \
1080
1093
  "The file may be corrupted or the license key is incorrect."
data/lib/clacky/client.rb CHANGED
@@ -185,20 +185,31 @@ module Clacky
185
185
 
186
186
  # ── Prompt caching helpers ────────────────────────────────────────────────
187
187
 
188
- # Add cache_control marker to the appropriate message in the array.
189
- # Strategy: mark the last message, unless that message is a compression
190
- # instruction (system_injected: true) — in that case mark the one before it.
188
+ # Add cache_control markers to the last 2 messages in the array.
189
+ #
190
+ # Why 2 markers:
191
+ # Turn N — marks messages[-2] and messages[-1]; server caches prefix up to [-1]
192
+ # Turn N+1 — messages[-2] is Turn N's last message (still marked) → cache READ hit;
193
+ # messages[-1] is the new message (marked) → cache WRITE for Turn N+2
194
+ #
195
+ # With only 1 marker (old behavior): Turn N marks messages[-1]; in Turn N+1 that same
196
+ # message is now [-2] and carries no marker → server sees a different prefix → cache MISS.
197
+ #
198
+ # Compression instructions (system_injected: true) are skipped — we never want to cache
199
+ # those ephemeral injection messages.
191
200
  def apply_message_caching(messages)
192
201
  return messages if messages.empty?
193
202
 
194
- cache_index = if is_compression_instruction?(messages.last)
195
- [messages.length - 2, 0].max
196
- else
197
- messages.length - 1
198
- end
203
+ # Collect up to 2 candidate indices from the tail, skipping compression instructions.
204
+ candidate_indices = []
205
+ (messages.length - 1).downto(0) do |i|
206
+ break if candidate_indices.length >= 2
207
+
208
+ candidate_indices << i unless is_compression_instruction?(messages[i])
209
+ end
199
210
 
200
211
  messages.map.with_index do |msg, idx|
201
- idx == cache_index ? add_cache_control_to_message(msg) : msg
212
+ candidate_indices.include?(idx) ? add_cache_control_to_message(msg) : msg
202
213
  end
203
214
  end
204
215
 
@@ -49,8 +49,8 @@ GIVEN_QRCODE_ID = QRCODE_ID_IDX ? ARGV[QRCODE_ID_IDX + 1] : nil
49
49
  # Logging (suppress in --fetch-qr mode so stdout is clean JSON)
50
50
  # ---------------------------------------------------------------------------
51
51
 
52
- def step(msg) = $stderr.puts("[weixin-setup] #{msg}") unless FETCH_QR_MODE
53
- def ok(msg) = $stderr.puts("[weixin-setup] ✅ #{msg}") unless FETCH_QR_MODE
52
+ def step(msg); $stderr.puts("[weixin-setup] #{msg}") unless FETCH_QR_MODE; end
53
+ def ok(msg); $stderr.puts("[weixin-setup] ✅ #{msg}") unless FETCH_QR_MODE; end
54
54
 
55
55
  # In fetch-qr mode, write to stderr so stdout stays clean JSON
56
56
  def log(msg)
@@ -194,7 +194,7 @@ module Clacky
194
194
 
195
195
  def load_config
196
196
  return {} unless File.exist?(BROWSER_CONFIG_PATH)
197
- YAML.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol]) || {}
197
+ YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol]) || {}
198
198
  rescue StandardError => e
199
199
  Clacky::Logger.warn("[BrowserManager] Failed to read browser.yml: #{e.message}")
200
200
  {}
@@ -37,7 +37,10 @@ module Clacky
37
37
  # Raised for non-zero API return codes or HTTP errors.
38
38
  class ApiError < StandardError
39
39
  attr_reader :code
40
- def initialize(code, msg) = (@code = code; super("WeixinApiError(#{code}): #{msg.to_s.slice(0, 200)}"))
40
+ def initialize(code, msg)
41
+ @code = code
42
+ super("WeixinApiError(#{code}): #{msg.to_s.slice(0, 200)}")
43
+ end
41
44
  end
42
45
 
43
46
  # Raised on network/read timeouts.
@@ -38,7 +38,7 @@ module Clacky
38
38
  # @return [ChannelConfig]
39
39
  def self.load(config_file = CONFIG_FILE)
40
40
  if File.exist?(config_file)
41
- data = YAML.safe_load(File.read(config_file), permitted_classes: [Symbol]) || {}
41
+ data = YAMLCompat.safe_load(File.read(config_file), permitted_classes: [Symbol]) || {}
42
42
  else
43
43
  data = {}
44
44
  end
@@ -75,7 +75,7 @@ module Clacky
75
75
 
76
76
  # Ignore all other UI methods (progress, errors, etc.) during history replay
77
77
  def method_missing(name, *args, **kwargs); end
78
- def respond_to_missing?(name, include_private = false) = true
78
+ def respond_to_missing?(name, include_private = false); true; end
79
79
  end
80
80
 
81
81
  # HttpServer runs an embedded WEBrick HTTP server with WebSocket support.
@@ -244,7 +244,7 @@ module Clacky
244
244
  private def load_schedules
245
245
  return [] unless File.exist?(SCHEDULES_FILE)
246
246
 
247
- data = YAML.load_file(SCHEDULES_FILE, permitted_classes: [Symbol])
247
+ data = YAMLCompat.load_file(SCHEDULES_FILE, permitted_classes: [Symbol])
248
248
  Array(data)
249
249
  rescue => e
250
250
  Clacky::Logger.error("scheduler_load_schedules_error", error: e)
@@ -234,7 +234,7 @@ module Clacky
234
234
  end
235
235
 
236
236
  private def browser_enabled?
237
- config = YAML.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
237
+ config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
238
238
  config.is_a?(Hash) && config["enabled"] == true
239
239
  end
240
240
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.11"
4
+ VERSION = "0.9.12"
5
5
  end
data/lib/clacky.rb CHANGED
@@ -1,5 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # ── Ruby < 2.7 polyfills ──────────────────────────────────────────────────────
4
+
5
+ # Enumerable#filter_map was added in Ruby 2.7.
6
+ if RUBY_VERSION < "2.7"
7
+ module Enumerable
8
+ def filter_map(&block)
9
+ return to_enum(:filter_map) unless block
10
+
11
+ each_with_object([]) do |item, result|
12
+ mapped = block.call(item)
13
+ result << mapped if mapped
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ # File.absolute_path? was added in Ruby 2.7.
20
+ # Polyfill: a path is absolute if it starts with "/" (Unix) or a drive letter (Windows).
21
+ unless File.respond_to?(:absolute_path?)
22
+ def File.absolute_path?(path)
23
+ File.expand_path(path) == path.to_s
24
+ end
25
+ end
26
+
27
+ # URI.encode_uri_component was added in Ruby 3.2.
28
+ # CGI.escape encodes spaces as '+'; replace with '%20' to match URI encoding.
29
+ require "uri"
30
+ require "cgi"
31
+ unless URI.respond_to?(:encode_uri_component)
32
+ def URI.encode_uri_component(str)
33
+ CGI.escape(str.to_s).gsub("+", "%20")
34
+ end
35
+ end
36
+
37
+ # YAML.safe_load with permitted_classes: keyword was added in Psych 4 (Ruby 3.1).
38
+ # On older Ruby, the second positional argument serves the same purpose.
39
+ # This helper provides a unified interface across Ruby versions.
40
+ module YAMLCompat
41
+ def self.safe_load(yaml_string, permitted_classes: [])
42
+ if Psych::VERSION >= "4.0"
43
+ YAML.safe_load(yaml_string, permitted_classes: permitted_classes)
44
+ else
45
+ YAML.safe_load(yaml_string, permitted_classes)
46
+ end
47
+ end
48
+
49
+ def self.load_file(path, permitted_classes: [])
50
+ safe_load(File.read(path), permitted_classes: permitted_classes)
51
+ end
52
+ end
53
+
3
54
  require_relative "clacky/version"
4
55
  require_relative "clacky/message_format/anthropic"
5
56
  require_relative "clacky/message_format/open_ai"