openclacky 1.2.10 → 1.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -1
- data/lib/clacky/agent/session_serializer.rb +1 -1
- data/lib/clacky/agent/tool_registry.rb +10 -0
- data/lib/clacky/agent.rb +59 -22
- data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
- data/lib/clacky/client.rb +25 -3
- data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
- data/lib/clacky/message_history.rb +57 -0
- data/lib/clacky/openai_stream_aggregator.rb +26 -2
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
- data/lib/clacky/server/channel/channel_manager.rb +65 -4
- data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
- data/lib/clacky/server/http_server.rb +73 -7
- data/lib/clacky/server/session_registry.rb +4 -6
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +21 -3
- data/lib/clacky/web/apple-touch-icon-180.png +0 -0
- data/lib/clacky/web/brand.js +22 -2
- data/lib/clacky/web/favicon.ico +0 -0
- data/lib/clacky/web/i18n.js +4 -0
- data/lib/clacky/web/index.html +4 -3
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/model-tester.js +8 -1
- data/lib/clacky/web/sessions.js +169 -41
- data/lib/clacky/web/theme.js +1 -0
- data/scripts/build/lib/gem.sh +9 -2
- data/scripts/build/src/install_full.sh.cc +2 -0
- data/scripts/build/src/uninstall.sh.cc +1 -1
- data/scripts/install.ps1 +19 -5
- data/scripts/install.sh +9 -2
- data/scripts/install_full.sh +11 -2
- data/scripts/install_rails_deps.sh +9 -2
- data/scripts/uninstall.sh +10 -3
- metadata +7 -2
|
@@ -193,6 +193,7 @@ module Clacky
|
|
|
193
193
|
# this bypass lets us recover on the retry without a server restart.
|
|
194
194
|
def to_api(force_reasoning_content_pad: false)
|
|
195
195
|
msgs = @messages.map { |m| strip_for_api(m) }
|
|
196
|
+
msgs = repair_tool_call_pairing(msgs)
|
|
196
197
|
ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
|
|
197
198
|
end
|
|
198
199
|
|
|
@@ -276,6 +277,62 @@ module Clacky
|
|
|
276
277
|
message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
|
|
277
278
|
end
|
|
278
279
|
|
|
280
|
+
# Defensive integrity pass before sending history to the LLM.
|
|
281
|
+
# OpenAI-compat protocols (incl. DeepSeek) require every assistant.tool_calls
|
|
282
|
+
# to be followed by a tool message for each tool_call_id. If a previous turn
|
|
283
|
+
# was interrupted mid-act() — e.g. a channel user sent a second message
|
|
284
|
+
# before the first finished — the tool segment may be missing entries,
|
|
285
|
+
# producing HTTP 400 "insufficient tool messages following tool_calls".
|
|
286
|
+
# This pass scans the whole history and inserts placeholder tool messages
|
|
287
|
+
# for any unmatched ids. It is deterministic (same input → same output)
|
|
288
|
+
# so prompt caching is not disturbed.
|
|
289
|
+
private def repair_tool_call_pairing(msgs)
|
|
290
|
+
result = []
|
|
291
|
+
i = 0
|
|
292
|
+
while i < msgs.size
|
|
293
|
+
msg = msgs[i]
|
|
294
|
+
result << msg
|
|
295
|
+
|
|
296
|
+
if msg[:role] == "assistant" && msg[:tool_calls].is_a?(Array) && !msg[:tool_calls].empty?
|
|
297
|
+
expected_ids = msg[:tool_calls].map { |tc| tc[:id] }.compact
|
|
298
|
+
seen_ids = []
|
|
299
|
+
|
|
300
|
+
j = i + 1
|
|
301
|
+
while j < msgs.size && tool_result_message?(msgs[j])
|
|
302
|
+
result << msgs[j]
|
|
303
|
+
seen_ids.concat(tool_result_ids(msgs[j]))
|
|
304
|
+
j += 1
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
(expected_ids - seen_ids).each do |id|
|
|
308
|
+
result << {
|
|
309
|
+
role: "tool",
|
|
310
|
+
tool_call_id: id,
|
|
311
|
+
content: '{"error":"Tool result missing (interrupted)"}'
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
i = j
|
|
316
|
+
else
|
|
317
|
+
i += 1
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
result
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private def tool_result_message?(msg)
|
|
324
|
+
MessageFormat::OpenAI.tool_result_message?(msg) ||
|
|
325
|
+
MessageFormat::Anthropic.tool_result_message?(msg)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private def tool_result_ids(msg)
|
|
329
|
+
if MessageFormat::OpenAI.tool_result_message?(msg)
|
|
330
|
+
MessageFormat::OpenAI.tool_call_ids(msg)
|
|
331
|
+
else
|
|
332
|
+
MessageFormat::Anthropic.tool_use_ids(msg)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
279
336
|
# Detect thinking-mode providers purely from history content and pad
|
|
280
337
|
# synthetic assistant messages with an empty reasoning_content when needed.
|
|
281
338
|
#
|
|
@@ -28,10 +28,25 @@ module Clacky
|
|
|
28
28
|
@usage = nil
|
|
29
29
|
@last_input_tokens = 0
|
|
30
30
|
@last_output_tokens = 0
|
|
31
|
+
@parse_failures = 0
|
|
32
|
+
@frames_seen = 0
|
|
33
|
+
@bytes_seen = 0
|
|
34
|
+
@saw_done = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
38
|
+
|
|
39
|
+
def saw_done?
|
|
40
|
+
@saw_done
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
def handle(data_str)
|
|
34
|
-
|
|
44
|
+
@bytes_seen += data_str.bytesize
|
|
45
|
+
if data_str == "[DONE]"
|
|
46
|
+
@saw_done = true
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
@frames_seen += 1
|
|
35
50
|
data = parse_or_nil(data_str)
|
|
36
51
|
return unless data
|
|
37
52
|
|
|
@@ -93,7 +108,16 @@ module Clacky
|
|
|
93
108
|
|
|
94
109
|
private def parse_or_nil(s)
|
|
95
110
|
JSON.parse(s)
|
|
96
|
-
rescue JSON::ParserError
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
@parse_failures += 1
|
|
113
|
+
if @parse_failures == 1
|
|
114
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
115
|
+
provider: "openai",
|
|
116
|
+
error: "#{e.class}: #{e.message}",
|
|
117
|
+
frame_head: s.to_s[0, 200],
|
|
118
|
+
frame_bytes: s.to_s.bytesize
|
|
119
|
+
)
|
|
120
|
+
end
|
|
97
121
|
nil
|
|
98
122
|
end
|
|
99
123
|
|
|
@@ -181,6 +181,10 @@ module Clacky
|
|
|
181
181
|
at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
|
|
182
182
|
bot_id = data.dig("chatbotUserId").to_s
|
|
183
183
|
unless at_users.include?(bot_id) || content.include?("@")
|
|
184
|
+
observe_text = content.strip
|
|
185
|
+
unless observe_text.empty?
|
|
186
|
+
@on_message&.call({ platform: :dingtalk, chat_id: chat_id, user_id: sender_id, text: observe_text, observe_only: true })
|
|
187
|
+
end
|
|
184
188
|
return
|
|
185
189
|
end
|
|
186
190
|
end
|
|
@@ -188,7 +192,11 @@ module Clacky
|
|
|
188
192
|
allowed = @config[:allowed_users]
|
|
189
193
|
return if allowed && !allowed.empty? && !allowed.include?(sender_id)
|
|
190
194
|
|
|
191
|
-
text, files = extract_payload(data, robot_code)
|
|
195
|
+
text, files, unsupported = extract_payload(data, robot_code)
|
|
196
|
+
if unsupported
|
|
197
|
+
@on_message&.call({ platform: :dingtalk, chat_id: chat_id, unsupported: true })
|
|
198
|
+
return
|
|
199
|
+
end
|
|
192
200
|
return if text.strip.empty? && files.empty?
|
|
193
201
|
|
|
194
202
|
event = {
|
|
@@ -242,6 +250,7 @@ module Clacky
|
|
|
242
250
|
end
|
|
243
251
|
else
|
|
244
252
|
Clacky::Logger.info("[dingtalk] unsupported msgtype=#{msgtype}, ignoring")
|
|
253
|
+
return ["", [], true]
|
|
245
254
|
end
|
|
246
255
|
|
|
247
256
|
[text, files]
|
|
@@ -142,7 +142,10 @@ module Clacky
|
|
|
142
142
|
Clacky::Logger.warn("[DiscordAdapter] bot_user_id unavailable; dropping group message")
|
|
143
143
|
return
|
|
144
144
|
end
|
|
145
|
-
|
|
145
|
+
unless mentioned_ids.include?(@bot_user_id)
|
|
146
|
+
@on_message&.call(event.merge(observe_only: true))
|
|
147
|
+
return
|
|
148
|
+
end
|
|
146
149
|
end
|
|
147
150
|
|
|
148
151
|
allowed_users = @config[:allowed_users]
|
|
@@ -153,7 +156,10 @@ module Clacky
|
|
|
153
156
|
text = strip_bot_mention(msg["content"].to_s, @bot_user_id)
|
|
154
157
|
files = process_attachments(Array(msg["attachments"]), chat_id)
|
|
155
158
|
|
|
156
|
-
|
|
159
|
+
if text.strip.empty? && files.empty?
|
|
160
|
+
@on_message&.call({ type: :message, platform: :discord, chat_id: chat_id, unsupported: true })
|
|
161
|
+
return
|
|
162
|
+
end
|
|
157
163
|
|
|
158
164
|
event = {
|
|
159
165
|
type: :message,
|
|
@@ -177,7 +177,11 @@ module Clacky
|
|
|
177
177
|
Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
|
|
178
178
|
return
|
|
179
179
|
end
|
|
180
|
-
|
|
180
|
+
unless Array(event[:mentioned_open_ids]).include?(bot_id)
|
|
181
|
+
user_name = @bot.fetch_user_name(event[:user_id])
|
|
182
|
+
@on_message&.call(event.merge(observe_only: true, user_name: user_name))
|
|
183
|
+
return
|
|
184
|
+
end
|
|
181
185
|
end
|
|
182
186
|
|
|
183
187
|
allowed_users = @config[:allowed_users]
|
|
@@ -185,6 +189,11 @@ module Clacky
|
|
|
185
189
|
return unless allowed_users.include?(event[:user_id])
|
|
186
190
|
end
|
|
187
191
|
|
|
192
|
+
if event[:unsupported]
|
|
193
|
+
@on_message&.call(event)
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
|
|
188
197
|
# Download images and attach as file hashes
|
|
189
198
|
image_files = []
|
|
190
199
|
if event[:image_keys] && !event[:image_keys].empty?
|
|
@@ -42,6 +42,7 @@ module Clacky
|
|
|
42
42
|
@domain = domain
|
|
43
43
|
@token_cache = nil
|
|
44
44
|
@token_expires_at = nil
|
|
45
|
+
@user_name_cache = {}
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Send plain text message
|
|
@@ -236,6 +237,17 @@ module Clacky
|
|
|
236
237
|
nil
|
|
237
238
|
end
|
|
238
239
|
|
|
240
|
+
def fetch_user_name(open_id)
|
|
241
|
+
return @user_name_cache[open_id] if @user_name_cache.key?(open_id)
|
|
242
|
+
|
|
243
|
+
name = get("/open-apis/contact/v3/users/#{open_id}", params: { user_id_type: "open_id" })
|
|
244
|
+
.dig("data", "user", "name")
|
|
245
|
+
@user_name_cache[open_id] = name.to_s.strip.then { |n| n.empty? ? open_id : n }
|
|
246
|
+
rescue => e
|
|
247
|
+
Clacky::Logger.warn("[feishu] Failed to fetch user name for #{open_id}: #{e.message}")
|
|
248
|
+
@user_name_cache[open_id] = open_id
|
|
249
|
+
end
|
|
250
|
+
|
|
239
251
|
# Get tenant access token (cached)
|
|
240
252
|
# @return [String] Access token
|
|
241
253
|
def tenant_access_token
|
|
@@ -56,8 +56,16 @@ module Clacky
|
|
|
56
56
|
msg_type = message["message_type"]
|
|
57
57
|
Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
|
|
58
58
|
unless %w[text image file post].include?(msg_type)
|
|
59
|
-
Clacky::Logger.info("[feishu]
|
|
60
|
-
|
|
59
|
+
Clacky::Logger.info("[feishu] unsupported msg_type=#{msg_type}")
|
|
60
|
+
chat_type = message["chat_type"] == "p2p" ? :direct : :group
|
|
61
|
+
return {
|
|
62
|
+
type: :message,
|
|
63
|
+
platform: :feishu,
|
|
64
|
+
chat_id: message["chat_id"],
|
|
65
|
+
chat_type: chat_type,
|
|
66
|
+
mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
|
|
67
|
+
unsupported: true
|
|
68
|
+
}
|
|
61
69
|
end
|
|
62
70
|
|
|
63
71
|
content_raw = message["content"]
|
|
@@ -70,7 +78,7 @@ module Clacky
|
|
|
70
78
|
|
|
71
79
|
case msg_type
|
|
72
80
|
when "text"
|
|
73
|
-
text =
|
|
81
|
+
text = resolve_mentions(content["text"].to_s.strip, message["mentions"])
|
|
74
82
|
return nil if text.empty?
|
|
75
83
|
when "image"
|
|
76
84
|
image_keys = [content["image_key"]].compact
|
|
@@ -131,6 +139,18 @@ module Clacky
|
|
|
131
139
|
text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
# Replace @_user_N placeholders in text with real display names from mentions array.
|
|
143
|
+
# Falls back to stripping unresolved placeholders via strip_mentions.
|
|
144
|
+
def resolve_mentions(text, mentions)
|
|
145
|
+
mapping = Array(mentions).each_with_object({}) do |m, h|
|
|
146
|
+
key = m["key"].to_s
|
|
147
|
+
name = m.dig("name").to_s
|
|
148
|
+
h[key] = name unless key.empty? || name.empty?
|
|
149
|
+
end
|
|
150
|
+
result = text.gsub(/@_user_\d+/) { |k| mapping[k] ? "@#{mapping[k]}" : k }
|
|
151
|
+
strip_mentions(result).strip
|
|
152
|
+
end
|
|
153
|
+
|
|
134
154
|
# Parse a Feishu post content body into text and image_keys.
|
|
135
155
|
# post content structure from event payloads:
|
|
136
156
|
# {"title": "", "content": [[{tag, text, ...}, ...], ...]}
|
|
@@ -243,7 +243,13 @@ module Clacky
|
|
|
243
243
|
text = msg["text"].to_s
|
|
244
244
|
|
|
245
245
|
if is_group
|
|
246
|
-
|
|
246
|
+
unless group_mention?(msg, text)
|
|
247
|
+
observe_text = text.strip
|
|
248
|
+
unless observe_text.empty?
|
|
249
|
+
@on_message&.call({ type: :message, platform: :telegram, chat_id: chat_id.to_s, user_id: user_id.to_s, text: observe_text, observe_only: true })
|
|
250
|
+
end
|
|
251
|
+
return
|
|
252
|
+
end
|
|
247
253
|
text = strip_bot_mention(text)
|
|
248
254
|
end
|
|
249
255
|
|
|
@@ -255,7 +261,11 @@ module Clacky
|
|
|
255
261
|
files = collect_files(msg)
|
|
256
262
|
caption = msg["caption"].to_s
|
|
257
263
|
text = caption if text.empty? && !caption.empty?
|
|
258
|
-
|
|
264
|
+
|
|
265
|
+
if text.strip.empty? && files.empty?
|
|
266
|
+
@on_message&.call({ type: :message, platform: :telegram, chat_id: chat_id.to_s, unsupported: true })
|
|
267
|
+
return
|
|
268
|
+
end
|
|
259
269
|
|
|
260
270
|
event = {
|
|
261
271
|
type: :message,
|
|
@@ -78,11 +78,15 @@ module Clacky
|
|
|
78
78
|
def handle_raw_message(raw)
|
|
79
79
|
msgtype = raw["msgtype"]
|
|
80
80
|
Clacky::Logger.info("[wecom] msgtype=#{msgtype} raw=#{raw.to_s[0..300]}")
|
|
81
|
-
return unless %w[text image file mixed].include?(msgtype)
|
|
82
81
|
|
|
83
82
|
chat_id = raw["chatid"] || raw.dig("from", "userid")
|
|
84
83
|
return unless chat_id
|
|
85
84
|
|
|
85
|
+
unless %w[text image file mixed].include?(msgtype)
|
|
86
|
+
@on_message&.call({ type: :message, platform: :wecom, chat_id: chat_id, unsupported: true })
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
86
90
|
user_id = raw.dig("from", "userid")
|
|
87
91
|
chat_type = raw["chattype"] == "group" ? :group : :direct
|
|
88
92
|
text = ""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "channel_ui_controller"
|
|
4
|
+
require_relative "group_message_buffer"
|
|
4
5
|
|
|
5
6
|
module Clacky
|
|
6
7
|
module Channel
|
|
@@ -46,6 +47,7 @@ module Clacky
|
|
|
46
47
|
@running = false
|
|
47
48
|
@mutex = Mutex.new
|
|
48
49
|
@session_counters = Hash.new(0) # platform => count, for short session names
|
|
50
|
+
@group_buffer = GroupMessageBuffer.new
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
# Start all enabled adapters in background threads. Non-blocking.
|
|
@@ -80,6 +82,15 @@ module Clacky
|
|
|
80
82
|
@mutex.synchronize { @adapters.map(&:platform_id) }
|
|
81
83
|
end
|
|
82
84
|
|
|
85
|
+
# Return all buffered group chat messages for a given chat.
|
|
86
|
+
# @param chat_id [String]
|
|
87
|
+
# @return [Array<Hash>] each entry has :user_id, :user_name, :text
|
|
88
|
+
def group_history(chat_id)
|
|
89
|
+
@group_buffer.peek(chat_id).map do |e|
|
|
90
|
+
{ user_id: e.user_id, user_name: e.user_name, text: e.text }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
83
94
|
# Proactively send a message to a user on the given platform.
|
|
84
95
|
#
|
|
85
96
|
# For Weixin (iLink protocol) a context_token is required for every outbound
|
|
@@ -232,6 +243,16 @@ module Clacky
|
|
|
232
243
|
end
|
|
233
244
|
|
|
234
245
|
def route_message(adapter, event)
|
|
246
|
+
if event[:observe_only]
|
|
247
|
+
@group_buffer.push(event[:chat_id], user_id: event[:user_id], user_name: event[:user_name], text: event[:text].to_s)
|
|
248
|
+
return
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if event[:unsupported]
|
|
252
|
+
adapter.send_text(event[:chat_id], "Sorry, this message type is not supported.")
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
235
256
|
text = event[:text]&.strip
|
|
236
257
|
files = event[:files] || []
|
|
237
258
|
return if (text.nil? || text.empty?) && files.empty?
|
|
@@ -259,12 +280,25 @@ module Clacky
|
|
|
259
280
|
sub_count = web_ui_for_session_diag(session_id)
|
|
260
281
|
Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]}, text=#{text.inspect}, channel_subs=#{sub_count})")
|
|
261
282
|
|
|
262
|
-
# If session is running, interrupt it
|
|
283
|
+
# If session is running, interrupt it AND wait for the old thread to
|
|
284
|
+
# actually unwind before starting a new task. Without the join, two
|
|
285
|
+
# threads briefly race on the same agent/history and the old thread
|
|
286
|
+
# can land an assistant.tool_calls message that the new thread then
|
|
287
|
+
# ships to the LLM with no matching tool result — DeepSeek (strict
|
|
288
|
+
# OpenAI-compat) rejects this with HTTP 400 "insufficient tool
|
|
289
|
+
# messages following tool_calls message". CLI already waits via
|
|
290
|
+
# join(2); we do the same here so all entrypoints behave alike.
|
|
263
291
|
if session[:status] == :running
|
|
264
292
|
Clacky::Logger.info("[ChannelManager] Session busy, interrupting previous task")
|
|
293
|
+
old_thread = nil
|
|
294
|
+
@registry.with_session(session_id) { |s| old_thread = s[:thread] }
|
|
265
295
|
@interrupt_session.call(session_id)
|
|
266
|
-
|
|
267
|
-
|
|
296
|
+
if old_thread&.alive?
|
|
297
|
+
old_thread.join(2)
|
|
298
|
+
if old_thread.alive?
|
|
299
|
+
Clacky::Logger.warn("[ChannelManager] previous task did not finish within 2s; continuing anyway (watchdog will escalate)")
|
|
300
|
+
end
|
|
301
|
+
end
|
|
268
302
|
end
|
|
269
303
|
|
|
270
304
|
agent = session[:agent]
|
|
@@ -283,6 +317,11 @@ module Clacky
|
|
|
283
317
|
# source: :channel prevents the message from being echoed back to the IM channel.
|
|
284
318
|
web_ui&.show_user_message(text, source: :channel) unless text.nil? || text.empty?
|
|
285
319
|
|
|
320
|
+
# Prepend buffered group history so the agent knows what was discussed
|
|
321
|
+
# before it was @-mentioned. Buffer is cleared atomically on take.
|
|
322
|
+
# WebUI always receives the raw user text — context is agent-only.
|
|
323
|
+
prompt = build_prompt_with_context(event[:chat_id], text)
|
|
324
|
+
|
|
286
325
|
# Start typing keepalive BEFORE sending any message.
|
|
287
326
|
# sendmessage cancels the typing indicator in WeChat protocol,
|
|
288
327
|
# so keepalive must be running when "Thinking..." is sent so it
|
|
@@ -297,7 +336,7 @@ module Clacky
|
|
|
297
336
|
@run_agent_task.call(session_id, agent) do
|
|
298
337
|
begin
|
|
299
338
|
Clacky::Logger.info("[ChannelManager] agent.run START session=#{session_id[0, 8]} text=#{text.inspect}")
|
|
300
|
-
agent.run(
|
|
339
|
+
agent.run(prompt, files: files, display_text: text)
|
|
301
340
|
Clacky::Logger.info("[ChannelManager] agent.run END session=#{session_id[0, 8]} text=#{text.inspect}")
|
|
302
341
|
rescue StandardError => e
|
|
303
342
|
Clacky::Logger.error("[ChannelManager] agent.run RAISED session=#{session_id[0, 8]} #{e.class}: #{e.message}\n#{e.backtrace.first(8).join("\n")}")
|
|
@@ -669,6 +708,9 @@ module Clacky
|
|
|
669
708
|
bound_keys = Set.new
|
|
670
709
|
restored_count = 0
|
|
671
710
|
@registry.list(limit: nil).each do |summary|
|
|
711
|
+
info = summary[:channel_info]
|
|
712
|
+
next unless info.is_a?(Hash) && info[:platform] && info[:user_id] && info[:chat_id]
|
|
713
|
+
|
|
672
714
|
@registry.ensure(summary[:id])
|
|
673
715
|
agent = nil
|
|
674
716
|
@registry.with_session(summary[:id]) { |s| agent = s[:agent] }
|
|
@@ -696,6 +738,25 @@ module Clacky
|
|
|
696
738
|
rescue StandardError => e
|
|
697
739
|
Clacky::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
|
|
698
740
|
end
|
|
741
|
+
|
|
742
|
+
# Prepend recent group chat history to the user's message so the agent
|
|
743
|
+
# has context for questions like "what were we just discussing?".
|
|
744
|
+
# Returns the original text unchanged when there is no buffered history.
|
|
745
|
+
private def build_prompt_with_context(chat_id, text)
|
|
746
|
+
entries = @group_buffer.peek(chat_id, limit: GroupMessageBuffer::PROMPT_LIMIT)
|
|
747
|
+
return text if entries.empty?
|
|
748
|
+
|
|
749
|
+
total = @group_buffer.peek(chat_id).size
|
|
750
|
+
lines = entries.map { |e| "#{e.user_name || e.user_id}: #{e.text}" }.join("\n")
|
|
751
|
+
|
|
752
|
+
partial = total > entries.size
|
|
753
|
+
header = partial ? "[Group chat partial history (latest #{entries.size} of #{total}, max buffer #{GroupMessageBuffer::MAX_MESSAGES} messages)]" : "[Group chat history (#{total} messages, max buffer #{GroupMessageBuffer::MAX_MESSAGES})]"
|
|
754
|
+
footer = if partial
|
|
755
|
+
"[INSTRUCTION: If the above partial history is insufficient to answer, MUST web_fetch http://#{ENV["CLACKY_SERVER_HOST"]}:#{ENV["CLACKY_SERVER_PORT"]}/api/channels/group_history/#{chat_id} for full context.]"
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
[header, lines, footer, "---", text].compact.join("\n")
|
|
759
|
+
end
|
|
699
760
|
end
|
|
700
761
|
end
|
|
701
762
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Channel
|
|
5
|
+
# Stores recent group chat messages per chat_id so that when the bot is
|
|
6
|
+
# @-mentioned it can inject prior conversation context into the agent prompt.
|
|
7
|
+
# Thread-safe; bounded to MAX_MESSAGES per chat to limit memory growth.
|
|
8
|
+
class GroupMessageBuffer
|
|
9
|
+
MAX_MESSAGES = 15
|
|
10
|
+
PROMPT_LIMIT = 5
|
|
11
|
+
|
|
12
|
+
Entry = Struct.new(:user_id, :user_name, :text, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@buffers = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param chat_id [String]
|
|
20
|
+
# @param user_id [String]
|
|
21
|
+
# @param user_name [String, nil]
|
|
22
|
+
# @param text [String]
|
|
23
|
+
def push(chat_id, user_id:, text:, user_name: nil)
|
|
24
|
+
return if text.nil? || text.strip.empty?
|
|
25
|
+
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
buf = (@buffers[chat_id] ||= [])
|
|
28
|
+
buf << Entry.new(user_id: user_id, user_name: user_name, text: text.strip)
|
|
29
|
+
buf.shift if buf.size > MAX_MESSAGES
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Return the most recent `limit` entries without clearing the buffer.
|
|
34
|
+
# @param chat_id [String]
|
|
35
|
+
# @param limit [Integer, nil] max entries to return; nil = all
|
|
36
|
+
# @return [Array<Entry>]
|
|
37
|
+
def peek(chat_id, limit: nil)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
buf = @buffers[chat_id] || []
|
|
40
|
+
limit ? buf.last(limit) : buf.dup
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Return buffered entries for a chat and clear them atomically.
|
|
45
|
+
# Returns an empty array when there is no history.
|
|
46
|
+
# @param chat_id [String]
|
|
47
|
+
# @return [Array<Entry>]
|
|
48
|
+
def take(chat_id)
|
|
49
|
+
@mutex.synchronize { @buffers.delete(chat_id) || [] }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -223,8 +223,10 @@ module Clacky
|
|
|
223
223
|
# Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
|
|
224
224
|
# so they can call back into the server without hardcoding the port,
|
|
225
225
|
# and use the correct product name without re-reading brand.yml.
|
|
226
|
+
# CLACKY_SERVER_HOST always points at 127.0.0.1 so child processes hit
|
|
227
|
+
# the loopback listener (no access key required), regardless of bind.
|
|
226
228
|
ENV["CLACKY_SERVER_PORT"] = @port.to_s
|
|
227
|
-
ENV["CLACKY_SERVER_HOST"] =
|
|
229
|
+
ENV["CLACKY_SERVER_HOST"] = "127.0.0.1"
|
|
228
230
|
product_name = Clacky::BrandConfig.load.product_name
|
|
229
231
|
ENV["CLACKY_PRODUCT_NAME"] = (product_name.nil? || product_name.strip.empty?) ? "OpenClacky" : product_name
|
|
230
232
|
|
|
@@ -268,6 +270,13 @@ module Clacky
|
|
|
268
270
|
server.listeners.delete(@inherited_socket)
|
|
269
271
|
Clacky::Logger.info("[HttpServer PID=#{Process.pid}] detached inherited socket fd=#{@inherited_socket.fileno} before shutdown")
|
|
270
272
|
end
|
|
273
|
+
# Close the loopback listener we created in this worker so the port
|
|
274
|
+
# is freed before the next worker starts (hot restart path).
|
|
275
|
+
if @loopback_listener
|
|
276
|
+
server.listeners.delete(@loopback_listener)
|
|
277
|
+
@loopback_listener.close rescue nil
|
|
278
|
+
@loopback_listener = nil
|
|
279
|
+
end
|
|
271
280
|
t1 = Thread.new { @channel_manager.stop rescue nil }
|
|
272
281
|
t2 = Thread.new { Clacky::BrowserManager.instance.stop rescue nil }
|
|
273
282
|
t3 = Thread.new { @mcp_registry&.shutdown rescue nil }
|
|
@@ -286,6 +295,24 @@ module Clacky
|
|
|
286
295
|
Clacky::Logger.info("[HttpServer PID=#{Process.pid}] standalone, WEBrick listeners=#{server.listeners.map(&:fileno).inspect}")
|
|
287
296
|
end
|
|
288
297
|
|
|
298
|
+
# When bound to a specific non-loopback address (e.g. 192.168.x.x),
|
|
299
|
+
# local skills using 127.0.0.1 cannot reach the server. Attach an
|
|
300
|
+
# extra loopback listener so child processes (curl in skills, MCP, etc.)
|
|
301
|
+
# can always talk to the server via 127.0.0.1 without an access key.
|
|
302
|
+
# Skipped for 0.0.0.0 (already covers loopback) and for loopback binds.
|
|
303
|
+
@loopback_listener = nil
|
|
304
|
+
if !@localhost_only && @host.to_s != "0.0.0.0"
|
|
305
|
+
begin
|
|
306
|
+
@loopback_listener = TCPServer.new("127.0.0.1", @port)
|
|
307
|
+
@loopback_listener.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
|
308
|
+
server.listeners << @loopback_listener
|
|
309
|
+
Clacky::Logger.info("[HttpServer PID=#{Process.pid}] added loopback listener fd=#{@loopback_listener.fileno} on 127.0.0.1:#{@port}")
|
|
310
|
+
rescue Errno::EADDRINUSE, Errno::EACCES => e
|
|
311
|
+
Clacky::Logger.warn("[HttpServer PID=#{Process.pid}] could not add loopback listener on 127.0.0.1:#{@port}: #{e.class}: #{e.message}")
|
|
312
|
+
@loopback_listener = nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
289
316
|
# Mount API + WebSocket handler (takes priority).
|
|
290
317
|
# Use a custom Servlet so that DELETE/PUT/PATCH requests are not rejected
|
|
291
318
|
# by WEBrick's default method whitelist before reaching our dispatcher.
|
|
@@ -457,6 +484,9 @@ module Clacky
|
|
|
457
484
|
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
|
|
458
485
|
platform = path.sub("/api/channels/", "").sub("/send", "")
|
|
459
486
|
api_send_channel_message(platform, req, res)
|
|
487
|
+
elsif method == "GET" && path.match?(%r{^/api/channels/group_history/})
|
|
488
|
+
chat_id = URI.decode_www_form_component(path.sub("/api/channels/group_history/", ""))
|
|
489
|
+
api_group_history(chat_id, res)
|
|
460
490
|
elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
|
|
461
491
|
platform = path.sub("/api/channels/", "").sub("/users", "")
|
|
462
492
|
api_list_channel_users(platform, res)
|
|
@@ -651,8 +681,13 @@ module Clacky
|
|
|
651
681
|
def create_default_session
|
|
652
682
|
return unless @agent_config.models_configured?
|
|
653
683
|
|
|
654
|
-
# Restore up to
|
|
655
|
-
|
|
684
|
+
# Restore up to 2 sessions per source type from disk. Earlier this was
|
|
685
|
+
# 5/source (≤20 sessions), which exceeded max_idle_agents=10 and caused
|
|
686
|
+
# the first user message after a restart to spend several seconds in
|
|
687
|
+
# evict_excess_idle! serializing 10+ sessions back to disk. 2/source
|
|
688
|
+
# keeps the most-recent items hot for fast switch without blowing the
|
|
689
|
+
# idle budget.
|
|
690
|
+
@registry.restore_from_disk(n: 2)
|
|
656
691
|
|
|
657
692
|
# If nothing was restored (no persisted sessions), create a fresh default.
|
|
658
693
|
unless @registry.list(limit: 1).any?
|
|
@@ -1515,6 +1550,13 @@ module Clacky
|
|
|
1515
1550
|
["127.0.0.1", "::1", "localhost"].include?(host.to_s.strip)
|
|
1516
1551
|
end
|
|
1517
1552
|
|
|
1553
|
+
private def loopback_ip?(ip)
|
|
1554
|
+
return false if ip.nil?
|
|
1555
|
+
s = ip.to_s.strip
|
|
1556
|
+
return true if s == "127.0.0.1" || s == "::1"
|
|
1557
|
+
s.start_with?("127.") || s == "::ffff:127.0.0.1"
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1518
1560
|
# Resolve access key from CLACKY_ACCESS_KEY env var only.
|
|
1519
1561
|
private def resolve_access_key
|
|
1520
1562
|
key = ENV.fetch("CLACKY_ACCESS_KEY", "").strip
|
|
@@ -1562,6 +1604,11 @@ module Clacky
|
|
|
1562
1604
|
return true unless @access_key # public but no key configured (cli already blocked this)
|
|
1563
1605
|
|
|
1564
1606
|
ip = req.peeraddr.last rescue "unknown"
|
|
1607
|
+
# Requests arriving on the loopback interface are always trusted,
|
|
1608
|
+
# even when the server is bound to a public address. This lets local
|
|
1609
|
+
# skills/curl talk to the server without an access key.
|
|
1610
|
+
return true if loopback_ip?(ip)
|
|
1611
|
+
|
|
1565
1612
|
candidate = extract_key(req)
|
|
1566
1613
|
|
|
1567
1614
|
# Lazily evict expired lockout entries to prevent unbounded memory growth.
|
|
@@ -2043,7 +2090,7 @@ module Clacky
|
|
|
2043
2090
|
|
|
2044
2091
|
private def mcp_localhost_only(req, res)
|
|
2045
2092
|
ip = req.peeraddr.last rescue nil
|
|
2046
|
-
return true if
|
|
2093
|
+
return true if loopback_ip?(ip)
|
|
2047
2094
|
|
|
2048
2095
|
json_response(res, 403, { ok: false, error: "MCP write operations are only allowed from localhost" })
|
|
2049
2096
|
false
|
|
@@ -2258,6 +2305,13 @@ module Clacky
|
|
|
2258
2305
|
json_response(res, 500, { ok: false, error: e.message })
|
|
2259
2306
|
end
|
|
2260
2307
|
|
|
2308
|
+
def api_group_history(chat_id, res)
|
|
2309
|
+
messages = @channel_manager.group_history(chat_id)
|
|
2310
|
+
json_response(res, 200, { chat_id: chat_id, messages: messages })
|
|
2311
|
+
rescue StandardError => e
|
|
2312
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2313
|
+
end
|
|
2314
|
+
|
|
2261
2315
|
# POST /api/upload
|
|
2262
2316
|
# Accepts a multipart/form-data file upload (field name: "file").
|
|
2263
2317
|
# Runs the file through FileProcessor: saves original + generates structured
|
|
@@ -3809,7 +3863,7 @@ module Clacky
|
|
|
3809
3863
|
elsif result[:success]
|
|
3810
3864
|
json_response(res, 200, { ok: true, message: "Connected successfully" })
|
|
3811
3865
|
else
|
|
3812
|
-
json_response(res, 200, { ok: false, message: result[:error].to_s })
|
|
3866
|
+
json_response(res, 200, { ok: false, message: result[:error].to_s, error_code: result[:error_code] })
|
|
3813
3867
|
end
|
|
3814
3868
|
rescue => e
|
|
3815
3869
|
json_response(res, 200, { ok: false, message: e.message })
|
|
@@ -4583,15 +4637,27 @@ module Clacky
|
|
|
4583
4637
|
return
|
|
4584
4638
|
end
|
|
4585
4639
|
|
|
4586
|
-
@registry.evict_excess_idle!
|
|
4587
|
-
|
|
4588
4640
|
idle_timer = nil
|
|
4589
4641
|
@registry.with_session(session_id) { |s| idle_timer = s[:idle_timer] }
|
|
4590
4642
|
|
|
4591
4643
|
# Cancel any pending idle compression before starting a new task
|
|
4592
4644
|
idle_timer&.cancel
|
|
4593
4645
|
|
|
4646
|
+
# Mark running BEFORE evict_excess_idle! — otherwise this session
|
|
4647
|
+
# (still :idle here) can be evicted from the registry along with
|
|
4648
|
+
# other idle agents, breaking subsequent status updates and any
|
|
4649
|
+
# follow-up handle_user_message (which would early-return on
|
|
4650
|
+
# @registry.exist? == false).
|
|
4594
4651
|
@registry.update(session_id, status: :running)
|
|
4652
|
+
|
|
4653
|
+
# evict_excess_idle! serializes + writes 1 file per evicted session
|
|
4654
|
+
# (can be 5+ on first message after a restart when restore_from_disk
|
|
4655
|
+
# warmed up many idles). Running it inline added multi-second latency
|
|
4656
|
+
# to the first user message. Run it off the request path; eviction
|
|
4657
|
+
# is a memory-pressure relief, not a correctness requirement for
|
|
4658
|
+
# starting this task.
|
|
4659
|
+
Thread.new { @registry.evict_excess_idle! }
|
|
4660
|
+
|
|
4595
4661
|
broadcast_session_update(session_id)
|
|
4596
4662
|
|
|
4597
4663
|
thread = Thread.new do
|