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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -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/skill_manager.rb +18 -5
- 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/brand_config.rb +68 -15
- data/lib/clacky/cli.rb +18 -19
- 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/channel/adapters/weixin/adapter.rb +169 -6
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
- data/lib/clacky/server/http_server.rb +9 -3
- data/lib/clacky/server/web_ui_controller.rb +8 -4
- data/lib/clacky/tools/terminal.rb +11 -0
- data/lib/clacky/ui2/components/input_area.rb +10 -1
- data/lib/clacky/ui2/components/todo_area.rb +22 -2
- data/lib/clacky/ui2/layout_manager.rb +70 -14
- data/lib/clacky/ui2/progress_handle.rb +86 -15
- data/lib/clacky/ui2/ui_controller.rb +47 -7
- data/lib/clacky/utils/logger.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +6 -4
- data/lib/clacky/web/i18n.js +21 -6
- data/lib/clacky/web/index.html +8 -6
- data/lib/clacky/web/sessions.js +171 -58
- 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/build/src/install.sh.cc +15 -5
- data/scripts/install.ps1 +14 -3
- data/scripts/install.sh +15 -5
- metadata +28 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# Reassembles an OpenAI-compatible chat-completion event stream into the
|
|
7
|
+
# non-streaming response shape that MessageFormat::OpenAI.parse_response
|
|
8
|
+
# consumes, while invoking on_chunk(input_tokens:, output_tokens:) every
|
|
9
|
+
# time the upstream emits a new usage frame.
|
|
10
|
+
#
|
|
11
|
+
# Streaming frames look like:
|
|
12
|
+
#
|
|
13
|
+
# {"id":"...","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
|
14
|
+
# {"id":"...","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
|
|
15
|
+
# {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"shell","arguments":"{\"cmd"}}]}}]}
|
|
16
|
+
# {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"ls\"}"}}]}}]}
|
|
17
|
+
# {"id":"...","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
|
|
18
|
+
# {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":2}}}
|
|
19
|
+
# data: [DONE]
|
|
20
|
+
class OpenAIStreamAggregator
|
|
21
|
+
def initialize(on_chunk: nil)
|
|
22
|
+
@on_chunk = on_chunk
|
|
23
|
+
@content = +""
|
|
24
|
+
@reasoning_content = +""
|
|
25
|
+
@role = "assistant"
|
|
26
|
+
@finish_reason = nil
|
|
27
|
+
@tool_calls = {}
|
|
28
|
+
@usage = nil
|
|
29
|
+
@last_input_tokens = 0
|
|
30
|
+
@last_output_tokens = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle(data_str)
|
|
34
|
+
return if data_str == "[DONE]"
|
|
35
|
+
data = parse_or_nil(data_str)
|
|
36
|
+
return unless data
|
|
37
|
+
|
|
38
|
+
if (choice = (data["choices"] || []).first)
|
|
39
|
+
delta = choice["delta"] || {}
|
|
40
|
+
@role = delta["role"] if delta["role"]
|
|
41
|
+
@content << delta["content"] if delta["content"].is_a?(String)
|
|
42
|
+
@reasoning_content << delta["reasoning_content"] if delta["reasoning_content"].is_a?(String)
|
|
43
|
+
if (tcs = delta["tool_calls"])
|
|
44
|
+
tcs.each { |tc| merge_tool_call(tc) }
|
|
45
|
+
end
|
|
46
|
+
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
47
|
+
emit_estimate_progress
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if (u = data["usage"])
|
|
51
|
+
@usage = u
|
|
52
|
+
emit_usage_progress(u)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Render the canonical non-streaming response shape.
|
|
57
|
+
def to_h
|
|
58
|
+
tool_calls = @tool_calls.keys.sort.map do |idx|
|
|
59
|
+
tc = @tool_calls[idx]
|
|
60
|
+
{
|
|
61
|
+
"id" => tc[:id],
|
|
62
|
+
"type" => tc[:type] || "function",
|
|
63
|
+
"function" => {
|
|
64
|
+
"name" => tc[:name],
|
|
65
|
+
"arguments" => tc[:arguments].to_s
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
message = {
|
|
71
|
+
"role" => @role,
|
|
72
|
+
"content" => @content.empty? ? nil : @content
|
|
73
|
+
}
|
|
74
|
+
message["tool_calls"] = tool_calls unless tool_calls.empty?
|
|
75
|
+
message["reasoning_content"] = @reasoning_content unless @reasoning_content.empty?
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
"choices" => [{ "index" => 0, "message" => message, "finish_reason" => @finish_reason }],
|
|
79
|
+
"usage" => @usage || {}
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private def merge_tool_call(tc)
|
|
84
|
+
idx = tc["index"] || @tool_calls.size
|
|
85
|
+
slot = (@tool_calls[idx] ||= { id: nil, type: nil, name: nil, arguments: +"" })
|
|
86
|
+
slot[:id] ||= tc["id"] if tc["id"]
|
|
87
|
+
slot[:type] ||= tc["type"] if tc["type"]
|
|
88
|
+
if (fn = tc["function"])
|
|
89
|
+
slot[:name] ||= fn["name"] if fn["name"]
|
|
90
|
+
slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private def parse_or_nil(s)
|
|
95
|
+
JSON.parse(s)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private def emit_estimate_progress
|
|
101
|
+
return unless @on_chunk
|
|
102
|
+
output = approximate_output_tokens
|
|
103
|
+
return if output == @last_output_tokens
|
|
104
|
+
@last_output_tokens = output
|
|
105
|
+
@on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
|
|
106
|
+
rescue => e
|
|
107
|
+
Clacky::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Rough char/4 estimate; replaced by the real count when the upstream
|
|
111
|
+
# finally emits a usage frame (with stream_options.include_usage=true).
|
|
112
|
+
private def approximate_output_tokens
|
|
113
|
+
total_chars = @content.bytesize + @reasoning_content.bytesize +
|
|
114
|
+
@tool_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
|
|
115
|
+
(total_chars / 4.0).ceil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private def emit_usage_progress(u)
|
|
119
|
+
return unless @on_chunk
|
|
120
|
+
total_prompt = u["prompt_tokens"].to_i
|
|
121
|
+
output = u["completion_tokens"].to_i
|
|
122
|
+
return if total_prompt == @last_input_tokens && output == @last_output_tokens
|
|
123
|
+
@last_input_tokens = total_prompt
|
|
124
|
+
@last_output_tokens = output
|
|
125
|
+
@on_chunk.call(input_tokens: total_prompt, output_tokens: output)
|
|
126
|
+
rescue => e
|
|
127
|
+
Clacky::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -8,6 +8,163 @@ module Clacky
|
|
|
8
8
|
module Channel
|
|
9
9
|
module Adapters
|
|
10
10
|
module Weixin
|
|
11
|
+
# Per-user send queue with buffering, throttling, and retry for Weixin iLink.
|
|
12
|
+
#
|
|
13
|
+
# Design:
|
|
14
|
+
# - Each chat_id has a pending buffer of text fragments.
|
|
15
|
+
# - A background flusher thread periodically checks all buffers.
|
|
16
|
+
# - Flush triggers: char threshold reached, time interval elapsed, or explicit flush.
|
|
17
|
+
# - Actual send calls are spaced by MIN_SEND_INTERVAL to avoid rate-limiting.
|
|
18
|
+
# - ret:-2 (rate-limited) triggers exponential backoff retry.
|
|
19
|
+
class SendQueue
|
|
20
|
+
FLUSH_CHAR_THRESHOLD = 400
|
|
21
|
+
FLUSH_INTERVAL = 0.8
|
|
22
|
+
MIN_SEND_INTERVAL = 1.0
|
|
23
|
+
RETRY_BACKOFFS = [1.0, 2.0, 4.0]
|
|
24
|
+
|
|
25
|
+
Entry = Struct.new(:text, :context_token, :enqueued_at, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
def initialize(api_client, logger: Clacky::Logger)
|
|
28
|
+
@api_client = api_client
|
|
29
|
+
@logger = logger
|
|
30
|
+
@buffers = {}
|
|
31
|
+
@buffer_mutex = Mutex.new
|
|
32
|
+
@last_sent_at = {}
|
|
33
|
+
@last_mutex = Mutex.new
|
|
34
|
+
@running = true
|
|
35
|
+
@flusher = Thread.new { flush_loop }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Enqueue text for a chat_id. Non-blocking.
|
|
39
|
+
def enqueue(chat_id, text, context_token)
|
|
40
|
+
@buffer_mutex.synchronize do
|
|
41
|
+
@buffers[chat_id] ||= []
|
|
42
|
+
@buffers[chat_id] << Entry.new(text: text, context_token: context_token, enqueued_at: Time.now)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Force-flush all pending text for a chat_id. Non-blocking.
|
|
47
|
+
def flush(chat_id)
|
|
48
|
+
entries = @buffer_mutex.synchronize { @buffers.delete(chat_id) || [] }
|
|
49
|
+
send_entries(chat_id, entries) unless entries.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Stop the flusher thread. Waits up to 30s for pending messages to drain.
|
|
53
|
+
def stop
|
|
54
|
+
@running = false
|
|
55
|
+
@flusher.join(30)
|
|
56
|
+
# Force-flush any remaining entries regardless of threshold.
|
|
57
|
+
drain_all_buffers
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private def flush_loop
|
|
61
|
+
while @running
|
|
62
|
+
sleep 0.2
|
|
63
|
+
begin
|
|
64
|
+
drain_buffers
|
|
65
|
+
rescue => e
|
|
66
|
+
@logger.error("[WeixinSendQueue] drain_buffers error: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private def drain_buffers
|
|
72
|
+
now = Time.now
|
|
73
|
+
ready = {}
|
|
74
|
+
|
|
75
|
+
@buffer_mutex.synchronize do
|
|
76
|
+
@buffers.each do |chat_id, entries|
|
|
77
|
+
next if entries.empty?
|
|
78
|
+
total_chars = entries.sum { |e| e.text.chars.length }
|
|
79
|
+
elapsed = now - entries.first.enqueued_at
|
|
80
|
+
if total_chars >= FLUSH_CHAR_THRESHOLD || elapsed >= FLUSH_INTERVAL
|
|
81
|
+
ready[chat_id] = entries
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
ready.each_key { |chat_id| @buffers.delete(chat_id) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ready.each do |chat_id, entries|
|
|
88
|
+
send_entries(chat_id, entries)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Unconditionally drain every buffer. Used on stop to guarantee delivery.
|
|
93
|
+
private def drain_all_buffers
|
|
94
|
+
ready = @buffer_mutex.synchronize do
|
|
95
|
+
snapshot = @buffers.reject { |_, entries| entries.empty? }
|
|
96
|
+
@buffers.clear
|
|
97
|
+
snapshot
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ready.each do |chat_id, entries|
|
|
101
|
+
begin
|
|
102
|
+
send_entries(chat_id, entries)
|
|
103
|
+
rescue => e
|
|
104
|
+
@logger.error("[WeixinSendQueue] final drain error for #{chat_id}: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private def send_entries(chat_id, entries)
|
|
110
|
+
return if entries.empty?
|
|
111
|
+
|
|
112
|
+
combined = entries.map(&:text).join("\n")
|
|
113
|
+
ctoken = entries.last.context_token
|
|
114
|
+
|
|
115
|
+
# Split into ≤2000 char chunks
|
|
116
|
+
chunks = split_message(combined)
|
|
117
|
+
chunks.each do |chunk|
|
|
118
|
+
throttle
|
|
119
|
+
send_with_retry(chat_id, chunk, ctoken)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private def throttle
|
|
124
|
+
@last_mutex.synchronize do
|
|
125
|
+
last = @last_sent_at[:global] || Time.at(0)
|
|
126
|
+
wait = MIN_SEND_INTERVAL - (Time.now - last)
|
|
127
|
+
sleep(wait) if wait > 0
|
|
128
|
+
@last_sent_at[:global] = Time.now
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private def send_with_retry(chat_id, text, context_token)
|
|
133
|
+
RETRY_BACKOFFS.each_with_index do |delay, idx|
|
|
134
|
+
begin
|
|
135
|
+
@api_client.send_text(to_user_id: chat_id, text: text, context_token: context_token)
|
|
136
|
+
return
|
|
137
|
+
rescue ApiClient::ApiError => e
|
|
138
|
+
if e.code == -2 && idx < RETRY_BACKOFFS.length - 1
|
|
139
|
+
@logger.warn("[WeixinSendQueue] ret=-2 for #{chat_id}, retry in #{delay}s (#{idx + 1}/#{RETRY_BACKOFFS.length})")
|
|
140
|
+
sleep delay
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
raise
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
rescue => e
|
|
147
|
+
@logger.error("[WeixinSendQueue] send_text failed for #{chat_id}: #{e.message}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Split text into ≤2000 Unicode character chunks.
|
|
151
|
+
private def split_message(text, limit: 2000)
|
|
152
|
+
return [text] if text.chars.length <= limit
|
|
153
|
+
chunks = []
|
|
154
|
+
while text.chars.length > limit
|
|
155
|
+
window = text.chars.first(limit).join
|
|
156
|
+
cut = window.rindex("\n\n")
|
|
157
|
+
cut = window.rindex("\n") if cut.nil?
|
|
158
|
+
cut = window.rindex(" ") if cut.nil?
|
|
159
|
+
cut = limit if cut.nil? || cut.zero?
|
|
160
|
+
chunks << text.chars.first(cut).join.rstrip
|
|
161
|
+
text = text.chars.drop(cut).join.lstrip
|
|
162
|
+
end
|
|
163
|
+
chunks << text unless text.empty?
|
|
164
|
+
chunks
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
11
168
|
# Weixin (WeChat iLink) adapter.
|
|
12
169
|
#
|
|
13
170
|
# Protocol: HTTP long-poll via ilinkai.weixin.qq.com
|
|
@@ -76,6 +233,7 @@ module Clacky
|
|
|
76
233
|
@context_tokens = {}
|
|
77
234
|
@ctx_mutex = Mutex.new
|
|
78
235
|
@api_client = ApiClient.new(base_url: @base_url, token: @token)
|
|
236
|
+
@send_queue = SendQueue.new(@api_client)
|
|
79
237
|
# Typing keepalive: user_id → { ticket:, thread:, cached_at: }
|
|
80
238
|
@typing_tickets = {}
|
|
81
239
|
@typing_mutex = Mutex.new
|
|
@@ -129,10 +287,12 @@ module Clacky
|
|
|
129
287
|
|
|
130
288
|
def stop
|
|
131
289
|
@running = false
|
|
290
|
+
@send_queue.stop
|
|
132
291
|
end
|
|
133
292
|
|
|
134
293
|
# Send a plain text reply to a user.
|
|
135
294
|
# The context_token from the inbound message is required by the Weixin protocol.
|
|
295
|
+
# Text is enqueued and sent in batches by the background flusher to avoid rate-limiting.
|
|
136
296
|
def send_text(chat_id, text, reply_to: nil)
|
|
137
297
|
ctoken = lookup_context_token(chat_id)
|
|
138
298
|
unless ctoken
|
|
@@ -141,14 +301,15 @@ module Clacky
|
|
|
141
301
|
end
|
|
142
302
|
|
|
143
303
|
plain = markdown_to_plain(text)
|
|
144
|
-
|
|
145
|
-
@api_client.send_text(to_user_id: chat_id, text: chunk, context_token: ctoken)
|
|
146
|
-
end
|
|
304
|
+
return { message_id: nil } if plain.empty?
|
|
147
305
|
|
|
306
|
+
@send_queue.enqueue(chat_id, plain, ctoken)
|
|
148
307
|
{ message_id: nil }
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Force-flush pending text for a chat_id. Called before sending files or on task completion.
|
|
311
|
+
def flush_pending(chat_id)
|
|
312
|
+
@send_queue.flush(chat_id)
|
|
152
313
|
end
|
|
153
314
|
|
|
154
315
|
# Send a file to a user.
|
|
@@ -161,6 +322,8 @@ module Clacky
|
|
|
161
322
|
return { message_id: nil }
|
|
162
323
|
end
|
|
163
324
|
|
|
325
|
+
@send_queue.flush(chat_id)
|
|
326
|
+
|
|
164
327
|
@api_client.send_file(
|
|
165
328
|
to_user_id: chat_id,
|
|
166
329
|
file_path: file_path,
|
|
@@ -62,6 +62,7 @@ module Clacky
|
|
|
62
62
|
# raw markdown links would just be noise in the chat.
|
|
63
63
|
text = content.to_s.gsub(/!?\[[^\]]*\]\(file:\/\/[^)]+\)/, "").strip
|
|
64
64
|
send_text(text) unless text.empty?
|
|
65
|
+
flush_adapter_pending
|
|
65
66
|
files.each do |f|
|
|
66
67
|
Clacky::Logger.info("[ChannelUI] sending file path=#{f[:path].inspect} name=#{f[:name].inspect}")
|
|
67
68
|
send_file(f[:path], f[:name])
|
|
@@ -120,6 +121,7 @@ module Clacky
|
|
|
120
121
|
end
|
|
121
122
|
parts << "#{duration.round(1)}s" if duration
|
|
122
123
|
send_text(parts.join(" · "))
|
|
124
|
+
flush_adapter_pending
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
def append_output(content)
|
|
@@ -218,6 +220,10 @@ module Clacky
|
|
|
218
220
|
send_text(@buffer.join("\n"))
|
|
219
221
|
@buffer.clear
|
|
220
222
|
end
|
|
223
|
+
|
|
224
|
+
def flush_adapter_pending
|
|
225
|
+
@adapter.flush_pending(@chat_id) if @adapter.respond_to?(:flush_pending)
|
|
226
|
+
end
|
|
221
227
|
end
|
|
222
228
|
end
|
|
223
229
|
end
|
|
@@ -3156,6 +3156,7 @@ module Clacky
|
|
|
3156
3156
|
# Export a session bundle as a .zip download containing:
|
|
3157
3157
|
# - session.json (always)
|
|
3158
3158
|
# - chunk-*.md (0..N archived conversation chunks)
|
|
3159
|
+
# - logs/clacky-YYYY-MM-DD.log (today's logger file, if present)
|
|
3159
3160
|
# Useful for debugging — user clicks "download" in the WebUI status bar
|
|
3160
3161
|
# and we can ask them to attach the zip to a bug report.
|
|
3161
3162
|
def api_export_session(session_id, res)
|
|
@@ -3177,6 +3178,12 @@ module Clacky
|
|
|
3177
3178
|
zos.put_next_entry(File.basename(chunk_path))
|
|
3178
3179
|
zos.write(File.binread(chunk_path))
|
|
3179
3180
|
end
|
|
3181
|
+
|
|
3182
|
+
log_path = Clacky::Logger.current_log_file
|
|
3183
|
+
if log_path && File.exist?(log_path)
|
|
3184
|
+
zos.put_next_entry("logs/#{File.basename(log_path)}")
|
|
3185
|
+
zos.write(File.binread(log_path))
|
|
3186
|
+
end
|
|
3180
3187
|
end
|
|
3181
3188
|
buffer.rewind
|
|
3182
3189
|
data = buffer.read
|
|
@@ -3365,9 +3372,8 @@ module Clacky
|
|
|
3365
3372
|
return unless agent
|
|
3366
3373
|
|
|
3367
3374
|
# Auto-name the session from the first user message (before agent starts running).
|
|
3368
|
-
#
|
|
3369
|
-
|
|
3370
|
-
if agent.history.empty?
|
|
3375
|
+
# Skip if the name looks like it was set by the user (not a system-generated "Session N").
|
|
3376
|
+
if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
|
|
3371
3377
|
auto_name = content.gsub(/\s+/, " ").strip[0, 30]
|
|
3372
3378
|
auto_name += "…" if content.strip.length > 30
|
|
3373
3379
|
agent.rename(auto_name)
|
|
@@ -225,15 +225,19 @@ module Clacky
|
|
|
225
225
|
|
|
226
226
|
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
227
227
|
if phase == "active"
|
|
228
|
-
|
|
229
|
-
#
|
|
228
|
+
# Only set start time when transitioning into a fresh progress phase.
|
|
229
|
+
# Streaming LLM calls fire show_progress every chunk for token updates;
|
|
230
|
+
# resetting the timer each time would make the elapsed counter jitter
|
|
231
|
+
# back to 0 in the UI and force the frontend to rebuild its interval.
|
|
232
|
+
if @live_progress_state.nil? || @live_progress_state[:progress_type] != progress_type
|
|
233
|
+
@progress_start_time = Time.now
|
|
234
|
+
@live_stdout_buffer = []
|
|
235
|
+
end
|
|
230
236
|
@live_progress_state = {
|
|
231
237
|
message: message,
|
|
232
238
|
progress_type: progress_type,
|
|
233
239
|
metadata: metadata
|
|
234
240
|
}
|
|
235
|
-
# Reset stdout buffer for each new command so re-subscribe only replays current run
|
|
236
|
-
@live_stdout_buffer = []
|
|
237
241
|
elsif phase == "done"
|
|
238
242
|
@live_tool_call = nil # command finished — nothing left to replay
|
|
239
243
|
# Keep @live_stdout_buffer intact — it will be reset on the next show_progress call.
|
|
@@ -494,6 +494,12 @@ module Clacky
|
|
|
494
494
|
else
|
|
495
495
|
cleanup_session(session)
|
|
496
496
|
end
|
|
497
|
+
if xcode_tools_missing?(cleaned)
|
|
498
|
+
cleaned = "Xcode Command Line Tools are not installed.\n" \
|
|
499
|
+
"Run: bash ~/.clacky/scripts/install_system_deps.sh\n" \
|
|
500
|
+
"Then retry the original command."
|
|
501
|
+
exit_code = 1
|
|
502
|
+
end
|
|
497
503
|
{
|
|
498
504
|
output: cleaned,
|
|
499
505
|
exit_code: exit_code,
|
|
@@ -521,6 +527,11 @@ module Clacky
|
|
|
521
527
|
end
|
|
522
528
|
end
|
|
523
529
|
|
|
530
|
+
private def xcode_tools_missing?(output)
|
|
531
|
+
return false if output.nil? || output.empty?
|
|
532
|
+
output.include?("xcode-select") && output.include?("No developer tools were found")
|
|
533
|
+
end
|
|
534
|
+
|
|
524
535
|
private def session_healthy?(session)
|
|
525
536
|
return false unless session
|
|
526
537
|
return false if %w[exited killed].include?(session.status.to_s)
|
|
@@ -57,6 +57,7 @@ module Clacky
|
|
|
57
57
|
|
|
58
58
|
# Session bar info
|
|
59
59
|
@sessionbar_info = {
|
|
60
|
+
session_id: nil, # Full session id; rendered as first 8 chars (parity with WebUI)
|
|
60
61
|
working_dir: nil,
|
|
61
62
|
mode: nil,
|
|
62
63
|
model: nil,
|
|
@@ -138,6 +139,7 @@ module Clacky
|
|
|
138
139
|
end
|
|
139
140
|
|
|
140
141
|
# Update session bar info
|
|
142
|
+
# @param session_id [String] Full session id (rendered as first 8 chars)
|
|
141
143
|
# @param working_dir [String] Working directory
|
|
142
144
|
# @param mode [String] Permission mode
|
|
143
145
|
# @param model [String] AI model name
|
|
@@ -145,7 +147,8 @@ module Clacky
|
|
|
145
147
|
# @param cost [Float] Total cost
|
|
146
148
|
# @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
|
|
147
149
|
# @param status [String] Workspace status ('idle' or 'working')
|
|
148
|
-
def update_sessionbar(working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
150
|
+
def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
151
|
+
@sessionbar_info[:session_id] = session_id if session_id
|
|
149
152
|
@sessionbar_info[:working_dir] = working_dir if working_dir
|
|
150
153
|
@sessionbar_info[:mode] = mode if mode
|
|
151
154
|
@sessionbar_info[:model] = model if model
|
|
@@ -1136,6 +1139,12 @@ module Clacky
|
|
|
1136
1139
|
parts << "#{status_indicator} #{@pastel.public_send(status_color, @sessionbar_info[:status])}"
|
|
1137
1140
|
end
|
|
1138
1141
|
|
|
1142
|
+
# Session id — first 8 chars (parity with WebUI #sib-id)
|
|
1143
|
+
if @sessionbar_info[:session_id]
|
|
1144
|
+
sid_short = @sessionbar_info[:session_id].to_s[0, 8]
|
|
1145
|
+
parts << theme.format_text(sid_short, :statusbar_secondary) unless sid_short.empty?
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1139
1148
|
# Working directory (shortened if too long)
|
|
1140
1149
|
if @sessionbar_info[:working_dir]
|
|
1141
1150
|
dir_display = shorten_path(@sessionbar_info[:working_dir])
|
|
@@ -14,9 +14,13 @@ module Clacky
|
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
16
16
|
@todos = []
|
|
17
|
+
@pending_todos = []
|
|
18
|
+
@completed_count = 0
|
|
19
|
+
@total_count = 0
|
|
17
20
|
@pastel = Pastel.new
|
|
18
21
|
@width = TTY::Screen.width
|
|
19
22
|
@height = 0 # Dynamic height based on todos
|
|
23
|
+
@hidden = false
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
# Update todos list
|
|
@@ -27,8 +31,24 @@ module Clacky
|
|
|
27
31
|
@completed_count = @todos.count { |t| t[:status] == "completed" }
|
|
28
32
|
@total_count = @todos.size
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
recalc_height
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Hide the area without discarding todos data; show again to restore.
|
|
38
|
+
def hide
|
|
39
|
+
return if @hidden
|
|
40
|
+
@hidden = true
|
|
41
|
+
@height = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
return unless @hidden
|
|
46
|
+
@hidden = false
|
|
47
|
+
recalc_height
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private def recalc_height
|
|
51
|
+
if @hidden || @pending_todos.empty?
|
|
32
52
|
@height = 0
|
|
33
53
|
else
|
|
34
54
|
@height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
|
|
@@ -126,6 +126,10 @@ module Clacky
|
|
|
126
126
|
|
|
127
127
|
old_lines = entry.lines.dup
|
|
128
128
|
new_lines = wrap_content_to_lines(content)
|
|
129
|
+
if old_lines == new_lines
|
|
130
|
+
screen.flush
|
|
131
|
+
return
|
|
132
|
+
end
|
|
129
133
|
@buffer.replace(id, new_lines)
|
|
130
134
|
|
|
131
135
|
unless @fullscreen_mode
|
|
@@ -288,24 +292,28 @@ module Clacky
|
|
|
288
292
|
end
|
|
289
293
|
end
|
|
290
294
|
|
|
291
|
-
# Clear
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Paint the new content
|
|
295
|
+
# Clear only rows whose content actually changed, then repaint
|
|
296
|
+
# those. Lines that are byte-identical to the previous frame stay
|
|
297
|
+
# untouched — avoiding the clear-then-redraw flicker that an
|
|
298
|
+
# always-on ticker produces 2-10x per second on slower terminals.
|
|
298
299
|
cur = start_row
|
|
299
|
-
new_lines.
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
new_lines.each_with_index do |line, i|
|
|
301
|
+
if i >= old_n || old_lines[i] != line
|
|
302
|
+
screen.move_cursor(cur, 0)
|
|
303
|
+
screen.clear_line
|
|
304
|
+
print line
|
|
305
|
+
end
|
|
302
306
|
cur += 1
|
|
303
307
|
end
|
|
308
|
+
# If content shrank, blank out the rows the old frame occupied
|
|
309
|
+
# below the new tail.
|
|
310
|
+
if new_n < old_n
|
|
311
|
+
(cur...(start_row + old_n)).each do |row|
|
|
312
|
+
screen.move_cursor(row, 0)
|
|
313
|
+
screen.clear_line
|
|
314
|
+
end
|
|
315
|
+
end
|
|
304
316
|
@output_row = start_row + new_n
|
|
305
|
-
|
|
306
|
-
# If content shrank, extra rows below may still hold the old content
|
|
307
|
-
# if they were outside the cleared range — but since we cleared the
|
|
308
|
-
# full old span above, nothing extra is needed here.
|
|
309
317
|
end
|
|
310
318
|
|
|
311
319
|
# Clear the last N rows of the output area (used by remove_entry on tail).
|
|
@@ -502,6 +510,54 @@ module Clacky
|
|
|
502
510
|
end
|
|
503
511
|
end
|
|
504
512
|
|
|
513
|
+
# Hide todo area while preserving its data; pair with show_todos.
|
|
514
|
+
def hide_todos
|
|
515
|
+
return unless @todo_area
|
|
516
|
+
|
|
517
|
+
@render_mutex.synchronize do
|
|
518
|
+
old_height = @todo_area.height
|
|
519
|
+
old_gap_row = @gap_row
|
|
520
|
+
|
|
521
|
+
@todo_area.hide
|
|
522
|
+
new_height = @todo_area.height
|
|
523
|
+
|
|
524
|
+
if old_height != new_height
|
|
525
|
+
calculate_layout
|
|
526
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
527
|
+
screen.move_cursor(row, 0)
|
|
528
|
+
screen.clear_line
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
render_fixed_areas
|
|
533
|
+
screen.flush
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Show todo area again after a previous hide_todos.
|
|
538
|
+
def show_todos
|
|
539
|
+
return unless @todo_area
|
|
540
|
+
|
|
541
|
+
@render_mutex.synchronize do
|
|
542
|
+
old_height = @todo_area.height
|
|
543
|
+
old_gap_row = @gap_row
|
|
544
|
+
|
|
545
|
+
@todo_area.show
|
|
546
|
+
new_height = @todo_area.height
|
|
547
|
+
|
|
548
|
+
if old_height != new_height
|
|
549
|
+
calculate_layout
|
|
550
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
551
|
+
screen.move_cursor(row, 0)
|
|
552
|
+
screen.clear_line
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
render_fixed_areas
|
|
557
|
+
screen.flush
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
505
561
|
|
|
506
562
|
|
|
507
563
|
# -----------------------------------------------------------------------
|