openclacky 0.9.7 → 0.9.9

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -1,228 +1,785 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+ require "tmpdir"
3
7
  require "shellwords"
4
8
  require "yaml"
5
- require "tmpdir"
6
9
  require_relative "base"
7
- require_relative "shell"
8
10
 
9
11
  module Clacky
10
12
  module Tools
13
+ # Browser tool — controls the user's real Chromium-based browser (Chrome 146+)
14
+ # via the Chrome DevTools MCP server (chrome-devtools-mcp).
15
+ #
16
+ # Architecture: profile="user" uses the existing-session driver (Chrome MCP).
17
+ # chrome-devtools-mcp --autoConnect --experimentalStructuredContent
18
+ # --experimental-page-id-routing [--userDataDir <path>]
19
+ #
20
+ # Communication: MCP stdio JSON-RPC 2.0 over a *persistent* (daemon) process.
21
+ # The MCP server process is started once, kept alive across all tool calls,
22
+ # and only restarted when the process dies unexpectedly. This means Chrome
23
+ # shows the "Allow remote debugging" dialog exactly once per daemon lifetime.
24
+ #
25
+ # No agent-browser, no DevToolsActivePort, no CDP port management.
11
26
  class Browser < Base
12
27
  self.tool_name = "browser"
13
28
  self.tool_description = <<~DESC
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
-
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:
17
- - 'snapshot -i -C' interactive + cursor-clickable elements (recommended default)
18
- - 'snapshot -i' — interactive elements only (faster, for simple forms)
19
- - 'snapshot' — full accessibility tree (when above miss elements)
20
-
21
- ELEMENT SELECTION prefer in this order:
22
- 1. Refs: 'click @e1', 'fill @e2 "text"'
23
- 2. Semantic find: 'find text "Submit" click', 'find role button "Login" click', 'find label "Email" fill "user@example.com"'
24
- 3. CSS: 'click "#submit-btn"'
25
-
26
- OTHER COMMANDS:
27
- - 'open <url>', 'back', 'reload', 'press Enter', 'key Control+a'
28
- - 'scroll down/up', 'scrollintoview @e1', 'wait @e1', 'wait --text "..."', 'wait --load networkidle'
29
- - 'dialog accept/dismiss', 'tab new <url>', 'tab <n>'
30
-
31
- SCREENSHOT: NEVER call on your own — costs far more tokens than snapshot. Last resort only. Ask user first: "Screenshots cost more tokens. Approve?" When approved: 'screenshot --screenshot-format jpeg --screenshot-quality 50'.
29
+ Control the browser for automation tasks (login, form submission, UI interaction, scraping).
30
+ For simple page fetch or search, prefer web_fetch or web_search instead.
31
+
32
+ Uses your real Chrome browser (profile="user") with existing logins & cookies. Requires Chrome 146+.
33
+
34
+ ACTIONS OVERVIEW:
35
+ - snapshot → get accessibility tree with element refs. ALWAYS run before interacting.
36
+ - act → interact with page: click, type, fill, press, hover, scroll, drag, select, wait, evaluate
37
+ - open → open URL in a new tab
38
+ - navigate → navigate current tab to URL
39
+ - tabs → list open tabs
40
+ - focus → switch to a tab by targetId
41
+ - close → close current tab
42
+ - screenshot EXPENSIVE. Only use when user explicitly asks to "see" or "show" the page. NEVER call without ref= unless user asks for a visual. Use ref= to screenshot a single element (much cheaper).
43
+ - status → check if browser is running
44
+
45
+ SNAPSHOT WORKFLOW — always snapshot first:
46
+ - action="snapshot" full accessibility tree
47
+ - action="snapshot", interactive=true → interactive elements only (recommended)
48
+ - action="snapshot", interactive=true, compact=true → compact interactive
49
+
50
+ SCREENSHOT RULES — read before calling screenshot:
51
+ 1. DEFAULT: use snapshot to understand the page. snapshot is FREE; screenshot costs ~30K tokens.
52
+ 2. WITH ref=: screenshot a single element (e.g. ref="e5") — costs ~1-2K tokens. OK to use.
53
+ 3. WITHOUT ref= (full page): ONLY if user explicitly says "show me", "screenshot", "what does it look like". NEVER call proactively.
54
+ 4. If you want to check state / find elements / verify result → use snapshot, NOT screenshot.
55
+
56
+ ACT KINDS: click, dblclick, type, fill, press, hover, drag, select, scroll, wait, evaluate, click_at
57
+ - click: ref="e1"
58
+ - click_at: x=100, y=200 → coordinate click, use when ref-based click fails (React/virtual lists)
59
+ - fill: ref="e1", text="value"
60
+ - press: key="Enter"
61
+ - scroll: direction="down", amount=300
62
+ - wait: ms=2000 OR selector=".spinner"
63
+ - evaluate: js="document.title"
64
+
65
+ TARGETING TABS — pass target_id from snapshot/tabs to subsequent acts.
32
66
  DESC
33
67
  self.tool_category = "web"
34
68
  self.tool_parameters = {
35
69
  type: "object",
36
70
  properties: {
37
- command: {
71
+ action: {
38
72
  type: "string",
39
- description: "agent-browser command, e.g. 'open https://...', 'snapshot -i', 'click @e1', 'fill @e2 \"text\"'"
40
- }
73
+ enum: %w[snapshot act open navigate tabs focus close screenshot status],
74
+ description: "Action to perform."
75
+ },
76
+ profile: {
77
+ type: "string",
78
+ enum: %w[user],
79
+ description: "Browser profile. Only 'user' is supported — uses your real Chrome browser with existing logins & cookies."
80
+ },
81
+ interactive: {
82
+ type: "boolean",
83
+ description: "snapshot: only include interactive elements."
84
+ },
85
+ compact: {
86
+ type: "boolean",
87
+ description: "snapshot: remove empty structural elements."
88
+ },
89
+ depth: {
90
+ type: "integer",
91
+ description: "snapshot: max tree depth."
92
+ },
93
+ selector: {
94
+ type: "string",
95
+ description: "snapshot scope / act CSS selector."
96
+ },
97
+ kind: {
98
+ type: "string",
99
+ enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
100
+ description: "act: interaction kind."
101
+ },
102
+ ref: {
103
+ type: "string",
104
+ description: "act: element ref from snapshot (e.g. 'e1'). screenshot: capture only this element (much cheaper than full-page)."
105
+ },
106
+ text: { type: "string", description: "act type/fill: text to enter." },
107
+ key: { type: "string", description: "act press: key (e.g. 'Enter')." },
108
+ direction: {
109
+ type: "string",
110
+ enum: %w[up down left right],
111
+ description: "act scroll: direction."
112
+ },
113
+ amount: { type: "integer", description: "act scroll: pixels." },
114
+ ms: { type: "integer", description: "act wait: milliseconds." },
115
+ load_state: {
116
+ type: "string",
117
+ enum: %w[load domcontentloaded networkidle],
118
+ description: "act wait: page load state."
119
+ },
120
+ js: { type: "string", description: "act evaluate: JS expression." },
121
+ target_ref: { type: "string", description: "act drag: destination ref." },
122
+ values: {
123
+ type: "array",
124
+ items: { type: "string" },
125
+ description: "act select: option values."
126
+ },
127
+ double_click: { type: "boolean", description: "act click: double-click." },
128
+ x: { type: "number", description: "act click_at: x coordinate in pixels." },
129
+ y: { type: "number", description: "act click_at: y coordinate in pixels." },
130
+ url: { type: "string", description: "open/navigate: URL." },
131
+ target_id: { type: "string", description: "tab targetId from open/tabs." },
132
+ format: {
133
+ type: "string",
134
+ enum: %w[png jpeg],
135
+ description: "screenshot: format (default jpeg)."
136
+ },
137
+ quality: { type: "integer", description: "screenshot: JPEG quality 0-100." },
138
+ full_page: { type: "boolean", description: "screenshot: full scrollable page." }
41
139
  },
42
- required: ["command"]
140
+ required: ["action"]
43
141
  }
44
142
 
45
- AGENT_BROWSER_BIN = "agent-browser"
46
- BROWSER_COMMAND_TIMEOUT = 30
47
- MIN_AGENT_BROWSER_VERSION = "0.20.0"
143
+ # Chrome MCP binary args (chrome-devtools-mcp is installed globally via npm install -g)
144
+ CHROME_MCP_BASE_ARGS = %w[
145
+ --autoConnect
146
+ --experimentalStructuredContent
147
+ --experimental-page-id-routing
148
+ --experimentalVision
149
+ ].freeze
48
150
 
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"]
151
+ # Minimum Chrome major version for Chrome MCP support
152
+ MIN_CHROME_MAJOR = 146
153
+
154
+ # MCP handshake/call timeout (seconds)
155
+ MCP_HANDSHAKE_TIMEOUT = 5
156
+ MCP_CALL_TIMEOUT = 10
157
+
158
+ # Minimum Node.js major version required by chrome-devtools-mcp
159
+ MIN_NODE_MAJOR = 20
160
+
161
+ MAX_SNAPSHOT_CHARS = 4000
162
+ MAX_LLM_OUTPUT_CHARS = 6000
163
+
164
+ # MCP daemon is managed by Clacky::BrowserManager (see lib/clacky/server/browser_manager.rb).
165
+ # Browser tool delegates all mcp_call / lifecycle operations to it via Clacky.browser_manager.
166
+
167
+ def execute(action:, profile: nil, working_dir: nil, **opts)
168
+ bypass = action.to_s == "status" ||
169
+ (action.to_s == "act" && (opts[:kind] || opts["kind"]).to_s == "evaluate")
170
+ unless bypass
171
+ return browser_not_setup_error unless File.exist?(BROWSER_CONFIG_PATH)
172
+ return browser_disabled_error unless browser_enabled?
71
173
  end
174
+ execute_user_browser(action, opts)
175
+ rescue StandardError => e
176
+ { error: "Browser error: #{e.message}\n\n#{BROWSER_RECONNECT_HINT}" }
177
+ end
72
178
 
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
179
+ def format_call(args)
180
+ action = args[:action] || args["action"] || "browser"
181
+ profile = args[:profile] || args["profile"]
182
+ suffix = profile ? "(#{action}, profile=#{profile})" : "(#{action})"
183
+ "browser#{suffix}"
184
+ end
185
+
186
+ def format_result(result)
187
+ return "[Error] #{result[:error].to_s[0..80]}" if result[:error]
188
+ return "[OK] #{result[:output].to_s.lines.size} lines" if result[:output]
189
+ "[OK] Done"
190
+ end
191
+
192
+ def format_result_for_llm(result)
193
+ return result if result[:error]
194
+
195
+ action = result[:action].to_s
196
+
197
+ # Screenshot with inline image data — return multipart content blocks so the
198
+ # LLM can actually see the image (both Anthropic and OpenAI vision formats).
199
+ if action == "screenshot" && result[:image_data]
200
+ mime_type = result[:mime_type] || "image/jpeg"
201
+ image_data = result[:image_data]
202
+ data_url = "data:#{mime_type};base64,#{image_data}"
203
+ # OpenAI vision format (also accepted by Anthropic via client conversion)
204
+ return [
205
+ { type: "text", text: "Screenshot captured." },
206
+ { type: "image_url", image_url: { url: data_url } }
207
+ ]
208
+ end
209
+
210
+ output = result[:output].to_s
211
+ output = compress_snapshot(output) if action == "snapshot"
212
+ max_chars = action == "snapshot" ? MAX_SNAPSHOT_CHARS : MAX_LLM_OUTPUT_CHARS
213
+
214
+ truncated = truncate_output(output, max_chars)
215
+
216
+ {
217
+ action: action,
218
+ success: result[:success],
219
+ stdout: truncated,
220
+ profile: result[:profile]
221
+ }.compact
222
+ end
223
+
224
+ private
225
+
226
+ # -----------------------------------------------------------------------
227
+ # User browser (Chrome MCP / existing-session driver)
228
+ # -----------------------------------------------------------------------
229
+
230
+ # Friendly setup guide returned when Chrome is not installed or not running.
231
+ # Shown to the user (and Agent) when Chrome remote debugging is not enabled.
232
+ # The strong wording ("STOP", "DO NOT") is intentional — it prevents the
233
+ # Agent from silently falling back to web_search or other workarounds.
234
+ BROWSER_CONFIG_PATH = File.expand_path("~/.clacky/browser.yml").freeze
235
+
236
+ # Shown when any browser action fails at runtime (Chrome closed, lost connection, etc.)
237
+ BROWSER_RECONNECT_HINT = <<~HINT.strip.freeze
238
+ Common causes for browser connection failure:
239
+ 1. Chrome is not running — ask the user to open Chrome.
240
+ 2. Remote Debugging is disabled — Chrome must be launched with --remote-debugging-port=9222.
241
+ 3. The browser MCP daemon crashed or lost the connection — it may recover on the next action.
242
+ 4. Chrome has been running for a long time and the CDP connection became unstable — restart Chrome to fix.
243
+
244
+ Inform the user of these possible causes and ask if they'd like to run a diagnosis.
245
+ If yes, invoke the browser-setup skill with subcommand "doctor" to diagnose and fix.
246
+ HINT
247
+
248
+ # Returns true if ~/.clacky/browser.yml exists and enabled: true.
249
+ # Returns true if browser.yml exists and enabled: true.
250
+ private def browser_enabled?
251
+ config = YAML.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
252
+ config.is_a?(Hash) && config["enabled"] == true
253
+ end
254
+
255
+ # Error when browser.yml doesn't exist — never been set up.
256
+ private def browser_not_setup_error
257
+ {
258
+ error: <<~MSG
259
+ The browser tool is not configured. This tool call has been rejected to protect user experience.
260
+
261
+ Ask the user if they'd like to set up the browser, then invoke the browser-setup skill to guide them through the setup. Retry this tool call after setup is complete.
262
+ MSG
263
+ }
264
+ end
265
+
266
+ # Error when browser.yml exists but enabled: false — user explicitly disabled it.
267
+ private def browser_disabled_error
268
+ {
269
+ error: <<~MSG
270
+ The browser tool is disabled by the user. This tool call has been rejected.
271
+
272
+ Inform the user that they have disabled the browser tool. They can re-enable it from settings or by running "/browser-setup".
273
+ MSG
274
+ }
275
+ end
276
+
277
+ private def execute_user_browser(action, opts)
278
+ if (err = node_error)
279
+ return err
78
280
  end
281
+
282
+ case action.to_s
283
+ when "tabs"
284
+ result = mcp_call("list_pages")
285
+ pages = extract_pages(result)
286
+ { action: "tabs", success: true, profile: "user", output: format_tabs(pages), tabs: pages }
287
+ when "snapshot"
288
+ do_user_snapshot(opts)
289
+ when "open"
290
+ url = require_url(opts)
291
+ return url if url.is_a?(Hash)
292
+ result = mcp_call("new_page", { url: url })
293
+ pages = extract_pages(result)
294
+ page = pages.last || {}
295
+ { action: "open", success: true, profile: "user",
296
+ targetId: page[:id]&.to_s, url: url, output: "Opened: #{url}" }
297
+ when "navigate"
298
+ url = require_url(opts)
299
+ return url if url.is_a?(Hash)
300
+ target_id = resolve_target_id(opts)
301
+ return target_id if target_id.is_a?(Hash)
302
+ mcp_call("navigate_page", { pageId: target_id.to_i, type: "url", url: url })
303
+ { action: "navigate", success: true, profile: "user",
304
+ targetId: target_id.to_s, url: url, output: "Navigated to: #{url}" }
305
+ when "focus"
306
+ target_id = resolve_target_id(opts)
307
+ return target_id if target_id.is_a?(Hash)
308
+ mcp_call("select_page", { pageId: target_id.to_i, bringToFront: true })
309
+ { action: "focus", success: true, profile: "user", output: "Focused tab #{target_id}" }
310
+ when "close"
311
+ target_id = resolve_target_id(opts)
312
+ return target_id if target_id.is_a?(Hash)
313
+ mcp_call("close_page", { pageId: target_id.to_i })
314
+ { action: "close", success: true, profile: "user", output: "Closed tab #{target_id}" }
315
+ when "act"
316
+ do_user_act(opts)
317
+ when "screenshot"
318
+ do_user_screenshot(opts)
319
+ when "status"
320
+ result = mcp_call("list_pages")
321
+ pages = extract_pages(result)
322
+ { action: "status", success: true, profile: "user",
323
+ output: "Browser running. #{pages.size} tab(s) open.", tabs: pages }
324
+ else
325
+ { error: "Action '#{action}' is not supported for profile=user." }
326
+ end
327
+ end
328
+
329
+ private def do_user_snapshot(opts)
330
+ target_id = resolve_target_id(opts)
331
+ return target_id if target_id.is_a?(Hash)
332
+
333
+ raw = mcp_call("take_snapshot", { pageId: target_id.to_i })
334
+ snapshot_node = extract_snapshot(raw)
335
+
336
+ interactive = opts[:interactive] || opts["interactive"]
337
+ compact_opt = opts[:compact] || opts["compact"]
338
+ max_depth = opts[:depth] || opts["depth"]
339
+
340
+ text = build_ai_snapshot(snapshot_node,
341
+ interactive: interactive,
342
+ compact: compact_opt,
343
+ max_depth: max_depth)
344
+
345
+ { action: "snapshot", success: true, profile: "user",
346
+ targetId: target_id.to_s, output: text }
79
347
  end
80
348
 
81
- def execute(command:, working_dir: nil)
82
- unless agent_browser_ready?
83
- return not_ready_response
349
+ private def do_user_act(opts)
350
+ kind = (opts[:kind] || opts["kind"] || "click").to_s
351
+ target_id = resolve_target_id(opts)
352
+ return target_id if target_id.is_a?(Hash)
353
+
354
+ page_id = target_id.to_i
355
+ ref = opts[:ref] || opts["ref"]
356
+
357
+ case kind
358
+ when "click", "dblclick"
359
+ uid = require_ref(ref)
360
+ return uid if uid.is_a?(Hash)
361
+ args = { pageId: page_id, uid: uid }
362
+ args[:dblClick] = true if kind == "dblclick" || opts[:double_click] || opts["double_click"]
363
+ mcp_call("click", args)
364
+ when "fill"
365
+ uid = require_ref(ref)
366
+ return uid if uid.is_a?(Hash)
367
+ value = opts[:text] || opts["text"] || ""
368
+ mcp_call("fill", { pageId: page_id, uid: uid, value: value })
369
+ when "type"
370
+ uid = require_ref(ref)
371
+ return uid if uid.is_a?(Hash)
372
+ value = opts[:text] || opts["text"] || ""
373
+ mcp_call("fill", { pageId: page_id, uid: uid, value: value })
374
+ when "press"
375
+ key = opts[:key] || opts["key"] || "Enter"
376
+ mcp_call("press_key", { pageId: page_id, key: key })
377
+ when "hover"
378
+ uid = require_ref(ref)
379
+ return uid if uid.is_a?(Hash)
380
+ mcp_call("hover", { pageId: page_id, uid: uid })
381
+ when "drag"
382
+ uid = require_ref(ref)
383
+ return uid if uid.is_a?(Hash)
384
+ target_uid = opts[:target_ref] || opts["target_ref"] || ""
385
+ mcp_call("drag", { pageId: page_id, from_uid: uid, to_uid: target_uid })
386
+ when "select"
387
+ uid = require_ref(ref)
388
+ return uid if uid.is_a?(Hash)
389
+ values = Array(opts[:values] || opts["values"] || [])
390
+ mcp_call("fill", { pageId: page_id, uid: uid, value: values.first.to_s })
391
+ when "scroll"
392
+ direction = opts[:direction] || opts["direction"] || "down"
393
+ amount = opts[:amount] || opts["amount"] || 300
394
+ js = "window.scrollBy(#{direction == 'right' || direction == 'left' ?
395
+ (direction == 'left' ? -amount.to_i : amount.to_i) : 0
396
+ }, #{direction == 'up' ? -amount.to_i :
397
+ direction == 'down' ? amount.to_i : 0})"
398
+ mcp_call("evaluate_script", { pageId: page_id, function: "() => { #{js} }" })
399
+ when "wait"
400
+ ms = opts[:ms] || opts["ms"]
401
+ load_state = opts[:load_state] || opts["load_state"]
402
+ sel = opts[:selector] || opts["selector"]
403
+ if ms
404
+ sleep(ms.to_i / 1000.0)
405
+ { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
406
+ return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
407
+ elsif sel
408
+ mcp_call("wait_for", { pageId: page_id, text: [sel] })
409
+ else
410
+ sleep(1)
411
+ end
412
+ when "evaluate"
413
+ js = opts[:js] || opts["js"] || ""
414
+ result = mcp_call("evaluate_script", {
415
+ pageId: page_id,
416
+ function: "() => { return (#{js}) }"
417
+ })
418
+ value = extract_message(result)
419
+ return { action: "act", success: true, profile: "user",
420
+ output: value.to_s }
421
+ when "click_at"
422
+ x = opts[:x] || opts["x"]
423
+ y = opts[:y] || opts["y"]
424
+ return { error: "click_at requires x and y coordinates" } unless x && y
425
+
426
+ click_args = { pageId: page_id, x: x.to_f, y: y.to_f }
427
+ click_args[:dblClick] = true if opts[:double_click] || opts["double_click"]
428
+ result = mcp_call("click_at", click_args)
429
+ return { action: "act", success: true, profile: "user",
430
+ output: extract_message(result).to_s }
431
+ else
432
+ return { error: "Unknown act kind: #{kind}" }
84
433
  end
85
434
 
86
- cfg = BrowserConfig.load
435
+ { action: "act", success: true, profile: "user", output: "#{kind} completed." }
436
+ end
87
437
 
88
- # In auto_connect mode, open commands become new tabs in user's Chrome
89
- effective_command = command
90
- if cfg.auto_connect && (m = command.strip.match(/\A(open|goto|navigate)\s+(.+)\z/i))
91
- effective_command = "tab new #{m[2].strip}"
438
+ # Max width (px) for screenshots sent to the LLM.
439
+ # Retina/4K screens produce 2x–4x oversized images — we always downscale to this width.
440
+ SCREENSHOT_MAX_WIDTH = 800
441
+ # Hard limit on base64 size after downscaling. If still too large, reject.
442
+ SCREENSHOT_MAX_BASE64_BYTES = 100_000
443
+
444
+ private def do_user_screenshot(opts)
445
+ target_id = resolve_target_id(opts)
446
+ return target_id if target_id.is_a?(Hash)
447
+
448
+ # Always request PNG — chunky_png resizer works on PNG without native deps.
449
+ # full_page defaults to false to avoid tall images that are expensive in tokens.
450
+ # uid: if provided, screenshots only that element (much cheaper in tokens).
451
+ full_page = opts[:full_page] || opts["full_page"] || false
452
+ uid = opts[:ref] || opts["ref"]
453
+
454
+ call_args = { pageId: target_id.to_i, format: "png", fullPage: full_page }
455
+ call_args[:uid] = uid if uid
456
+ result = mcp_call("take_screenshot", call_args)
457
+
458
+ # MCP returns: { "content": [{ "type": "image", "mimeType": "image/png", "data": "<base64>" }] }
459
+ image_block = Array(result["content"]).find { |b| b.is_a?(Hash) && b["type"] == "image" }
460
+
461
+ unless image_block
462
+ text = extract_text_content(result)
463
+ return { action: "screenshot", success: true, profile: "user",
464
+ output: text.empty? ? "Screenshot captured (large image saved to temp file)." : text }
92
465
  end
93
466
 
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)
467
+ image_data = image_block["data"]
468
+
469
+ # Downscale to SCREENSHOT_MAX_WIDTH using pure Ruby PNG resizer (no gems needed).
470
+ image_data = png_downscale_base64(image_data, SCREENSHOT_MAX_WIDTH)
471
+
472
+ if image_data.bytesize > SCREENSHOT_MAX_BASE64_BYTES
473
+ size_kb = image_data.bytesize / 1024
474
+ return { action: "screenshot", success: false, profile: "user",
475
+ output: "Screenshot too large after resize (#{size_kb}KB). " \
476
+ "Use action=snapshot instead it provides the full accessibility tree without token overhead." }
112
477
  end
113
478
 
114
- result[:command] = command
479
+ { action: "screenshot", success: true, profile: "user",
480
+ image_data: image_data, mime_type: "image/png",
481
+ output: "Screenshot captured." }
482
+ end
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # PNG downscaler using chunky_png — minimal, reliable, zero native deps
486
+ #
487
+ # Accepts base64-encoded PNG, decodes it, and if wider than max_width
488
+ # downscales proportionally and re-encodes as PNG.
489
+ # ---------------------------------------------------------------------------
490
+ private def png_downscale_base64(b64, max_width)
491
+ require "chunky_png"
492
+
493
+ image = ChunkyPNG::Image.from_blob(Base64.strict_decode64(b64))
494
+ # return b64 if image.width <= max_width
495
+
496
+ src_w, src_h = image.width, image.height
497
+ before_kb = b64.bytesize / 1024
498
+ dst_h = (src_h * max_width.to_f / src_w).round
499
+ image.resample_nearest_neighbor!(max_width, dst_h)
500
+ result = Base64.strict_encode64(image.to_blob)
501
+ after_kb = result.bytesize / 1024
502
+
503
+ Clacky::Logger.error("screenshot resized",
504
+ from: "#{src_w}x#{src_h} (#{before_kb}KB)",
505
+ to: "#{max_width}x#{dst_h} (#{after_kb}KB)")
506
+
115
507
  result
116
- rescue StandardError => e
117
- { error: "Failed to run agent-browser: #{e.message}" }
118
508
  end
119
509
 
120
- def format_call(args)
121
- cmd = args[:command] || args["command"] || ""
122
- "browser(#{cmd})"
510
+ # -----------------------------------------------------------------------
511
+ # Sandbox browser (agent-browser fallback not Chrome MCP)
512
+ # -----------------------------------------------------------------------
513
+
514
+ # -----------------------------------------------------------------------
515
+ # Chrome MCP — process management & JSON-RPC over stdio
516
+ # -----------------------------------------------------------------------
517
+
518
+ # Returns the path to the system `node` binary, or nil if not found.
519
+ # Does NOT search nvm or other version managers — the user is responsible
520
+ # for ensuring the correct Node.js version is active in their PATH.
521
+ private def find_node_binary
522
+ path = `which node 2>/dev/null`.strip
523
+ return nil if path.empty? || !File.executable?(path)
524
+
525
+ path
123
526
  end
124
527
 
125
- def format_result(result)
126
- if result[:error]
127
- "[Error] #{result[:error][0..80]}"
128
- elsif result[:success]
129
- stdout = result[:stdout] || ""
130
- lines = stdout.lines.size
131
- "[OK] #{lines > 0 ? "#{lines} lines" : "Done"}"
132
- else
133
- stderr = result[:stderr] || "Failed"
134
- "[Failed] #{stderr[0..80]}"
528
+ # Returns the installed Node.js major version, or nil if not installed.
529
+ private def node_major_version
530
+ node = find_node_binary
531
+ return nil unless node
532
+
533
+ out = `#{node} --version 2>/dev/null`.strip # e.g. "v22.1.0"
534
+ out.gsub(/^v/, "").split(".").first.to_i
535
+ end
536
+
537
+ # Checks Node.js availability and version.
538
+ # Returns nil if everything is fine, or an error Hash with a user-facing message.
539
+ private def node_error
540
+ major = node_major_version
541
+
542
+ if major.nil?
543
+ return {
544
+ error: <<~MSG
545
+ ⚠️ IMPORTANT — STOP and tell the user:
546
+
547
+ ──────────────────────────────────────────────────
548
+ 浏览器自动化需要 Node.js(>= #{MIN_NODE_MAJOR})。
549
+
550
+ 系统未检测到 Node.js,请安装后重试:
551
+ https://nodejs.org/en/download
552
+
553
+ 安装完成后告诉我,我来重试。
554
+ ──────────────────────────────────────────────────
555
+
556
+ Do NOT fall back to web_search or any other workaround.
557
+ MSG
558
+ }
559
+ end
560
+
561
+ if major < MIN_NODE_MAJOR
562
+ return {
563
+ error: <<~MSG
564
+ ⚠️ IMPORTANT — STOP and tell the user:
565
+
566
+ ──────────────────────────────────────────────────
567
+ 当前 Node.js 版本过低(v#{major}),需要 >= #{MIN_NODE_MAJOR}。
568
+
569
+ 请升级 Node.js 后重试:
570
+ https://nodejs.org/en/download
571
+
572
+ 升级完成后告诉我,我来重试。
573
+ ──────────────────────────────────────────────────
574
+
575
+ Do NOT fall back to web_search or any other workaround.
576
+ MSG
577
+ }
135
578
  end
579
+
580
+ nil
136
581
  end
137
582
 
138
- MAX_LLM_OUTPUT_CHARS = 6000
139
- MAX_SNAPSHOT_CHARS = 4000
583
+ # Build the command array for chrome-devtools-mcp.
584
+ # Public class method — called by BrowserManager so it doesn't need to
585
+ # duplicate the arg list.
586
+ # If user_data_dir is provided, appends --userDataDir.
587
+ def self.build_mcp_command(user_data_dir: nil)
588
+ args = CHROME_MCP_BASE_ARGS.dup
589
+ args += ["--userDataDir", user_data_dir.to_s] if user_data_dir && !user_data_dir.to_s.empty?
140
590
 
141
- def format_result_for_llm(result)
142
- return result if result[:error]
591
+ ["chrome-devtools-mcp", *args]
592
+ end
143
593
 
144
- stdout = result[:stdout] || ""
145
- stderr = result[:stderr] || ""
146
- command_name = command_name_for_temp(result[:command])
594
+ # Delegate MCP tool call to BrowserManager singleton.
595
+ # BrowserManager owns the daemon process — ensures it's alive, handles
596
+ # the JSON-RPC protocol, and restarts on crash. Thread-safe.
597
+ private def mcp_call(tool_name, arguments = {}, user_data_dir: nil)
598
+ Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
599
+ end
147
600
 
148
- compact = {
149
- command: result[:command],
150
- success: result[:success],
151
- exit_code: result[:exit_code]
152
- }
601
+ # -----------------------------------------------------------------------
602
+ # MCP response extractors
603
+ # -----------------------------------------------------------------------
153
604
 
154
- if snapshot_command?(result[:command])
155
- stdout = compress_snapshot(stdout)
156
- max_chars = MAX_SNAPSHOT_CHARS
157
- else
158
- max_chars = MAX_LLM_OUTPUT_CHARS
605
+ private def extract_pages(result)
606
+ return [] unless result.is_a?(Hash)
607
+
608
+ # Try structuredContent.pages first
609
+ structured = result["structuredContent"]
610
+ if structured.is_a?(Hash) && structured["pages"].is_a?(Array)
611
+ return structured["pages"].map do |p|
612
+ { id: p["id"], url: p["url"], selected: p["selected"] == true }
613
+ end
159
614
  end
160
615
 
161
- stdout_info = truncate_and_save(stdout, max_chars, "stdout", command_name)
162
- compact[:stdout] = stdout_info[:content]
163
- compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
616
+ # Fall back to text content parsing
617
+ text = extract_text_content(result)
618
+ parse_pages_from_text(text)
619
+ end
620
+
621
+ private def extract_snapshot(result)
622
+ return {} unless result.is_a?(Hash)
164
623
 
165
- stderr_info = truncate_and_save(stderr, 500, "stderr", command_name)
166
- compact[:stderr] = stderr_info[:content] unless stderr.empty?
167
- compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
624
+ structured = result["structuredContent"]
625
+ if structured.is_a?(Hash) && structured["snapshot"].is_a?(Hash)
626
+ return structured["snapshot"]
627
+ end
168
628
 
169
- compact
629
+ # Try content array
630
+ text = extract_text_content(result)
631
+ begin
632
+ JSON.parse(text)
633
+ rescue StandardError
634
+ {}
635
+ end
170
636
  end
171
637
 
172
- private
638
+ private def extract_message(result)
639
+ return "" unless result.is_a?(Hash)
640
+
641
+ structured = result["structuredContent"]
642
+ if structured.is_a?(Hash)
643
+ return structured["message"].to_s if structured["message"]
644
+ end
173
645
 
174
- def agent_browser_ready?
175
- agent_browser_installed? && !agent_browser_outdated?
646
+ extract_text_content(result)
176
647
  end
177
648
 
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
- }
649
+ private def extract_text_content(result)
650
+ return "" unless result.is_a?(Hash)
651
+
652
+ content = result["content"]
653
+ return "" unless content.is_a?(Array)
654
+
655
+ content.filter_map do |entry|
656
+ entry["text"] if entry.is_a?(Hash) && entry["text"].is_a?(String)
657
+ end.join("\n")
183
658
  end
184
659
 
185
- def agent_browser_installed?
186
- result = Shell.new.execute(command: "which #{AGENT_BROWSER_BIN}")
187
- result[:success] && !result[:stdout].to_s.strip.empty?
660
+ private def parse_pages_from_text(text)
661
+ text.each_line.filter_map do |line|
662
+ m = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i)
663
+ next unless m
664
+ { id: m[1].to_i, url: m[2].strip, selected: !m[3].nil? }
665
+ end
188
666
  end
189
667
 
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
668
+ private def format_tabs(pages)
669
+ return "No open tabs." if pages.empty?
670
+ pages.map { |p| "#{p[:id]}: #{p[:url]}#{p[:selected] ? ' [selected]' : ''}" }.join("\n")
197
671
  end
198
672
 
199
- def build_command(command, auto_connect: false, session_name: nil, headed: true)
200
- parts = [AGENT_BROWSER_BIN]
201
- parts << "--auto-connect" if auto_connect
202
- parts << "--headed" if headed
203
- parts += ["--session-name", Shellwords.escape(session_name)] if session_name
204
- parts << command
205
- parts.join(" ")
673
+ # -----------------------------------------------------------------------
674
+ # Snapshot rendering (ChromeMcpSnapshotNode → AI text format)
675
+ # -----------------------------------------------------------------------
676
+
677
+ INTERACTIVE_ROLES = %w[
678
+ button link textbox checkbox radio select combobox
679
+ menuitem option tab switch searchbox spinbutton
680
+ slider menuitemcheckbox menuitemradio
681
+ ].freeze
682
+
683
+ STRUCTURAL_ROLES = %w[
684
+ generic none presentation group region section
685
+ ].freeze
686
+
687
+ CONTENT_ROLES = %w[
688
+ heading paragraph text statictext image img
689
+ listitem term definition
690
+ ].freeze
691
+
692
+ private def build_ai_snapshot(node, interactive: false, compact: false, max_depth: nil)
693
+ return "" unless node.is_a?(Hash) && !node.empty?
694
+
695
+ lines = []
696
+ refs = {}
697
+ visit_node(node, 0, lines, refs,
698
+ interactive: interactive,
699
+ compact: compact,
700
+ max_depth: max_depth)
701
+ lines.join("\n")
206
702
  end
207
703
 
208
- def session_closed_error?(result)
209
- output = "#{result[:stderr]}#{result[:stdout]}"
210
- output.include?("has been close") || output.include?("has been closed")
704
+ private def visit_node(node, depth, lines, refs, interactive:, compact:, max_depth:)
705
+ return if max_depth && depth > max_depth
706
+
707
+ role = node["role"].to_s.downcase.strip
708
+ role = "generic" if role.empty?
709
+ name = node["name"].to_s.strip
710
+ uid = node["id"].to_s.strip
711
+ val = node["value"]
712
+ desc = node["description"].to_s.strip
713
+
714
+ # Decide whether to render this node (but always recurse into children)
715
+ render = true
716
+ render = false if interactive && !INTERACTIVE_ROLES.include?(role)
717
+ render = false if compact && STRUCTURAL_ROLES.include?(role) && name.empty?
718
+
719
+ if render
720
+ line = "#{" " * (depth * 2)}- #{role}"
721
+ line += " \"#{escape_quoted(name)}\"" unless name.empty?
722
+
723
+ # Assign ref if interactive or named content role
724
+ if uid && !uid.empty? && (INTERACTIVE_ROLES.include?(role) ||
725
+ (CONTENT_ROLES.include?(role) && !name.empty?))
726
+ refs[uid] = { role: role, name: name }
727
+ line += " [ref=#{uid}]"
728
+ end
729
+
730
+ line += " value=\"#{escape_quoted(val.to_s)}\"" unless val.nil? || val.to_s.empty?
731
+ line += " description=\"#{escape_quoted(desc)}\"" unless desc.empty?
732
+
733
+ lines << line
734
+ end
735
+
736
+ # Always recurse into children regardless of whether this node was rendered
737
+ child_depth = render ? depth + 1 : depth
738
+ Array(node["children"]).each do |child|
739
+ visit_node(child, child_depth, lines, refs,
740
+ interactive: interactive,
741
+ compact: compact,
742
+ max_depth: max_depth)
743
+ end
211
744
  end
212
745
 
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 ")
746
+ private def escape_quoted(str)
747
+ str.to_s.gsub("\\", "\\\\").gsub('"', '\\"')
217
748
  end
218
749
 
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)
750
+ # -----------------------------------------------------------------------
751
+ # Parameter helpers
752
+ # -----------------------------------------------------------------------
753
+
754
+ private def require_url(opts)
755
+ url = opts[:url] || opts["url"] || ""
756
+ return { error: "url is required for this action" } if url.empty?
757
+ url
758
+ end
759
+
760
+ private def require_ref(ref)
761
+ return { error: "ref is required for this act kind (snapshot first to get refs)" } if ref.nil? || ref.to_s.empty?
762
+ ref.to_s
763
+ end
764
+
765
+ private def resolve_target_id(opts)
766
+ tid = opts[:target_id] || opts["target_id"]
767
+ if tid && !tid.to_s.empty?
768
+ return tid.to_s
769
+ end
770
+ # Auto-select the first available page
771
+ result = mcp_call("list_pages")
772
+ pages = extract_pages(result)
773
+ page = pages.find { |p| p[:selected] } || pages.first
774
+ return { error: "No open tabs found. Use action=open first." } unless page
775
+ page[:id].to_s
776
+ end
777
+
778
+ # -----------------------------------------------------------------------
779
+ # Output helpers
780
+ # -----------------------------------------------------------------------
781
+
782
+ private def compress_snapshot(output)
226
783
  return output if output.empty?
227
784
 
228
785
  lines = output.lines
@@ -234,38 +791,26 @@ module Clacky
234
791
  end
235
792
 
236
793
  removed = orig - filtered.size
237
- filtered << "\n[snapshot compressed: #{removed} /url, /placeholder, empty-img lines removed]\n" if removed > 0
794
+ if removed > 0
795
+ filtered << "\n[snapshot compressed: #{removed} lines removed]\n"
796
+ end
238
797
  filtered.join
239
798
  end
240
799
 
241
- def command_name_for_temp(command)
242
- first_word = (command || "").strip.split(/\s+/).first
243
- File.basename(first_word.to_s, ".*")
244
- end
245
-
246
- def truncate_and_save(output, max_chars, _label, command_name)
247
- return { content: "", temp_file: nil } if output.empty?
248
- return { content: output, temp_file: nil } if output.length <= max_chars
249
-
250
- lines = output.lines
251
- return { content: output, temp_file: nil } if lines.length <= 2
800
+ private def truncate_output(output, max_chars)
801
+ return output if output.length <= max_chars
252
802
 
253
- safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
254
- temp_dir = Dir.mktmpdir
255
- temp_file = File.join(temp_dir, "browser_#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
256
- File.write(temp_file, output)
257
-
258
- available = max_chars - 200
803
+ lines = output.lines
804
+ available = max_chars - 150
259
805
  first_part = []
260
- accumulated = 0
806
+ acc = 0
261
807
  lines.each do |line|
262
- break if accumulated + line.length > available
808
+ break if acc + line.length > available
263
809
  first_part << line
264
- accumulated += line.length
810
+ acc += line.length
265
811
  end
266
-
267
- notice = "\n\n... [Output truncated: showing #{first_part.size} of #{lines.size} lines, full: #{temp_file} (use grep to search)] ...\n"
268
- { content: first_part.join + notice, temp_file: temp_file }
812
+ notice = "\n... [truncated: #{first_part.size}/#{lines.size} lines shown] ..."
813
+ first_part.join + notice
269
814
  end
270
815
  end
271
816
  end