openclacky 1.3.4 → 1.3.6

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/message_compressor.rb +32 -8
  5. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent/tool_executor.rb +0 -12
  8. data/lib/clacky/agent.rb +74 -12
  9. data/lib/clacky/api_extension.rb +81 -0
  10. data/lib/clacky/api_extension_loader.rb +13 -1
  11. data/lib/clacky/cli.rb +0 -1
  12. data/lib/clacky/client.rb +14 -17
  13. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  14. data/lib/clacky/default_agents/base_prompt.md +1 -0
  15. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  16. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  17. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  18. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  19. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  20. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  21. data/lib/clacky/json_ui_controller.rb +1 -1
  22. data/lib/clacky/media/base.rb +60 -0
  23. data/lib/clacky/media/dashscope.rb +385 -21
  24. data/lib/clacky/media/gemini.rb +9 -0
  25. data/lib/clacky/media/generator.rb +52 -0
  26. data/lib/clacky/media/openai_compat.rb +166 -0
  27. data/lib/clacky/null_ui_controller.rb +13 -0
  28. data/lib/clacky/plain_ui_controller.rb +1 -1
  29. data/lib/clacky/providers.rb +50 -2
  30. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  31. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  32. data/lib/clacky/server/http_server.rb +260 -122
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +3 -2
  35. data/lib/clacky/session_manager.rb +35 -4
  36. data/lib/clacky/skill_loader.rb +14 -2
  37. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  38. data/lib/clacky/tools/terminal.rb +0 -43
  39. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  40. data/lib/clacky/ui2/layout_manager.rb +0 -5
  41. data/lib/clacky/ui2/progress_handle.rb +0 -3
  42. data/lib/clacky/ui2/ui_controller.rb +140 -40
  43. data/lib/clacky/ui_interface.rb +10 -1
  44. data/lib/clacky/utils/encoding.rb +25 -0
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +199 -23
  47. data/lib/clacky/web/components/onboard.js +1 -14
  48. data/lib/clacky/web/components/sidebar.js +1 -3
  49. data/lib/clacky/web/features/backup/store.js +23 -0
  50. data/lib/clacky/web/features/backup/view.js +49 -22
  51. data/lib/clacky/web/features/brand/view.js +8 -5
  52. data/lib/clacky/web/features/channels/store.js +1 -20
  53. data/lib/clacky/web/features/mcp/store.js +1 -20
  54. data/lib/clacky/web/features/profile/store.js +1 -13
  55. data/lib/clacky/web/features/profile/view.js +16 -4
  56. data/lib/clacky/web/features/skills/store.js +6 -21
  57. data/lib/clacky/web/features/tasks/view.js +77 -28
  58. data/lib/clacky/web/features/version/store.js +2 -0
  59. data/lib/clacky/web/i18n.js +46 -3
  60. data/lib/clacky/web/index.html +67 -26
  61. data/lib/clacky/web/sessions.js +177 -87
  62. data/lib/clacky/web/settings.js +34 -2
  63. data/lib/clacky/web/ws-dispatcher.js +12 -4
  64. data/lib/clacky.rb +12 -5
  65. metadata +8 -1
@@ -17,7 +17,7 @@ module Clacky
17
17
  include Clacky::UIInterface
18
18
 
19
19
  attr_reader :layout, :renderer, :running, :inline_input, :input_area
20
- attr_accessor :config
20
+ attr_accessor :config, :available_models
21
21
 
22
22
  def initialize(config = {})
23
23
  @renderer = ViewRenderer.new
@@ -38,6 +38,7 @@ module Clacky
38
38
  @todo_area = Components::TodoArea.new
39
39
  @welcome_banner = Components::WelcomeBanner.new
40
40
  @inline_input = nil # Created when needed
41
+ @feedback_countdown = nil # Active auto-approve feedback countdown session
41
42
  @layout = LayoutManager.new(
42
43
  input_area: @input_area,
43
44
  todo_area: @todo_area
@@ -663,22 +664,13 @@ module Clacky
663
664
 
664
665
  # Called by ProgressHandle#finish.
665
666
  def unregister_progress(handle, final_frame:)
666
- Clacky::Logger.warn("[ph_debug] unreg_entry", oid: handle.object_id, eid: handle.entry_id, top: @progress_stack.last == handle, stack_size: @progress_stack.size, ff: final_frame.to_s[0, 200])
667
667
  @progress_mutex.synchronize do
668
- # If this handle still holds its entry (it's currently top), we
669
- # render one last frame there and release the id. If it was
670
- # previously detached (someone above is still active), its entry
671
- # is already gone and the final_frame is simply dropped.
672
668
  if handle.entry_id
673
669
  if final_frame && !final_frame.to_s.strip.empty?
674
- Clacky::Logger.warn("[ph_debug] unreg_update_entry", oid: handle.object_id, eid: handle.entry_id)
675
670
  update_entry(handle.entry_id, @renderer.render_progress(final_frame))
676
671
  else
677
- Clacky::Logger.warn("[ph_debug] unreg_remove_entry", oid: handle.object_id, eid: handle.entry_id)
678
672
  remove_entry(handle.entry_id)
679
673
  end
680
- else
681
- Clacky::Logger.warn("[ph_debug] unreg_no_entry_id", oid: handle.object_id)
682
674
  end
683
675
 
684
676
  @progress_stack.delete(handle)
@@ -874,7 +866,7 @@ module Clacky
874
866
 
875
867
  # Show error message
876
868
  # @param message [String] Error message
877
- def show_error(message, code: nil, top_up_url: nil)
869
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
878
870
  output = @renderer.render_error(message)
879
871
  append_output(output)
880
872
  end
@@ -1054,6 +1046,99 @@ module Clacky
1054
1046
  end
1055
1047
  end
1056
1048
 
1049
+ # Auto-approve countdown for request_user_feedback: show a single live
1050
+ # countdown line. If the user presses any key before timeout, collect
1051
+ # their answer and return it (intervention). Otherwise return :timeout so
1052
+ # the agent auto-decides and continues.
1053
+ # @param seconds [Integer] Countdown duration
1054
+ # @return [String, Symbol] feedback string, "" (bare Enter), or :timeout
1055
+ # Show a live single-line countdown in the output area while keeping the
1056
+ # normal input box usable. The agent thread blocks here until either:
1057
+ # * the user submits a message in the regular input box -> returns the text
1058
+ # * the user starts typing (cancels the auto-timeout but keeps waiting)
1059
+ # * the countdown reaches zero with no interaction -> returns :timeout
1060
+ # @param seconds [Integer] Countdown duration
1061
+ # @return [String, Symbol] feedback text, or :timeout
1062
+ def request_feedback_with_countdown(seconds: 10)
1063
+ theme = ThemeManager.current_theme
1064
+
1065
+ queue = Queue.new
1066
+ entry_id = @layout.append_output(countdown_prompt(seconds, theme))
1067
+
1068
+ session = {
1069
+ queue: queue,
1070
+ entry_id: entry_id,
1071
+ intervened: false,
1072
+ watchdog: nil
1073
+ }
1074
+ @feedback_countdown = session
1075
+
1076
+ session[:watchdog] = Thread.new do
1077
+ remaining = seconds.to_i
1078
+ while remaining.positive?
1079
+ break if session[:intervened]
1080
+
1081
+ @layout.replace_entry(entry_id, countdown_prompt(remaining, theme))
1082
+ sleep 1
1083
+ remaining -= 1
1084
+ end
1085
+ queue.push(:timeout) unless session[:intervened]
1086
+ end
1087
+
1088
+ result = queue.pop
1089
+ session[:watchdog].kill if session[:watchdog]&.alive?
1090
+ @feedback_countdown = nil
1091
+ @layout.remove_entry(entry_id) if entry_id
1092
+ @layout.recalculate_layout
1093
+ @layout.render_all
1094
+
1095
+ if result == :timeout
1096
+ append_output(theme.format_text(" No response — continuing automatically.", :thinking))
1097
+ elsif result.to_s.strip.empty?
1098
+ append_output(theme.format_text(" → (continue)", :success))
1099
+ result = ""
1100
+ end
1101
+
1102
+ result
1103
+ end
1104
+
1105
+ # Cancel the countdown's auto-timeout when the user starts interacting,
1106
+ # but keep waiting for their submitted answer. Returns true if a
1107
+ # countdown was active.
1108
+ private def intervene_feedback_countdown
1109
+ session = @feedback_countdown
1110
+ return false unless session && !session[:intervened]
1111
+
1112
+ session[:intervened] = true
1113
+ session[:watchdog].kill if session[:watchdog]&.alive?
1114
+ if session[:entry_id]
1115
+ @layout.remove_entry(session[:entry_id])
1116
+ session[:entry_id] = nil
1117
+ @layout.recalculate_layout
1118
+ @layout.render_all
1119
+ end
1120
+ true
1121
+ end
1122
+
1123
+ private def countdown_prompt(remaining, theme)
1124
+ theme.format_text(" Auto-continuing in #{remaining}s — type your answer to step in…", :info)
1125
+ end
1126
+
1127
+ # Whether a keystroke should count as the user stepping into a countdown.
1128
+ # Plain typing and pastes do; pure scroll/navigation keys do not.
1129
+ private def countdown_intervening_key?(key)
1130
+ case key
1131
+ when Hash
1132
+ key[:type] == :rapid_input
1133
+ when String
1134
+ key.length >= 1 && key.ord >= 32
1135
+ when :backspace, :enter
1136
+ true
1137
+ else
1138
+ false
1139
+ end
1140
+ end
1141
+
1057
1142
  # Show diff between old and new content
1058
1143
  # @param old_content [String] Old content
1059
1144
  # @param new_content [String] New content
@@ -1206,7 +1291,7 @@ module Clacky
1206
1291
  # Check if API key is configured and show warning if missing
1207
1292
  private def check_api_key_configuration
1208
1293
  config = Clacky::AgentConfig.load
1209
-
1294
+
1210
1295
  if !config.models_configured?
1211
1296
  show_warning("No models configured! Please run /config to set up your models and API keys.")
1212
1297
  elsif config.api_key.nil? || config.api_key.empty?
@@ -1250,7 +1335,7 @@ module Clacky
1250
1335
  while @running
1251
1336
  # Process any pending resize events
1252
1337
  @layout.process_pending_resize
1253
-
1338
+
1254
1339
  key = @layout.screen.read_key(timeout: 0.1)
1255
1340
  next unless key
1256
1341
 
@@ -1291,6 +1376,13 @@ module Clacky
1291
1376
  return
1292
1377
  end
1293
1378
 
1379
+ # During an auto-approve feedback countdown the normal input box stays
1380
+ # live; the first meaningful keystroke cancels the auto-timeout so the
1381
+ # user can finish typing their answer without being rushed.
1382
+ if @feedback_countdown && !@feedback_countdown[:intervened] && countdown_intervening_key?(key)
1383
+ intervene_feedback_countdown
1384
+ end
1385
+
1294
1386
  result = @input_area.handle_key(key)
1295
1387
 
1296
1388
  # Handle height change first
@@ -1416,6 +1508,14 @@ module Clacky
1416
1508
  append_output(output)
1417
1509
  end
1418
1510
 
1511
+ # If an auto-approve feedback countdown is waiting for an answer, this
1512
+ # submission IS that answer — deliver it to the waiting agent thread
1513
+ # instead of starting a brand-new turn.
1514
+ if (session = @feedback_countdown)
1515
+ session[:queue].push(data[:text].to_s)
1516
+ return
1517
+ end
1518
+
1419
1519
  # Then call callback (allows interrupting previous agent before processing new input)
1420
1520
  @input_callback&.call(data[:text], data[:files])
1421
1521
  end
@@ -1425,31 +1525,31 @@ module Clacky
1425
1525
  # @return [Hash, nil] Hash with updated config values, or nil if cancelled
1426
1526
  public def show_config_modal(current_config, test_callback: nil)
1427
1527
  modal = Components::ModalComponent.new
1428
-
1528
+
1429
1529
  loop do
1430
1530
  # Build menu choices
1431
1531
  choices = []
1432
-
1532
+
1433
1533
  # Add model list
1434
1534
  current_config.models.each_with_index do |model, idx|
1435
1535
  is_current = (idx == current_config.current_model_index)
1436
1536
  model_name = model["model"] || "unnamed"
1437
1537
  masked_key = mask_api_key(model["api_key"])
1438
-
1538
+
1439
1539
  # Add type badge if present
1440
1540
  type_badge = case model["type"]
1441
1541
  when "default" then "[default] "
1442
1542
  when "lite" then "[lite] "
1443
1543
  else ""
1444
1544
  end
1445
-
1545
+
1446
1546
  display_name = "#{type_badge}#{model_name} (#{masked_key})"
1447
1547
  choices << {
1448
1548
  name: display_name,
1449
1549
  value: { action: :switch, model_id: model["id"] }
1450
1550
  }
1451
1551
  end
1452
-
1552
+
1453
1553
  # Add action buttons
1454
1554
  choices << { name: "─" * 50, disabled: true }
1455
1555
  choices << { name: "[+] Add New Model", value: { action: :add } }
@@ -1458,16 +1558,16 @@ module Clacky
1458
1558
  choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1459
1559
  end
1460
1560
  choices << { name: "[X] Close", value: { action: :close } }
1461
-
1561
+
1462
1562
  # Show menu
1463
1563
  result = modal.show(
1464
- title: "Model Configuration",
1564
+ title: "Model Configuration",
1465
1565
  choices: choices,
1466
1566
  on_close: -> { @layout.rerender_all }
1467
1567
  )
1468
-
1568
+
1469
1569
  return nil if result.nil?
1470
-
1570
+
1471
1571
  case result[:action]
1472
1572
  when :switch
1473
1573
  # Just signal the caller which model to switch to.
@@ -1699,16 +1799,16 @@ module Clacky
1699
1799
 
1700
1800
  result # Return selected task_id or nil
1701
1801
  end
1702
-
1802
+
1703
1803
  # Show form for editing a model
1704
1804
  # @param model [Hash, nil] Existing model hash or nil for new model
1705
1805
  # @return [Hash, nil] Updated model hash or nil if cancelled
1706
1806
  private def show_model_edit_form(model, test_callback: nil)
1707
1807
  modal = Components::ModalComponent.new
1708
-
1808
+
1709
1809
  is_new = model.nil?
1710
1810
  model ||= {}
1711
-
1811
+
1712
1812
  # For new models, show provider selection first
1713
1813
  selected_provider = nil
1714
1814
  if is_new
@@ -1718,32 +1818,32 @@ module Clacky
1718
1818
  end
1719
1819
  provider_choices << { name: "─" * 40, disabled: true }
1720
1820
  provider_choices << { name: "Custom (manual configuration)", value: "custom" }
1721
-
1821
+
1722
1822
  # Show provider selection
1723
1823
  selected_provider = modal.show(
1724
1824
  title: "Select Provider",
1725
1825
  choices: provider_choices,
1726
1826
  on_close: -> { @layout.rerender_all }
1727
1827
  )
1728
-
1828
+
1729
1829
  # User cancelled
1730
1830
  return nil if selected_provider.nil?
1731
1831
  end
1732
-
1832
+
1733
1833
  # Prepare masked API key for display
1734
1834
  masked_key = mask_api_key(model["api_key"])
1735
-
1835
+
1736
1836
  # Pre-fill values from provider preset if selected
1737
1837
  provider_preset = nil
1738
1838
  if selected_provider && selected_provider != "custom"
1739
1839
  provider_preset = Clacky::Providers.get(selected_provider)
1740
1840
  end
1741
-
1841
+
1742
1842
  # Get default values from provider or existing model
1743
1843
  default_model = provider_preset ? provider_preset["default_model"] : model["model"]
1744
1844
  default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
1745
1845
  default_api_key = model["api_key"] || ""
1746
-
1846
+
1747
1847
  # Define fields
1748
1848
  fields = [
1749
1849
  {
@@ -1763,7 +1863,7 @@ module Clacky
1763
1863
  default: default_base_url || ""
1764
1864
  }
1765
1865
  ]
1766
-
1866
+
1767
1867
  # Create validator if test_callback provided
1768
1868
  validator = if test_callback
1769
1869
  lambda do |values|
@@ -1772,14 +1872,14 @@ module Clacky
1772
1872
  model_name = values[:model].to_s.empty? ? model["model"] : values[:model]
1773
1873
  base_url = values[:base_url].to_s.empty? ? model["base_url"] : values[:base_url]
1774
1874
  anthropic_format = model["anthropic_format"] # Not editable in form, use model's value
1775
-
1875
+
1776
1876
  test_config_values = {
1777
1877
  "api_key" => api_key,
1778
1878
  "model" => model_name,
1779
1879
  "base_url" => base_url,
1780
1880
  "anthropic_format" => anthropic_format
1781
1881
  }
1782
-
1882
+
1783
1883
  # For new models, require all fields
1784
1884
  if is_new
1785
1885
  if test_config_values["api_key"].to_s.empty?
@@ -1792,7 +1892,7 @@ module Clacky
1792
1892
  return { success: false, error: "Base URL is required" }
1793
1893
  end
1794
1894
  end
1795
-
1895
+
1796
1896
  # Create a temporary config for testing
1797
1897
  temp_config = Clacky::AgentConfig.new(models: [test_config_values], current_model_index: 0)
1798
1898
  test_callback.call(temp_config)
@@ -1800,7 +1900,7 @@ module Clacky
1800
1900
  else
1801
1901
  nil
1802
1902
  end
1803
-
1903
+
1804
1904
  # Determine modal title based on provider
1805
1905
  modal_title = if is_new && selected_provider && selected_provider != "custom"
1806
1906
  provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
@@ -1810,7 +1910,7 @@ module Clacky
1810
1910
  else
1811
1911
  "Edit Model"
1812
1912
  end
1813
-
1913
+
1814
1914
  # Show modal and collect values
1815
1915
  result = modal.show(
1816
1916
  title: modal_title,
@@ -1818,9 +1918,9 @@ module Clacky
1818
1918
  validator: validator,
1819
1919
  on_close: -> { @layout.rerender_all }
1820
1920
  )
1821
-
1921
+
1822
1922
  return nil if result.nil?
1823
-
1923
+
1824
1924
  # Merge with existing model values or provider defaults
1825
1925
  {
1826
1926
  api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
@@ -1829,7 +1929,7 @@ module Clacky
1829
1929
  provider: selected_provider
1830
1930
  }
1831
1931
  end
1832
-
1932
+
1833
1933
  # Mask API key for display
1834
1934
  private def mask_api_key(api_key)
1835
1935
  if api_key && !api_key.empty?
@@ -28,7 +28,7 @@ module Clacky
28
28
  # === Status messages ===
29
29
  def show_info(message, prefix_newline: true); end
30
30
  def show_warning(message); end
31
- def show_error(message, code: nil, top_up_url: nil); end
31
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil); end
32
32
  def show_success(message); end
33
33
  def log(message, level: :info); end
34
34
 
@@ -131,6 +131,15 @@ module Clacky
131
131
  # === Blocking interaction ===
132
132
  def request_confirmation(message, default: true); end
133
133
 
134
+ # Auto-approve countdown for request_user_feedback. Shows a live countdown
135
+ # and lets the user press a key to take over and answer. Returns :timeout
136
+ # when no one intervenes (agent should auto-decide and continue), or a
137
+ # feedback string / "" when the user steps in. Non-interactive UIs (web,
138
+ # json, channel) have no human watching a TTY, so they default to :timeout.
139
+ def request_feedback_with_countdown(seconds: 10)
140
+ :timeout
141
+ end
142
+
134
143
  # === Input control (CLI layer) ===
135
144
  def clear_input; end
136
145
  def set_input_tips(message, type: :info); end
@@ -75,6 +75,31 @@ module Clacky
75
75
  to_utf8(data)
76
76
  end
77
77
 
78
+ # Decode a raw PTY byte stream to valid UTF-8, auto-detecting the source
79
+ # encoding. UTF-8 is tried first (Linux/macOS and modern programs); when
80
+ # the bytes are not valid UTF-8 they are decoded as GBK/CP936 (Simplified
81
+ # Chinese Windows powershell.exe / cmd.exe default output); anything that
82
+ # still fails is scrubbed.
83
+ #
84
+ # MUST be called on complete byte runs — callers slice on "\n" (0x0A),
85
+ # which is never a trailing byte of a GBK or UTF-8 multibyte sequence, so
86
+ # a character is never split across the boundary.
87
+ #
88
+ # @param data [String, nil] raw PTY bytes (binary/ASCII-8BIT)
89
+ # @return [String] valid UTF-8 string
90
+ def self.pty_to_utf8(data)
91
+ return "" if data.nil? || data.empty?
92
+
93
+ s = data.dup.force_encoding("UTF-8")
94
+ return s if s.valid_encoding?
95
+
96
+ data.dup
97
+ .force_encoding("GBK")
98
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
99
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
100
+ to_utf8(data)
101
+ end
102
+
78
103
  # Return an ASCII-safe UTF-8 copy of *str* suitable for security regex
79
104
  # pattern matching. Any byte that is not valid in the source encoding, or
80
105
  # that cannot be represented in UTF-8, is replaced with '?'. The
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.4"
4
+ VERSION = "1.3.6"
5
5
  end