openclacky 0.9.10 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +4 -4
  3. data/CHANGELOG.md +36 -0
  4. data/README.md +5 -5
  5. data/clacky-legacy/clacky.gemspec +3 -3
  6. data/clacky-legacy/clarky.gemspec +3 -3
  7. data/docs/HOW-TO-USE-CN.md +2 -2
  8. data/docs/HOW-TO-USE.md +2 -2
  9. data/docs/install-script-simplification.md +89 -0
  10. data/docs/why-developer.md +2 -2
  11. data/docs/why-openclacky.md +2 -2
  12. data/homebrew/openclacky.rb +1 -1
  13. data/lib/clacky/aes_gcm.rb +205 -0
  14. data/lib/clacky/agent/cost_tracker.rb +0 -64
  15. data/lib/clacky/agent/message_compressor.rb +7 -2
  16. data/lib/clacky/agent/message_compressor_helper.rb +20 -6
  17. data/lib/clacky/agent/session_serializer.rb +7 -12
  18. data/lib/clacky/agent.rb +20 -1
  19. data/lib/clacky/brand_config.rb +18 -5
  20. data/lib/clacky/cli.rb +2 -0
  21. data/lib/clacky/client.rb +20 -9
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +47 -19
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +4 -16
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +2 -2
  25. data/lib/clacky/idle_compression_timer.rb +22 -7
  26. data/lib/clacky/message_history.rb +41 -0
  27. data/lib/clacky/providers.rb +2 -2
  28. data/lib/clacky/server/browser_manager.rb +29 -25
  29. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +4 -1
  30. data/lib/clacky/server/channel/channel_config.rb +1 -1
  31. data/lib/clacky/server/channel/channel_manager.rb +1 -1
  32. data/lib/clacky/server/http_server.rb +14 -9
  33. data/lib/clacky/server/scheduler.rb +1 -1
  34. data/lib/clacky/server/server_master.rb +18 -11
  35. data/lib/clacky/server/session_registry.rb +45 -7
  36. data/lib/clacky/tools/browser.rb +189 -306
  37. data/lib/clacky/tools/shell.rb +119 -97
  38. data/lib/clacky/version.rb +1 -1
  39. data/lib/clacky/web/sessions.js +1 -0
  40. data/lib/clacky.rb +51 -0
  41. data/scripts/install.ps1 +180 -0
  42. data/scripts/install.sh +324 -442
  43. data/scripts/install_original.sh +891 -0
  44. metadata +26 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 938c620eb9886a37c1e190c5bf2d8c804a6ed86eb496332a26f5f225e832a5ec
4
- data.tar.gz: c165097e40b1bde5339e9819f7ccdd2a3191ecd24f9233a758a4a2406f5d74db
3
+ metadata.gz: 791fa59b1013c559e294b373e1a7ba671545eef76ae7037ed3c604ed9f269b45
4
+ data.tar.gz: 8ffae20491125d886e36387b811f9ea8c2e2fcc76d76d10e08224bcc06a93972
5
5
  SHA512:
6
- metadata.gz: 845fef0c86bca42adcb00d2fe34946acd3b72b2d750412a744e78a080cef34b653457ae98318ddad5b623f22325b654ad2a77a61d39abc544edbfd11f44891f3
7
- data.tar.gz: ea770a8ac65695d64affd358b4af0788cccc2c4e70eb95d0afaebea61a639ba8829dbcf61af84d0d8229dac9b4af8e969316a5e5c8fb8ec75f9f430647f9b1cc
6
+ metadata.gz: 6895348aeef5d8ed713273a2d386e390a018e5b4991c37a7fd20b7fdbb4782fc945779b45556f31cb2a0894f78df72efc80ad4661b3b95d89cd8ab0c333b65a0
7
+ data.tar.gz: 01c35bb0a3377b81c4e3f89892d58b10472f9611347ed9b0478872e76e3c236def6b499475df32d6020c56fe94dcd33c11495512ee94a5a44a1c81aefbaaad3e
@@ -105,7 +105,7 @@ To use this skill, simply say:
105
105
  - Parse the CHANGELOG.md section for `[{version}]`
106
106
  - Write it to a temp file (e.g., `/tmp/release_notes_{version}.md`) to avoid shell escaping issues
107
107
  - Run `gh release create` with `--notes-file`
108
- - Verify the release appears at: `https://github.com/clacky-ai/open-clacky/releases`
108
+ - Verify the release appears at: `https://github.com/clacky-ai/openclacky/releases`
109
109
 
110
110
  > **Prerequisite**: `gh` CLI must be installed (`brew install gh`) and authenticated (`gh auth login`)
111
111
 
@@ -238,14 +238,14 @@ Present a clear, user-facing release summary after all steps complete:
238
238
 
239
239
  🔗 Links:
240
240
  - RubyGems: https://rubygems.org/gems/openclacky/versions/{version}
241
- - GitHub Release: https://github.com/clacky-ai/open-clacky/releases/tag/v{version}
241
+ - GitHub Release: https://github.com/clacky-ai/openclacky/releases/tag/v{version}
242
242
 
243
243
  ⬆️ Upgrade:
244
244
  - In the Clacky UI, click "Upgrade" in the bottom-left → detect new version → click upgrade → done
245
245
  - Manual upgrade (CLI): `gem update openclacky`
246
246
 
247
247
  🆕 Fresh install:
248
- /bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/open-clacky/main/scripts/install.sh)"
248
+ /bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh)"
249
249
  ```
250
250
 
251
251
  **Rules for writing the summary:**
@@ -308,7 +308,7 @@ gh release create vX.Y.Z \
308
308
  - New version successfully published to RubyGems
309
309
  - Git repository updated with version tag
310
310
  - CHANGELOG.md updated with release notes
311
- - GitHub Release created and visible at https://github.com/clacky-ai/open-clacky/releases
311
+ - GitHub Release created and visible at https://github.com/clacky-ai/openclacky/releases
312
312
  - No build or deployment errors
313
313
  - User-facing release summary presented at the end
314
314
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ 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
+
23
+ ## [0.9.11] - 2026-03-25
24
+
25
+ ### Added
26
+ - **Network-aware installer mirrors**: the install script now automatically detects whether you're in China and picks the fastest mirror (RubyGems China mirror, GitHub, etc.) — no manual configuration needed
27
+ - **Shell rc-file loading**: the shell tool now sources your `.zshrc` / `.bashrc` so commands that depend on environment variables or aliases set in your shell profile work correctly
28
+
29
+ ### Improved
30
+ - **Browser tool `evaluate` targets active page**: JavaScript evaluation now automatically targets the currently active browser tab instead of the last opened one, so `evaluate` always runs in the right context
31
+ - **Browser MCP process cleaned up on server shutdown**: the `chrome-devtools-mcp` node process is now stopped when the server shuts down, preventing orphaned processes that held onto port 7070
32
+ - **Server worker process isolation**: workers are now spawned in their own process group, ensuring grandchild processes (e.g. browser MCP) are fully cleaned up during zero-downtime restarts
33
+ - **Channel status via live API**: `channel status` now queries the running server API instead of reading `~/.clacky/channels.yml` directly, so it reflects the actual runtime state
34
+ - **Idle compression timer race fix**: the compression thread is now registered inside a mutex before starting, eliminating a race where `cancel()` could miss an in-flight compression and leave history in an inconsistent state
35
+ - **Compression token display accuracy**: the post-compression token count now uses the rebuilt history estimate instead of the stale pre-compression API count
36
+ - **Shell process group signals**: `SIGTERM`/`SIGKILL` are now sent to the entire process group (`-pgid`) instead of just the child PID, ensuring backgrounded subprocesses are also killed on timeout
37
+
38
+ ### Fixed
39
+ - **Task error session save**: sessions are now correctly saved to disk even when a task ends with an error, preventing session loss on agent failures
40
+ - **History load and model load bugs**: fixed crashes when loading sessions with missing or malformed history/model fields
41
+ - **Default model updated to Claude claude-sonnet-4-6**: bumped the default Gemini model reference from `gemini-2.5-flash` → `gemini-2.7-flash`
42
+
43
+ ### More
44
+ - Renamed gem references from `open-clacky` to `openclacky` across docs, gemspec, and scripts
45
+
10
46
  ## [0.9.10] - 2026-03-24
11
47
 
12
48
  ### Added
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenClacky
2
2
 
3
- [![Build](https://img.shields.io/github/actions/workflow/status/clacky-ai/open-clacky/main.yml?label=build&style=flat-square)](https://github.com/clacky-ai/open-clacky/actions)
3
+ [![Build](https://img.shields.io/github/actions/workflow/status/clacky-ai/openclacky/main.yml?label=build&style=flat-square)](https://github.com/clacky-ai/openclacky/actions)
4
4
  [![Release](https://img.shields.io/gem/v/openclacky?label=release&style=flat-square&color=blue)](https://rubygems.org/gems/openclacky)
5
5
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-red?style=flat-square)](https://www.ruby-lang.org)
6
6
  [![Downloads](https://img.shields.io/gem/dt/openclacky?label=downloads&style=flat-square&color=brightgreen)](https://rubygems.org/gems/openclacky)
@@ -73,7 +73,7 @@ Built on a production-ready Rails architecture with one-click deployment, dev/pr
73
73
  ### Method 1: One-line Install (Recommended)
74
74
 
75
75
  ```bash
76
- /bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/open-clacky/main/scripts/install.sh)"
76
+ /bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh)"
77
77
  ```
78
78
 
79
79
  ### Method 2: RubyGems
@@ -119,15 +119,15 @@ You'll be prompted to set your **API Key**, **Model**, and **Base URL** (any Ope
119
119
  ## Install from Source
120
120
 
121
121
  ```bash
122
- git clone https://github.com/clacky-ai/open-clacky.git
123
- cd open-clacky
122
+ git clone https://github.com/clacky-ai/openclacky.git
123
+ cd openclacky
124
124
  bundle install
125
125
  bin/clacky
126
126
  ```
127
127
 
128
128
  ## Contributing
129
129
 
130
- Bug reports and pull requests are welcome on GitHub at https://github.com/clacky-ai/open-clacky. Contributors are expected to adhere to the [code of conduct](https://github.com/clacky-ai/open-clacky/blob/main/CODE_OF_CONDUCT.md).
130
+ Bug reports and pull requests are welcome on GitHub at https://github.com/clacky-ai/openclacky. Contributors are expected to adhere to the [code of conduct](https://github.com/clacky-ai/openclacky/blob/main/CODE_OF_CONDUCT.md).
131
131
 
132
132
  ## License
133
133
 
@@ -8,13 +8,13 @@ Gem::Specification.new do |spec|
8
8
 
9
9
  spec.summary = "Legacy name for openclacky gem"
10
10
  spec.description = "This is a transitional gem that depends on openclacky. The clacky project has been renamed to openclacky. Installing this gem will automatically install openclacky."
11
- spec.homepage = "https://github.com/clacky-ai/open-clacky"
11
+ spec.homepage = "https://github.com/clacky-ai/openclacky"
12
12
  spec.license = "MIT"
13
13
  spec.required_ruby_version = ">= 3.1.0"
14
14
 
15
15
  spec.metadata["homepage_uri"] = spec.homepage
16
- spec.metadata["source_code_uri"] = "https://github.com/clacky-ai/open-clacky"
17
- spec.metadata["changelog_uri"] = "https://github.com/clacky-ai/open-clacky/blob/main/CHANGELOG.md"
16
+ spec.metadata["source_code_uri"] = "https://github.com/clacky-ai/openclacky"
17
+ spec.metadata["changelog_uri"] = "https://github.com/clacky-ai/openclacky/blob/main/CHANGELOG.md"
18
18
 
19
19
  spec.files = Dir["lib/**/*", "bin/*", "README.md", "LICENSE.txt"]
20
20
  spec.require_paths = ["lib"]
@@ -8,13 +8,13 @@ Gem::Specification.new do |spec|
8
8
 
9
9
  spec.summary = "Legacy name for openclacky - AI agent command-line interface"
10
10
  spec.description = "This is a placeholder gem. Installing 'clarky' will automatically install 'openclacky'. The clarky command is maintained for backward compatibility."
11
- spec.homepage = "https://github.com/clacky-ai/open-clacky"
11
+ spec.homepage = "https://github.com/clacky-ai/openclacky"
12
12
  spec.license = "MIT"
13
13
  spec.required_ruby_version = ">= 3.1.0"
14
14
 
15
15
  spec.metadata["homepage_uri"] = spec.homepage
16
- spec.metadata["source_code_uri"] = "https://github.com/clacky-ai/open-clacky"
17
- spec.metadata["changelog_uri"] = "https://github.com/clacky-ai/open-clacky/blob/main/CHANGELOG.md"
16
+ spec.metadata["source_code_uri"] = "https://github.com/clacky-ai/openclacky"
17
+ spec.metadata["changelog_uri"] = "https://github.com/clacky-ai/openclacky/blob/main/CHANGELOG.md"
18
18
 
19
19
  spec.files = Dir["lib/**/*", "bin/*", "README.md", "LICENSE.txt"]
20
20
  spec.require_paths = ["lib"]
@@ -91,6 +91,6 @@ Clacky 可以自动执行复杂任务,内置多种工具:
91
91
 
92
92
  ## 了解更多
93
93
 
94
- - GitHub:https://github.com/clacky-ai/open-clacky
95
- - 问题反馈:https://github.com/clacky-ai/open-clacky/issues
94
+ - GitHub:https://github.com/clacky-ai/openclacky
95
+ - 问题反馈:https://github.com/clacky-ai/openclacky/issues
96
96
  - 当前版本:0.7.0
data/docs/HOW-TO-USE.md CHANGED
@@ -89,6 +89,6 @@ Create your own skills in `.clacky/skills/` directory!
89
89
 
90
90
  ## Learn More
91
91
 
92
- - GitHub: https://github.com/clacky-ai/open-clacky
93
- - Report Issues: https://github.com/clacky-ai/open-clacky/issues
92
+ - GitHub: https://github.com/clacky-ai/openclacky
93
+ - Report Issues: https://github.com/clacky-ai/openclacky/issues
94
94
  - Version: 0.7.0
@@ -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 |
@@ -285,7 +285,7 @@ models:
285
285
 
286
286
  ```bash
287
287
  # One-line installation (macOS/Linux)
288
- curl -sSL https://raw.githubusercontent.com/clacky-ai/open-clacky/main/scripts/install.sh | bash
288
+ curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh | bash
289
289
 
290
290
  # Or via Ruby gem
291
291
  gem install openclacky
@@ -362,7 +362,7 @@ clacky tools
362
362
 
363
363
  ## Get Started
364
364
 
365
- - **GitHub**: https://github.com/clacky-ai/open-clacky
365
+ - **GitHub**: https://github.com/clacky-ai/openclacky
366
366
  - **Documentation**: https://docs.clacky.ai
367
367
  - **Discord**: https://discord.gg/clacky
368
368
 
@@ -211,7 +211,7 @@ We believe AI development tools should be accessible to everyone.
211
211
 
212
212
  ```bash
213
213
  # One-line installation (macOS/Linux)
214
- curl -sSL https://raw.githubusercontent.com/clacky-ai/open-clacky/main/scripts/install.sh | bash
214
+ curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh | bash
215
215
 
216
216
  # Or if you have Ruby 3.1+
217
217
  gem install openclacky
@@ -239,7 +239,7 @@ clacky tools
239
239
 
240
240
  Clacky is an open-source project. We welcome contributions!
241
241
 
242
- - **GitHub**: https://github.com/clacky-ai/open-clacky
242
+ - **GitHub**: https://github.com/clacky-ai/openclacky
243
243
  - **Discord**: https://discord.gg/clacky
244
244
  - **Twitter**: https://twitter.com/clacky_ai
245
245
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  class Openclacky < Formula
4
4
  desc "Command-line interface for AI models with autonomous agent capabilities"
5
- homepage "https://github.com/clacky-ai/open-clacky"
5
+ homepage "https://github.com/clacky-ai/openclacky"
6
6
  url "https://rubygems.org/downloads/openclacky-0.6.1.gem"
7
7
  sha256 "" # Will be updated when gem is published
8
8
  license "MIT"
@@ -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
@@ -82,71 +82,7 @@ module Clacky
82
82
 
83
83
  # Estimate token count for a message content
84
84
  # Simple approximation: characters / 4 (English text)
85
- # For Chinese/other languages, characters / 2 is more accurate
86
- # This is a rough estimate for compression triggering purposes
87
- # @param content [String, Array, Object] Message content
88
- # @return [Integer] Estimated token count
89
- def estimate_tokens(content)
90
- return 0 if content.nil?
91
-
92
- text = if content.is_a?(String)
93
- content
94
- elsif content.is_a?(Array)
95
- # Handle content arrays (e.g., with images)
96
- # Add safety check to prevent nil.compact error
97
- mapped = content.map { |c| c[:text] if c.is_a?(Hash) }
98
- (mapped || []).compact.join
99
- else
100
- content.to_s
101
- end
102
-
103
- return 0 if text.empty?
104
-
105
- # Detect language mix - count non-ASCII characters
106
- ascii_count = text.bytes.count { |b| b < 128 }
107
- total_bytes = text.bytes.length
108
-
109
- # Mix ratio (1.0 = all English, 0.5 = all Chinese)
110
- mix_ratio = total_bytes > 0 ? ascii_count.to_f / total_bytes : 1.0
111
-
112
- # English: ~4 chars/token, Chinese: ~2 chars/token
113
- base_chars_per_token = mix_ratio * 4 + (1 - mix_ratio) * 2
114
-
115
- (text.length / base_chars_per_token).to_i + 50 # Add overhead for message structure
116
- end
117
-
118
- # Calculate total token count for all messages
119
- # Returns estimated tokens and breakdown by category
120
- # @return [Hash] Token counts by role and total
121
- def total_message_tokens
122
- system_tokens = 0
123
- user_tokens = 0
124
- assistant_tokens = 0
125
- tool_tokens = 0
126
- summary_tokens = 0
127
-
128
- @history.to_a.each do |msg|
129
- tokens = estimate_tokens(msg[:content])
130
- case msg[:role]
131
- when "system"
132
- system_tokens += tokens
133
- when "user"
134
- user_tokens += tokens
135
- when "assistant"
136
- assistant_tokens += tokens
137
- when "tool"
138
- tool_tokens += tokens
139
- end
140
- end
141
85
 
142
- {
143
- total: system_tokens + user_tokens + assistant_tokens + tool_tokens,
144
- system: system_tokens,
145
- user: user_tokens,
146
- assistant: assistant_tokens,
147
- tool: tool_tokens
148
- }
149
- end
150
86
 
151
87
  private
152
88
 
@@ -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