pocketrb 0.1.0
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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- metadata +327 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "browser_session"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
module Tools
|
|
7
|
+
# Enhanced browser tool with sessions and tabs
|
|
8
|
+
class BrowserAdvanced < Base
|
|
9
|
+
ACTIONS = %w[
|
|
10
|
+
new_tab close_tab focus_tab list_tabs
|
|
11
|
+
navigate back forward refresh
|
|
12
|
+
click type hover scroll
|
|
13
|
+
screenshot snapshot
|
|
14
|
+
execute_js wait
|
|
15
|
+
get_text get_attribute
|
|
16
|
+
fill_form select_option
|
|
17
|
+
press_key
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
"browser"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def description
|
|
25
|
+
<<~DESC.strip
|
|
26
|
+
Browse the web with a persistent browser session. Supports multiple tabs,
|
|
27
|
+
navigation, clicking, typing, screenshots, and JavaScript execution.
|
|
28
|
+
The browser stays open between calls for efficient multi-step browsing.
|
|
29
|
+
DESC
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parameters
|
|
33
|
+
{
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
action: {
|
|
37
|
+
type: "string",
|
|
38
|
+
enum: ACTIONS,
|
|
39
|
+
description: "Browser action to perform"
|
|
40
|
+
},
|
|
41
|
+
url: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "URL for navigation or new tab"
|
|
44
|
+
},
|
|
45
|
+
tab_id: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "Tab ID (uses active tab if not specified)"
|
|
48
|
+
},
|
|
49
|
+
selector: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "CSS or XPath selector for element"
|
|
52
|
+
},
|
|
53
|
+
text: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Text to type or search for"
|
|
56
|
+
},
|
|
57
|
+
key: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "Key to press (Enter, Tab, Escape, etc.)"
|
|
60
|
+
},
|
|
61
|
+
javascript: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "JavaScript code to execute"
|
|
64
|
+
},
|
|
65
|
+
attribute: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description: "Element attribute to get"
|
|
68
|
+
},
|
|
69
|
+
options: {
|
|
70
|
+
type: "object",
|
|
71
|
+
description: "Additional options (timeout, wait_until, etc.)"
|
|
72
|
+
},
|
|
73
|
+
form_data: {
|
|
74
|
+
type: "object",
|
|
75
|
+
description: "Form fields to fill: {selector: value, ...}"
|
|
76
|
+
},
|
|
77
|
+
scroll_to: {
|
|
78
|
+
type: "string",
|
|
79
|
+
enum: %w[top bottom element],
|
|
80
|
+
description: "Where to scroll"
|
|
81
|
+
},
|
|
82
|
+
full_page: {
|
|
83
|
+
type: "boolean",
|
|
84
|
+
description: "Take full page screenshot"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
required: ["action"]
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def available?
|
|
92
|
+
# Check if playwright is available
|
|
93
|
+
system("which npx > /dev/null 2>&1") || system("which playwright > /dev/null 2>&1")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def execute(action:, **args)
|
|
97
|
+
case action
|
|
98
|
+
# Tab management
|
|
99
|
+
when "new_tab"
|
|
100
|
+
new_tab(args[:url])
|
|
101
|
+
when "close_tab"
|
|
102
|
+
close_tab(args[:tab_id])
|
|
103
|
+
when "focus_tab"
|
|
104
|
+
focus_tab(args[:tab_id])
|
|
105
|
+
when "list_tabs"
|
|
106
|
+
list_tabs
|
|
107
|
+
|
|
108
|
+
# Navigation
|
|
109
|
+
when "navigate"
|
|
110
|
+
navigate(args[:url], args[:options] || {})
|
|
111
|
+
when "back"
|
|
112
|
+
go_back
|
|
113
|
+
when "forward"
|
|
114
|
+
go_forward
|
|
115
|
+
when "refresh"
|
|
116
|
+
refresh_page
|
|
117
|
+
|
|
118
|
+
# Interaction
|
|
119
|
+
when "click"
|
|
120
|
+
click_element(args[:selector], args[:options] || {})
|
|
121
|
+
when "type"
|
|
122
|
+
type_text(args[:selector], args[:text], args[:options] || {})
|
|
123
|
+
when "hover"
|
|
124
|
+
hover_element(args[:selector])
|
|
125
|
+
when "scroll"
|
|
126
|
+
scroll_page(args[:scroll_to], args[:selector])
|
|
127
|
+
when "press_key"
|
|
128
|
+
press_key(args[:key], args[:selector])
|
|
129
|
+
when "fill_form"
|
|
130
|
+
fill_form(args[:form_data])
|
|
131
|
+
when "select_option"
|
|
132
|
+
select_option(args[:selector], args[:text])
|
|
133
|
+
|
|
134
|
+
# Content
|
|
135
|
+
when "screenshot"
|
|
136
|
+
take_screenshot(args[:full_page])
|
|
137
|
+
when "snapshot"
|
|
138
|
+
get_snapshot
|
|
139
|
+
when "get_text"
|
|
140
|
+
get_text(args[:selector])
|
|
141
|
+
when "get_attribute"
|
|
142
|
+
get_attribute(args[:selector], args[:attribute])
|
|
143
|
+
when "execute_js"
|
|
144
|
+
execute_javascript(args[:javascript])
|
|
145
|
+
|
|
146
|
+
# Wait
|
|
147
|
+
when "wait"
|
|
148
|
+
wait_for(args[:selector], args[:options] || {})
|
|
149
|
+
|
|
150
|
+
else
|
|
151
|
+
error("Unknown action: #{action}")
|
|
152
|
+
end
|
|
153
|
+
rescue Playwright::TimeoutError => e
|
|
154
|
+
error("Timeout: #{e.message}")
|
|
155
|
+
rescue Playwright::Error => e
|
|
156
|
+
error("Browser error: #{e.message}")
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
error("Error: #{e.message}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def session
|
|
164
|
+
BrowserSession.instance
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def page(tab_id = nil)
|
|
168
|
+
if tab_id
|
|
169
|
+
session.tabs[tab_id]&.dig(:page)
|
|
170
|
+
else
|
|
171
|
+
session.active_page
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def ensure_page!
|
|
176
|
+
p = page
|
|
177
|
+
return p if p
|
|
178
|
+
|
|
179
|
+
# Auto-create a tab if none exists
|
|
180
|
+
session.new_tab
|
|
181
|
+
session.active_page
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# === Tab Management ===
|
|
185
|
+
|
|
186
|
+
def new_tab(url)
|
|
187
|
+
tab_id = session.new_tab(url: url)
|
|
188
|
+
info = session.tab_info(tab_id)
|
|
189
|
+
|
|
190
|
+
if url
|
|
191
|
+
success("Opened new tab #{tab_id}: #{info[:title]}\nURL: #{info[:url]}")
|
|
192
|
+
else
|
|
193
|
+
success("Opened new empty tab: #{tab_id}")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def close_tab(tab_id)
|
|
198
|
+
tab_id ||= session.active_tab_id
|
|
199
|
+
return error("No tab to close") unless tab_id
|
|
200
|
+
|
|
201
|
+
if session.close_tab(tab_id)
|
|
202
|
+
remaining = session.tabs.size
|
|
203
|
+
success("Closed tab #{tab_id}. #{remaining} tabs remaining.")
|
|
204
|
+
else
|
|
205
|
+
error("Tab not found: #{tab_id}")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def focus_tab(tab_id)
|
|
210
|
+
return error("Tab ID required") unless tab_id
|
|
211
|
+
|
|
212
|
+
if session.focus_tab(tab_id)
|
|
213
|
+
info = session.tab_info(tab_id)
|
|
214
|
+
success("Focused tab #{tab_id}: #{info[:title]}")
|
|
215
|
+
else
|
|
216
|
+
error("Tab not found: #{tab_id}")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def list_tabs
|
|
221
|
+
tabs = session.list_tabs
|
|
222
|
+
return "No tabs open. Use action 'new_tab' to open one." if tabs.empty?
|
|
223
|
+
|
|
224
|
+
lines = ["Open tabs (#{tabs.size}):"]
|
|
225
|
+
tabs.each do |tab|
|
|
226
|
+
marker = tab[:active] ? "→" : " "
|
|
227
|
+
lines << "#{marker} [#{tab[:id]}] #{tab[:title]}"
|
|
228
|
+
lines << " #{tab[:url]}"
|
|
229
|
+
end
|
|
230
|
+
lines.join("\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# === Navigation ===
|
|
234
|
+
|
|
235
|
+
def navigate(url, options)
|
|
236
|
+
return error("URL required") unless url
|
|
237
|
+
|
|
238
|
+
p = ensure_page!
|
|
239
|
+
wait_until = options[:wait_until] || "domcontentloaded"
|
|
240
|
+
|
|
241
|
+
p.goto(url, wait_until: wait_until)
|
|
242
|
+
session.update_tab_info(session.active_tab_id)
|
|
243
|
+
|
|
244
|
+
title = p.title
|
|
245
|
+
success("Navigated to: #{title}\nURL: #{p.url}")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def go_back
|
|
249
|
+
p = ensure_page!
|
|
250
|
+
p.go_back
|
|
251
|
+
session.update_tab_info(session.active_tab_id)
|
|
252
|
+
success("Navigated back to: #{p.title}")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def go_forward
|
|
256
|
+
p = ensure_page!
|
|
257
|
+
p.go_forward
|
|
258
|
+
session.update_tab_info(session.active_tab_id)
|
|
259
|
+
success("Navigated forward to: #{p.title}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def refresh_page
|
|
263
|
+
p = ensure_page!
|
|
264
|
+
p.reload
|
|
265
|
+
success("Page refreshed: #{p.title}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# === Interaction ===
|
|
269
|
+
|
|
270
|
+
def click_element(selector, options)
|
|
271
|
+
return error("Selector required") unless selector
|
|
272
|
+
|
|
273
|
+
p = ensure_page!
|
|
274
|
+
timeout = options[:timeout] || 5000
|
|
275
|
+
|
|
276
|
+
p.click(selector, timeout: timeout)
|
|
277
|
+
success("Clicked: #{selector}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def type_text(selector, text, _options)
|
|
281
|
+
return error("Selector and text required") unless selector && text
|
|
282
|
+
|
|
283
|
+
p = ensure_page!
|
|
284
|
+
|
|
285
|
+
p.fill(selector, text)
|
|
286
|
+
success("Typed into #{selector}: #{text[0..50]}#{"..." if text.length > 50}")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def hover_element(selector)
|
|
290
|
+
return error("Selector required") unless selector
|
|
291
|
+
|
|
292
|
+
p = ensure_page!
|
|
293
|
+
p.hover(selector)
|
|
294
|
+
success("Hovering over: #{selector}")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def scroll_page(direction, selector)
|
|
298
|
+
p = ensure_page!
|
|
299
|
+
|
|
300
|
+
case direction
|
|
301
|
+
when "top"
|
|
302
|
+
p.evaluate("window.scrollTo(0, 0)")
|
|
303
|
+
success("Scrolled to top")
|
|
304
|
+
when "bottom"
|
|
305
|
+
p.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
306
|
+
success("Scrolled to bottom")
|
|
307
|
+
when "element"
|
|
308
|
+
return error("Selector required for element scroll") unless selector
|
|
309
|
+
|
|
310
|
+
p.evaluate("document.querySelector('#{selector}')?.scrollIntoView({behavior: 'smooth'})")
|
|
311
|
+
success("Scrolled to element: #{selector}")
|
|
312
|
+
else
|
|
313
|
+
p.evaluate("window.scrollBy(0, 500)")
|
|
314
|
+
success("Scrolled down")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def press_key(key, selector)
|
|
319
|
+
return error("Key required") unless key
|
|
320
|
+
|
|
321
|
+
p = ensure_page!
|
|
322
|
+
|
|
323
|
+
if selector
|
|
324
|
+
p.press(selector, key)
|
|
325
|
+
else
|
|
326
|
+
p.keyboard.press(key)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
success("Pressed key: #{key}")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def fill_form(form_data)
|
|
333
|
+
return error("Form data required") unless form_data.is_a?(Hash)
|
|
334
|
+
|
|
335
|
+
p = ensure_page!
|
|
336
|
+
filled = []
|
|
337
|
+
|
|
338
|
+
form_data.each do |selector, value|
|
|
339
|
+
p.fill(selector.to_s, value.to_s)
|
|
340
|
+
filled << selector
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
success("Filled #{filled.size} form fields: #{filled.join(", ")}")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def select_option(selector, value)
|
|
347
|
+
return error("Selector and value required") unless selector && value
|
|
348
|
+
|
|
349
|
+
p = ensure_page!
|
|
350
|
+
p.select_option(selector, value: value)
|
|
351
|
+
success("Selected '#{value}' in #{selector}")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# === Content ===
|
|
355
|
+
|
|
356
|
+
def take_screenshot(full_page)
|
|
357
|
+
p = ensure_page!
|
|
358
|
+
|
|
359
|
+
# Save to workspace
|
|
360
|
+
filename = "screenshot_#{Time.now.strftime("%Y%m%d_%H%M%S")}.png"
|
|
361
|
+
path = resolve_path(filename)
|
|
362
|
+
|
|
363
|
+
p.screenshot(path: path.to_s, full_page: full_page || false)
|
|
364
|
+
success("Screenshot saved: #{path}")
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def get_snapshot
|
|
368
|
+
p = ensure_page!
|
|
369
|
+
|
|
370
|
+
# Get a simplified view of the page
|
|
371
|
+
title = p.title
|
|
372
|
+
url = p.url
|
|
373
|
+
|
|
374
|
+
# Get visible text content
|
|
375
|
+
content = p.evaluate(<<~JS)
|
|
376
|
+
(() => {
|
|
377
|
+
const walker = document.createTreeWalker(
|
|
378
|
+
document.body,
|
|
379
|
+
NodeFilter.SHOW_TEXT,
|
|
380
|
+
{ acceptNode: (node) => {
|
|
381
|
+
const parent = node.parentElement;
|
|
382
|
+
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
383
|
+
const style = getComputedStyle(parent);
|
|
384
|
+
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
385
|
+
return NodeFilter.FILTER_REJECT;
|
|
386
|
+
}
|
|
387
|
+
return node.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
|
388
|
+
}}
|
|
389
|
+
);
|
|
390
|
+
const texts = [];
|
|
391
|
+
while (walker.nextNode()) {
|
|
392
|
+
texts.push(walker.currentNode.textContent.trim());
|
|
393
|
+
}
|
|
394
|
+
return texts.join(' ').substring(0, 5000);
|
|
395
|
+
})()
|
|
396
|
+
JS
|
|
397
|
+
|
|
398
|
+
# Get interactive elements
|
|
399
|
+
elements = p.evaluate(<<~JS)
|
|
400
|
+
(() => {
|
|
401
|
+
const items = [];
|
|
402
|
+
document.querySelectorAll('a, button, input, select, textarea, [onclick], [role="button"]').forEach((el, i) => {
|
|
403
|
+
if (i > 50) return;
|
|
404
|
+
const rect = el.getBoundingClientRect();
|
|
405
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
406
|
+
|
|
407
|
+
let desc = el.textContent?.trim()?.substring(0, 50) || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || '';
|
|
408
|
+
const tag = el.tagName.toLowerCase();
|
|
409
|
+
const type = el.getAttribute('type') || '';
|
|
410
|
+
|
|
411
|
+
items.push(`[${i}] <${tag}${type ? ' type=' + type : ''}> ${desc}`);
|
|
412
|
+
});
|
|
413
|
+
return items.join('\\n');
|
|
414
|
+
})()
|
|
415
|
+
JS
|
|
416
|
+
|
|
417
|
+
<<~SNAPSHOT
|
|
418
|
+
# Page Snapshot
|
|
419
|
+
|
|
420
|
+
**Title:** #{title}
|
|
421
|
+
**URL:** #{url}
|
|
422
|
+
|
|
423
|
+
## Content Summary
|
|
424
|
+
#{content[0..2000]}#{"...(truncated)" if content.length > 2000}
|
|
425
|
+
|
|
426
|
+
## Interactive Elements
|
|
427
|
+
#{elements}
|
|
428
|
+
SNAPSHOT
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def get_text(selector)
|
|
432
|
+
p = ensure_page!
|
|
433
|
+
|
|
434
|
+
text = p.text_content(selector || "body")
|
|
435
|
+
|
|
436
|
+
text = "#{text[0..3000]}...(truncated)" if text.length > 3000
|
|
437
|
+
success(text)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def get_attribute(selector, attribute)
|
|
441
|
+
return error("Selector and attribute required") unless selector && attribute
|
|
442
|
+
|
|
443
|
+
p = ensure_page!
|
|
444
|
+
value = p.get_attribute(selector, attribute)
|
|
445
|
+
success("#{selector}[#{attribute}] = #{value}")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def execute_javascript(code)
|
|
449
|
+
return error("JavaScript code required") unless code
|
|
450
|
+
|
|
451
|
+
p = ensure_page!
|
|
452
|
+
result = p.evaluate(code)
|
|
453
|
+
success("Result: #{result.inspect}")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# === Wait ===
|
|
457
|
+
|
|
458
|
+
def wait_for(selector, options)
|
|
459
|
+
return error("Selector required") unless selector
|
|
460
|
+
|
|
461
|
+
p = ensure_page!
|
|
462
|
+
timeout = options[:timeout] || 10_000
|
|
463
|
+
state = options[:state] || "visible"
|
|
464
|
+
|
|
465
|
+
p.wait_for_selector(selector, state: state, timeout: timeout)
|
|
466
|
+
success("Element found: #{selector}")
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
module Tools
|
|
7
|
+
# Manages persistent browser sessions with tabs
|
|
8
|
+
class BrowserSession
|
|
9
|
+
include Singleton
|
|
10
|
+
|
|
11
|
+
attr_reader :tabs, :active_tab_id
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@playwright = nil
|
|
15
|
+
@browser = nil
|
|
16
|
+
@context = nil
|
|
17
|
+
@tabs = {} # id => { page:, url:, title: }
|
|
18
|
+
@active_tab_id = nil
|
|
19
|
+
@tab_counter = 0
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@started = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start!
|
|
25
|
+
return if @started
|
|
26
|
+
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
return if @started
|
|
29
|
+
|
|
30
|
+
require "playwright"
|
|
31
|
+
@playwright = Playwright.create(playwright_cli_executable_path: find_playwright_cli)
|
|
32
|
+
@browser = @playwright.chromium.launch(
|
|
33
|
+
headless: ENV["BROWSER_HEADLESS"] != "false",
|
|
34
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
35
|
+
)
|
|
36
|
+
@context = @browser.new_context(
|
|
37
|
+
viewport: { width: 1280, height: 800 },
|
|
38
|
+
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
|
39
|
+
)
|
|
40
|
+
@started = true
|
|
41
|
+
Pocketrb.logger.info("Browser session started")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop!
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@tabs.each_value do |tab|
|
|
48
|
+
tab[:page]&.close
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
@tabs.clear
|
|
53
|
+
@context&.close
|
|
54
|
+
@browser&.close
|
|
55
|
+
@playwright&.stop
|
|
56
|
+
@playwright = nil
|
|
57
|
+
@browser = nil
|
|
58
|
+
@context = nil
|
|
59
|
+
@active_tab_id = nil
|
|
60
|
+
@started = false
|
|
61
|
+
Pocketrb.logger.info("Browser session stopped")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def started?
|
|
66
|
+
@started
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Create a new tab
|
|
70
|
+
def new_tab(url: nil)
|
|
71
|
+
start! unless started?
|
|
72
|
+
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@tab_counter += 1
|
|
75
|
+
tab_id = "tab_#{@tab_counter}"
|
|
76
|
+
|
|
77
|
+
page = @context.new_page
|
|
78
|
+
page.goto(url) if url
|
|
79
|
+
|
|
80
|
+
@tabs[tab_id] = {
|
|
81
|
+
page: page,
|
|
82
|
+
url: url,
|
|
83
|
+
title: page.title,
|
|
84
|
+
created_at: Time.now
|
|
85
|
+
}
|
|
86
|
+
@active_tab_id = tab_id
|
|
87
|
+
|
|
88
|
+
tab_id
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Close a tab
|
|
93
|
+
def close_tab(tab_id)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
tab = @tabs.delete(tab_id)
|
|
96
|
+
return false unless tab
|
|
97
|
+
|
|
98
|
+
tab[:page]&.close
|
|
99
|
+
@active_tab_id = @tabs.keys.last if @active_tab_id == tab_id
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Focus a tab
|
|
105
|
+
def focus_tab(tab_id)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
return false unless @tabs.key?(tab_id)
|
|
108
|
+
|
|
109
|
+
@active_tab_id = tab_id
|
|
110
|
+
@tabs[tab_id][:page].bring_to_front
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get active page
|
|
116
|
+
def active_page
|
|
117
|
+
return nil unless @active_tab_id
|
|
118
|
+
|
|
119
|
+
@tabs[@active_tab_id]&.dig(:page)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get tab info
|
|
123
|
+
def tab_info(tab_id)
|
|
124
|
+
tab = @tabs[tab_id]
|
|
125
|
+
return nil unless tab
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
id: tab_id,
|
|
129
|
+
url: tab[:page].url,
|
|
130
|
+
title: tab[:page].title,
|
|
131
|
+
active: tab_id == @active_tab_id
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# List all tabs
|
|
136
|
+
def list_tabs
|
|
137
|
+
@tabs.map do |id, tab|
|
|
138
|
+
{
|
|
139
|
+
id: id,
|
|
140
|
+
url: tab[:page].url,
|
|
141
|
+
title: tab[:page].title,
|
|
142
|
+
active: id == @active_tab_id
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Update tab metadata
|
|
148
|
+
def update_tab_info(tab_id)
|
|
149
|
+
tab = @tabs[tab_id]
|
|
150
|
+
return unless tab
|
|
151
|
+
|
|
152
|
+
tab[:url] = tab[:page].url
|
|
153
|
+
tab[:title] = tab[:page].title
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def find_playwright_cli
|
|
159
|
+
# Try common locations
|
|
160
|
+
["npx playwright", "playwright", "node_modules/.bin/playwright"].each do |cmd|
|
|
161
|
+
return cmd if system("which #{cmd.split.first} > /dev/null 2>&1")
|
|
162
|
+
end
|
|
163
|
+
"npx playwright"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|