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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/lib/clacky/agent/session_serializer.rb +1 -1
  4. data/lib/clacky/agent/tool_registry.rb +10 -0
  5. data/lib/clacky/agent.rb +59 -22
  6. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  7. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  8. data/lib/clacky/client.rb +25 -3
  9. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  10. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  11. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  12. data/lib/clacky/message_history.rb +57 -0
  13. data/lib/clacky/openai_stream_aggregator.rb +26 -2
  14. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  15. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  16. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  17. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  18. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  19. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  20. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  21. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  22. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  23. data/lib/clacky/server/http_server.rb +73 -7
  24. data/lib/clacky/server/session_registry.rb +4 -6
  25. data/lib/clacky/tools/trash_manager.rb +1 -1
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +21 -3
  28. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  29. data/lib/clacky/web/brand.js +22 -2
  30. data/lib/clacky/web/favicon.ico +0 -0
  31. data/lib/clacky/web/i18n.js +4 -0
  32. data/lib/clacky/web/index.html +4 -3
  33. data/lib/clacky/web/logo_nav_dark.png +0 -0
  34. data/lib/clacky/web/model-tester.js +8 -1
  35. data/lib/clacky/web/sessions.js +169 -41
  36. data/lib/clacky/web/theme.js +1 -0
  37. data/scripts/build/lib/gem.sh +9 -2
  38. data/scripts/build/src/install_full.sh.cc +2 -0
  39. data/scripts/build/src/uninstall.sh.cc +1 -1
  40. data/scripts/install.ps1 +19 -5
  41. data/scripts/install.sh +9 -2
  42. data/scripts/install_full.sh +11 -2
  43. data/scripts/install_rails_deps.sh +9 -2
  44. data/scripts/uninstall.sh +10 -3
  45. 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
- return if data_str == "[DONE]"
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
- return unless mentioned_ids.include?(@bot_user_id)
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
- return if text.strip.empty? && files.empty?
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
- return unless Array(event[:mentioned_open_ids]).include?(bot_id)
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] dropping unsupported msg_type=#{msg_type}")
60
- return nil
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 = strip_mentions(content["text"].to_s.strip)
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
- return unless group_mention?(msg, text)
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
- return if text.strip.empty? && files.empty?
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 automatically (mimics CLI behavior)
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
- # Wait briefly for the thread to catch the interrupt and update status
267
- sleep 0.1
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(text, files: files)
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"] = (@host == "0.0.0.0" ? "127.0.0.1" : @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 5 sessions per source type from disk into the registry.
655
- @registry.restore_from_disk(n: 5)
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 %w[127.0.0.1 ::1].include?(ip)
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