openclacky 1.2.9 → 1.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/llm_caller.rb +3 -0
- data/lib/clacky/agent/message_compressor_helper.rb +6 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +9 -0
- data/lib/clacky/agent_config.rb +20 -1
- data/lib/clacky/brand_config.rb +1 -0
- data/lib/clacky/cli.rb +49 -22
- data/lib/clacky/idle_compression_timer.rb +38 -15
- data/lib/clacky/providers.rb +7 -2
- data/lib/clacky/rich_ui_controller.rb +1549 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
- data/lib/clacky/server/channel/channel_manager.rb +89 -2
- data/lib/clacky/server/http_server.rb +124 -9
- data/lib/clacky/session_manager.rb +9 -8
- data/lib/clacky/telemetry.rb +16 -2
- data/lib/clacky/ui2/layout_manager.rb +11 -7
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +75 -53
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +221 -2
- data/lib/clacky/web/billing.js +1 -1
- data/lib/clacky/web/i18n.js +28 -4
- data/lib/clacky/web/index.html +9 -1
- data/lib/clacky/web/sessions.js +443 -2
- data/lib/clacky/web/settings.js +50 -0
- data/lib/clacky/web/workspace.js +9 -1
- data/scripts/build/lib/network.sh +3 -3
- data/scripts/install.ps1 +16 -4
- data/scripts/install.sh +3 -3
- data/scripts/install_browser.sh +3 -3
- data/scripts/install_full.sh +3 -3
- data/scripts/install_rails_deps.sh +3 -3
- data/scripts/install_system_deps.sh +3 -3
- metadata +6 -2
|
@@ -300,12 +300,31 @@ module Clacky
|
|
|
300
300
|
return { message_id: nil }
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
+
text = sanitize_for_weixin(text)
|
|
303
304
|
return { message_id: nil } if text.strip.empty?
|
|
304
305
|
|
|
305
306
|
@send_queue.enqueue(chat_id, text, ctoken)
|
|
306
307
|
{ message_id: nil }
|
|
307
308
|
end
|
|
308
309
|
|
|
310
|
+
private def sanitize_for_weixin(text)
|
|
311
|
+
s = text.to_s
|
|
312
|
+
s = s.gsub(/^[ \t]{0,3}>[ \t]?/, "")
|
|
313
|
+
s = s.gsub(/\*\*([^\n*][^*]*?)\*\*/m) { Regexp.last_match(1) }
|
|
314
|
+
s = s.gsub(/`([^`\n]+)`/) { Regexp.last_match(1) }
|
|
315
|
+
s = s.gsub(/^[ \t]{0,3}\#{1,6}[ \t]+/, "")
|
|
316
|
+
s
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private def sanitize_file_name_for_weixin(name)
|
|
320
|
+
ext = File.extname(name.to_s)
|
|
321
|
+
stem = File.basename(name.to_s, ext)
|
|
322
|
+
stem = stem.tr("&<>\"'\/", "_")
|
|
323
|
+
stem = stem.gsub(/[\x00-\x1f]/, "_").strip
|
|
324
|
+
stem = "file" if stem.empty?
|
|
325
|
+
"#{stem}#{ext}"
|
|
326
|
+
end
|
|
327
|
+
|
|
309
328
|
# Force-flush pending text for a chat_id. Called before sending files or on task completion.
|
|
310
329
|
def flush_pending(chat_id)
|
|
311
330
|
@send_queue.flush(chat_id)
|
|
@@ -321,17 +340,20 @@ module Clacky
|
|
|
321
340
|
return { message_id: nil }
|
|
322
341
|
end
|
|
323
342
|
|
|
343
|
+
display_name = name || File.basename(file_path)
|
|
344
|
+
safe_name = sanitize_file_name_for_weixin(display_name)
|
|
345
|
+
|
|
324
346
|
@send_queue.flush(chat_id)
|
|
325
347
|
|
|
326
348
|
@api_client.send_file(
|
|
327
349
|
to_user_id: chat_id,
|
|
328
350
|
file_path: file_path,
|
|
329
|
-
file_name:
|
|
351
|
+
file_name: safe_name,
|
|
330
352
|
context_token: ctoken
|
|
331
353
|
)
|
|
332
354
|
{ message_id: nil }
|
|
333
355
|
rescue => e
|
|
334
|
-
Clacky::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.message}")
|
|
356
|
+
Clacky::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.class}: #{e.message}")
|
|
335
357
|
{ message_id: nil }
|
|
336
358
|
end
|
|
337
359
|
|
|
@@ -58,6 +58,9 @@ module Clacky
|
|
|
58
58
|
|
|
59
59
|
Clacky::Logger.info("[ChannelManager] Starting channels: #{enabled_platforms.join(", ")}")
|
|
60
60
|
@running = true
|
|
61
|
+
|
|
62
|
+
restore_channel_bindings
|
|
63
|
+
|
|
61
64
|
enabled_platforms.each { |platform| start_adapter(platform) }
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -240,7 +243,11 @@ module Clacky
|
|
|
240
243
|
end
|
|
241
244
|
|
|
242
245
|
session_id = resolve_session(event)
|
|
243
|
-
|
|
246
|
+
if session_id
|
|
247
|
+
bind_key_to_session(channel_key(event), session_id)
|
|
248
|
+
else
|
|
249
|
+
session_id = auto_create_session(adapter, event)
|
|
250
|
+
end
|
|
244
251
|
|
|
245
252
|
session = @registry.get(session_id)
|
|
246
253
|
unless session
|
|
@@ -263,8 +270,13 @@ module Clacky
|
|
|
263
270
|
agent = session[:agent]
|
|
264
271
|
web_ui = session[:ui]
|
|
265
272
|
|
|
273
|
+
# Set channel info on the agent so session context includes platform/sender.
|
|
274
|
+
agent.channel_info = extract_channel_info(event) if agent.respond_to?(:channel_info=)
|
|
275
|
+
|
|
276
|
+
# Re-attach channel UI if it was dropped (session was evicted from memory and rebuilt by ensure).
|
|
277
|
+
ensure_channel_ui_subscribed(session_id, event)
|
|
278
|
+
|
|
266
279
|
# Update reply context so responses thread under the current message.
|
|
267
|
-
# channel_ui is bound to the session for its full lifetime (created in auto_create_session).
|
|
268
280
|
channel_ui_for_session(session_id)&.update_message_context(event)
|
|
269
281
|
|
|
270
282
|
# Sync the inbound message to WebUI so it shows up in the browser session.
|
|
@@ -499,6 +511,16 @@ module Clacky
|
|
|
499
511
|
found = nil
|
|
500
512
|
@registry.with_session(summary[:id]) { |s| found = s[:channel_keys]&.include?(key) }
|
|
501
513
|
return summary[:id] if found
|
|
514
|
+
|
|
515
|
+
# Check evicted channel sessions via persisted channel_info
|
|
516
|
+
next unless summary[:source] == "channel"
|
|
517
|
+
next unless @registry.ensure(summary[:id])
|
|
518
|
+
agent = nil
|
|
519
|
+
@registry.with_session(summary[:id]) { |s| agent = s[:agent] }
|
|
520
|
+
next unless agent&.channel_info
|
|
521
|
+
next unless channel_key_from_info(agent.channel_info) == key
|
|
522
|
+
bind_key_to_session(key, summary[:id])
|
|
523
|
+
return summary[:id]
|
|
502
524
|
end
|
|
503
525
|
nil
|
|
504
526
|
rescue StandardError => e
|
|
@@ -534,6 +556,24 @@ module Clacky
|
|
|
534
556
|
result
|
|
535
557
|
end
|
|
536
558
|
|
|
559
|
+
# Make sure session has a ChannelUIController subscribed to its WebUIController.
|
|
560
|
+
# Needed both at startup (for restored sessions) and after a session is evicted
|
|
561
|
+
# from memory and rebuilt by SessionRegistry#ensure (which drops :ui/:channel_ui).
|
|
562
|
+
def ensure_channel_ui_subscribed(session_id, event)
|
|
563
|
+
needs_attach = false
|
|
564
|
+
@registry.with_session(session_id) do |s|
|
|
565
|
+
needs_attach = s[:ui] && s[:channel_ui].nil?
|
|
566
|
+
end
|
|
567
|
+
return unless needs_attach
|
|
568
|
+
|
|
569
|
+
channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
|
|
570
|
+
@registry.with_session(session_id) do |s|
|
|
571
|
+
next unless s[:ui] && s[:channel_ui].nil?
|
|
572
|
+
s[:ui].subscribe_channel(channel_ui)
|
|
573
|
+
s[:channel_ui] = channel_ui
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
537
577
|
def web_ui_for_session_diag(session_id)
|
|
538
578
|
result = nil
|
|
539
579
|
@registry.with_session(session_id) do |s|
|
|
@@ -581,6 +621,27 @@ module Clacky
|
|
|
581
621
|
end
|
|
582
622
|
end
|
|
583
623
|
|
|
624
|
+
def channel_key_from_info(channel_info)
|
|
625
|
+
platform = channel_info[:platform].to_s
|
|
626
|
+
chat_id = channel_info[:chat_id].to_s
|
|
627
|
+
user_id = channel_info[:user_id].to_s
|
|
628
|
+
case @binding_mode
|
|
629
|
+
when :chat then "#{platform}:chat:#{chat_id}"
|
|
630
|
+
when :user then "#{platform}:user:#{user_id}"
|
|
631
|
+
else # :chat_user (default)
|
|
632
|
+
"#{platform}:chat:#{chat_id}:user:#{user_id}"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
private def extract_channel_info(event)
|
|
637
|
+
{
|
|
638
|
+
platform: event[:platform],
|
|
639
|
+
user_id: event[:user_id],
|
|
640
|
+
user_name: event[:user_name],
|
|
641
|
+
chat_id: event[:chat_id]
|
|
642
|
+
}
|
|
643
|
+
end
|
|
644
|
+
|
|
584
645
|
# Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
|
|
585
646
|
#
|
|
586
647
|
# Possible formats:
|
|
@@ -604,6 +665,32 @@ module Clacky
|
|
|
604
665
|
end
|
|
605
666
|
end
|
|
606
667
|
|
|
668
|
+
def restore_channel_bindings
|
|
669
|
+
bound_keys = Set.new
|
|
670
|
+
restored_count = 0
|
|
671
|
+
@registry.list(limit: nil).each do |summary|
|
|
672
|
+
@registry.ensure(summary[:id])
|
|
673
|
+
agent = nil
|
|
674
|
+
@registry.with_session(summary[:id]) { |s| agent = s[:agent] }
|
|
675
|
+
next unless agent&.channel_info
|
|
676
|
+
|
|
677
|
+
info = agent.channel_info
|
|
678
|
+
next unless info[:platform] && info[:user_id] && info[:chat_id]
|
|
679
|
+
|
|
680
|
+
key = channel_key_from_info(info)
|
|
681
|
+
|
|
682
|
+
event = { platform: info[:platform], chat_id: info[:chat_id] }
|
|
683
|
+
ensure_channel_ui_subscribed(summary[:id], event)
|
|
684
|
+
|
|
685
|
+
next unless bound_keys.add?(key)
|
|
686
|
+
bind_key_to_session(key, summary[:id])
|
|
687
|
+
|
|
688
|
+
Clacky::Logger.info("[ChannelManager] Restored channel binding #{key} -> session #{summary[:id][0, 8]}")
|
|
689
|
+
restored_count += 1
|
|
690
|
+
end
|
|
691
|
+
Clacky::Logger.info("[ChannelManager] Restored #{restored_count} channel binding(s)") if restored_count > 0
|
|
692
|
+
end
|
|
693
|
+
|
|
607
694
|
def safe_stop_adapter(adapter)
|
|
608
695
|
adapter.stop
|
|
609
696
|
rescue StandardError => e
|
|
@@ -4,6 +4,7 @@ require "webrick"
|
|
|
4
4
|
require "websocket"
|
|
5
5
|
require "socket"
|
|
6
6
|
require "json"
|
|
7
|
+
require "net/http"
|
|
7
8
|
require "thread"
|
|
8
9
|
require "fileutils"
|
|
9
10
|
require "tmpdir"
|
|
@@ -107,6 +108,8 @@ module Clacky
|
|
|
107
108
|
# GET /** → static files served from lib/clacky/web/ directory
|
|
108
109
|
class HttpServer
|
|
109
110
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
111
|
+
EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
|
|
112
|
+
EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
|
|
110
113
|
|
|
111
114
|
# Default SOUL.md written when the user skips the onboard conversation.
|
|
112
115
|
# A richer version is created by the Agent during the soul_setup phase.
|
|
@@ -368,6 +371,8 @@ module Clacky
|
|
|
368
371
|
90
|
|
369
372
|
elsif path == "/api/tool/browser"
|
|
370
373
|
30
|
|
374
|
+
elsif path == "/api/exchange-rate"
|
|
375
|
+
20
|
|
371
376
|
elsif path.end_with?("/benchmark")
|
|
372
377
|
20
|
|
373
378
|
elsif path == "/api/media/image"
|
|
@@ -401,6 +406,7 @@ module Clacky
|
|
|
401
406
|
when ["GET", "/api/skills"] then api_list_skills(res)
|
|
402
407
|
when ["GET", "/api/config"] then api_get_config(res)
|
|
403
408
|
when ["GET", "/api/config/settings"] then api_get_settings(res)
|
|
409
|
+
when ["GET", "/api/exchange-rate"] then api_exchange_rate(req, res)
|
|
404
410
|
when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
|
|
405
411
|
when ["POST", "/api/config/models"] then api_add_model(req, res)
|
|
406
412
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
@@ -656,6 +662,103 @@ module Clacky
|
|
|
656
662
|
end
|
|
657
663
|
end
|
|
658
664
|
|
|
665
|
+
# GET /api/exchange-rate?from=USD&to=CNY
|
|
666
|
+
# Fetches the latest exchange rate on demand. The browser still owns the
|
|
667
|
+
# saved preference in localStorage; this API is only a lightweight proxy
|
|
668
|
+
# that avoids CORS issues and normalizes provider responses.
|
|
669
|
+
def api_exchange_rate(req, res)
|
|
670
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
671
|
+
from = normalize_currency_code(query["from"], fallback: "USD")
|
|
672
|
+
to = normalize_currency_code(query["to"], fallback: "CNY")
|
|
673
|
+
|
|
674
|
+
unless from && to
|
|
675
|
+
return json_response(res, 400, { error: "from and to must be 3-letter currency codes" })
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
data = fetch_exchange_rate(from, to)
|
|
679
|
+
json_response(res, 200, data)
|
|
680
|
+
rescue StandardError => e
|
|
681
|
+
Clacky::Logger.warn("[ExchangeRate] failed: #{e.class}: #{e.message}")
|
|
682
|
+
json_response(res, 502, { error: "Failed to fetch exchange rate" })
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def normalize_currency_code(value, fallback:)
|
|
686
|
+
code = value.to_s.strip.upcase
|
|
687
|
+
code = fallback if code.empty?
|
|
688
|
+
code.match?(/\A[A-Z]{3}\z/) ? code : nil
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def fetch_exchange_rate(from, to)
|
|
692
|
+
fetch_open_exchange_rate(from, to)
|
|
693
|
+
rescue StandardError => primary_error
|
|
694
|
+
Clacky::Logger.warn("[ExchangeRate] primary source failed: #{primary_error.message}")
|
|
695
|
+
fetch_frankfurter_exchange_rate(from, to)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def fetch_open_exchange_rate(from, to)
|
|
699
|
+
data = fetch_exchange_rate_json("#{EXCHANGE_RATE_PRIMARY_BASE_URL}/#{URI.encode_www_form_component(from)}")
|
|
700
|
+
raise "open.er-api.com returned #{data["result"] || "unknown"}" unless data["result"] == "success"
|
|
701
|
+
|
|
702
|
+
rate = positive_float(data.dig("rates", to))
|
|
703
|
+
raise "open.er-api.com missing #{to} rate" unless rate
|
|
704
|
+
|
|
705
|
+
updated_at = data["time_last_update_utc"].to_s
|
|
706
|
+
{
|
|
707
|
+
from: from,
|
|
708
|
+
to: to,
|
|
709
|
+
rate: rate,
|
|
710
|
+
date: parse_exchange_rate_date(updated_at),
|
|
711
|
+
updated_at: updated_at,
|
|
712
|
+
source: "open.er-api.com"
|
|
713
|
+
}
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def fetch_frankfurter_exchange_rate(from, to)
|
|
717
|
+
query = URI.encode_www_form("from" => from, "to" => to)
|
|
718
|
+
data = fetch_exchange_rate_json("#{EXCHANGE_RATE_FALLBACK_URL}?#{query}")
|
|
719
|
+
|
|
720
|
+
rate = positive_float(data.dig("rates", to))
|
|
721
|
+
raise "frankfurter.app missing #{to} rate" unless rate
|
|
722
|
+
|
|
723
|
+
{
|
|
724
|
+
from: from,
|
|
725
|
+
to: to,
|
|
726
|
+
rate: rate,
|
|
727
|
+
date: data["date"].to_s,
|
|
728
|
+
updated_at: data["date"].to_s,
|
|
729
|
+
source: "frankfurter.app"
|
|
730
|
+
}
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def fetch_exchange_rate_json(url)
|
|
734
|
+
uri = URI(url)
|
|
735
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
736
|
+
http.use_ssl = uri.scheme == "https"
|
|
737
|
+
http.open_timeout = 5
|
|
738
|
+
http.read_timeout = 8
|
|
739
|
+
|
|
740
|
+
req = Net::HTTP::Get.new(uri.request_uri, "Accept" => "application/json")
|
|
741
|
+
response = http.request(req)
|
|
742
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
743
|
+
|
|
744
|
+
JSON.parse(response.body.to_s)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def positive_float(value)
|
|
748
|
+
rate = Float(value)
|
|
749
|
+
rate.positive? ? rate : nil
|
|
750
|
+
rescue ArgumentError, TypeError
|
|
751
|
+
nil
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def parse_exchange_rate_date(value)
|
|
755
|
+
return "" if value.to_s.strip.empty?
|
|
756
|
+
|
|
757
|
+
Date.parse(value.to_s).iso8601
|
|
758
|
+
rescue ArgumentError
|
|
759
|
+
""
|
|
760
|
+
end
|
|
761
|
+
|
|
659
762
|
# ── Onboard API ───────────────────────────────────────────────────────────
|
|
660
763
|
|
|
661
764
|
# GET /api/onboard/status
|
|
@@ -2649,16 +2752,28 @@ module Clacky
|
|
|
2649
2752
|
root = File.expand_path(agent.working_dir.to_s)
|
|
2650
2753
|
return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
|
|
2651
2754
|
|
|
2652
|
-
|
|
2653
|
-
rel =
|
|
2654
|
-
|
|
2755
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
2756
|
+
rel = query["path"].to_s
|
|
2757
|
+
absolute_mode = query["absolute"] == "true"
|
|
2758
|
+
|
|
2759
|
+
# Absolute mode: allow browsing outside working directory (e.g., root "/")
|
|
2760
|
+
if absolute_mode
|
|
2761
|
+
target = File.expand_path(rel.empty? ? "/" : rel)
|
|
2762
|
+
display_root = target
|
|
2763
|
+
# Normalize rel for API response
|
|
2764
|
+
rel = target
|
|
2765
|
+
else
|
|
2766
|
+
rel = rel.sub(%r{\A/+}, "").strip
|
|
2767
|
+
target = File.expand_path(File.join(root, rel))
|
|
2768
|
+
display_root = root
|
|
2655
2769
|
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2770
|
+
# Reject traversal outside the working directory.
|
|
2771
|
+
unless target == root || target.start_with?("#{root}/")
|
|
2772
|
+
return json_response(res, 403, { error: "Path outside working directory" })
|
|
2773
|
+
end
|
|
2659
2774
|
end
|
|
2660
|
-
return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
|
|
2661
2775
|
|
|
2776
|
+
return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
|
|
2662
2777
|
entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
|
|
2663
2778
|
|
|
2664
2779
|
items = entries.filter_map do |name|
|
|
@@ -2668,7 +2783,7 @@ module Clacky
|
|
|
2668
2783
|
next unless File.exist?(full)
|
|
2669
2784
|
{
|
|
2670
2785
|
name: name,
|
|
2671
|
-
path:
|
|
2786
|
+
path: "#{rel}/#{name}".gsub(%r{/+}, "/"),
|
|
2672
2787
|
type: is_dir ? "dir" : "file",
|
|
2673
2788
|
size: is_dir ? nil : (File.size(full) rescue nil)
|
|
2674
2789
|
}
|
|
@@ -2679,7 +2794,7 @@ module Clacky
|
|
|
2679
2794
|
# Directories first, then files; both case-insensitive alphabetical.
|
|
2680
2795
|
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
2681
2796
|
|
|
2682
|
-
json_response(res, 200, { root:
|
|
2797
|
+
json_response(res, 200, { root: display_root, path: rel, entries: items })
|
|
2683
2798
|
rescue StandardError => e
|
|
2684
2799
|
json_response(res, 500, { error: e.message })
|
|
2685
2800
|
end
|
|
@@ -179,16 +179,17 @@ module Clacky
|
|
|
179
179
|
deleted
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
-
# Keep only the most recent N sessions by created_at;
|
|
183
|
-
#
|
|
182
|
+
# Keep only the most recent N non-pinned sessions by created_at; the rest
|
|
183
|
+
# are soft-deleted (moved to the session trash, recoverable). Pinned
|
|
184
|
+
# sessions are never deleted and do not count toward the cap.
|
|
185
|
+
# Returns count of soft-deleted sessions.
|
|
184
186
|
def cleanup_by_count(keep:)
|
|
185
|
-
|
|
186
|
-
return 0 if
|
|
187
|
+
non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
|
|
188
|
+
return 0 if non_pinned.size <= keep
|
|
187
189
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
end.size
|
|
190
|
+
victims = non_pinned[keep..]
|
|
191
|
+
victims.each { |session| soft_delete(session[:session_id]) }
|
|
192
|
+
victims.size
|
|
192
193
|
end
|
|
193
194
|
|
|
194
195
|
# ── Session trash (delegates to Tools::TrashManager) ──────────────
|
data/lib/clacky/telemetry.rb
CHANGED
|
@@ -42,7 +42,8 @@ module Clacky
|
|
|
42
42
|
os: RbConfig::CONFIG["host_os"],
|
|
43
43
|
ruby_version: RUBY_VERSION,
|
|
44
44
|
brand: brand.branded? ? brand.package_name : nil,
|
|
45
|
-
launch_source: LAUNCH_SOURCES.fetch(ENV["CLACKY_LAUNCHED_BY"], "cli")
|
|
45
|
+
launch_source: LAUNCH_SOURCES.fetch(ENV["CLACKY_LAUNCHED_BY"], "cli"),
|
|
46
|
+
container: detect_container
|
|
46
47
|
}.compact
|
|
47
48
|
|
|
48
49
|
fire_and_forget("/api/v1/telemetry/startup", payload)
|
|
@@ -62,7 +63,8 @@ module Clacky
|
|
|
62
63
|
payload = {
|
|
63
64
|
device_id: resolve_device_id(brand),
|
|
64
65
|
version: Clacky::VERSION,
|
|
65
|
-
brand: brand.branded? ? brand.package_name : nil
|
|
66
|
+
brand: brand.branded? ? brand.package_name : nil,
|
|
67
|
+
container: detect_container
|
|
66
68
|
}
|
|
67
69
|
payload.merge!(extract_task_metrics(result)) if result.is_a?(Hash)
|
|
68
70
|
|
|
@@ -80,6 +82,18 @@ module Clacky
|
|
|
80
82
|
brand.device_id
|
|
81
83
|
end
|
|
82
84
|
|
|
85
|
+
private def detect_container
|
|
86
|
+
return "docker" if File.exist?("/.dockerenv")
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
cgroup = File.read("/proc/1/cgroup")
|
|
90
|
+
return "docker" if cgroup.include?("docker") || cgroup.include?("containerd")
|
|
91
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
83
97
|
private def extract_task_metrics(result)
|
|
84
98
|
cache = result[:cache_stats] || {}
|
|
85
99
|
duration = result[:duration_seconds]
|
|
@@ -381,15 +381,19 @@ module Clacky
|
|
|
381
381
|
render_all
|
|
382
382
|
end
|
|
383
383
|
|
|
384
|
-
def cleanup_screen
|
|
384
|
+
def cleanup_screen(clear_screen: false)
|
|
385
385
|
@render_mutex.synchronize do
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
386
|
+
if clear_screen
|
|
387
|
+
screen.clear_screen(mode: :reset)
|
|
388
|
+
else
|
|
389
|
+
fixed_start = fixed_area_start_row
|
|
390
|
+
(fixed_start...screen.height).each do |row|
|
|
391
|
+
screen.move_cursor(row, 0)
|
|
392
|
+
screen.clear_line
|
|
393
|
+
end
|
|
394
|
+
screen.move_cursor([@output_row, 0].max, 0)
|
|
395
|
+
print "\r"
|
|
390
396
|
end
|
|
391
|
-
screen.move_cursor([@output_row, 0].max, 0)
|
|
392
|
-
print "\r"
|
|
393
397
|
screen.show_cursor
|
|
394
398
|
screen.flush
|
|
395
399
|
end
|