openclacky 1.5.20 → 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 +49 -0
- data/README.md +18 -0
- 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 +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +5 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -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 +21 -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 +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +23 -14
- 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 +194 -47
- data/lib/clacky/server/model_prices.rb +5 -3
- 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 +19 -7
- data/lib/clacky/server/web_ui_controller.rb +61 -38
- data/lib/clacky/session_manager.rb +7 -1
- 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 +132 -13
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +366 -105
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +3 -1
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/i18n.js +106 -30
- data/lib/clacky/web/index.html +90 -60
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +244 -131
- data/lib/clacky/web/settings.js +143 -8
- data/lib/clacky/web/ws-dispatcher.js +9 -13
- data/lib/clacky.rb +1 -0
- metadata +4 -1
|
@@ -29,7 +29,6 @@ module Clacky
|
|
|
29
29
|
|
|
30
30
|
# Pending confirmation state: { id => ConditionVariable, result => value }
|
|
31
31
|
@pending_confirmations = {}
|
|
32
|
-
@pending_feedback = nil
|
|
33
32
|
|
|
34
33
|
# Channel subscribers: array of objects implementing UIInterface.
|
|
35
34
|
# All emitted events are forwarded to each subscriber after WebSocket broadcast.
|
|
@@ -69,49 +68,68 @@ module Clacky
|
|
|
69
68
|
end
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
# Use the same contract as the CLI. The server owns the deadline;
|
|
72
|
+
# typing cancels automatic continuation but keeps the answer wait alive.
|
|
73
|
+
def request_feedback_with_countdown(seconds: 20)
|
|
74
|
+
pending = { id: @feedback_request_id || SecureRandom.uuid, cond: ConditionVariable.new,
|
|
75
|
+
deadline_ms: (Time.now.to_f * 1000).to_i + seconds * 1000 }
|
|
76
|
+
pending[:seconds] = seconds
|
|
77
|
+
pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
73
78
|
@mutex.synchronize do
|
|
74
|
-
pending = @
|
|
75
|
-
|
|
76
|
-
pending[:answer], pending[:resolution], pending[:options] = answer, "human", options
|
|
77
|
-
pending[:cond].signal
|
|
78
|
-
true
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# The caller supplies a timeout for this wait; nil keeps manual feedback.
|
|
83
|
-
def wait_for_feedback(timeout_seconds:, question: nil, recommended_label: nil)
|
|
84
|
-
unless recommended_label && !timeout_seconds.nil?
|
|
85
|
-
yield
|
|
86
|
-
return
|
|
79
|
+
pending[:manual] = @feedback_permission_mode == :confirm_all
|
|
80
|
+
@pending_feedback = pending
|
|
87
81
|
end
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
pending = { id: SecureRandom.uuid, cond: ConditionVariable.new }
|
|
91
|
-
countdown = { request_id: pending[:id], question: question, recommended_label: recommended_label,
|
|
92
|
-
deadline_ms: (Time.now.to_f * 1000).to_i + timeout_seconds * 1000,
|
|
93
|
-
duration_seconds: timeout_seconds }
|
|
94
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
95
|
-
pending[:countdown] = countdown
|
|
96
|
-
@mutex.synchronize { @pending_feedback = pending }
|
|
97
|
-
yield
|
|
98
|
-
emit("feedback_countdown_started", **countdown)
|
|
82
|
+
emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms])
|
|
99
83
|
@mutex.synchronize do
|
|
100
|
-
until pending[:
|
|
101
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
-
if remaining <= 0
|
|
103
|
-
pending
|
|
84
|
+
until pending[:result]
|
|
85
|
+
remaining = pending[:deadline] - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
if !pending[:manual] && remaining <= 0
|
|
87
|
+
pending[:result] = :timeout
|
|
104
88
|
else
|
|
105
|
-
pending[:cond].wait(@mutex, remaining)
|
|
89
|
+
pending[:cond].wait(@mutex, pending[:manual] ? nil : remaining)
|
|
106
90
|
end
|
|
107
91
|
end
|
|
108
|
-
|
|
92
|
+
pending[:result]
|
|
109
93
|
end
|
|
110
94
|
ensure
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
cleared = @mutex.synchronize do
|
|
96
|
+
if pending && @pending_feedback.equal?(pending)
|
|
97
|
+
@pending_feedback = nil
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
if cleared
|
|
102
|
+
emit("feedback_countdown", request_id: pending[:id], resolved: true,
|
|
103
|
+
resolution: pending[:result] || :cancelled)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def update_permission_mode(mode)
|
|
108
|
+
countdown = @mutex.synchronize do
|
|
109
|
+
@feedback_permission_mode = mode.to_sym
|
|
110
|
+
pending = @pending_feedback
|
|
111
|
+
if pending && !pending[:result]
|
|
112
|
+
if mode.to_sym == :auto_approve && pending[:manual]
|
|
113
|
+
pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pending[:seconds]
|
|
114
|
+
pending[:deadline_ms] = (Time.now.to_f * 1000).to_i + pending[:seconds] * 1000
|
|
115
|
+
end
|
|
116
|
+
pending[:manual] = mode.to_sym != :auto_approve
|
|
117
|
+
pending[:cond].signal
|
|
118
|
+
{ request_id: pending[:id],
|
|
119
|
+
deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
emit("feedback_countdown", **countdown) if countdown
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def intervene_feedback(request_id)
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
pending = @pending_feedback
|
|
128
|
+
return false unless pending && pending[:id] == request_id && !pending[:result]
|
|
129
|
+
pending[:result] = :intervened
|
|
130
|
+
pending[:cond].signal
|
|
131
|
+
emit("feedback_countdown", request_id: pending[:id], deadline_ms: nil)
|
|
132
|
+
true
|
|
115
133
|
end
|
|
116
134
|
end
|
|
117
135
|
|
|
@@ -176,7 +194,9 @@ module Clacky
|
|
|
176
194
|
context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
|
|
177
195
|
first = questions.first || {}
|
|
178
196
|
|
|
197
|
+
@feedback_request_id = Clacky::Tools::AskUser.fetch_key(args_data, :request_id)
|
|
179
198
|
emit("request_feedback",
|
|
199
|
+
request_id: @feedback_request_id,
|
|
180
200
|
question: first[:question].to_s,
|
|
181
201
|
context: context,
|
|
182
202
|
options: first[:options] || [],
|
|
@@ -383,8 +403,11 @@ module Clacky
|
|
|
383
403
|
# The frontend's appendToolStdout will attach to the last visible .tool-item
|
|
384
404
|
# even when _liveLastToolItem is null (after the tab re-loaded).
|
|
385
405
|
def replay_live_state
|
|
386
|
-
countdown = @mutex.synchronize
|
|
387
|
-
|
|
406
|
+
countdown = @mutex.synchronize do
|
|
407
|
+
pending = @pending_feedback
|
|
408
|
+
pending ? { request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] } : { resolved: true }
|
|
409
|
+
end
|
|
410
|
+
emit("feedback_countdown", **countdown)
|
|
388
411
|
return unless @live_progress_state
|
|
389
412
|
|
|
390
413
|
# Replay complete progress state (not just message).
|
|
@@ -216,7 +216,7 @@ module Clacky
|
|
|
216
216
|
def all_sessions(current_dir: nil, limit: nil)
|
|
217
217
|
sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
|
|
218
218
|
load_session_file(filepath)
|
|
219
|
-
end.sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
|
|
219
|
+
end.sort_by { |s| [s[:updated_at] || s[:created_at] || "", s[:session_id].to_s] }.reverse
|
|
220
220
|
|
|
221
221
|
if current_dir
|
|
222
222
|
current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
|
|
@@ -436,6 +436,12 @@ module Clacky
|
|
|
436
436
|
Clacky::Tools::TrashManager.permanent_delete_trash_session(session_id, sessions_dir: @sessions_dir)
|
|
437
437
|
end
|
|
438
438
|
|
|
439
|
+
# Permanently delete exactly these sessions from the trash. Returns the count deleted.
|
|
440
|
+
def permanent_delete_trash_sessions(session_ids)
|
|
441
|
+
require_relative "tools/trash_manager"
|
|
442
|
+
Clacky::Tools::TrashManager.empty_trash_sessions(sessions_dir: @sessions_dir, ids: session_ids)
|
|
443
|
+
end
|
|
444
|
+
|
|
439
445
|
# Keep file-trash on the same rolling window as sessions-trash.
|
|
440
446
|
def cleanup_file_trash(days: 8)
|
|
441
447
|
require_relative "tools/trash_manager"
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -319,7 +319,7 @@ module Clacky
|
|
|
319
319
|
> should either:
|
|
320
320
|
>
|
|
321
321
|
> 1. Ask the user to start the server in another terminal: `clacky server`
|
|
322
|
-
> (then retry — the CLI auto-detects it via
|
|
322
|
+
> (then retry — the CLI auto-detects it via `clacky-master-*.pid` in the configured PID directory), or
|
|
323
323
|
> 2. If the task can be completed without the server API, skip those steps
|
|
324
324
|
> and tell the user which parts require the server.
|
|
325
325
|
>
|
|
@@ -110,7 +110,7 @@ module Clacky
|
|
|
110
110
|
recommended = recommended.to_i if recommended.is_a?(Numeric) || recommended.to_s =~ /\A-?\d+\z/
|
|
111
111
|
recommended = nil unless recommended.is_a?(Integer) && recommended >= 0 && recommended < options.size
|
|
112
112
|
|
|
113
|
-
{
|
|
113
|
+
normalized = {
|
|
114
114
|
question: text,
|
|
115
115
|
description: fetch_key(item, :description).to_s.strip,
|
|
116
116
|
options: options,
|
|
@@ -119,6 +119,9 @@ module Clacky
|
|
|
119
119
|
allow_free_text: options.empty? || truthy?(fetch_key(item, :allow_free_text)),
|
|
120
120
|
recommended: recommended
|
|
121
121
|
}
|
|
122
|
+
metadata = fetch_key(item, :metadata)
|
|
123
|
+
normalized[:metadata] = metadata if metadata.is_a?(Hash)
|
|
124
|
+
normalized
|
|
122
125
|
end
|
|
123
126
|
|
|
124
127
|
def self.fetch_key(hash, key)
|
data/lib/clacky/tools/base.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Clacky
|
|
|
14
14
|
attr_accessor :receives_tool_call_id
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
# Host agent, injected at registration for extension tools
|
|
18
|
-
#
|
|
17
|
+
# Host agent, injected at registration for extension tools and for built-ins
|
|
18
|
+
# that need its public API (config, fan_out_labeled, fork_subagent,
|
|
19
|
+
# skill_loader).
|
|
19
20
|
attr_accessor :agent
|
|
20
21
|
|
|
21
22
|
def name
|
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -543,12 +543,21 @@ module Clacky
|
|
|
543
543
|
# Save original (full-resolution) PNG to disk before any downscaling
|
|
544
544
|
original_path = save_screenshot_to_disk(image_block["data"], suffix: "original")
|
|
545
545
|
|
|
546
|
+
config = screenshot_config
|
|
547
|
+
limit = config["screenshot_max_base64_bytes"]
|
|
548
|
+
max_base64_bytes = limit.is_a?(Integer) && limit.positive? ? limit : SCREENSHOT_MAX_BASE64_BYTES
|
|
546
549
|
image_data = png_downscale_base64(image_block["data"], SCREENSHOT_MAX_WIDTH)
|
|
550
|
+
if image_data.bytesize > max_base64_bytes
|
|
551
|
+
image_data = Clacky::Utils::FileProcessor.quantize_png_base64(image_data)
|
|
552
|
+
end
|
|
547
553
|
|
|
548
|
-
if image_data.bytesize >
|
|
554
|
+
if image_data.bytesize > max_base64_bytes
|
|
549
555
|
size_kb = image_data.bytesize / 1024
|
|
556
|
+
saved = original_path ? " The full-resolution file is saved at #{original_path}." : ""
|
|
550
557
|
return { action: "screenshot", success: false, profile: "user",
|
|
551
|
-
|
|
558
|
+
original_path: original_path,
|
|
559
|
+
output: "Screenshot still too large after compression (#{size_kb}KB).#{saved} " \
|
|
560
|
+
"Use action=snapshot instead." }
|
|
552
561
|
end
|
|
553
562
|
|
|
554
563
|
# Save compressed (800px) PNG for AI reference
|
|
@@ -560,6 +569,13 @@ module Clacky
|
|
|
560
569
|
output: "Screenshot captured." }
|
|
561
570
|
end
|
|
562
571
|
|
|
572
|
+
private def screenshot_config
|
|
573
|
+
config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
|
|
574
|
+
config.is_a?(Hash) ? config : {}
|
|
575
|
+
rescue StandardError
|
|
576
|
+
{}
|
|
577
|
+
end
|
|
578
|
+
|
|
563
579
|
private def png_downscale_base64(b64, max_width)
|
|
564
580
|
Clacky::Utils::FileProcessor.downscale_image_base64(
|
|
565
581
|
b64, "image/png", max_width: max_width
|
|
@@ -46,7 +46,7 @@ module Clacky
|
|
|
46
46
|
MAX_CONTENT_CHARS = 60_000
|
|
47
47
|
|
|
48
48
|
# Maximum characters per line (prevent single huge lines from bloating tokens)
|
|
49
|
-
MAX_LINE_CHARS =
|
|
49
|
+
MAX_LINE_CHARS = 8_000
|
|
50
50
|
|
|
51
51
|
def execute(path:, max_lines: 1000, start_line: nil, end_line: nil, working_dir: nil, image_max_width: nil)
|
|
52
52
|
# Expand path relative to working_dir when provided
|
|
@@ -43,7 +43,9 @@ module Clacky
|
|
|
43
43
|
:read_offset, # Integer, bytes already returned by previous read calls
|
|
44
44
|
:mutex, # per-session mutex for PTY writes
|
|
45
45
|
:resource_group, # Optional cgroup for task limits and OOM attribution
|
|
46
|
+
:script_paths, # Internal script paths for cleanup and output masking
|
|
46
47
|
:shell_name, # "zsh" | "bash" | "sh" — informs marker syntax & rc reload
|
|
48
|
+
:echo_head, # String, last `{ CMD` line written; strips its wrapped echo
|
|
47
49
|
keyword_init: true
|
|
48
50
|
)
|
|
49
51
|
|
|
@@ -77,6 +79,7 @@ module Clacky
|
|
|
77
79
|
read_offset: 0,
|
|
78
80
|
mutex: Mutex.new,
|
|
79
81
|
resource_group: resource_group,
|
|
82
|
+
script_paths: [],
|
|
80
83
|
shell_name: shell_name
|
|
81
84
|
)
|
|
82
85
|
@sessions[session.id] = session
|
|
@@ -116,7 +119,21 @@ module Clacky
|
|
|
116
119
|
# Forget a session (after it has been killed/exited). Does NOT kill
|
|
117
120
|
# the process — callers should kill first.
|
|
118
121
|
def forget(id)
|
|
119
|
-
@mutex.synchronize { @sessions.delete(id) }
|
|
122
|
+
session = @mutex.synchronize { @sessions.delete(id) }
|
|
123
|
+
cleanup_scripts(session) if session
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Cleanup must never replace the command's result with an unlink error.
|
|
127
|
+
def cleanup_scripts(session)
|
|
128
|
+
# The reader may clean up before buffered output is packaged.
|
|
129
|
+
# Preserve paths so error messages can still mask internal filenames.
|
|
130
|
+
Array(session.script_paths).each do |path|
|
|
131
|
+
begin
|
|
132
|
+
File.unlink(path)
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
# Best effort; retain the path for a later cleanup attempt.
|
|
135
|
+
end
|
|
136
|
+
end
|
|
120
137
|
end
|
|
121
138
|
|
|
122
139
|
# Refresh status of one session in-place (mutex-held).
|
|
@@ -188,6 +205,7 @@ module Clacky
|
|
|
188
205
|
s.log_io&.close rescue nil
|
|
189
206
|
s.writer&.close rescue nil
|
|
190
207
|
s.reader&.close rescue nil
|
|
208
|
+
cleanup_scripts(s)
|
|
191
209
|
end
|
|
192
210
|
end
|
|
193
211
|
|
|
@@ -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)
|