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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +28 -7
  4. data/lib/clacky/agent/llm_caller.rb +23 -1
  5. data/lib/clacky/agent/session_serializer.rb +6 -1
  6. data/lib/clacky/agent.rb +14 -5
  7. data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
  8. data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
  9. data/lib/clacky/cli.rb +9 -2
  10. data/lib/clacky/client.rb +146 -17
  11. data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
  12. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
  13. data/lib/clacky/openai_stream_aggregator.rb +130 -0
  14. data/lib/clacky/server/http_server.rb +2 -3
  15. data/lib/clacky/server/web_ui_controller.rb +8 -4
  16. data/lib/clacky/ui2/progress_handle.rb +77 -15
  17. data/lib/clacky/ui2/ui_controller.rb +4 -2
  18. data/lib/clacky/version.rb +1 -1
  19. data/lib/clacky/web/app.css +6 -4
  20. data/lib/clacky/web/i18n.js +6 -0
  21. data/lib/clacky/web/index.html +3 -1
  22. data/lib/clacky/web/sessions.js +152 -48
  23. data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
  24. data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  25. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  26. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  27. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  28. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  29. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  30. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  31. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  32. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  33. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  34. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  35. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  36. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  37. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  38. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  39. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  40. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  41. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  42. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  43. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  44. data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
  45. data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
  46. data/lib/clacky/web/ws-dispatcher.js +19 -4
  47. data/lib/clacky.rb +3 -0
  48. data/scripts/install.ps1 +14 -3
  49. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22bdc5132636582b787ebc49b43157871d15cfc0519a8ba93bc69fcfa9b58a2b
4
- data.tar.gz: 97c40c43d3ae3252f81b1d3b92c89812cf28bdb181094d1fca6f5fea8ea9daa9
3
+ metadata.gz: 47f1d9f1ecb4338e0a19ce771dea37724ebfc70d9e0052dc7403e096a25c415d
4
+ data.tar.gz: 6887db06529972b393c75c92e17b47da8294abae765ceff7280d71943a160c85
5
5
  SHA512:
6
- metadata.gz: 1c195480e06945b0a09e031498e5f1f4df5d5bb661ef123cf139cd570b33522a47475b8d698e3999a44de8979884e7e8f4dcc3adc424896965ad69cbac83902d
7
- data.tar.gz: a151a7178e6541880ddad41930adf0e652571bb732cd82ba6b6c2de8e9d880e587a1e680d2c76a3dcb8f9609152f092b23a38afb1c443f8082b1f081d34d81da
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–1.2×** | 16 tools · ~100% cache hit · subagent routing |
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–1.2×** |
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 / WeCom / WeChat) | ❌ | ✅ | ✅ | ✅ |
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
- **Requirements:** Ruby >= 3.1.0
83
+ One-line install(Mac/Ubuntu):
84
84
 
85
85
  ```bash
86
- gem install openclacky
86
+ /bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh)"
87
87
  ```
88
88
 
89
- Or one-line install:
89
+ Windows:
90
90
 
91
91
  ```bash
92
- /bin/bash -c "$(curl -sSL https://raw.githubusercontent.com/clacky-ai/openclacky/main/scripts/install.sh)"
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
- return if content.nil? || content.empty?
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(parsed[:text], files: parsed[:files])
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.