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 +4 -4
- data/CHANGELOG.md +13 -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 +18 -5
- 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/weixin/api_client.rb +4 -1
- data/lib/clacky/server/channel/channel_config.rb +1 -1
- data/lib/clacky/server/http_server.rb +1 -1
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/tools/browser.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +51 -0
- data/scripts/install.sh +251 -415
- data/scripts/install_original.sh +891 -0
- metadata +25 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 791fa59b1013c559e294b373e1a7ba671545eef76ae7037ed3c604ed9f269b45
|
|
4
|
+
data.tar.gz: 8ffae20491125d886e36387b811f9ea8c2e2fcc76d76d10e08224bcc06a93972
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
|
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
|
{}
|
|
@@ -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)
|
|
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 =
|
|
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)
|
|
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 =
|
|
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)
|
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -234,7 +234,7 @@ module Clacky
|
|
|
234
234
|
end
|
|
235
235
|
|
|
236
236
|
private def browser_enabled?
|
|
237
|
-
config =
|
|
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
|
|
data/lib/clacky/version.rb
CHANGED
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"
|