openclacky 1.1.1 → 1.1.2
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 +21 -0
- data/README.md +28 -7
- data/lib/clacky/agent/llm_caller.rb +23 -1
- data/lib/clacky/agent/session_serializer.rb +6 -1
- data/lib/clacky/agent.rb +14 -5
- data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
- data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
- data/lib/clacky/cli.rb +9 -2
- data/lib/clacky/client.rb +146 -17
- data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
- data/lib/clacky/openai_stream_aggregator.rb +130 -0
- data/lib/clacky/server/http_server.rb +2 -3
- data/lib/clacky/server/web_ui_controller.rb +8 -4
- data/lib/clacky/ui2/progress_handle.rb +77 -15
- data/lib/clacky/ui2/ui_controller.rb +4 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +6 -4
- data/lib/clacky/web/i18n.js +6 -0
- data/lib/clacky/web/index.html +3 -1
- data/lib/clacky/web/sessions.js +152 -48
- data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
- data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -4
- data/lib/clacky.rb +3 -0
- data/scripts/install.ps1 +14 -3
- metadata +28 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47f1d9f1ecb4338e0a19ce771dea37724ebfc70d9e0052dc7403e096a25c415d
|
|
4
|
+
data.tar.gz: 6887db06529972b393c75c92e17b47da8294abae765ceff7280d71943a160c85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b49465d66eb2be634790d10813ac5988aca23f54a4d3c36c9690bd40bf0024ea5f7275b3bc1b89247aaa16cc30dbf1c1439733c51bdfc1f5750e02c7efbc8e80
|
|
7
|
+
data.tar.gz: f0f4397dd4cca183f199545e7c5db6b4902382c66c237257e8b2a99847e49702881dd8bcf60a78201ee18526ae2b04bd4217bf6def502ccf3ce8a7fb6fb0bdd0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.2] - 2026-05-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Streaming response with real-time token display in WebUI
|
|
12
|
+
- Stream thinking progress indicator during agent reasoning
|
|
13
|
+
- Time-to-first-token (TTFT) display in WebUI
|
|
14
|
+
- LaTeX rendering support in WebUI
|
|
15
|
+
- Cache hit rate display in WebUI
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Reasoning content properly passed as `<think>` tags to WebUI
|
|
19
|
+
- User-set session name no longer overwritten by auto-rename (#136)
|
|
20
|
+
- Server command now supports `--help`/`-h`/`--bind`/`-b` and `-p` alias for `--port` (#135)
|
|
21
|
+
- WSL.exe output encoding and premature WSL1 fallback detection (#130)
|
|
22
|
+
- Hide edit/delete model options when no models are configured (#133)
|
|
23
|
+
- BrowserManager MCP process cleanup on agent exit in CLI mode (#132)
|
|
24
|
+
- Windows-native OpenClaw config detection on WSL during onboarding (#129)
|
|
25
|
+
|
|
26
|
+
### More
|
|
27
|
+
- Updated Windows installation docs and added GitHub star history
|
|
28
|
+
|
|
8
29
|
## [1.1.1] - 2026-05-17
|
|
9
30
|
|
|
10
31
|
### Added
|
data/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Same task, how much do you pay? Under comparable agent workloads, OpenClacky sav
|
|
|
18
18
|
|
|
19
19
|
| Agent | Relative cost | Notes |
|
|
20
20
|
|---|---|---|
|
|
21
|
-
| **OpenClacky** | **~0.8
|
|
21
|
+
| **OpenClacky** | **~0.8** | 16 tools · ~100% cache hit · subagent routing |
|
|
22
22
|
| Claude Code | 1.0× (baseline) | World-class harness, closed-source subscription |
|
|
23
23
|
| OpenClaw | ~1.5× | Comparable harness agent |
|
|
24
24
|
| Hermes | ~3× | 52 built-in tools — schema bloat ~3–4× |
|
|
@@ -31,11 +31,11 @@ Core agent capability is roughly on par across the field — the real differenti
|
|
|
31
31
|
|
|
32
32
|
| Feature | Claude Code | OpenClaw | Hermes | **OpenClacky** |
|
|
33
33
|
|---|:---:|:---:|:---:|:---:|
|
|
34
|
-
| Token cost | 1.0× | ~1.5× | ~3× | **~0.8
|
|
34
|
+
| Token cost | 1.0× | ~1.5× | ~3× | **~0.8** |
|
|
35
35
|
| Open source | ❌ Closed | ✅ Open | ✅ Open | ✅ MIT |
|
|
36
36
|
| BYOK / model freedom | ❌ Anthropic only | ✅ | ✅ | ✅ |
|
|
37
37
|
| Skill self-evolution | ❌ | ❌ | ✅ | ✅ |
|
|
38
|
-
| IM integration (Feishu
|
|
38
|
+
| IM integration (Feishu/WeCom/WeChat/Discord/Telegram) | ❌ | ✅ | ✅ | ✅ |
|
|
39
39
|
|
|
40
40
|
## How we get the cost down
|
|
41
41
|
|
|
@@ -80,18 +80,29 @@ More options: https://www.openclacky.com/
|
|
|
80
80
|
|
|
81
81
|
### Command line
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
One-line install(Mac/Ubuntu):
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
|
-
|
|
86
|
+
/bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh)"
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
Windows:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
|
|
92
|
+
powershell -c "& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.ps1')))"
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
or using Ruby(3.x/4.x):
|
|
96
|
+
|
|
97
|
+
**Requirements:** Ruby >= 3.1.0
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
gem install openclacky
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
see more: https://www.openclacky.com/docs/installation
|
|
104
|
+
|
|
105
|
+
|
|
95
106
|
## Quick Start
|
|
96
107
|
|
|
97
108
|
### Terminal (CLI)
|
|
@@ -137,6 +148,16 @@ $ openclacky
|
|
|
137
148
|
> How does the payment module work?
|
|
138
149
|
```
|
|
139
150
|
|
|
151
|
+
## Star History
|
|
152
|
+
|
|
153
|
+
<a href="https://www.star-history.com/?repos=clacky-ai%2Fopenclacky&type=date&legend=top-left">
|
|
154
|
+
<picture>
|
|
155
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&theme=dark&legend=top-left" />
|
|
156
|
+
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
|
|
157
|
+
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=clacky-ai/openclacky&type=date&legend=top-left" />
|
|
158
|
+
</picture>
|
|
159
|
+
</a>
|
|
160
|
+
|
|
140
161
|
## Advanced — Creator Program
|
|
141
162
|
|
|
142
163
|
Already power users are turning their workflows into vertical AI experts on OpenClacky — encrypted distribution, License management, self-set pricing. Legal, healthcare, financial planning, and more.
|
|
@@ -103,7 +103,8 @@ module Clacky
|
|
|
103
103
|
model: current_model,
|
|
104
104
|
tools: tools_to_send,
|
|
105
105
|
max_tokens: @config.max_tokens,
|
|
106
|
-
enable_caching: @config.enable_prompt_caching
|
|
106
|
+
enable_caching: @config.enable_prompt_caching,
|
|
107
|
+
on_chunk: build_progress_on_chunk
|
|
107
108
|
)
|
|
108
109
|
|
|
109
110
|
# Successful response — if we were probing, confirm primary is healthy.
|
|
@@ -748,6 +749,27 @@ module Clacky
|
|
|
748
749
|
"Upstream response was truncated mid tool-call — asking model to use smaller steps and retrying..."
|
|
749
750
|
)
|
|
750
751
|
end
|
|
752
|
+
|
|
753
|
+
# Build a streaming progress callback for Client#send_messages_with_tools.
|
|
754
|
+
# Returns nil when no UI is attached, so the client skips the streaming
|
|
755
|
+
# plumbing entirely. Callback throttles UI updates to avoid flooding the
|
|
756
|
+
# progress handle on fast streams.
|
|
757
|
+
private def build_progress_on_chunk
|
|
758
|
+
return nil unless @ui
|
|
759
|
+
|
|
760
|
+
last_emit_at = 0.0
|
|
761
|
+
min_interval = 0.25
|
|
762
|
+
->(input_tokens:, output_tokens:) {
|
|
763
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
764
|
+
return if now - last_emit_at < min_interval && output_tokens > 0
|
|
765
|
+
last_emit_at = now
|
|
766
|
+
@ui.show_progress(
|
|
767
|
+
progress_type: "thinking",
|
|
768
|
+
phase: "active",
|
|
769
|
+
metadata: { input_tokens: input_tokens, output_tokens: output_tokens }
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
end
|
|
751
773
|
end
|
|
752
774
|
end
|
|
753
775
|
end
|
|
@@ -471,8 +471,13 @@ module Clacky
|
|
|
471
471
|
|
|
472
472
|
case msg[:role].to_s
|
|
473
473
|
when "assistant"
|
|
474
|
-
# Text content
|
|
474
|
+
# Text content — prepend reasoning/thinking content wrapped in <think> tags
|
|
475
|
+
# so the Web UI renders it as a collapsible thinking block
|
|
475
476
|
text = extract_text_from_content(msg[:content]).to_s.strip
|
|
477
|
+
reasoning = msg[:reasoning_content]
|
|
478
|
+
if reasoning && !reasoning.to_s.strip.empty?
|
|
479
|
+
text = "<think>\n#{reasoning}\n</think>\n#{text}"
|
|
480
|
+
end
|
|
476
481
|
ui.show_assistant_message(text, files: []) unless text.empty?
|
|
477
482
|
|
|
478
483
|
# Tool calls embedded in assistant message
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -427,7 +427,7 @@ module Clacky
|
|
|
427
427
|
tool_calls_count: (response[:tool_calls] || []).size
|
|
428
428
|
)
|
|
429
429
|
if response[:content] && !response[:content].empty?
|
|
430
|
-
emit_assistant_message(response[:content])
|
|
430
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
|
|
431
431
|
end
|
|
432
432
|
|
|
433
433
|
# Show token usage after the assistant message so WebUI renders it below the bubble
|
|
@@ -448,7 +448,7 @@ module Clacky
|
|
|
448
448
|
|
|
449
449
|
# Show assistant message if there's content before tool calls
|
|
450
450
|
if response[:content] && !response[:content].empty?
|
|
451
|
-
emit_assistant_message(response[:content])
|
|
451
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
|
|
452
452
|
end
|
|
453
453
|
|
|
454
454
|
# Show token usage after assistant message (or immediately if no message).
|
|
@@ -1532,11 +1532,20 @@ module Clacky
|
|
|
1532
1532
|
# and cannot load file:// directly) and must stay scoped to the Web UI
|
|
1533
1533
|
# controller. IM channel subscribers need the original file:// markdown so
|
|
1534
1534
|
# parse_file_links can extract paths and deliver images as native attachments.
|
|
1535
|
-
private def emit_assistant_message(content)
|
|
1536
|
-
|
|
1535
|
+
private def emit_assistant_message(content, reasoning_content: nil)
|
|
1536
|
+
# Prepend reasoning/thinking content (from thinking-mode providers like
|
|
1537
|
+
# DeepSeek V4, Kimi K2) wrapped in <think> tags so the Web UI renders it
|
|
1538
|
+
# as a collapsible thinking block (see sessions.js _renderMarkdown).
|
|
1539
|
+
if reasoning_content && !reasoning_content.to_s.strip.empty?
|
|
1540
|
+
full_content = "<think>\n#{reasoning_content}\n</think>\n#{content}"
|
|
1541
|
+
else
|
|
1542
|
+
full_content = content
|
|
1543
|
+
end
|
|
1544
|
+
|
|
1545
|
+
return if full_content.nil? || full_content.to_s.strip.empty?
|
|
1537
1546
|
|
|
1538
1547
|
parsed = parse_file_links(content)
|
|
1539
|
-
@ui&.show_assistant_message(
|
|
1548
|
+
@ui&.show_assistant_message(full_content, files: parsed[:files])
|
|
1540
1549
|
end
|
|
1541
1550
|
|
|
1542
1551
|
# Track modified files for Time Machine snapshots
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# Reassembles an Anthropic Messages SSE stream (event: message_start /
|
|
7
|
+
# content_block_start / content_block_delta / content_block_stop /
|
|
8
|
+
# message_delta / message_stop / ping) into the same hash shape that
|
|
9
|
+
# MessageFormat::Anthropic.parse_response expects from a non-streaming
|
|
10
|
+
# response, while invoking on_chunk(input_tokens:, output_tokens:) as
|
|
11
|
+
# usage accumulates.
|
|
12
|
+
#
|
|
13
|
+
# Wire reference: https://docs.anthropic.com/en/api/messages-streaming
|
|
14
|
+
class AnthropicStreamAggregator
|
|
15
|
+
def initialize(on_chunk: nil)
|
|
16
|
+
@on_chunk = on_chunk
|
|
17
|
+
@blocks = {}
|
|
18
|
+
@stop_reason = nil
|
|
19
|
+
@usage = {}
|
|
20
|
+
@last_input_tokens = 0
|
|
21
|
+
@last_output_tokens = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handle(event, data_str)
|
|
25
|
+
data = parse_or_nil(data_str)
|
|
26
|
+
return unless data
|
|
27
|
+
|
|
28
|
+
case event
|
|
29
|
+
when "message_start"
|
|
30
|
+
msg = data["message"] || {}
|
|
31
|
+
if (u = msg["usage"])
|
|
32
|
+
@usage.merge!(u)
|
|
33
|
+
emit_usage_progress
|
|
34
|
+
end
|
|
35
|
+
when "content_block_start"
|
|
36
|
+
idx = data["index"] || @blocks.size
|
|
37
|
+
cb = data["content_block"] || {}
|
|
38
|
+
case cb["type"]
|
|
39
|
+
when "tool_use"
|
|
40
|
+
@blocks[idx] = { kind: :tool_use, id: cb["id"], name: cb["name"], input_str: +"" }
|
|
41
|
+
else
|
|
42
|
+
@blocks[idx] = { kind: :text, text: +"" }
|
|
43
|
+
end
|
|
44
|
+
when "content_block_delta"
|
|
45
|
+
idx = data["index"] || 0
|
|
46
|
+
delta = data["delta"] || {}
|
|
47
|
+
block = (@blocks[idx] ||= { kind: :text, text: +"" })
|
|
48
|
+
case delta["type"]
|
|
49
|
+
when "text_delta"
|
|
50
|
+
block[:kind] ||= :text
|
|
51
|
+
block[:text] ||= +""
|
|
52
|
+
block[:text] << delta["text"].to_s
|
|
53
|
+
when "input_json_delta"
|
|
54
|
+
block[:kind] = :tool_use
|
|
55
|
+
block[:input_str] ||= +""
|
|
56
|
+
block[:input_str] << delta["partial_json"].to_s
|
|
57
|
+
when "thinking_delta"
|
|
58
|
+
block[:kind] = :thinking
|
|
59
|
+
block[:thinking] ||= +""
|
|
60
|
+
block[:thinking] << delta["thinking"].to_s
|
|
61
|
+
end
|
|
62
|
+
emit_estimate_progress
|
|
63
|
+
when "content_block_stop"
|
|
64
|
+
# Nothing to do: blocks are finalised in to_h.
|
|
65
|
+
when "message_delta"
|
|
66
|
+
if (d = data["delta"])
|
|
67
|
+
@stop_reason = d["stop_reason"] if d["stop_reason"]
|
|
68
|
+
end
|
|
69
|
+
if (u = data["usage"])
|
|
70
|
+
@usage.merge!(u)
|
|
71
|
+
emit_usage_progress
|
|
72
|
+
end
|
|
73
|
+
when "message_stop", "ping", "error"
|
|
74
|
+
# no-op
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Canonical non-streaming Anthropic response shape consumed by
|
|
79
|
+
# MessageFormat::Anthropic.parse_response.
|
|
80
|
+
def to_h
|
|
81
|
+
content_blocks = @blocks.keys.sort.map do |idx|
|
|
82
|
+
b = @blocks[idx]
|
|
83
|
+
case b[:kind]
|
|
84
|
+
when :tool_use
|
|
85
|
+
input_value =
|
|
86
|
+
if b[:input_str].to_s.empty?
|
|
87
|
+
{}
|
|
88
|
+
else
|
|
89
|
+
JSON.parse(b[:input_str]) rescue b[:input_str]
|
|
90
|
+
end
|
|
91
|
+
{ "type" => "tool_use", "id" => b[:id], "name" => b[:name], "input" => input_value }
|
|
92
|
+
else
|
|
93
|
+
{ "type" => "text", "text" => b[:text].to_s }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
{ "content" => content_blocks, "stop_reason" => @stop_reason, "usage" => @usage }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private def parse_or_nil(s)
|
|
101
|
+
JSON.parse(s)
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private def emit_usage_progress
|
|
107
|
+
return unless @on_chunk
|
|
108
|
+
input = @usage["input_tokens"].to_i + @usage["cache_read_input_tokens"].to_i
|
|
109
|
+
output = @usage["output_tokens"].to_i
|
|
110
|
+
return if input == @last_input_tokens && output == @last_output_tokens
|
|
111
|
+
@last_input_tokens = input
|
|
112
|
+
@last_output_tokens = output
|
|
113
|
+
@on_chunk.call(input_tokens: input, output_tokens: output)
|
|
114
|
+
rescue => e
|
|
115
|
+
Clacky::Logger.warn("[AnthropicStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private def emit_estimate_progress
|
|
119
|
+
return unless @on_chunk
|
|
120
|
+
output = approximate_output_tokens
|
|
121
|
+
return if output == @last_output_tokens
|
|
122
|
+
@last_output_tokens = output
|
|
123
|
+
@on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
|
|
124
|
+
rescue => e
|
|
125
|
+
Clacky::Logger.warn("[AnthropicStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def approximate_output_tokens
|
|
129
|
+
total_chars = @blocks.values.sum do |b|
|
|
130
|
+
b[:text].to_s.bytesize + b[:input_str].to_s.bytesize + b[:thinking].to_s.bytesize
|
|
131
|
+
end
|
|
132
|
+
(total_chars / 4.0).ceil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# Reassembles a Bedrock Converse event stream into the same hash shape that
|
|
7
|
+
# MessageFormat::Bedrock.parse_response expects from a non-streaming response,
|
|
8
|
+
# while invoking on_chunk(input_tokens:, output_tokens:) as usage information
|
|
9
|
+
# accumulates.
|
|
10
|
+
#
|
|
11
|
+
# Bedrock event-stream events handled (passed through as raw event JSON):
|
|
12
|
+
#
|
|
13
|
+
# messageStart → { role: "assistant" }
|
|
14
|
+
# contentBlockStart → { start: {toolUse: {toolUseId, name}} | {}, contentBlockIndex: N }
|
|
15
|
+
# contentBlockDelta → { delta: {text: "..."} | {toolUse: {input: "..."}}, contentBlockIndex: N }
|
|
16
|
+
# contentBlockStop → { contentBlockIndex: N }
|
|
17
|
+
# messageStop → { stopReason: "end_turn" | "tool_use" | "max_tokens" | ... }
|
|
18
|
+
# metadata → { usage: {inputTokens, outputTokens, cacheReadInputTokens, cacheWriteInputTokens}, metrics: {...} }
|
|
19
|
+
#
|
|
20
|
+
# Tool-use input is streamed as a sequence of partial JSON strings; we
|
|
21
|
+
# concatenate and let the response parser leave it as a string for downstream
|
|
22
|
+
# tool dispatch (which calls JSON.parse with a {} fallback).
|
|
23
|
+
class BedrockStreamAggregator
|
|
24
|
+
def initialize(on_chunk: nil)
|
|
25
|
+
@on_chunk = on_chunk
|
|
26
|
+
@role = "assistant"
|
|
27
|
+
@blocks = {}
|
|
28
|
+
@stop_reason = nil
|
|
29
|
+
@usage = {}
|
|
30
|
+
@last_input_tokens = 0
|
|
31
|
+
@last_output_tokens = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle(event, data_str)
|
|
35
|
+
data = parse_or_nil(data_str)
|
|
36
|
+
return unless data
|
|
37
|
+
|
|
38
|
+
case event
|
|
39
|
+
when "messageStart"
|
|
40
|
+
@role = data["role"] || @role
|
|
41
|
+
when "contentBlockStart"
|
|
42
|
+
idx = data["contentBlockIndex"] || @blocks.size
|
|
43
|
+
start = data["start"] || {}
|
|
44
|
+
if (tu = start["toolUse"])
|
|
45
|
+
@blocks[idx] = { kind: :tool_use, id: tu["toolUseId"], name: tu["name"], input_str: +"" }
|
|
46
|
+
else
|
|
47
|
+
@blocks[idx] = { kind: :text, text: +"" }
|
|
48
|
+
end
|
|
49
|
+
when "contentBlockDelta"
|
|
50
|
+
idx = data["contentBlockIndex"] || 0
|
|
51
|
+
delta = data["delta"] || {}
|
|
52
|
+
block = (@blocks[idx] ||= { kind: :text, text: +"" })
|
|
53
|
+
if delta["text"]
|
|
54
|
+
block[:kind] ||= :text
|
|
55
|
+
block[:text] ||= +""
|
|
56
|
+
block[:text] << delta["text"]
|
|
57
|
+
elsif (tu = delta["toolUse"])
|
|
58
|
+
block[:kind] = :tool_use
|
|
59
|
+
block[:input_str] ||= +""
|
|
60
|
+
block[:input_str] << tu["input"].to_s
|
|
61
|
+
block[:id] ||= tu["toolUseId"]
|
|
62
|
+
block[:name] ||= tu["name"]
|
|
63
|
+
elsif (rc = delta["reasoningContent"])
|
|
64
|
+
block[:kind] = :reasoning
|
|
65
|
+
block[:reasoning] ||= +""
|
|
66
|
+
block[:reasoning] << rc["text"].to_s
|
|
67
|
+
end
|
|
68
|
+
emit_estimate_progress
|
|
69
|
+
when "contentBlockStop"
|
|
70
|
+
# Nothing to assemble: blocks are kept as-is until messageStop.
|
|
71
|
+
when "messageStop"
|
|
72
|
+
@stop_reason = data["stopReason"] || @stop_reason
|
|
73
|
+
when "metadata"
|
|
74
|
+
if (u = data["usage"])
|
|
75
|
+
@usage.merge!(u)
|
|
76
|
+
emit_usage_progress(u)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Render the canonical non-streaming Bedrock response hash so the existing
|
|
82
|
+
# MessageFormat::Bedrock.parse_response can consume it unchanged.
|
|
83
|
+
def to_h
|
|
84
|
+
content_blocks = @blocks.keys.sort.map do |idx|
|
|
85
|
+
b = @blocks[idx]
|
|
86
|
+
case b[:kind]
|
|
87
|
+
when :tool_use
|
|
88
|
+
input_value = b[:input_str].to_s.empty? ? {} : (JSON.parse(b[:input_str]) rescue b[:input_str])
|
|
89
|
+
{ "toolUse" => { "toolUseId" => b[:id], "name" => b[:name], "input" => input_value } }
|
|
90
|
+
else
|
|
91
|
+
{ "text" => b[:text].to_s }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
"output" => { "message" => { "role" => @role, "content" => content_blocks } },
|
|
97
|
+
"stopReason" => @stop_reason,
|
|
98
|
+
"usage" => @usage
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private def parse_or_nil(s)
|
|
103
|
+
JSON.parse(s)
|
|
104
|
+
rescue JSON::ParserError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private def emit_usage_progress(u)
|
|
109
|
+
return unless @on_chunk
|
|
110
|
+
input = u["inputTokens"].to_i + u["cacheReadInputTokens"].to_i
|
|
111
|
+
output = u["outputTokens"].to_i
|
|
112
|
+
return if input == @last_input_tokens && output == @last_output_tokens
|
|
113
|
+
@last_input_tokens = input
|
|
114
|
+
@last_output_tokens = output
|
|
115
|
+
@on_chunk.call(input_tokens: input, output_tokens: output)
|
|
116
|
+
rescue => e
|
|
117
|
+
Clacky::Logger.warn("[BedrockStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private def emit_estimate_progress
|
|
121
|
+
return unless @on_chunk
|
|
122
|
+
output = approximate_output_tokens
|
|
123
|
+
return if output == @last_output_tokens
|
|
124
|
+
@last_output_tokens = output
|
|
125
|
+
@on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
|
|
126
|
+
rescue => e
|
|
127
|
+
Clacky::Logger.warn("[BedrockStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private def approximate_output_tokens
|
|
131
|
+
total_chars = @blocks.values.sum do |b|
|
|
132
|
+
b[:text].to_s.bytesize + b[:input_str].to_s.bytesize + b[:reasoning].to_s.bytesize
|
|
133
|
+
end
|
|
134
|
+
(total_chars / 4.0).ceil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -163,6 +163,7 @@ module Clacky
|
|
|
163
163
|
end
|
|
164
164
|
ensure
|
|
165
165
|
Dir.chdir(original_dir)
|
|
166
|
+
Clacky::BrowserManager.instance.stop rescue nil
|
|
166
167
|
end
|
|
167
168
|
end
|
|
168
169
|
|
|
@@ -942,8 +943,8 @@ module Clacky
|
|
|
942
943
|
$ clacky server
|
|
943
944
|
$ clacky server --port 8080
|
|
944
945
|
LONGDESC
|
|
945
|
-
option :host, type: :string, default: "127.0.0.1", desc: "Bind host (default: 127.0.0.1)"
|
|
946
|
-
option :port, type: :numeric, default: 7070, desc: "Listen port (default: 7070)"
|
|
946
|
+
option :host, type: :string, aliases: ["-b", "--bind"], default: "127.0.0.1", desc: "Bind host (default: 127.0.0.1)"
|
|
947
|
+
option :port, type: :numeric, aliases: "-p", default: 7070, desc: "Listen port (default: 7070)"
|
|
947
948
|
option :brand_test, type: :boolean, default: false,
|
|
948
949
|
desc: "Enable brand test mode: mock license activation without calling remote API"
|
|
949
950
|
option :no_compression, type: :boolean, default: false,
|
|
@@ -954,7 +955,13 @@ module Clacky
|
|
|
954
955
|
desc: "Disable prompt caching"
|
|
955
956
|
option :no_skill_evolution, type: :boolean, default: false,
|
|
956
957
|
desc: "Disable automatic skill evolution"
|
|
958
|
+
option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
|
|
957
959
|
def server
|
|
960
|
+
if options[:help]
|
|
961
|
+
invoke :help, ["server"]
|
|
962
|
+
return
|
|
963
|
+
end
|
|
964
|
+
|
|
958
965
|
# ── Security gate ──────────────────────────────────────────────────────
|
|
959
966
|
# Binding to 0.0.0.0 exposes the server to the public network.
|
|
960
967
|
# Refuse to start unless CLACKY_ACCESS_KEY env var is set.
|