openclacky 1.2.10 → 1.2.13

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +56 -1
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/session_serializer.rb +1 -1
  8. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  9. data/lib/clacky/agent/skill_evolution.rb +23 -5
  10. data/lib/clacky/agent/skill_manager.rb +86 -1
  11. data/lib/clacky/agent/skill_reflector.rb +18 -23
  12. data/lib/clacky/agent/tool_registry.rb +10 -0
  13. data/lib/clacky/agent.rb +68 -23
  14. data/lib/clacky/agent_config.rb +59 -15
  15. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  16. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  17. data/lib/clacky/cli.rb +55 -0
  18. data/lib/clacky/client.rb +25 -3
  19. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  20. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  21. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  22. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  23. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  24. data/lib/clacky/idle_compression_timer.rb +1 -1
  25. data/lib/clacky/message_format/open_ai.rb +7 -1
  26. data/lib/clacky/message_history.rb +57 -0
  27. data/lib/clacky/openai_stream_aggregator.rb +30 -3
  28. data/lib/clacky/providers.rb +40 -12
  29. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  30. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  31. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  32. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  33. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  34. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  35. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  36. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  37. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  38. data/lib/clacky/server/http_server.rb +190 -10
  39. data/lib/clacky/server/session_registry.rb +34 -14
  40. data/lib/clacky/server/web_ui_controller.rb +24 -1
  41. data/lib/clacky/session_manager.rb +120 -0
  42. data/lib/clacky/tools/trash_manager.rb +1 -1
  43. data/lib/clacky/tools/web_search.rb +59 -8
  44. data/lib/clacky/ui2/layout_manager.rb +15 -5
  45. data/lib/clacky/ui2/progress_handle.rb +7 -1
  46. data/lib/clacky/ui2/ui_controller.rb +27 -0
  47. data/lib/clacky/ui_interface.rb +22 -0
  48. data/lib/clacky/utils/model_pricing.rb +96 -0
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +230 -7
  51. data/lib/clacky/web/app.js +6 -5
  52. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  53. data/lib/clacky/web/brand.js +22 -2
  54. data/lib/clacky/web/favicon.ico +0 -0
  55. data/lib/clacky/web/i18n.js +22 -4
  56. data/lib/clacky/web/index.html +6 -4
  57. data/lib/clacky/web/logo_nav_dark.png +0 -0
  58. data/lib/clacky/web/model-tester.js +8 -1
  59. data/lib/clacky/web/sessions.js +576 -120
  60. data/lib/clacky/web/settings.js +213 -51
  61. data/lib/clacky/web/skills.js +5 -14
  62. data/lib/clacky/web/theme.js +1 -0
  63. data/lib/clacky/web/utils.js +57 -0
  64. data/lib/clacky/web/ws-dispatcher.js +136 -0
  65. data/scripts/build/lib/gem.sh +9 -2
  66. data/scripts/build/src/install_full.sh.cc +2 -0
  67. data/scripts/build/src/uninstall.sh.cc +1 -1
  68. data/scripts/install.ps1 +19 -5
  69. data/scripts/install.sh +9 -2
  70. data/scripts/install_full.sh +11 -2
  71. data/scripts/install_rails_deps.sh +9 -2
  72. data/scripts/uninstall.sh +10 -3
  73. metadata +9 -2
@@ -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
@@ -5,6 +5,7 @@ require "websocket"
5
5
  require "socket"
6
6
  require "json"
7
7
  require "net/http"
8
+ require "faraday"
8
9
  require "thread"
9
10
  require "fileutils"
10
11
  require "tmpdir"
@@ -223,8 +224,10 @@ module Clacky
223
224
  # Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
224
225
  # so they can call back into the server without hardcoding the port,
225
226
  # and use the correct product name without re-reading brand.yml.
227
+ # CLACKY_SERVER_HOST always points at 127.0.0.1 so child processes hit
228
+ # the loopback listener (no access key required), regardless of bind.
226
229
  ENV["CLACKY_SERVER_PORT"] = @port.to_s
227
- ENV["CLACKY_SERVER_HOST"] = (@host == "0.0.0.0" ? "127.0.0.1" : @host)
230
+ ENV["CLACKY_SERVER_HOST"] = "127.0.0.1"
228
231
  product_name = Clacky::BrandConfig.load.product_name
229
232
  ENV["CLACKY_PRODUCT_NAME"] = (product_name.nil? || product_name.strip.empty?) ? "OpenClacky" : product_name
230
233
 
@@ -268,6 +271,13 @@ module Clacky
268
271
  server.listeners.delete(@inherited_socket)
269
272
  Clacky::Logger.info("[HttpServer PID=#{Process.pid}] detached inherited socket fd=#{@inherited_socket.fileno} before shutdown")
270
273
  end
274
+ # Close the loopback listener we created in this worker so the port
275
+ # is freed before the next worker starts (hot restart path).
276
+ if @loopback_listener
277
+ server.listeners.delete(@loopback_listener)
278
+ @loopback_listener.close rescue nil
279
+ @loopback_listener = nil
280
+ end
271
281
  t1 = Thread.new { @channel_manager.stop rescue nil }
272
282
  t2 = Thread.new { Clacky::BrowserManager.instance.stop rescue nil }
273
283
  t3 = Thread.new { @mcp_registry&.shutdown rescue nil }
@@ -286,6 +296,24 @@ module Clacky
286
296
  Clacky::Logger.info("[HttpServer PID=#{Process.pid}] standalone, WEBrick listeners=#{server.listeners.map(&:fileno).inspect}")
287
297
  end
288
298
 
299
+ # When bound to a specific non-loopback address (e.g. 192.168.x.x),
300
+ # local skills using 127.0.0.1 cannot reach the server. Attach an
301
+ # extra loopback listener so child processes (curl in skills, MCP, etc.)
302
+ # can always talk to the server via 127.0.0.1 without an access key.
303
+ # Skipped for 0.0.0.0 (already covers loopback) and for loopback binds.
304
+ @loopback_listener = nil
305
+ if !@localhost_only && @host.to_s != "0.0.0.0"
306
+ begin
307
+ @loopback_listener = TCPServer.new("127.0.0.1", @port)
308
+ @loopback_listener.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
309
+ server.listeners << @loopback_listener
310
+ Clacky::Logger.info("[HttpServer PID=#{Process.pid}] added loopback listener fd=#{@loopback_listener.fileno} on 127.0.0.1:#{@port}")
311
+ rescue Errno::EADDRINUSE, Errno::EACCES => e
312
+ Clacky::Logger.warn("[HttpServer PID=#{Process.pid}] could not add loopback listener on 127.0.0.1:#{@port}: #{e.class}: #{e.message}")
313
+ @loopback_listener = nil
314
+ end
315
+ end
316
+
289
317
  # Mount API + WebSocket handler (takes priority).
290
318
  # Use a custom Servlet so that DELETE/PUT/PATCH requests are not rejected
291
319
  # by WEBrick's default method whitelist before reaching our dispatcher.
@@ -410,6 +438,7 @@ module Clacky
410
438
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
411
439
  when ["POST", "/api/config/models"] then api_add_model(req, res)
412
440
  when ["POST", "/api/config/test"] then api_test_config(req, res)
441
+ when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
413
442
  when ["GET", "/api/config/media"] then api_get_media_config(res)
414
443
  when ["GET", "/api/providers"] then api_list_providers(res)
415
444
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
@@ -457,6 +486,9 @@ module Clacky
457
486
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
458
487
  platform = path.sub("/api/channels/", "").sub("/send", "")
459
488
  api_send_channel_message(platform, req, res)
489
+ elsif method == "GET" && path.match?(%r{^/api/channels/group_history/})
490
+ chat_id = URI.decode_www_form_component(path.sub("/api/channels/group_history/", ""))
491
+ api_group_history(chat_id, res)
460
492
  elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
461
493
  platform = path.sub("/api/channels/", "").sub("/users", "")
462
494
  api_list_channel_users(platform, res)
@@ -522,6 +554,9 @@ module Clacky
522
554
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/working_dir$})
523
555
  session_id = path.sub("/api/sessions/", "").sub("/working_dir", "")
524
556
  api_change_session_working_dir(session_id, req, res)
557
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/fork$})
558
+ session_id = path.sub("/api/sessions/", "").sub("/fork", "")
559
+ api_fork_session(session_id, req, res)
525
560
  elsif method == "DELETE" && path.start_with?("/api/sessions/")
526
561
  session_id = path.sub("/api/sessions/", "")
527
562
  api_delete_session(session_id, res)
@@ -580,6 +615,7 @@ module Clacky
580
615
  limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
581
616
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
582
617
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
618
+ q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
583
619
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
584
620
  type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
585
621
  # Backward-compat: ?source=<x> and ?profile=coding → type
@@ -590,7 +626,7 @@ module Clacky
590
626
  # `registry.list` always returns ALL matching pinned rows first (on the
591
627
  # first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
592
628
  # So has_more is determined by whether the non-pinned section overflowed.
593
- sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
629
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type)
594
630
 
595
631
  # Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
596
632
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
@@ -651,8 +687,13 @@ module Clacky
651
687
  def create_default_session
652
688
  return unless @agent_config.models_configured?
653
689
 
654
- # Restore up to 5 sessions per source type from disk into the registry.
655
- @registry.restore_from_disk(n: 5)
690
+ # Restore up to 2 sessions per source type from disk. Earlier this was
691
+ # 5/source (≤20 sessions), which exceeded max_idle_agents=10 and caused
692
+ # the first user message after a restart to spend several seconds in
693
+ # evict_excess_idle! serializing 10+ sessions back to disk. 2/source
694
+ # keeps the most-recent items hot for fast switch without blowing the
695
+ # idle budget.
696
+ @registry.restore_from_disk(n: 2)
656
697
 
657
698
  # If nothing was restored (no persisted sessions), create a fresh default.
658
699
  unless @registry.list(limit: 1).any?
@@ -900,6 +941,9 @@ module Clacky
900
941
  api_key_masked: entry ? mask_api_key(entry["api_key"]) : nil,
901
942
  provider: state["provider"],
902
943
  available: state["available"],
944
+ aliases: state["aliases"] || {},
945
+ stale: state["stale"] || false,
946
+ requested_model: state["requested_model"],
903
947
  configured: state["configured"]
904
948
  }
905
949
  end
@@ -917,7 +961,8 @@ module Clacky
917
961
  defaults[t] = {
918
962
  provider: provider_id,
919
963
  model: provider_id ? Clacky::Providers.default_media_model(provider_id, t) : nil,
920
- available: provider_id ? Clacky::Providers.media_models(provider_id, t) : []
964
+ available: provider_id ? Clacky::Providers.media_models(provider_id, t) : [],
965
+ aliases: provider_id ? Clacky::Providers.media_model_aliases(provider_id, t) : {}
921
966
  }
922
967
  end
923
968
 
@@ -930,6 +975,85 @@ module Clacky
930
975
  # off / auto — remove any custom entry; "auto" lets the virtual
931
976
  # derivation in AgentConfig#find_model_by_type take over.
932
977
  # custom — replace any existing custom entry with the supplied fields.
978
+ # POST /api/config/media/test
979
+ # Body: { kind, source, model, base_url, api_key }
980
+ # Lightweight preflight: GET <base_url>/models to verify connectivity,
981
+ # auth, and that the requested model is exposed by the endpoint.
982
+ # No image is generated — zero cost, sub-second.
983
+ def api_test_media_config(req, res)
984
+ body = parse_json_body(req) || {}
985
+ kind = body["kind"].to_s
986
+ return json_response(res, 422, { error: "invalid kind" }) unless %w[image video audio].include?(kind)
987
+ return json_response(res, 422, { error: "only image kind supported" }) unless kind == "image"
988
+
989
+ api_key = body["api_key"].to_s
990
+ if api_key.empty? || api_key.include?("****")
991
+ existing = @agent_config.find_model_by_type(kind) || {}
992
+ api_key = existing["api_key"].to_s
993
+ end
994
+
995
+ model = body["model"].to_s.strip
996
+ base_url = body["base_url"].to_s.strip
997
+
998
+ if model.empty? || base_url.empty? || api_key.empty?
999
+ return json_response(res, 200, { ok: false, message: "model, base_url, api_key are required" })
1000
+ end
1001
+
1002
+ result = preflight_media_endpoint(base_url: base_url, api_key: api_key, model: model)
1003
+ json_response(res, 200, result)
1004
+ rescue => e
1005
+ json_response(res, 200, { ok: false, message: e.message })
1006
+ end
1007
+
1008
+ private def preflight_media_endpoint(base_url:, api_key:, model:)
1009
+ url = "#{base_url.chomp("/")}/models"
1010
+ conn = Faraday.new(url: url) do |f|
1011
+ f.options.timeout = 10
1012
+ f.options.open_timeout = 5
1013
+ end
1014
+
1015
+ response =
1016
+ begin
1017
+ conn.get do |req|
1018
+ req.headers["Authorization"] = "Bearer #{api_key}"
1019
+ req.headers["Accept"] = "application/json"
1020
+ end
1021
+ rescue Faraday::Error => e
1022
+ return { ok: false, message: "Network error: #{e.message}" }
1023
+ end
1024
+
1025
+ case response.status
1026
+ when 401, 403
1027
+ return { ok: false, message: "Authentication failed (HTTP #{response.status}). Check API key." }
1028
+ when 404
1029
+ return { ok: false, message: "Endpoint not found at #{url}. Check Base URL." }
1030
+ end
1031
+
1032
+ unless response.success?
1033
+ return { ok: false, message: "HTTP #{response.status}: #{response.body.to_s[0, 200]}" }
1034
+ end
1035
+
1036
+ body = JSON.parse(response.body) rescue nil
1037
+ ids =
1038
+ if body.is_a?(Hash) && body["data"].is_a?(Array)
1039
+ body["data"].map { |m| m["id"].to_s }
1040
+ elsif body.is_a?(Array)
1041
+ body.map { |m| m["id"].to_s }
1042
+ else
1043
+ []
1044
+ end
1045
+
1046
+ if ids.empty?
1047
+ return { ok: true, message: "Connected (model list unavailable; cannot verify model id)" }
1048
+ end
1049
+
1050
+ if ids.include?(model)
1051
+ { ok: true, message: "Connected. Model '#{model}' is available." }
1052
+ else
1053
+ { ok: false, message: "Connected, but model '#{model}' not found on this endpoint." }
1054
+ end
1055
+ end
1056
+
933
1057
  def api_update_media_config(kind, req, res)
934
1058
  body = parse_json_body(req) || {}
935
1059
  source = body["source"].to_s
@@ -939,7 +1063,23 @@ module Clacky
939
1063
 
940
1064
  @agent_config.models.reject! { |m| m["type"] == kind }
941
1065
 
942
- if source == "custom"
1066
+ case source
1067
+ when "off"
1068
+ @agent_config.models << {
1069
+ "id" => SecureRandom.uuid,
1070
+ "type" => kind,
1071
+ "disabled" => true
1072
+ }
1073
+ when "auto"
1074
+ override = body["model"].to_s.strip
1075
+ unless override.empty?
1076
+ @agent_config.models << {
1077
+ "id" => SecureRandom.uuid,
1078
+ "type" => kind,
1079
+ "model" => override
1080
+ }
1081
+ end
1082
+ when "custom"
943
1083
  model = body["model"].to_s.strip
944
1084
  base_url = body["base_url"].to_s.strip
945
1085
  api_key = body["api_key"].to_s
@@ -1515,6 +1655,13 @@ module Clacky
1515
1655
  ["127.0.0.1", "::1", "localhost"].include?(host.to_s.strip)
1516
1656
  end
1517
1657
 
1658
+ private def loopback_ip?(ip)
1659
+ return false if ip.nil?
1660
+ s = ip.to_s.strip
1661
+ return true if s == "127.0.0.1" || s == "::1"
1662
+ s.start_with?("127.") || s == "::ffff:127.0.0.1"
1663
+ end
1664
+
1518
1665
  # Resolve access key from CLACKY_ACCESS_KEY env var only.
1519
1666
  private def resolve_access_key
1520
1667
  key = ENV.fetch("CLACKY_ACCESS_KEY", "").strip
@@ -1562,6 +1709,11 @@ module Clacky
1562
1709
  return true unless @access_key # public but no key configured (cli already blocked this)
1563
1710
 
1564
1711
  ip = req.peeraddr.last rescue "unknown"
1712
+ # Requests arriving on the loopback interface are always trusted,
1713
+ # even when the server is bound to a public address. This lets local
1714
+ # skills/curl talk to the server without an access key.
1715
+ return true if loopback_ip?(ip)
1716
+
1565
1717
  candidate = extract_key(req)
1566
1718
 
1567
1719
  # Lazily evict expired lockout entries to prevent unbounded memory growth.
@@ -2043,7 +2195,7 @@ module Clacky
2043
2195
 
2044
2196
  private def mcp_localhost_only(req, res)
2045
2197
  ip = req.peeraddr.last rescue nil
2046
- return true if %w[127.0.0.1 ::1].include?(ip)
2198
+ return true if loopback_ip?(ip)
2047
2199
 
2048
2200
  json_response(res, 403, { ok: false, error: "MCP write operations are only allowed from localhost" })
2049
2201
  false
@@ -2258,6 +2410,13 @@ module Clacky
2258
2410
  json_response(res, 500, { ok: false, error: e.message })
2259
2411
  end
2260
2412
 
2413
+ def api_group_history(chat_id, res)
2414
+ messages = @channel_manager.group_history(chat_id)
2415
+ json_response(res, 200, { chat_id: chat_id, messages: messages })
2416
+ rescue StandardError => e
2417
+ json_response(res, 500, { ok: false, error: e.message })
2418
+ end
2419
+
2261
2420
  # POST /api/upload
2262
2421
  # Accepts a multipart/form-data file upload (field name: "file").
2263
2422
  # Runs the file through FileProcessor: saves original + generates structured
@@ -3809,7 +3968,7 @@ module Clacky
3809
3968
  elsif result[:success]
3810
3969
  json_response(res, 200, { ok: true, message: "Connected successfully" })
3811
3970
  else
3812
- json_response(res, 200, { ok: false, message: result[:error].to_s })
3971
+ json_response(res, 200, { ok: false, message: result[:error].to_s, error_code: result[:error_code] })
3813
3972
  end
3814
3973
  rescue => e
3815
3974
  json_response(res, 200, { ok: false, message: e.message })
@@ -4147,6 +4306,15 @@ module Clacky
4147
4306
  json_response(res, 500, { error: e.message })
4148
4307
  end
4149
4308
 
4309
+ def api_fork_session(session_id, req, res)
4310
+ fork_data = @session_manager.fork(session_id)
4311
+ return json_response(res, 404, { error: "Session not found" }) unless fork_data
4312
+
4313
+ fork_id = fork_data[:session_id]
4314
+ broadcast_session_update(fork_id)
4315
+ json_response(res, 201, { session: @registry.snapshot(fork_id) })
4316
+ end
4317
+
4150
4318
  def api_delete_session(session_id, res)
4151
4319
  # A session exists if it's either in the runtime registry OR on disk.
4152
4320
  # Old sessions that were never restored into memory this server run
@@ -4583,15 +4751,27 @@ module Clacky
4583
4751
  return
4584
4752
  end
4585
4753
 
4586
- @registry.evict_excess_idle!
4587
-
4588
4754
  idle_timer = nil
4589
4755
  @registry.with_session(session_id) { |s| idle_timer = s[:idle_timer] }
4590
4756
 
4591
4757
  # Cancel any pending idle compression before starting a new task
4592
4758
  idle_timer&.cancel
4593
4759
 
4760
+ # Mark running BEFORE evict_excess_idle! — otherwise this session
4761
+ # (still :idle here) can be evicted from the registry along with
4762
+ # other idle agents, breaking subsequent status updates and any
4763
+ # follow-up handle_user_message (which would early-return on
4764
+ # @registry.exist? == false).
4594
4765
  @registry.update(session_id, status: :running)
4766
+
4767
+ # evict_excess_idle! serializes + writes 1 file per evicted session
4768
+ # (can be 5+ on first message after a restart when restore_from_disk
4769
+ # warmed up many idles). Running it inline added multi-second latency
4770
+ # to the first user message. Run it off the request path; eviction
4771
+ # is a memory-pressure relief, not a correctness requirement for
4772
+ # starting this task.
4773
+ Thread.new { @registry.evict_excess_idle! }
4774
+
4595
4775
  broadcast_session_update(session_id)
4596
4776
 
4597
4777
  thread = Thread.new do
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Clacky
4
6
  module Server
5
7
  # SessionRegistry is the single authoritative source for session state.
@@ -99,16 +101,15 @@ module Clacky
99
101
  @sessions.key?(session_id)
100
102
  end
101
103
 
102
- # Restore all sessions from disk (up to n per source type) into the registry.
103
- # Used at startup. Already-registered sessions are skipped.
104
- def restore_from_disk(n: 5)
104
+ # Restore at most n sessions per source as a hot cache at startup.
105
+ # Everything else is loaded on demand via ensure(id).
106
+ def restore_from_disk(n: 2)
105
107
  return unless @session_manager && @session_restorer
106
108
 
107
109
  all = @session_manager.all_sessions
108
110
  .sort_by { |s| s[:created_at] || "" }
109
111
  .reverse
110
112
 
111
- # Take up to n per source type
112
113
  counts = Hash.new(0)
113
114
  all.each do |session_data|
114
115
  src = (session_data[:source] || "manual").to_s
@@ -159,7 +160,7 @@ module Clacky
159
160
  # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
160
161
  #
161
162
  # source and profile are orthogonal — either can be nil independently.
162
- def list(limit: nil, before: nil, q: nil, date: nil, type: nil, include_pinned: true)
163
+ def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, include_pinned: true)
163
164
  return [] unless @session_manager
164
165
 
165
166
  live = @mutex.synchronize do
@@ -196,13 +197,25 @@ module Clacky
196
197
  # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
197
198
  all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
198
199
 
199
- # ── name / id search ─────────────────────────────────────────────────
200
+ # ── name / id / content search ───────────────────────────────────────
201
+ content_snippets = nil
200
202
  if q && !q.empty?
201
- q_down = q.downcase
202
- all = all.select { |s|
203
- (s[:name] || "").downcase.include?(q_down) ||
204
- (s[:session_id] || "").downcase.include?(q_down)
205
- }
203
+ if q_scope == "content"
204
+ content_snippets = @session_manager.search_content(q)
205
+ if content_snippets.empty?
206
+ all = []
207
+ else
208
+ prefix_set = content_snippets.keys.to_set
209
+ all = all.select { |s| prefix_set.include?((s[:session_id] || "")[0, 8]) }
210
+ end
211
+ else
212
+ q_down = q.downcase
213
+ id_match_eligible = q_down.match?(/\A[0-9a-f]{6,}\z/)
214
+ all = all.select { |s|
215
+ (s[:name] || "").downcase.include?(q_down) ||
216
+ (id_match_eligible && (s[:session_id] || "").downcase.include?(q_down))
217
+ }
218
+ end
206
219
  end
207
220
 
208
221
  # ── Split pinned vs non-pinned BEFORE applying `before`/`limit`.
@@ -224,7 +237,15 @@ module Clacky
224
237
 
225
238
  ordered = pinned_section + non_pinned
226
239
 
227
- ordered.map { |s| build_enriched_row(s, live[s[:session_id]]) }
240
+ ordered.map do |s|
241
+ row = build_enriched_row(s, live[s[:session_id]])
242
+ if content_snippets
243
+ short = (s[:session_id] || "")[0, 8]
244
+ snip = content_snippets[short]
245
+ row[:search_snippet] = snip if snip
246
+ end
247
+ row
248
+ end
228
249
  end
229
250
 
230
251
  # Return the same enriched hash that a `list` row would produce, for a
@@ -291,6 +312,7 @@ module Clacky
291
312
  latest_latency: ls&.dig(:latest_latency),
292
313
  reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
293
314
  pinned: s[:pinned] || false,
315
+ channel_info: s[:channel_info],
294
316
  }
295
317
  end
296
318
 
@@ -375,8 +397,6 @@ module Clacky
375
397
  count_by_status(:running) >= max_running_agents
376
398
  end
377
399
 
378
- # Evict oldest idle agents beyond MAX_IDLE_AGENTS.
379
- # Persists session data to disk before releasing the agent from memory.
380
400
  def evict_excess_idle!
381
401
  to_evict = []
382
402