openclacky 1.5.19 → 1.5.21

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -1
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pty"
4
+ require "tempfile"
5
+ require "shellwords"
4
6
  require "securerandom"
5
7
  require "fileutils"
6
8
  require_relative "base"
@@ -73,9 +75,6 @@ module Clacky
73
75
  {session_id, input:"pw\n"} reply to prompt / poll (input:"")
74
76
  {session_id, kill:true} stop
75
77
 
76
- Single-line only. For multi-line scripts (heredoc, loops, multi-statement blocks)
77
- write a file first, then run it: write(path:"/tmp/run.sh", content:...) → terminal(command:"bash /tmp/run.sh").
78
-
79
78
  Response: exit_code = done; session_id = running (state: waiting/background/timeout).
80
79
  If output exceeds the limit, `output` is truncated and `full_output_file` points
81
80
  at a file on disk — use terminal(command: "grep ... <path>") to search it.
@@ -133,6 +132,7 @@ module Clacky
133
132
  BACKGROUND_COLLECT_SECONDS = 2
134
133
  # Sentinel: when passed as idle_ms, disables idle early-return.
135
134
  DISABLED_IDLE_MS = 10_000_000
135
+ PTY_COLS = 120
136
136
  # Gap between log-file polls while waiting for a command's end marker.
137
137
  MARKER_POLL_SECONDS = 0.05
138
138
  # A freshly spawned login shell prints its banner asynchronously; wait
@@ -244,17 +244,6 @@ module Clacky
244
244
 
245
245
  # Start a new command
246
246
  if command && !command.to_s.strip.empty?
247
- if multiline_command?(command)
248
- return {
249
- error: "Multi-line commands are unreliable in our PTY shell " \
250
- "(heredocs / unclosed quotes / multi-line blocks can hang the session).",
251
- hint: "Write the script to a file first, then execute it. " \
252
- "Example: 1) write(path: \"/tmp/run.sh\", content: \"...\") " \
253
- "2) terminal(command: \"bash /tmp/run.sh\")",
254
- multiline_blocked: true
255
- }
256
- end
257
-
258
247
  return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout,
259
248
  idle_ms: idle_ms, background: background ? true : false,
260
249
  on_output: on_output)
@@ -402,6 +391,43 @@ module Clacky
402
391
  )
403
392
  end
404
393
 
394
+ SCRIPT_DIR = "/tmp/.clacky"
395
+ private def prepare_execution_command(session, command)
396
+ return command unless multiline_command?(command)
397
+
398
+ prepare_multiline_command(session, command)
399
+ end
400
+
401
+ # Keep script contents out of the interactive PTY input parser.
402
+ private def prepare_multiline_command(session, command)
403
+ session.script_paths.delete_if { |path| !File.exist?(path) }
404
+
405
+ script_dir = File.join(SCRIPT_DIR, session.id.to_s)
406
+ [SCRIPT_DIR, script_dir].each do |path|
407
+ FileUtils.mkdir_p(path, mode: 0700)
408
+ directory = File.lstat(path)
409
+ unless directory.directory? && directory.uid == Process.uid && (directory.mode & 0077).zero?
410
+ raise IOError, "#{path} must be a private directory owned by the current user"
411
+ end
412
+ end
413
+
414
+ file = Tempfile.create(["script-", ".sh"], script_dir)
415
+ session.script_paths << file.path
416
+ begin
417
+ file.write(command)
418
+ file.write("\n") unless command.end_with?("\n")
419
+ ensure
420
+ file.close
421
+ end
422
+ "( . #{Shellwords.escape(file.path)} )"
423
+ end
424
+
425
+ private def hide_script_paths(session, output)
426
+ Array(session.script_paths).reduce(output) do |text, path|
427
+ text.gsub(/#{Regexp.escape(path)}(?:: ?)?/, "")
428
+ end
429
+ end
430
+
405
431
  # ---------------------------------------------------------------------
406
432
  # 2) Continue / poll an existing session
407
433
  # ---------------------------------------------------------------------
@@ -475,11 +501,12 @@ module Clacky
475
501
  raw = read_log_slice(session.log_file, start_offset, new_offset)
476
502
  cleaned = OutputCleaner.clean(raw)
477
503
  cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
478
- cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
504
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token, echo_head: session.echo_head)
479
505
  # Per-line cap first: one minified JSON blob shouldn't blow the
480
506
  # whole 4 KB budget. MUST run before overflow spill so the file
481
507
  # on disk also has the long lines shortened (otherwise grep-ing
482
508
  # the spill file returns thousand-char lines the LLM chokes on).
509
+ cleaned = hide_script_paths(session, cleaned)
483
510
  cleaned = truncate_long_lines(cleaned)
484
511
  truncated = false
485
512
  overflow_file = nil
@@ -516,6 +543,7 @@ module Clacky
516
543
 
517
544
  case state
518
545
  when :matched, :eof
546
+ SessionManager.cleanup_scripts(session)
519
547
  exit_code = code || session.exit_code
520
548
  if persistent && state == :matched && session_healthy?(session)
521
549
  # Command finished cleanly — return the shell to the pool so
@@ -611,7 +639,7 @@ module Clacky
611
639
  # safe: the real completion marker was already removed via
612
640
  # session.marker_regex above, so any surviving occurrence of
613
641
  # __CLACKY_DONE_<token>_ is by definition an echoed wrapper.
614
- private def strip_command_echo(text, marker_token: nil)
642
+ private def strip_command_echo(text, marker_token: nil, echo_head: nil)
615
643
  return text if text.nil? || text.empty?
616
644
 
617
645
  # Pass 0: strip the hooks prefix echo if `stty -echo` failed and
@@ -641,6 +669,16 @@ module Clacky
641
669
  text = text.sub(/\A\{[^\n]*\n/, "")
642
670
  end
643
671
 
672
+ # Pass 1b: a wrapped `{ CMD` echo leaves its last screen row as a bare command suffix above the tail.
673
+ if echo_head && echo_head.bytesize > PTY_COLS
674
+ lines = text.lines
675
+ text = lines.reject.with_index do |ln, i|
676
+ body = ln.chomp
677
+ !body.empty? && body.size < echo_head.size && echo_head.end_with?(body) &&
678
+ lines[i + 1].to_s.match?(/\A\}[^\n;]*; *__clacky_ec=\$\?/)
679
+ end.join
680
+ end
681
+
644
682
  # Pass 2: token-aware global strip — remove any leftover wrapper
645
683
  # echo fragment, wherever it sits. Requires the session token so
646
684
  # we never touch unrelated user output that happens to mention
@@ -881,7 +919,7 @@ module Clacky
881
919
  end
882
920
 
883
921
  begin
884
- writer.winsize = [40, 120]
922
+ writer.winsize = [40, PTY_COLS]
885
923
  rescue StandardError
886
924
  # unsupported on some platforms
887
925
  end
@@ -1054,8 +1092,15 @@ module Clacky
1054
1092
  # non-WSL hosts (and on WSL when not invoking Windows binaries)
1055
1093
  # keep their PTY stdin and continue to behave as before.
1056
1094
  stdin_redirect = exe_needs_stdin_isolation?(command) ? " </dev/null" : ""
1095
+ # Inspect the original command before replacing multiline input with a script.
1096
+ # Internal shell setup must execute inline to update the session itself.
1097
+ command = prepare_execution_command(session, command) if with_hooks
1057
1098
  line = %Q|#{hooks_line}{ #{command}\n}#{stdin_redirect}; __clacky_ec=$?; printf "\n__CLACKY_DONE_#{token}_%s__\n" "$__clacky_ec"\n|
1099
+ session.echo_head = "{ #{command}".lines.last.chomp
1058
1100
  session.mutex.synchronize { session.writer.write(line) }
1101
+ rescue StandardError
1102
+ cleanup_session(session)
1103
+ raise
1059
1104
  end
1060
1105
 
1061
1106
  # True when the command should run with stdin redirected from
@@ -1277,7 +1322,7 @@ module Clacky
1277
1322
  cleaned = OutputCleaner.clean(buffered)
1278
1323
  # Strip any wrapper echo that still slipped through (e.g. when a
1279
1324
  # session is reused and ZLE re-echoes our wrapper mid-stream).
1280
- cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
1325
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token, echo_head: session.echo_head)
1281
1326
  # Belt-and-braces: drop any line that still carries our internal
1282
1327
  # tokens.
1283
1328
  cleaned = cleaned.lines.reject do |ln|
@@ -1292,7 +1337,7 @@ module Clacky
1292
1337
  # doesn't produce a wall of whitespace in the streaming UI.
1293
1338
  cleaned = cleaned.gsub(/\n{3,}/, "\n\n")
1294
1339
  cleaned = cleaned.lstrip if cleaned.match?(/\A\n+\z/)
1295
- on_output.call(cleaned) unless cleaned.empty? || cleaned.match?(/\A\s*\z/)
1340
+ on_output.call(hide_script_paths(session, cleaned)) unless cleaned.empty? || cleaned.match?(/\A\s*\z/)
1296
1341
  rescue StandardError
1297
1342
  # Streaming is best-effort — never let a UI bug abort the command.
1298
1343
  end
@@ -1394,10 +1439,7 @@ module Clacky
1394
1439
 
1395
1440
  # True when `command` spans multiple lines. Trailing newlines are
1396
1441
  # ignored — a single-line command terminated with "\n" is still
1397
- # single-line. Multi-line commands frequently hang the persistent
1398
- # PTY shell (incomplete heredoc, unclosed quote, multi-line block
1399
- # without closer) — the agent should write a script file and
1400
- # invoke it instead.
1442
+ # single-line. Multi-line input is sourced in a subshell.
1401
1443
  private def multiline_command?(command)
1402
1444
  return false if command.nil?
1403
1445
  command.to_s.sub(/\n+\z/, "").include?("\n")
@@ -490,9 +490,10 @@ module Clacky
490
490
  true
491
491
  end
492
492
 
493
- # Permanently delete all sessions in the trash, or only those older than
494
- # :days days. Returns the count of permanently deleted sessions.
495
- def self.empty_trash_sessions(sessions_dir:, days: nil)
493
+ # Permanently delete all sessions in the trash, only those older than
494
+ # :days days, or only the sessions whose full id is in :ids.
495
+ # Returns the count of permanently deleted sessions.
496
+ def self.empty_trash_sessions(sessions_dir:, days: nil, ids: nil)
496
497
  trash_dir = Clacky::TrashDirectory.sessions_trash_dir
497
498
  return 0 unless Dir.exist?(trash_dir)
498
499
 
@@ -503,6 +504,7 @@ module Clacky
503
504
  Dir.glob(File.join(trash_dir, "*.json")).each do |filepath|
504
505
  session = sm.send(:load_session_file, filepath)
505
506
  next unless session
507
+ next if ids && !ids.include?(session[:session_id])
506
508
 
507
509
  if cutoff
508
510
  trash_time = session[:deleted_at] || session[:updated_at]
@@ -5,6 +5,7 @@ require "uri"
5
5
  require "tmpdir"
6
6
  require "fileutils"
7
7
  require_relative "../utils/encoding"
8
+ require_relative "../deepseek_web_tools"
8
9
 
9
10
  module Clacky
10
11
  module Tools
@@ -39,30 +40,88 @@ module Clacky
39
40
  return { error: "Invalid URL: #{e.message}" }
40
41
  end
41
42
 
42
- begin
43
- # Fetch the web page
44
- response = fetch_url(uri)
45
-
46
- # Extract content and force UTF-8 encoding at the source
47
- content = Clacky::Utils::Encoding.to_utf8(response.body)
48
- content_type = response["content-type"] || ""
49
-
50
- # Parse HTML if it's an HTML page
51
- if content_type.include?("text/html")
52
- result = parse_html(content, max_length, url)
53
- result[:url] = url
54
- result[:content_type] = content_type
55
- result[:status_code] = response.code.to_i
56
- result[:error] = nil
57
- result
58
- else
59
- # For non-HTML content, return raw text
60
- result = handle_raw_content(content, max_length, url, content_type, response.code.to_i)
61
- result
43
+ deepseek_error = nil
44
+ if deepseek_fetch_selected?
45
+ begin
46
+ return fetch_via_deepseek(url, max_length)
47
+ rescue StandardError => e
48
+ # A paid upstream failure must not cost the user the page, so keep
49
+ # going with the local scraper. The cooldown stops the next fetch
50
+ # from paying the same timeout.
51
+ deepseek_error = e
52
+ Clacky::DeepSeekWebTools.mark_unavailable!
62
53
  end
63
- rescue StandardError => e
64
- { error: "Failed to fetch URL: #{e.message}" }
65
54
  end
55
+
56
+ result = fetch_locally(uri, url, max_length)
57
+ if deepseek_error && result[:error]
58
+ result[:error] = "#{result[:error]} (DeepSeek: #{deepseek_error.message})"
59
+ end
60
+ result
61
+ end
62
+
63
+ private def fetch_locally(uri, url, max_length)
64
+ response = fetch_url(uri)
65
+
66
+ content = Clacky::Utils::Encoding.to_utf8(response.body)
67
+ content_type = response["content-type"] || ""
68
+
69
+ if content_type.include?("text/html")
70
+ result = parse_html(content, max_length, url)
71
+ result[:url] = url
72
+ result[:content_type] = content_type
73
+ result[:status_code] = response.code.to_i
74
+ result[:error] = nil
75
+ result
76
+ else
77
+ handle_raw_content(content, max_length, url, content_type, response.code.to_i)
78
+ end
79
+ rescue StandardError => e
80
+ { error: "Failed to fetch URL: #{e.message}" }
81
+ end
82
+
83
+ # DeepSeek renders pages to clean Markdown, which covers JS-heavy sites the
84
+ # local scraper cannot read. Runs only when the user picks it as the search
85
+ # provider; #execute falls back to the local scraper when it raises.
86
+ private def fetch_via_deepseek(url, max_length)
87
+ config = agent.config
88
+ key = config.deepseek_web_key
89
+ data = Clacky::DeepSeekWebTools.fetch(
90
+ url: url,
91
+ api_key: key,
92
+ base_url: config.deepseek_web_base_url(key)
93
+ )
94
+
95
+ text = data[:content].to_s
96
+ truncated = text.length > max_length
97
+ temp_file = nil
98
+ if truncated
99
+ temp_dir = Dir.mktmpdir
100
+ safe_name = extract_domain(url).gsub(/[^\w\-.]/, '_')[0...50]
101
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
102
+ temp_file = File.join(temp_dir, "#{safe_name}_#{timestamp}.md")
103
+ File.write(temp_file, text)
104
+ end
105
+
106
+ {
107
+ url: url,
108
+ title: data[:title].to_s.strip,
109
+ content_type: "text/markdown",
110
+ status_code: 200,
111
+ content: text[0, max_length],
112
+ truncated: truncated,
113
+ temp_file: temp_file,
114
+ error: nil
115
+ }
116
+ end
117
+
118
+ private def deepseek_fetch_selected?
119
+ return false unless agent
120
+ return false if Clacky::DeepSeekWebTools.cooling_down?
121
+
122
+ agent.config.deepseek_web_ready?
123
+ rescue StandardError
124
+ false
66
125
  end
67
126
 
68
127
  def handle_raw_content(content, max_length, url, content_type, status_code)
@@ -6,6 +6,7 @@ require "uri"
6
6
  require "cgi"
7
7
  require "base64"
8
8
  require_relative "../utils/encoding"
9
+ require_relative "../deepseek_web_tools"
9
10
 
10
11
  module Clacky
11
12
  module Tools
@@ -37,7 +38,9 @@ module Clacky
37
38
  SEARCHER_TIMEOUT = 30
38
39
  PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp"
39
40
  PARALLEL_SNIPPET_MAX_CHARS = 400
40
- PROVIDER_COOLDOWN_SECONDS = 600
41
+ # Shared by every provider, hosted or free: one failure keeps that
42
+ # provider out of the retry path for the whole window.
43
+ PROVIDER_COOLDOWN_SECONDS = 3600
41
44
 
42
45
  def execute(query:, max_results: 10, working_dir: nil)
43
46
  if query.nil? || query.strip.empty?
@@ -62,10 +65,13 @@ module Clacky
62
65
  }
63
66
  rescue StandardError => e
64
67
  # Avoid paying the same network/handshake failure on every search.
65
- if provider == :parallel
68
+ case provider
69
+ when :deepseek
70
+ Clacky::DeepSeekWebTools.mark_unavailable!
71
+ when :parallel
66
72
  reset_parallel_client
67
73
  @parallel_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
68
- elsif provider == :duckduckgo
74
+ when :duckduckgo
69
75
  @ddg_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
70
76
  end
71
77
  last_error = e
@@ -90,6 +96,10 @@ module Clacky
90
96
  "Configured search provider '#{name}' failed. #{reason} " \
91
97
  "Check the key and the script at #{Clacky::SearchConfig.script_path}, " \
92
98
  "or reconfigure it in Settings → Search."
99
+ elsif providers.include?(:deepseek)
100
+ "DeepSeek web search failed and no free engine returned results. #{reason} " \
101
+ "Check the API key and balance in Settings → Web Search; DeepSeek will be " \
102
+ "retried in #{PROVIDER_COOLDOWN_SECONDS / 60} minutes."
93
103
  else
94
104
  "All search providers failed. #{reason}"
95
105
  end
@@ -102,13 +112,23 @@ module Clacky
102
112
  # broken key or script look like a low-quality result set.
103
113
  return [:custom] if Clacky::SearchConfig.script_path
104
114
 
105
- PROVIDERS.reject do |provider|
115
+ free_chain = PROVIDERS.reject do |provider|
106
116
  unavailable_until = case provider
107
117
  when :parallel then @parallel_unavailable_until
108
118
  when :duckduckgo then @ddg_unavailable_until
109
119
  end
110
120
  unavailable_until && Time.now < unavailable_until
111
121
  end
122
+
123
+ return free_chain unless Clacky::SearchConfig.deepseek_web?
124
+
125
+ # DeepSeek leads the chain but does not replace the free engines: a
126
+ # missing key, an exhausted balance or an outage should degrade to a
127
+ # working search rather than return nothing.
128
+ return free_chain if Clacky::DeepSeekWebTools.cooling_down?
129
+ return free_chain unless deepseek_web_available?
130
+
131
+ [:deepseek] + free_chain
112
132
  end
113
133
 
114
134
  # ── Parallel Free MCP ──────────────────────────────────────────────────
@@ -171,6 +191,27 @@ module Clacky
171
191
  end.join("\n")
172
192
  end
173
193
 
194
+ # ── DeepSeek hosted search (paid, opt-in) ──────────────────────────────
195
+
196
+ private def deepseek_web_available?
197
+ return false unless agent
198
+
199
+ agent.config.deepseek_web_ready?
200
+ rescue StandardError
201
+ false
202
+ end
203
+
204
+ private def search_deepseek(query, max_results)
205
+ config = agent.config
206
+ key = config.deepseek_web_key
207
+ Clacky::DeepSeekWebTools.search(
208
+ query: query,
209
+ count: max_results,
210
+ api_key: key,
211
+ base_url: config.deepseek_web_base_url(key)
212
+ )
213
+ end
214
+
174
215
  # ── Configured searcher (~/.clacky/searchers/<provider>.rb) ────────────
175
216
 
176
217
  private def search_custom(query, max_results)
@@ -1396,7 +1396,7 @@ module Clacky
1396
1396
  # * the countdown reaches zero with no interaction -> returns :timeout
1397
1397
  # @param seconds [Integer] Countdown duration
1398
1398
  # @return [String, Symbol] feedback text, or :timeout
1399
- def request_feedback_with_countdown(seconds: 10)
1399
+ def request_feedback_with_countdown(seconds: 20)
1400
1400
  theme = ThemeManager.current_theme
1401
1401
 
1402
1402
  queue = Queue.new
@@ -145,9 +145,10 @@ module Clacky
145
145
  # Auto-approve countdown for ask_user. Shows a live countdown
146
146
  # and lets the user press a key to take over and answer. Returns :timeout
147
147
  # when no one intervenes (agent should auto-decide and continue), or a
148
- # feedback string / "" when the user steps in. Non-interactive UIs (web,
149
- # json, channel) have no human watching a TTY, so they default to :timeout.
150
- def request_feedback_with_countdown(seconds: 10)
148
+ # feedback string / "" when the user steps in, or :answered when Web has
149
+ # queued a full answer for steering. Non-interactive UIs (json, channel)
150
+ # default to :timeout.
151
+ def request_feedback_with_countdown(seconds: 20)
151
152
  :timeout
152
153
  end
153
154
 
@@ -226,6 +226,57 @@ module Clacky
226
226
  MIME_TYPES[File.extname(path).downcase] || "application/octet-stream"
227
227
  end
228
228
 
229
+ # Quantize with a frequency-weighted median-cut palette using only chunky_png.
230
+ # Keep the input on failure so the caller retains its existing size handling.
231
+ def self.quantize_png_base64(b64)
232
+ require "base64"
233
+ require "chunky_png"
234
+
235
+ image = ChunkyPNG::Image.from_blob(Base64.strict_decode64(b64))
236
+ counts = Hash.new(0)
237
+ image.pixels.each { |color| counts[color] += 1 }
238
+ if counts.size > 256
239
+ # Cache each box's widest RGBA channel; split until at most 256 remain.
240
+ make_box = lambda do |colors|
241
+ ranges = [24, 16, 8, 0].map do |shift|
242
+ values = colors.map { |color| (color >> shift) & 255 }
243
+ values.max - values.min
244
+ end
245
+ channel = ranges.each_index.max_by { |i| ranges[i] }
246
+ [colors, [24, 16, 8, 0][channel], ranges[channel]]
247
+ end
248
+ boxes = [make_box.call(counts.keys)]
249
+ while boxes.size < 256
250
+ index = boxes.each_index.max_by { |i| boxes[i][2] }
251
+ colors, shift, range = boxes.delete_at(index)
252
+ break if range.zero?
253
+
254
+ colors.sort_by! { |color| (color >> shift) & 255 }
255
+ halfway = colors.sum { |color| counts[color] } / 2.0
256
+ total = 0
257
+ split = colors.index { |color| (total += counts[color]) >= halfway } + 1
258
+ split = [split, colors.size - 1].min
259
+ boxes << make_box.call(colors.take(split))
260
+ boxes << make_box.call(colors.drop(split))
261
+ end
262
+
263
+ mapping = {}
264
+ boxes.each do |colors, _shift, _range|
265
+ weight = colors.sum { |color| counts[color] }
266
+ channels = [24, 16, 8, 0].map do |shift|
267
+ (colors.sum { |color| ((color >> shift) & 255) * counts[color] }.to_f / weight).round
268
+ end
269
+ representative = ChunkyPNG::Color.rgba(*channels)
270
+ colors.each { |color| mapping[color] = representative }
271
+ end
272
+ image.pixels.map! { |color| mapping.fetch(color) }
273
+ end
274
+ Base64.strict_encode64(image.to_blob(color_mode: ChunkyPNG::COLOR_INDEXED))
275
+ rescue StandardError => e
276
+ Clacky::Logger.debug("image_quantization_skipped", format: "png", reason: e.message)
277
+ b64
278
+ end
279
+
229
280
  # Downscale a base64-encoded image so its width is at most max_width pixels.
230
281
  #
231
282
  # Strategy: