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.
@@ -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: name || File.basename(file_path),
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
- session_id = auto_create_session(adapter, event) unless session_id
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
- rel = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
2653
- rel = rel.sub(%r{\A/+}, "").strip
2654
- target = File.expand_path(File.join(root, rel))
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
- # Reject traversal outside the working directory.
2657
- unless target == root || target.start_with?("#{root}/")
2658
- return json_response(res, 403, { error: "Path outside working directory" })
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: rel.empty? ? name : "#{rel}/#{name}",
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: root, path: rel, entries: items })
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; delete the rest.
183
- # Returns count of deleted sessions.
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
- sessions = all_sessions # already sorted newest-first
186
- return 0 if sessions.size <= keep
187
+ non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
188
+ return 0 if non_pinned.size <= keep
187
189
 
188
- sessions[keep..].each do |session|
189
- filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
190
- _hard_delete_session_with_chunks(filepath) if File.exist?(filepath)
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) ──────────────
@@ -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
- fixed_start = fixed_area_start_row
387
- (fixed_start...screen.height).each do |row|
388
- screen.move_cursor(row, 0)
389
- screen.clear_line
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
@@ -150,9 +150,9 @@ module Clacky
150
150
  end
151
151
 
152
152
  # Stop the UI controller
153
- def stop
153
+ def stop(clear_screen: false)
154
154
  @running = false
155
- @layout.cleanup_screen
155
+ @layout.cleanup_screen(clear_screen: clear_screen)
156
156
  end
157
157
 
158
158
  # Clear the input area
@@ -135,6 +135,6 @@ module Clacky
135
135
 
136
136
  # === Path redaction (for encrypted brand skill tmpdirs) ===
137
137
  # === Lifecycle ===
138
- def stop; end
138
+ def stop(clear_screen: false); end
139
139
  end
140
140
  end