openclacky 0.9.2 → 0.9.4
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 +32 -0
- data/docs/security-design.md +109 -0
- data/lib/clacky/agent/message_compressor_helper.rb +82 -69
- data/lib/clacky/agent/session_serializer.rb +9 -1
- data/lib/clacky/agent/skill_manager.rb +7 -0
- data/lib/clacky/agent.rb +11 -3
- data/lib/clacky/banner.rb +65 -0
- data/lib/clacky/block_font.rb +331 -0
- data/lib/clacky/brand_config.rb +73 -5
- data/lib/clacky/client.rb +129 -633
- data/lib/clacky/default_skills/activate-license/SKILL.md +118 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +10 -20
- data/lib/clacky/message_format/anthropic.rb +241 -0
- data/lib/clacky/message_format/open_ai.rb +135 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -0
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +13 -0
- data/lib/clacky/server/http_server.rb +12 -2
- data/lib/clacky/session_manager.rb +7 -2
- data/lib/clacky/tools/browser.rb +109 -280
- data/lib/clacky/ui2/block_font.rb +10 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -22
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +588 -6
- data/lib/clacky/web/app.js +30 -15
- data/lib/clacky/web/brand.js +141 -9
- data/lib/clacky/web/i18n.js +28 -2
- data/lib/clacky/web/index.html +142 -127
- data/lib/clacky/web/onboard.js +192 -225
- data/lib/clacky/web/sessions.js +12 -8
- data/lib/clacky/web/settings.js +57 -4
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +60 -15
- metadata +8 -1
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "shellwords"
|
|
4
|
-
require "
|
|
4
|
+
require "yaml"
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require_relative "base"
|
|
7
7
|
require_relative "shell"
|
|
@@ -13,8 +13,6 @@ module Clacky
|
|
|
13
13
|
self.tool_description = <<~DESC
|
|
14
14
|
Browser automation for login-related operations (sign-in, OAuth, form submission requiring session). For simple page fetch or search, prefer web_fetch or web_search instead.
|
|
15
15
|
|
|
16
|
-
isolated: true = built-in browser (default, no setup, login persists). false = user's Chrome (keeps cookies/login, needs one-time debug setup; opens URLs in new tab).
|
|
17
|
-
|
|
18
16
|
SNAPSHOT — always run before interacting with a page. Refs (@e1, @e2...) expire after page changes, always re-snapshot before acting on a changed page:
|
|
19
17
|
- 'snapshot -i -C' — interactive + cursor-clickable elements (recommended default)
|
|
20
18
|
- 'snapshot -i' — interactive elements only (faster, for simple forms)
|
|
@@ -39,101 +37,78 @@ module Clacky
|
|
|
39
37
|
command: {
|
|
40
38
|
type: "string",
|
|
41
39
|
description: "agent-browser command, e.g. 'open https://...', 'snapshot -i', 'click @e1', 'fill @e2 \"text\"'"
|
|
42
|
-
},
|
|
43
|
-
session: {
|
|
44
|
-
type: "string",
|
|
45
|
-
description: "Named session for parallel browser instances (optional)"
|
|
46
|
-
},
|
|
47
|
-
isolated: {
|
|
48
|
-
type: "boolean",
|
|
49
|
-
description: "true = built-in browser (default, no setup, login persists). false = user's Chrome (keeps login, needs one-time debug setup; opens URLs in new tab). Default true when omitted."
|
|
50
40
|
}
|
|
51
41
|
},
|
|
52
42
|
required: ["command"]
|
|
53
43
|
}
|
|
54
44
|
|
|
55
45
|
AGENT_BROWSER_BIN = "agent-browser"
|
|
56
|
-
DEFAULT_SESSION_NAME = "clacky"
|
|
57
|
-
CHROME_DEBUG_PORT = 9222
|
|
58
46
|
BROWSER_COMMAND_TIMEOUT = 30
|
|
59
|
-
CHROME_DEBUG_PAGE = "chrome://inspect/#remote-debugging"
|
|
60
47
|
MIN_AGENT_BROWSER_VERSION = "0.20.0"
|
|
61
48
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
49
|
+
# Inline config — reads ~/.clacky/browser.yml, falls back to built-in defaults.
|
|
50
|
+
#
|
|
51
|
+
# Example ~/.clacky/browser.yml:
|
|
52
|
+
# headed: true # show browser window (default: true)
|
|
53
|
+
# session_name: clacky # persistent session name (default: clacky)
|
|
54
|
+
# auto_connect: false # false = built-in browser (default), true = user's Chrome
|
|
55
|
+
class BrowserConfig
|
|
56
|
+
USER_CONFIG_FILE = File.join(Dir.home, ".clacky", "browser.yml")
|
|
57
|
+
|
|
58
|
+
DEFAULTS = {
|
|
59
|
+
"headed" => true,
|
|
60
|
+
"session_name" => "clacky",
|
|
61
|
+
"auto_connect" => false
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
attr_reader :headed, :session_name, :auto_connect
|
|
65
|
+
|
|
66
|
+
def initialize(attrs = {})
|
|
67
|
+
merged = DEFAULTS.merge(attrs)
|
|
68
|
+
@headed = merged["headed"]
|
|
69
|
+
@session_name = merged["session_name"]
|
|
70
|
+
@auto_connect = merged["auto_connect"]
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
73
|
+
def self.load
|
|
74
|
+
data = File.exist?(USER_CONFIG_FILE) ? YAML.safe_load(File.read(USER_CONFIG_FILE)) || {} : {}
|
|
75
|
+
new(data)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
new
|
|
74
78
|
end
|
|
79
|
+
end
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
79
|
-
error: "agent-browser version too old",
|
|
80
|
-
message: "agent-browser #{current} is installed but version >= #{MIN_AGENT_BROWSER_VERSION} is required.",
|
|
81
|
-
instructions: "Tell the user: 'agent-browser needs to be upgraded from #{current} to #{MIN_AGENT_BROWSER_VERSION}+. Run `browser(command: \"install\")` to upgrade — this may take a minute. Would you like me to upgrade it now?' Wait for user confirmation before calling install."
|
|
82
|
-
}
|
|
81
|
+
def execute(command:, working_dir: nil)
|
|
82
|
+
unless agent_browser_ready?
|
|
83
|
+
return not_ready_response
|
|
83
84
|
end
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
use_auto_connect = isolated == false
|
|
87
|
-
persistent_session_name = use_auto_connect ? nil : DEFAULT_SESSION_NAME
|
|
88
|
-
|
|
89
|
-
we_launched_chrome = false
|
|
90
|
-
if use_auto_connect && !chrome_debug_running?
|
|
91
|
-
launch_result = ensure_chrome_debug_ready
|
|
92
|
-
if launch_result == :not_installed
|
|
93
|
-
use_auto_connect = false
|
|
94
|
-
persistent_session_name = DEFAULT_SESSION_NAME
|
|
95
|
-
elsif launch_result
|
|
96
|
-
we_launched_chrome = true
|
|
97
|
-
else
|
|
98
|
-
return chrome_setup_instructions
|
|
99
|
-
end
|
|
100
|
-
end
|
|
86
|
+
cfg = BrowserConfig.load
|
|
101
87
|
|
|
102
|
-
|
|
103
|
-
auto_connect: use_auto_connect,
|
|
104
|
-
session_name: persistent_session_name,
|
|
105
|
-
headed: use_auto_connect ? false : true
|
|
106
|
-
}
|
|
88
|
+
# In auto_connect mode, open commands become new tabs in user's Chrome
|
|
107
89
|
effective_command = command
|
|
108
|
-
if
|
|
90
|
+
if cfg.auto_connect && (m = command.strip.match(/\A(open|goto|navigate)\s+(.+)\z/i))
|
|
109
91
|
effective_command = "tab new #{m[2].strip}"
|
|
110
92
|
end
|
|
111
|
-
full_command = build_command(effective_command, session, **build_opts)
|
|
112
|
-
|
|
113
|
-
result = Shell.new.execute(command: full_command, hard_timeout: BROWSER_COMMAND_TIMEOUT, working_dir: working_dir)
|
|
114
|
-
|
|
115
|
-
if !result[:success] && session_closed_error?(result) && persistent_session_name
|
|
116
|
-
full_command = build_command(
|
|
117
|
-
effective_command, session,
|
|
118
|
-
auto_connect: use_auto_connect,
|
|
119
|
-
session_name: nil,
|
|
120
|
-
headed: use_auto_connect ? false : true
|
|
121
|
-
)
|
|
122
|
-
result = Shell.new.execute(command: full_command, hard_timeout: BROWSER_COMMAND_TIMEOUT, working_dir: working_dir)
|
|
123
|
-
end
|
|
124
93
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if
|
|
136
|
-
|
|
94
|
+
full_command = build_command(effective_command,
|
|
95
|
+
auto_connect: cfg.auto_connect,
|
|
96
|
+
session_name: cfg.auto_connect ? nil : cfg.session_name,
|
|
97
|
+
headed: cfg.headed)
|
|
98
|
+
|
|
99
|
+
result = Shell.new.execute(command: full_command,
|
|
100
|
+
hard_timeout: BROWSER_COMMAND_TIMEOUT,
|
|
101
|
+
working_dir: working_dir)
|
|
102
|
+
|
|
103
|
+
# Session may have been closed — retry without session name
|
|
104
|
+
if !result[:success] && session_closed_error?(result) && cfg.session_name
|
|
105
|
+
full_command = build_command(effective_command,
|
|
106
|
+
auto_connect: cfg.auto_connect,
|
|
107
|
+
session_name: nil,
|
|
108
|
+
headed: cfg.headed)
|
|
109
|
+
result = Shell.new.execute(command: full_command,
|
|
110
|
+
hard_timeout: BROWSER_COMMAND_TIMEOUT,
|
|
111
|
+
working_dir: working_dir)
|
|
137
112
|
end
|
|
138
113
|
|
|
139
114
|
result[:command] = command
|
|
@@ -144,11 +119,7 @@ module Clacky
|
|
|
144
119
|
|
|
145
120
|
def format_call(args)
|
|
146
121
|
cmd = args[:command] || args["command"] || ""
|
|
147
|
-
|
|
148
|
-
isolated = args[:isolated] || args["isolated"]
|
|
149
|
-
session_label = session ? " [#{session}]" : ""
|
|
150
|
-
isolated_label = (isolated != false) ? " [built-in]" : " [user Chrome]"
|
|
151
|
-
"browser(#{cmd})#{session_label}#{isolated_label}"
|
|
122
|
+
"browser(#{cmd})"
|
|
152
123
|
end
|
|
153
124
|
|
|
154
125
|
def format_result(result)
|
|
@@ -156,7 +127,7 @@ module Clacky
|
|
|
156
127
|
"[Error] #{result[:error][0..80]}"
|
|
157
128
|
elsif result[:success]
|
|
158
129
|
stdout = result[:stdout] || ""
|
|
159
|
-
lines
|
|
130
|
+
lines = stdout.lines.size
|
|
160
131
|
"[OK] #{lines > 0 ? "#{lines} lines" : "Done"}"
|
|
161
132
|
else
|
|
162
133
|
stderr = result[:stderr] || "Failed"
|
|
@@ -165,36 +136,34 @@ module Clacky
|
|
|
165
136
|
end
|
|
166
137
|
|
|
167
138
|
MAX_LLM_OUTPUT_CHARS = 6000
|
|
168
|
-
|
|
169
|
-
MAX_SNAPSHOT_CHARS = 4000
|
|
139
|
+
MAX_SNAPSHOT_CHARS = 4000
|
|
170
140
|
|
|
171
141
|
def format_result_for_llm(result)
|
|
172
142
|
return result if result[:error]
|
|
173
143
|
|
|
174
|
-
stdout
|
|
175
|
-
stderr
|
|
144
|
+
stdout = result[:stdout] || ""
|
|
145
|
+
stderr = result[:stderr] || ""
|
|
176
146
|
command_name = command_name_for_temp(result[:command])
|
|
177
147
|
|
|
178
148
|
compact = {
|
|
179
|
-
command:
|
|
180
|
-
success:
|
|
149
|
+
command: result[:command],
|
|
150
|
+
success: result[:success],
|
|
181
151
|
exit_code: result[:exit_code]
|
|
182
152
|
}
|
|
183
153
|
|
|
184
|
-
# Apply snapshot-specific compression before generic truncation
|
|
185
154
|
if snapshot_command?(result[:command])
|
|
186
|
-
stdout
|
|
155
|
+
stdout = compress_snapshot(stdout)
|
|
187
156
|
max_chars = MAX_SNAPSHOT_CHARS
|
|
188
157
|
else
|
|
189
158
|
max_chars = MAX_LLM_OUTPUT_CHARS
|
|
190
159
|
end
|
|
191
160
|
|
|
192
161
|
stdout_info = truncate_and_save(stdout, max_chars, "stdout", command_name)
|
|
193
|
-
compact[:stdout]
|
|
162
|
+
compact[:stdout] = stdout_info[:content]
|
|
194
163
|
compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
|
|
195
164
|
|
|
196
165
|
stderr_info = truncate_and_save(stderr, 500, "stderr", command_name)
|
|
197
|
-
compact[:stderr]
|
|
166
|
+
compact[:stderr] = stderr_info[:content] unless stderr.empty?
|
|
198
167
|
compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
|
|
199
168
|
|
|
200
169
|
compact
|
|
@@ -202,206 +171,71 @@ module Clacky
|
|
|
202
171
|
|
|
203
172
|
private
|
|
204
173
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return false unless command.is_a?(String)
|
|
208
|
-
cmd = command.strip.downcase
|
|
209
|
-
cmd == "snapshot" || cmd.start_with?("snapshot ")
|
|
174
|
+
def agent_browser_ready?
|
|
175
|
+
agent_browser_installed? && !agent_browser_outdated?
|
|
210
176
|
end
|
|
211
177
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
#
|
|
219
|
-
# What we keep intact:
|
|
220
|
-
# - All [ref=eN] anchors (essential for click/fill commands)
|
|
221
|
-
# - All visible text and headings
|
|
222
|
-
# - All interactive elements (button, textbox, link, select, etc.)
|
|
223
|
-
# - img lines that do have alt text
|
|
224
|
-
def compress_snapshot(output)
|
|
225
|
-
return output if output.empty?
|
|
226
|
-
|
|
227
|
-
lines = output.lines
|
|
228
|
-
original_size = lines.size
|
|
229
|
-
|
|
230
|
-
compressed = lines.reject do |line|
|
|
231
|
-
stripped = line.strip
|
|
232
|
-
stripped.start_with?("- /url:", "/url:") ||
|
|
233
|
-
stripped.start_with?("- /placeholder:", "/placeholder:") ||
|
|
234
|
-
stripped == "- img" ||
|
|
235
|
-
stripped.match?(/\A-\s+img\s*\z/)
|
|
236
|
-
end
|
|
178
|
+
def not_ready_response
|
|
179
|
+
{
|
|
180
|
+
error: "agent-browser not ready",
|
|
181
|
+
instructions: "Tell the user that browser automation is not set up yet, and ask them to run `/onboard browser` to complete the setup."
|
|
182
|
+
}
|
|
183
|
+
end
|
|
237
184
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
end
|
|
185
|
+
def agent_browser_installed?
|
|
186
|
+
result = Shell.new.execute(command: "which #{AGENT_BROWSER_BIN}")
|
|
187
|
+
result[:success] && !result[:stdout].to_s.strip.empty?
|
|
188
|
+
end
|
|
243
189
|
|
|
244
|
-
|
|
190
|
+
def agent_browser_outdated?
|
|
191
|
+
result = Shell.new.execute(command: "#{AGENT_BROWSER_BIN} --version")
|
|
192
|
+
version = result[:stdout].to_s.strip.split.last
|
|
193
|
+
return false if version.nil? || version.empty?
|
|
194
|
+
Gem::Version.new(version) < Gem::Version.new(MIN_AGENT_BROWSER_VERSION)
|
|
195
|
+
rescue StandardError
|
|
196
|
+
false
|
|
245
197
|
end
|
|
246
198
|
|
|
247
|
-
def build_command(command,
|
|
199
|
+
def build_command(command, auto_connect: false, session_name: nil, headed: true)
|
|
248
200
|
parts = [AGENT_BROWSER_BIN]
|
|
249
|
-
parts << "--auto-connect"
|
|
250
|
-
parts << "--headed"
|
|
251
|
-
parts += ["--session", Shellwords.escape(session)] if session
|
|
201
|
+
parts << "--auto-connect" if auto_connect
|
|
202
|
+
parts << "--headed" if headed
|
|
252
203
|
parts += ["--session-name", Shellwords.escape(session_name)] if session_name
|
|
253
204
|
parts << command
|
|
254
205
|
parts.join(" ")
|
|
255
206
|
end
|
|
256
207
|
|
|
257
|
-
def chrome_debug_running?
|
|
258
|
-
TCPSocket.new("127.0.0.1", CHROME_DEBUG_PORT).close
|
|
259
|
-
true
|
|
260
|
-
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
|
|
261
|
-
false
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def connection_error?(result)
|
|
265
|
-
output = "#{result[:stderr]}#{result[:stdout]}"
|
|
266
|
-
output.include?("Could not connect") ||
|
|
267
|
-
output.include?("No running Chrome instance") ||
|
|
268
|
-
output.include?("remote debugging")
|
|
269
|
-
end
|
|
270
|
-
|
|
271
208
|
def session_closed_error?(result)
|
|
272
209
|
output = "#{result[:stderr]}#{result[:stdout]}"
|
|
273
210
|
output.include?("has been close") || output.include?("has been closed")
|
|
274
211
|
end
|
|
275
212
|
|
|
276
|
-
def
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def find_chrome
|
|
282
|
-
@chrome_path ||= resolve_chrome_path
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def resolve_chrome_path
|
|
286
|
-
%w[CHROME_PATH CHROME_BIN GOOGLE_CHROME_BIN].each do |var|
|
|
287
|
-
path = ENV[var]
|
|
288
|
-
return path if path && File.executable?(path)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
%w[google-chrome google-chrome-stable chromium chromium-browser].each do |bin|
|
|
292
|
-
path = find_in_path(bin)
|
|
293
|
-
return path if path
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
paths = []
|
|
297
|
-
|
|
298
|
-
if macos?
|
|
299
|
-
paths += [
|
|
300
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
301
|
-
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
302
|
-
"#{Dir.home}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
303
|
-
]
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
if linux?
|
|
307
|
-
paths += [
|
|
308
|
-
"/usr/bin/google-chrome",
|
|
309
|
-
"/usr/bin/google-chrome-stable",
|
|
310
|
-
"/usr/bin/chromium",
|
|
311
|
-
"/usr/bin/chromium-browser",
|
|
312
|
-
"/snap/bin/chromium"
|
|
313
|
-
]
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
paths.find { |path| File.executable?(path) }
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def find_in_path(bin)
|
|
320
|
-
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
|
|
321
|
-
path = File.join(dir, bin)
|
|
322
|
-
return path if File.executable?(path) && !File.directory?(path)
|
|
323
|
-
end
|
|
324
|
-
nil
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
def ensure_chrome_debug_ready
|
|
328
|
-
executable = find_chrome
|
|
329
|
-
return :not_installed unless executable
|
|
330
|
-
|
|
331
|
-
spawn_chrome_with_debug_port(executable)
|
|
332
|
-
return true
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
def spawn_chrome_with_debug_port(executable)
|
|
336
|
-
pid = Process.spawn(
|
|
337
|
-
executable,
|
|
338
|
-
"--remote-debugging-port=#{CHROME_DEBUG_PORT}",
|
|
339
|
-
"--no-first-run",
|
|
340
|
-
"--no-default-browser-check",
|
|
341
|
-
out: File::NULL,
|
|
342
|
-
err: File::NULL
|
|
343
|
-
)
|
|
344
|
-
Process.detach(pid)
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
def open_chrome_remote_debugging_page
|
|
348
|
-
if macos?
|
|
349
|
-
system("open", "-a", "Google Chrome", CHROME_DEBUG_PAGE)
|
|
350
|
-
else
|
|
351
|
-
executable = find_chrome
|
|
352
|
-
if executable
|
|
353
|
-
system("sh", "-c", "#{Shellwords.escape(executable)} #{Shellwords.escape(CHROME_DEBUG_PAGE)} > /dev/null 2>&1 &")
|
|
354
|
-
end
|
|
355
|
-
end
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
def macos?
|
|
359
|
-
RbConfig::CONFIG["host_os"].include?("darwin")
|
|
213
|
+
def snapshot_command?(command)
|
|
214
|
+
return false unless command.is_a?(String)
|
|
215
|
+
cmd = command.strip.downcase
|
|
216
|
+
cmd == "snapshot" || cmd.start_with?("snapshot ")
|
|
360
217
|
end
|
|
361
218
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
219
|
+
# Strip noise from snapshot output to reduce token usage.
|
|
220
|
+
#
|
|
221
|
+
# Removes:
|
|
222
|
+
# - "- /url: ..." lines — LLM uses [ref=eN], not URLs
|
|
223
|
+
# - "- /placeholder: ..." lines — already shown inline in textbox label
|
|
224
|
+
# - bare "- img" lines with no alt text — zero information
|
|
225
|
+
def compress_snapshot(output)
|
|
226
|
+
return output if output.empty?
|
|
365
227
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
"Phase 2 — Once the user confirms, retry the browser command. The Allow dialog appears once per Chrome session (each time the user reopens Chrome) — this is Chrome's security and cannot be skipped. Ask the user to click Allow when it appears. " \
|
|
373
|
-
"Phase 3 — If connection still fails and the page shows 'Server running at: starting...', tell the user to fully quit Chrome and reopen it; the server may be stuck. Once it shows 'Server running at: 127.0.0.1:9222', retry.",
|
|
374
|
-
note: "Do NOT retry immediately. Wait for the user to confirm. The toggle persists — if they enabled it before, reopening Chrome is enough. Allow dialog is once per session."
|
|
375
|
-
}
|
|
376
|
-
if timeout
|
|
377
|
-
base[:error] = "Browser command timed out"
|
|
378
|
-
base[:message] = "Command timed out. This usually means the Allow dialog is showing — please click Allow in the Chrome dialog, then tell me when done."
|
|
379
|
-
base[:instructions] = "Timeout usually means the Allow dialog is waiting. Do NOT retry. Ask the user: 'Please click the Allow button in the Chrome dialog, then tell me when done.' Only retry after the user confirms they clicked Allow."
|
|
228
|
+
lines = output.lines
|
|
229
|
+
orig = lines.size
|
|
230
|
+
filtered = lines.reject do |line|
|
|
231
|
+
s = line.strip
|
|
232
|
+
s.start_with?("- /url:", "/url:", "- /placeholder:", "/placeholder:") ||
|
|
233
|
+
s == "- img" || s.match?(/\A-\s+img\s*\z/)
|
|
380
234
|
end
|
|
381
|
-
base
|
|
382
|
-
end
|
|
383
235
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def agent_browser_outdated?
|
|
389
|
-
version = `agent-browser --version 2>/dev/null`.strip.split.last
|
|
390
|
-
return false if version.nil? || version.empty?
|
|
391
|
-
Gem::Version.new(version) < Gem::Version.new(MIN_AGENT_BROWSER_VERSION)
|
|
392
|
-
rescue StandardError
|
|
393
|
-
false
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
def do_install_agent_browser
|
|
397
|
-
script = File.expand_path("../../../../scripts/install_agent_browser.sh", __FILE__)
|
|
398
|
-
result = Shell.new.execute(command: "bash #{Shellwords.escape(script)}", hard_timeout: 180)
|
|
399
|
-
if result[:success]
|
|
400
|
-
version = `agent-browser --version 2>/dev/null`.strip.split.last
|
|
401
|
-
{ success: true, message: "agent-browser #{version} installed successfully. You can now use browser commands." }
|
|
402
|
-
else
|
|
403
|
-
{ error: "Failed to install agent-browser", message: result[:stdout].to_s.strip }
|
|
404
|
-
end
|
|
236
|
+
removed = orig - filtered.size
|
|
237
|
+
filtered << "\n[snapshot compressed: #{removed} /url, /placeholder, empty-img lines removed]\n" if removed > 0
|
|
238
|
+
filtered.join
|
|
405
239
|
end
|
|
406
240
|
|
|
407
241
|
def command_name_for_temp(command)
|
|
@@ -411,33 +245,28 @@ module Clacky
|
|
|
411
245
|
|
|
412
246
|
def truncate_and_save(output, max_chars, _label, command_name)
|
|
413
247
|
return { content: "", temp_file: nil } if output.empty?
|
|
414
|
-
|
|
415
248
|
return { content: output, temp_file: nil } if output.length <= max_chars
|
|
416
249
|
|
|
417
250
|
lines = output.lines
|
|
418
251
|
return { content: output, temp_file: nil } if lines.length <= 2
|
|
419
252
|
|
|
420
253
|
safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
|
|
421
|
-
temp_dir
|
|
254
|
+
temp_dir = Dir.mktmpdir
|
|
422
255
|
temp_file = File.join(temp_dir, "browser_#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
|
|
423
256
|
File.write(temp_file, output)
|
|
424
257
|
|
|
425
|
-
|
|
426
|
-
available_chars = max_chars - notice_overhead
|
|
427
|
-
|
|
258
|
+
available = max_chars - 200
|
|
428
259
|
first_part = []
|
|
429
260
|
accumulated = 0
|
|
430
261
|
lines.each do |line|
|
|
431
|
-
break if accumulated + line.length >
|
|
262
|
+
break if accumulated + line.length > available
|
|
432
263
|
first_part << line
|
|
433
264
|
accumulated += line.length
|
|
434
265
|
end
|
|
435
266
|
|
|
436
267
|
notice = "\n\n... [Output truncated: showing #{first_part.size} of #{lines.size} lines, full: #{temp_file} (use grep to search)] ...\n"
|
|
437
|
-
|
|
438
268
|
{ content: first_part.join + notice, temp_file: temp_file }
|
|
439
269
|
end
|
|
440
|
-
|
|
441
270
|
end
|
|
442
271
|
end
|
|
443
272
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pastel"
|
|
4
4
|
require_relative "../../version"
|
|
5
|
+
require_relative "../block_font"
|
|
5
6
|
|
|
6
7
|
module Clacky
|
|
7
8
|
module UI2
|
|
@@ -33,9 +34,6 @@ module Clacky
|
|
|
33
34
|
# Minimum terminal width required for full logo display
|
|
34
35
|
MIN_WIDTH_FOR_LOGO = 90
|
|
35
36
|
|
|
36
|
-
# Artii font used for brand name generation
|
|
37
|
-
ARTII_FONT = "big"
|
|
38
|
-
|
|
39
37
|
def initialize
|
|
40
38
|
@pastel = Pastel.new
|
|
41
39
|
end
|
|
@@ -115,7 +113,7 @@ module Clacky
|
|
|
115
113
|
private def logo_content(width)
|
|
116
114
|
brand = brand_config
|
|
117
115
|
if brand.branded?
|
|
118
|
-
generate_brand_logo(brand
|
|
116
|
+
generate_brand_logo(brand, width)
|
|
119
117
|
else
|
|
120
118
|
if width >= MIN_WIDTH_FOR_LOGO
|
|
121
119
|
@pastel.bright_green(LOGO)
|
|
@@ -125,28 +123,31 @@ module Clacky
|
|
|
125
123
|
end
|
|
126
124
|
end
|
|
127
125
|
|
|
128
|
-
# Generate a brand
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
# Generate a brand logo using BlockFont (Unicode █ ╗ ╔ style).
|
|
127
|
+
# Renders brand_command as the big ASCII art logo.
|
|
128
|
+
# Shows brand_name as a subtitle when it differs from brand_command.
|
|
129
|
+
# Falls back to plain brand_name text when terminal is too narrow.
|
|
130
|
+
private def generate_brand_logo(brand, width)
|
|
131
|
+
# Use brand_command as the renderable ASCII-safe identifier for the logo.
|
|
132
|
+
# brand_name may contain CJK or special characters unsuitable for block art.
|
|
133
|
+
render_key = brand.brand_command.to_s.strip
|
|
134
|
+
render_key = brand.brand_name.to_s.strip if render_key.empty?
|
|
135
|
+
|
|
136
|
+
art = UI2::BlockFont.render(render_key)
|
|
132
137
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# Terminal too narrow for full art — centre-clip or use plain fallback
|
|
137
|
-
@pastel.bright_green(brand_name)
|
|
138
|
+
lines = []
|
|
139
|
+
if !art.strip.empty? && art_fits?(art, width)
|
|
140
|
+
lines << @pastel.bright_green(art)
|
|
138
141
|
else
|
|
139
|
-
@pastel.bright_green(
|
|
142
|
+
lines << @pastel.bright_green(render_key)
|
|
140
143
|
end
|
|
141
|
-
end
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
nil
|
|
145
|
+
# Show brand_name as subtitle when it differs from the render key
|
|
146
|
+
if brand.brand_name.to_s.strip != render_key
|
|
147
|
+
lines << @pastel.bright_cyan(" #{brand.brand_name}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
lines.join("\n")
|
|
150
151
|
end
|
|
151
152
|
|
|
152
153
|
# Check whether the ASCII art fits within the terminal width.
|
data/lib/clacky/version.rb
CHANGED