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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +19 -1
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +17 -3
- data/lib/clacky/agent/message_compressor_helper.rb +36 -2
- data/lib/clacky/agent/session_serializer.rb +45 -4
- data/lib/clacky/agent.rb +135 -55
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +10 -5
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +5 -4
- data/lib/clacky/client.rb +12 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +45 -4
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +38 -138
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +285 -53
- data/lib/clacky/server/model_prices.rb +10 -5
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +37 -7
- data/lib/clacky/server/web_ui_controller.rb +72 -0
- data/lib/clacky/session_manager.rb +28 -6
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +242 -39
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1158 -249
- data/lib/clacky/web/app.js +82 -13
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/code-editor.js +6 -2
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +92 -97
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/components/sidebar.js +6 -2
- data/lib/clacky/web/features/archive/store.js +196 -0
- data/lib/clacky/web/features/archive/view.js +409 -0
- data/lib/clacky/web/features/backup/view.js +17 -7
- data/lib/clacky/web/features/billing/view.js +84 -10
- data/lib/clacky/web/features/channels/view.js +75 -29
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/new-session/view.js +1 -0
- data/lib/clacky/web/features/trash/store.js +14 -11
- data/lib/clacky/web/features/trash/view.js +66 -13
- data/lib/clacky/web/features/workspace/view.js +421 -16
- data/lib/clacky/web/i18n.js +242 -70
- data/lib/clacky/web/index.html +270 -117
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +609 -267
- data/lib/clacky/web/settings.js +186 -32
- data/lib/clacky/web/ws-dispatcher.js +33 -21
- data/lib/clacky.rb +4 -2
- 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,
|
|
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
|
|
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,
|
|
494
|
-
# :days days
|
|
495
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1399
|
+
def request_feedback_with_countdown(seconds: 20)
|
|
1400
1400
|
theme = ThemeManager.current_theme
|
|
1401
1401
|
|
|
1402
1402
|
queue = Queue.new
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -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
|
|
149
|
-
#
|
|
150
|
-
|
|
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:
|