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 +4 -4
- data/CHANGELOG.md +23 -0
- data/docs/install-script-simplification.md +89 -0
- data/lib/clacky/aes_gcm.rb +205 -0
- data/lib/clacky/agent/message_compressor.rb +7 -2
- data/lib/clacky/agent/message_compressor_helper.rb +16 -0
- data/lib/clacky/agent.rb +3 -1
- data/lib/clacky/brand_config.rb +42 -10
- data/lib/clacky/cli.rb +20 -8
- data/lib/clacky/client.rb +20 -9
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +2 -2
- data/lib/clacky/server/browser_manager.rb +1 -1
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +43 -33
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +41 -34
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +4 -1
- data/lib/clacky/server/channel/channel_config.rb +1 -1
- data/lib/clacky/server/http_server.rb +75 -47
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/tools/browser.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +3 -0
- data/lib/clacky/utils/parser_manager.rb +4 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +51 -0
- data/scripts/install.sh +343 -198
- data/scripts/install_simple.sh +582 -0
- metadata +50 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d21f7af79e1b66e118c909432e379375359e2bf650099c7bd17669985411409b
|
|
4
|
+
data.tar.gz: 1f927c5ce98c2560773cd0a8011ef0d523c56788fe47e17aec3dc1f952d1cd5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -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
|
-
|
|
105
|
+
if @license_last_heartbeat.nil?
|
|
106
|
+
Clacky::Logger.debug("[Brand] heartbeat_due? => true (never sent)")
|
|
107
|
+
return true
|
|
108
|
+
end
|
|
106
109
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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)
|
|
53
|
-
def ok(msg)
|
|
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
|
-
|
|
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
|
{}
|