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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +56 -1
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/session_serializer.rb +1 -1
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent/tool_registry.rb +10 -0
- data/lib/clacky/agent.rb +68 -23
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
- data/lib/clacky/cli.rb +55 -0
- 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/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/message_history.rb +57 -0
- data/lib/clacky/openai_stream_aggregator.rb +30 -3
- data/lib/clacky/providers.rb +40 -12
- 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 +190 -10
- data/lib/clacky/server/session_registry.rb +34 -14
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +230 -7
- data/lib/clacky/web/app.js +6 -5
- 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 +22 -4
- data/lib/clacky/web/index.html +6 -4
- 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 +576 -120
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/theme.js +1 -0
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -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 +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
|
|
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
|
|
@@ -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"] =
|
|
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
|
|
655
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
103
|
-
#
|
|
104
|
-
def restore_from_disk(n:
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
|