openclacky 0.9.11 → 0.9.13

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: d21f7af79e1b66e118c909432e379375359e2bf650099c7bd17669985411409b
4
+ data.tar.gz: 1f927c5ce98c2560773cd0a8011ef0d523c56788fe47e17aec3dc1f952d1cd5c
5
5
  SHA512:
6
- metadata.gz: 55fe30f89017da76ac2a9348fea1a1f4d701006d4c882878e09e915600ac80dcf72131d546b7e786c3588c1c848e47db4628478ce018e619a2d7781bd0cdbfe3
7
- data.tar.gz: a6923ec51d5a5d60c2a5bd07b7fa269a76087456ea5adf4d7b33a9dc1c8a43f043570a6d4c6f6309b24dc2ad60850dd4d993cc9444fd22c76445e3749952ca09
6
+ metadata.gz: a3a51590171a24e10a002d9c89ff5a46e17ff2b09080553e886f156ab53b9dca3a64050a80816678cdd8510c1aa3fe0158732139e07053503c366ddf17e8ea9d
7
+ data.tar.gz: 7c76475762cf8ecf506337ec80fb6aa12eac582d604f8245c801e1c6d89bea7d32f7e508e689a8a1f6bc6ed3f7ec9f7a2ce85682ffb33954d8e6e2178c1def1f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.13] - 2026-03-27
11
+
12
+ ### Added
13
+ - **Ruby 2.6 compatibility**: the gem now installs cleanly on Ruby 2.6 (including macOS system Ruby 2.6.x) — dependency version constraints for `faraday` and `rouge` are now capped so RubyGems automatically selects compatible versions on older Ruby environments
14
+
15
+ ### Fixed
16
+ - **WebSocket pure-Ruby replacement**: replaced the native WebSocket dependency with a pure-Ruby implementation to improve cross-platform compatibility
17
+ - **Ctrl+C warning in UI suppressed**: fixed a spurious warning printed to the terminal when pressing Ctrl+C in the interactive UI
18
+ - **Parser stderr pollution from Bundler warnings filtered**: Ruby/Bundler version warnings no longer contaminate parser error messages
19
+
20
+ ## [0.9.12] - 2026-03-27
21
+
22
+ ### Added
23
+ - **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
24
+
25
+ ### Improved
26
+ - **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
27
+ - **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
28
+ - **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
29
+
30
+ ### Fixed
31
+ - **Compression crash on frozen system prompt**: `MessageCompressor` now calls `.dup` on the system prompt before injecting the compression instruction, preventing `FrozenError` in long sessions
32
+
10
33
  ## [0.9.11] - 2026-03-25
11
34
 
12
35
  ### 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
@@ -102,16 +102,28 @@ module Clacky
102
102
 
103
103
  # Returns true when a heartbeat should be sent (interval elapsed).
104
104
  def heartbeat_due?
105
- return true if @license_last_heartbeat.nil?
105
+ if @license_last_heartbeat.nil?
106
+ Clacky::Logger.debug("[Brand] heartbeat_due? => true (never sent)")
107
+ return true
108
+ end
106
109
 
107
- (Time.now.utc - @license_last_heartbeat) >= HEARTBEAT_INTERVAL
110
+ elapsed = Time.now.utc - @license_last_heartbeat
111
+ due = elapsed >= HEARTBEAT_INTERVAL
112
+ Clacky::Logger.debug("[Brand] heartbeat_due? elapsed=#{elapsed.to_i}s interval=#{HEARTBEAT_INTERVAL}s => #{due}")
113
+ due
108
114
  end
109
115
 
110
116
  # Returns true when the grace period for missed heartbeats has expired.
111
117
  def grace_period_exceeded?
112
- return false if @license_last_heartbeat.nil?
118
+ if @license_last_heartbeat.nil?
119
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no heartbeat recorded)")
120
+ return false
121
+ end
113
122
 
114
- (Time.now.utc - @license_last_heartbeat) >= HEARTBEAT_GRACE_PERIOD
123
+ elapsed = Time.now.utc - @license_last_heartbeat
124
+ exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD
125
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
126
+ exceeded
115
127
  end
116
128
 
117
129
  # Returns true when the license is bound to a specific user (user_id present).
@@ -221,7 +233,12 @@ module Clacky
221
233
  # Send a heartbeat to the API and update last_heartbeat timestamp.
222
234
  # Returns a result hash: { success: bool, message: String }
223
235
  def heartbeat!
224
- return { success: false, message: "License not activated" } unless activated?
236
+ unless activated?
237
+ Clacky::Logger.debug("[Brand] heartbeat! skipped — license not activated")
238
+ return { success: false, message: "License not activated" }
239
+ end
240
+
241
+ Clacky::Logger.info("[Brand] heartbeat! sending — last_heartbeat=#{@license_last_heartbeat&.iso8601 || "nil"} expires_at=#{@license_expires_at&.iso8601 || "nil"}")
225
242
 
226
243
  user_id = parse_user_id_from_key(@license_key)
227
244
  key_hash = Digest::SHA256.hexdigest(@license_key)
@@ -246,8 +263,10 @@ module Clacky
246
263
  @license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
247
264
  apply_distribution(response[:data]["distribution"])
248
265
  save
266
+ Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}")
249
267
  { success: true, message: "Heartbeat OK" }
250
268
  else
269
+ Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]}")
251
270
  { success: false, message: response[:error] || "Heartbeat failed" }
252
271
  end
253
272
  end
@@ -1070,11 +1089,24 @@ module Clacky
1070
1089
  # @raise [RuntimeError] on decryption failure (wrong key, tampered data)
1071
1090
  private def aes_gcm_decrypt(key, ciphertext, iv_b64, tag_b64)
1072
1091
  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")
1092
+ require_relative "aes_gcm"
1093
+
1094
+ iv = Base64.strict_decode64(iv_b64)
1095
+ tag = Base64.strict_decode64(tag_b64)
1096
+
1097
+ # Try native OpenSSL AES-GCM first (fastest path; works on real OpenSSL).
1098
+ # LibreSSL 3.3.x has a known bug where AES-GCM raises CipherError even
1099
+ # for valid inputs, so we fall back to the pure-Ruby implementation.
1100
+ begin
1101
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
1102
+ cipher.key = key
1103
+ cipher.iv = iv
1104
+ cipher.auth_tag = tag
1105
+ (cipher.update(ciphertext) + cipher.final).force_encoding("UTF-8")
1106
+ rescue OpenSSL::Cipher::CipherError
1107
+ # Native GCM failed — use pure-Ruby fallback (LibreSSL-safe)
1108
+ Clacky::AesGcm.decrypt(key, iv, ciphertext, tag)
1109
+ end
1078
1110
  rescue OpenSSL::Cipher::CipherError => e
1079
1111
  raise "Decryption failed: #{e.message}. " \
1080
1112
  "The file may be corrupted or the license key is incorrect."
data/lib/clacky/cli.rb CHANGED
@@ -252,12 +252,16 @@ module Clacky
252
252
  brand = Clacky::BrandConfig.load
253
253
  return unless brand.branded?
254
254
 
255
+ Clacky::Logger.info("[Brand] check_brand_license_cli: activated=#{brand.activated?} expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
256
+
255
257
  unless brand.activated?
258
+ Clacky::Logger.info("[Brand] check_brand_license_cli: not activated, prompting user")
256
259
  cli_prompt_license_activation(brand)
257
260
  return
258
261
  end
259
262
 
260
263
  if brand.expired?
264
+ Clacky::Logger.warn("[Brand] check_brand_license_cli: license expired at #{brand.license_expires_at&.iso8601}")
261
265
  say ""
262
266
  say "WARNING: Your #{brand.product_name} license has expired. Please renew to continue.", :yellow
263
267
  say ""
@@ -265,17 +269,25 @@ module Clacky
265
269
  end
266
270
 
267
271
  if brand.heartbeat_due?
272
+ Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, sending...")
268
273
  result = brand.heartbeat!
269
- unless result[:success]
270
- if brand.grace_period_exceeded?
271
- say ""
272
- say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
273
- say "License has been offline for more than 3 days. Please check your connection.", :yellow
274
- say ""
275
- else
276
- say "(License heartbeat failed - will retry tomorrow.)", :cyan
274
+ if result[:success]
275
+ Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat OK")
276
+ else
277
+ Clacky::Logger.warn("[Brand] check_brand_license_cli: heartbeat failed #{result[:message]} grace_exceeded=#{brand.grace_period_exceeded?}")
278
+ unless result[:success]
279
+ if brand.grace_period_exceeded?
280
+ say ""
281
+ say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
282
+ say "License has been offline for more than 3 days. Please check your connection.", :yellow
283
+ say ""
284
+ else
285
+ say "(License heartbeat failed - will retry tomorrow.)", :cyan
286
+ end
277
287
  end
278
288
  end
289
+ else
290
+ Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
279
291
  end
280
292
  end
281
293
 
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
  {}