openclacky 1.1.0 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -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/skill_manager.rb +18 -5
  7. data/lib/clacky/agent.rb +14 -5
  8. data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
  9. data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
  10. data/lib/clacky/brand_config.rb +68 -15
  11. data/lib/clacky/cli.rb +18 -19
  12. data/lib/clacky/client.rb +146 -17
  13. data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
  14. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
  15. data/lib/clacky/openai_stream_aggregator.rb +130 -0
  16. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +169 -6
  17. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
  18. data/lib/clacky/server/http_server.rb +9 -3
  19. data/lib/clacky/server/web_ui_controller.rb +8 -4
  20. data/lib/clacky/tools/terminal.rb +11 -0
  21. data/lib/clacky/ui2/components/input_area.rb +10 -1
  22. data/lib/clacky/ui2/components/todo_area.rb +22 -2
  23. data/lib/clacky/ui2/layout_manager.rb +70 -14
  24. data/lib/clacky/ui2/progress_handle.rb +86 -15
  25. data/lib/clacky/ui2/ui_controller.rb +47 -7
  26. data/lib/clacky/utils/logger.rb +7 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +6 -4
  29. data/lib/clacky/web/i18n.js +21 -6
  30. data/lib/clacky/web/index.html +8 -6
  31. data/lib/clacky/web/sessions.js +171 -58
  32. data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
  33. data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  34. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  35. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  36. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  37. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  38. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  39. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  40. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  41. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  42. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  43. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  45. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  46. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  47. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  48. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  49. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  50. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  51. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  52. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  53. data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
  54. data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
  55. data/lib/clacky/web/ws-dispatcher.js +19 -4
  56. data/lib/clacky.rb +3 -0
  57. data/scripts/build/src/install.sh.cc +15 -5
  58. data/scripts/install.ps1 +14 -3
  59. data/scripts/install.sh +15 -5
  60. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e897a7568d8b457d317c56ff402a76c1907b4a158e0e7382586e2b66115ee0f7
4
- data.tar.gz: 5fbeb452695441035d7f7d4f7ba1f0ef63dbb198daebb17600eb6e9f9fb72c46
3
+ metadata.gz: 47f1d9f1ecb4338e0a19ce771dea37724ebfc70d9e0052dc7403e096a25c415d
4
+ data.tar.gz: 6887db06529972b393c75c92e17b47da8294abae765ceff7280d71943a160c85
5
5
  SHA512:
6
- metadata.gz: 64d4764470f2f8bac52e7e8233afbf441336c8a81238def9800cf6735a90798435cc8d43e9056aa4c6e53f51760067e57b5b2670263339590b7f9fd744cc6920
7
- data.tar.gz: c27a2313d3595adcb48a66e396c9eec8947550132a74bb5b8535b11d93e639b240bc1dafeab0a7bf9b4af665c2fa6a22630f8b23ab0061b2de4ebbfcae1f3299
6
+ metadata.gz: b49465d66eb2be634790d10813ac5988aca23f54a4d3c36c9690bd40bf0024ea5f7275b3bc1b89247aaa16cc30dbf1c1439733c51bdfc1f5750e02c7efbc8e80
7
+ data.tar.gz: f0f4397dd4cca183f199545e7c5db6b4902382c66c237257e8b2a99847e49702881dd8bcf60a78201ee18526ae2b04bd4217bf6def502ccf3ce8a7fb6fb0bdd0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ 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
+
29
+ ## [1.1.1] - 2026-05-17
30
+
31
+ ### Added
32
+ - **WeChat SendQueue with batching, throttling, and retry.** Messages sent to multiple WeChat official account users are now queued, batched (up to 100 recipients per call), throttled to 1 batch/second, and automatically retried on failure — preventing 45007 rate-limit errors during broadcasts. (#127)
33
+ - **Session ID in TUI session bar.** The terminal UI session bar now displays the session ID alongside the session name, making it easy to identify sessions when cross-referencing with logs or Web UI.
34
+ - **TUI todo clean-up on task completion.** Completed todos are now removed from the terminal display when a task finishes, keeping the TUI uncluttered. (#94)
35
+
36
+ ### Improved
37
+ - **Brand skills persist across same-brand upgrades.** Brand skills are no longer removed and re-downloaded when the brand stays the same after an upgrade — eliminating unnecessary network calls and keeping skill state stable.
38
+ - **Ruby 2.6 install reliability.** The installer now pre-installs rouge 3.30.0 before `gem install` and retries with a pinned version on Ruby 2.6, avoiding dependency resolution failures on older macOS system Ruby.
39
+
40
+ ### Fixed
41
+ - **TUI progress bar flicker.** The progress bar in terminal mode no longer flashes when updating rapidly, providing a smoother visual experience.
42
+ - **Xcode command auto-install loop.** The agent no longer gets stuck in a loop trying to auto-install missing Xcode command-line tools.
43
+ - **Brand license warning after 3-day idle.** Fixed a spurious license warning that appeared on startup after the server had been idle for 3 days.
44
+
8
45
  ## [1.1.0] - 2026-05-15
9
46
 
10
47
  ### 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
@@ -91,6 +91,20 @@ module Clacky
91
91
  # Keeps context tokens bounded regardless of how many skills are installed.
92
92
  MAX_CONTEXT_SKILLS = 30
93
93
 
94
+ # Process-wide deduper for the "skill context limit" warning so that
95
+ # every newly constructed Agent (sub-agents, retries, web turns…) doesn't
96
+ # re-emit the same line.
97
+ @skill_limit_warned_signatures = {}
98
+ @skill_limit_warn_mutex = Mutex.new
99
+
100
+ def self.warn_skill_limit_once(signature, &block)
101
+ @skill_limit_warn_mutex.synchronize do
102
+ return if @skill_limit_warned_signatures[signature]
103
+ @skill_limit_warned_signatures[signature] = true
104
+ end
105
+ block.call
106
+ end
107
+
94
108
  # Generate skill context - loads all auto-invocable skills allowed by the agent profile
95
109
  # @return [String] Skill context to add to system prompt
96
110
  def build_skill_context
@@ -103,17 +117,16 @@ module Clacky
103
117
  auto_invocable = all_skills.select(&:model_invocation_allowed?)
104
118
 
105
119
  # Enforce system prompt injection limit to control token usage.
106
- # Warn only when the set of dropped skills *changes* this message
107
- # is otherwise emitted once per agent turn (build_skill_context is
108
- # called during every system prompt assembly) and floods the log.
120
+ # Warn at most once per process per dropped-set signaturebuild_skill_context
121
+ # runs on every system-prompt assembly and is invoked from many short-lived
122
+ # Agent instances (sub-agents, web turns…), so per-instance dedup wasn't enough.
109
123
  if auto_invocable.size > MAX_CONTEXT_SKILLS
110
124
  kept = auto_invocable.first(MAX_CONTEXT_SKILLS)
111
125
  dropped = auto_invocable.drop(MAX_CONTEXT_SKILLS)
112
126
  dropped_names = dropped.map(&:identifier)
113
127
  signature = dropped_names.sort.join(",")
114
128
 
115
- if @skill_limit_warned_signature != signature
116
- @skill_limit_warned_signature = signature
129
+ SkillManager.warn_skill_limit_once(signature) do
117
130
  Clacky::Logger.warn(
118
131
  "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
119
132
  "only injecting first #{MAX_CONTEXT_SKILLS} " \
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