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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +5 -5
- data/clacky-legacy/clacky.gemspec +3 -3
- data/clacky-legacy/clarky.gemspec +3 -3
- data/docs/HOW-TO-USE-CN.md +2 -2
- data/docs/HOW-TO-USE.md +2 -2
- data/docs/install-script-simplification.md +89 -0
- data/docs/why-developer.md +2 -2
- data/docs/why-openclacky.md +2 -2
- data/homebrew/openclacky.rb +1 -1
- data/lib/clacky/aes_gcm.rb +205 -0
- data/lib/clacky/agent/cost_tracker.rb +0 -64
- data/lib/clacky/agent/message_compressor.rb +7 -2
- data/lib/clacky/agent/message_compressor_helper.rb +20 -6
- data/lib/clacky/agent/session_serializer.rb +7 -12
- data/lib/clacky/agent.rb +20 -1
- data/lib/clacky/brand_config.rb +18 -5
- data/lib/clacky/cli.rb +2 -0
- data/lib/clacky/client.rb +20 -9
- data/lib/clacky/default_skills/channel-setup/SKILL.md +47 -19
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +4 -16
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +2 -2
- data/lib/clacky/idle_compression_timer.rb +22 -7
- data/lib/clacky/message_history.rb +41 -0
- data/lib/clacky/providers.rb +2 -2
- data/lib/clacky/server/browser_manager.rb +29 -25
- 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/channel/channel_manager.rb +1 -1
- data/lib/clacky/server/http_server.rb +14 -9
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/server_master.rb +18 -11
- data/lib/clacky/server/session_registry.rb +45 -7
- data/lib/clacky/tools/browser.rb +189 -306
- data/lib/clacky/tools/shell.rb +119 -97
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/sessions.js +1 -0
- data/lib/clacky.rb +51 -0
- data/scripts/install.ps1 +180 -0
- data/scripts/install.sh +324 -442
- data/scripts/install_original.sh +891 -0
- metadata +26 -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
|
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
[](https://github.com/clacky-ai/openclacky/actions)
|
|
4
4
|
[](https://rubygems.org/gems/openclacky)
|
|
5
5
|
[](https://www.ruby-lang.org)
|
|
6
6
|
[](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/
|
|
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/
|
|
123
|
-
cd
|
|
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/
|
|
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/
|
|
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/
|
|
17
|
-
spec.metadata["changelog_uri"] = "https://github.com/clacky-ai/
|
|
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/
|
|
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/
|
|
17
|
-
spec.metadata["changelog_uri"] = "https://github.com/clacky-ai/
|
|
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"]
|
data/docs/HOW-TO-USE-CN.md
CHANGED
|
@@ -91,6 +91,6 @@ Clacky 可以自动执行复杂任务,内置多种工具:
|
|
|
91
91
|
|
|
92
92
|
## 了解更多
|
|
93
93
|
|
|
94
|
-
- GitHub:https://github.com/clacky-ai/
|
|
95
|
-
- 问题反馈:https://github.com/clacky-ai/
|
|
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/
|
|
93
|
-
- Report Issues: https://github.com/clacky-ai/
|
|
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 |
|
data/docs/why-developer.md
CHANGED
|
@@ -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/
|
|
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/
|
|
365
|
+
- **GitHub**: https://github.com/clacky-ai/openclacky
|
|
366
366
|
- **Documentation**: https://docs.clacky.ai
|
|
367
367
|
- **Discord**: https://discord.gg/clacky
|
|
368
368
|
|
data/docs/why-openclacky.md
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
|
data/homebrew/openclacky.rb
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|