@bytesbrains/pi-textbrowser 1.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.
- package/AGENTS.md +151 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/package.json +50 -0
- package/src/index.ts +394 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# TextBrowser — Agent Usage Guide
|
|
2
|
+
|
|
3
|
+
> You are an AI agent. This document helps you use the TextBrowser extension effectively.
|
|
4
|
+
|
|
5
|
+
## Available Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Purpose | When to use |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `browser_navigate` | Open a URL, return page context | First visit to a site or explicit navigation |
|
|
10
|
+
| `browser_click` | Click an element by CSS, XPath, or text | Interact with links, buttons, form elements |
|
|
11
|
+
| `browser_type` | Type text into an input field | Fill forms, search boxes, login fields |
|
|
12
|
+
| `browser_scroll` | Scroll the page or an element into view | Explore long pages, find off-screen content |
|
|
13
|
+
| `browser_screenshot` | Capture current page + OCR text | Re-read page without changing anything |
|
|
14
|
+
| `browser_read` | Get current page context (no action) | Check if page state changed after an action |
|
|
15
|
+
| `browser_evaluate` | Execute arbitrary JavaScript | **Escape hatch** for shadow DOM, complex interactions |
|
|
16
|
+
|
|
17
|
+
## Dual-Mode Design: When to Use Visual vs Text-Only
|
|
18
|
+
|
|
19
|
+
> ⚠️ **DEFAULT: Always use text-only mode first.** Only switch to visual mode when the task explicitly requires seeing pixels.
|
|
20
|
+
|
|
21
|
+
### Decision Framework
|
|
22
|
+
|
|
23
|
+
Ask yourself: *"Does this task require seeing colors, positions, or images?"*
|
|
24
|
+
|
|
25
|
+
#### ✅ Text-Only Mode (`visual: false` or omit) — Use for:
|
|
26
|
+
|
|
27
|
+
| Category | Examples | Why text works |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **Navigation** | Open a URL, find a link, click a button | DOM elements + OCR reveal all interactive items |
|
|
30
|
+
| **Form filling** | Login, search, type into inputs | CSS selectors + text labels identify fields |
|
|
31
|
+
| **Data extraction** | Scrape a table, read article text | OCR + DOM textContent capture all visible text |
|
|
32
|
+
| **Workflow automation** | Multi-step process (login → search → click) | Text elements + OCR track state changes |
|
|
33
|
+
| **API/Admin panels** | Gitea, Firebase Console, GitHub | Structured UI labels are fully readable via text |
|
|
34
|
+
| **Code review** | Read docs, explore repos | Content is textual by nature |
|
|
35
|
+
|
|
36
|
+
#### 🖼️ Visual Mode (`visual: true`) — Use ONLY for:
|
|
37
|
+
|
|
38
|
+
| Category | Examples | Why images are needed |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| **Layout verification** | "Does the button align with the header?" "Is the sidebar overlapping?" | Pixel positions matter |
|
|
41
|
+
| **Color/theme checks** | "Is the dark mode working?" "Does this component match the brand color?" | Colors are invisible in text |
|
|
42
|
+
| **Visual regression** | "Compare before/after screenshots of this component" | Diffing pixels |
|
|
43
|
+
| **Image content** | "What does this dashboard chart show?" "Read the text in this logo" | Images are non-text |
|
|
44
|
+
| **UI design review** | "Does the spacing look right?" "Is the font readable?" | Visual aesthetics |
|
|
45
|
+
| **Debugging rendering** | "Why is this element invisible?" "Check if CSS is loading" | CSS bugs invisible to OCR |
|
|
46
|
+
|
|
47
|
+
#### 🎯 Quick Reference
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
User says: "Open Gitea and explore repos" → text-only ✅
|
|
51
|
+
User says: "Login to LinkedIn and post" → text-only ✅
|
|
52
|
+
User says: "Check if the dark mode looks correct" → visual 🖼️
|
|
53
|
+
User says: "Is the button centered on the page?" → visual 🖼️
|
|
54
|
+
User says: "Read the article content" → text-only ✅
|
|
55
|
+
User says: "Compare this page to the mockup" → visual 🖼️
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Cost Comparison
|
|
59
|
+
|
|
60
|
+
| Mode | Avg Tokens | Relative Cost | When |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| Text-only | ~200-400 | **1x** | Default — 90% of tasks |
|
|
63
|
+
| Visual | ~1,500-3,000 | **5-15x** | Only when pixels matter |
|
|
64
|
+
|
|
65
|
+
### Escalation Path
|
|
66
|
+
|
|
67
|
+
If text-only mode provides insufficient information for the task, **state why** and escalate:
|
|
68
|
+
|
|
69
|
+
> *"I can see the login form (elements [22]-[33]) but need to verify the button color matches the brand palette. Let me switch to visual mode."*
|
|
70
|
+
|
|
71
|
+
Don't silently switch to visual — explain the rationale. This helps users understand token costs and keeps you accountable.
|
|
72
|
+
|
|
73
|
+
## Session Persistence
|
|
74
|
+
|
|
75
|
+
The browser maintains one Playwright Chromium instance per Pi session. All tools share the same page, cookies, and state. This means:
|
|
76
|
+
|
|
77
|
+
- ✅ After `browser_navigate`, `browser_click` and `browser_type` work on the same page
|
|
78
|
+
- ✅ Login sessions persist across tool calls
|
|
79
|
+
- ⚠️ `browser_navigate` reloads the page — LinkedIn/GitHub may kill sessions
|
|
80
|
+
|
|
81
|
+
## Best Practices
|
|
82
|
+
|
|
83
|
+
> **⚠️ CRITICAL: Prefer `browser_read` + `browser_click` over `browser_navigate`.**
|
|
84
|
+
> Every `browser_navigate` triggers a full page load, destroying login sessions and current state.
|
|
85
|
+
> Once you're on a site, stay there. Use read/click/type/scroll to move around.
|
|
86
|
+
> Only use `browser_navigate` when you genuinely need a completely different URL domain.
|
|
87
|
+
>
|
|
88
|
+
> **⚠️ COST: Default to text-only mode.** Visual mode burns 5-15× more tokens.
|
|
89
|
+
> Use `visual: true` ONLY for layout, colors, design review, or image-content tasks.
|
|
90
|
+
> If text-only is insufficient, explain why before switching.
|
|
91
|
+
|
|
92
|
+
### Rule 1: Navigate once, then read & click
|
|
93
|
+
```
|
|
94
|
+
browser_navigate(url="https://example.com") ← open the page
|
|
95
|
+
browser_read() ← check what's there
|
|
96
|
+
browser_click(text="Sign In") ← interact
|
|
97
|
+
browser_type(selector="#email", text="user") ← fill form
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Avoid `browser_navigate` after logging in — it destroys the session.
|
|
101
|
+
|
|
102
|
+
### Rule 2: Use `headless: false` for visible browsing
|
|
103
|
+
The user can see and interact with a visible browser window. After opening with `headless: false`, all subsequent tools inherit this setting — the window stays open.
|
|
104
|
+
|
|
105
|
+
### Rule 3: Use element indices for precision
|
|
106
|
+
The page context returns elements with `[index]` markers. Refer to: `"click element [15] (the Sign In button)"`
|
|
107
|
+
|
|
108
|
+
### Rule 4: `browser_evaluate` is your shadow DOM escape hatch
|
|
109
|
+
When elements live inside shadow DOM (LinkedIn, some React apps), standard clicks fail. Use `browser_evaluate` to query and interact through `el.shadowRoot`:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
// Find and click a button inside shadow DOM
|
|
113
|
+
const el = document.querySelector('#host').shadowRoot.querySelector('button');
|
|
114
|
+
el.click();
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Rule 5: Wait after actions
|
|
118
|
+
After clicking, the page may need time to load. Use `browser_read` to confirm the new state before the next action.
|
|
119
|
+
|
|
120
|
+
## Common Patterns
|
|
121
|
+
|
|
122
|
+
### Form login
|
|
123
|
+
```
|
|
124
|
+
browser_navigate(url="https://site.com/login", headless=false)
|
|
125
|
+
browser_type(selector="#username", text="user")
|
|
126
|
+
browser_type(selector="#password", text="pass")
|
|
127
|
+
browser_click(selector="button[type='submit']")
|
|
128
|
+
browser_read() ← verify logged in
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Explore with filters
|
|
132
|
+
```
|
|
133
|
+
browser_navigate(url="https://site.com/search")
|
|
134
|
+
browser_type(selector="input[placeholder='Search']", text="query")
|
|
135
|
+
browser_click(text="Search")
|
|
136
|
+
browser_scroll(direction="down", amount=500)
|
|
137
|
+
browser_read()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Extract data via JS
|
|
141
|
+
```
|
|
142
|
+
browser_evaluate(script="Array.from(document.querySelectorAll('.item')).map(el => el.textContent)")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Known Limitations
|
|
146
|
+
|
|
147
|
+
- **Shadow DOM**: LinkedIn, Salesforce, and some SPA frameworks use shadow DOM that blocks standard clicks. Use `browser_evaluate`.
|
|
148
|
+
- **Login sessions**: Some sites (LinkedIn) aggressively expire sessions when navigating. Log in once, then use read/click only.
|
|
149
|
+
- **Captchas / 2FA**: Cannot bypass security checks. Ask the user to complete them in the visible browser window.
|
|
150
|
+
- **File downloads**: Not supported. Use `browser_evaluate` to extract data as text.
|
|
151
|
+
- **OCR accuracy**: Tesseract OCR depends on screenshot quality. Complex layouts, small fonts, or low-contrast text may produce garbled results. When OCR is unreliable, rely on DOM elements instead.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nandal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# TextBrowser for Pi
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-textbrowser)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
> Headless browser extension for Pi — browse the web with **structured DOM + OCR text maps**. **10-50x cheaper** than screenshot-based browsing.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
┌─────────────┐ browser_navigate(url) ┌─────────────┐
|
|
10
|
+
│ Pi Agent │ ─────────────────────────────>│ Playwright │
|
|
11
|
+
│ (you) │ │ Chromium │
|
|
12
|
+
│ │ <─ DOM + OCR text map ────────│ │
|
|
13
|
+
└─────────────┘ (~200 tokens) └─────────────┘
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why TextBrowser?
|
|
17
|
+
|
|
18
|
+
| Approach | ~Tokens | Relative Cost |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| PNG 1920×1080 (vision model) | ~1,500–3,000 | 100% |
|
|
21
|
+
| **TextBrowser (text-only)** | **~150–400** | **5–15%** |
|
|
22
|
+
|
|
23
|
+
Vision-model screenshots burn thousands of tokens per page. TextBrowser captures the **DOM structure** + runs **OCR** on a screenshot, then **discards the image**. Only clean, structured text reaches the AI. You get element lists, bounding boxes, visible text, and OCR content — all for a fraction of the cost.
|
|
24
|
+
|
|
25
|
+
Need to see colors or layout? Flip to **visual mode** and get the PNG too.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install npm:pi-textbrowser
|
|
31
|
+
npx playwright install chromium
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or add to your `.pi/settings.json`:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"packages": ["npm:pi-textbrowser"]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> **Note:** The Playwright Chromium binary is a one-time install.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | What it does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `browser_navigate` | Open a URL, return page context |
|
|
49
|
+
| `browser_click` | Click by selector / text / XPath |
|
|
50
|
+
| `browser_type` | Fill input fields |
|
|
51
|
+
| `browser_scroll` | Scroll page or element into view |
|
|
52
|
+
| `browser_screenshot` | Capture current page context |
|
|
53
|
+
| `browser_read` | Read current page without changing it |
|
|
54
|
+
| `browser_evaluate` | Run JavaScript in the page |
|
|
55
|
+
|
|
56
|
+
## Dual-Mode Design
|
|
57
|
+
|
|
58
|
+
### Text-only mode (default) — use for 90% of tasks
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
browser_navigate(url="https://example.com")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- Screenshot captured **only for OCR** → image discarded
|
|
65
|
+
- Returns: structured DOM elements + OCR text
|
|
66
|
+
- **Zero image tokens** reach the AI
|
|
67
|
+
- **5-15× cheaper** than visual mode
|
|
68
|
+
|
|
69
|
+
**Use when**: navigating, form filling, data extraction, workflow automation, reading content
|
|
70
|
+
|
|
71
|
+
### Visual mode — use ONLY for pixels, colors, layout
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
browser_navigate(url="https://example.com", visual=true)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- Screenshot captured for OCR **and** returned as base64 PNG
|
|
78
|
+
- Returns: text map + actual image
|
|
79
|
+
- **5-15× more tokens** than text-only
|
|
80
|
+
|
|
81
|
+
**Use ONLY when**: checking layout alignment, verifying color/theme, debugging CSS, reviewing design, reading image content
|
|
82
|
+
|
|
83
|
+
### When to use which
|
|
84
|
+
|
|
85
|
+
| Task | Mode |
|
|
86
|
+
|---|---|
|
|
87
|
+
| "Open Gitea and explore repos" | Text-only ✅ |
|
|
88
|
+
| "Login to LinkedIn and post" | Text-only ✅ |
|
|
89
|
+
| "Check if dark mode looks correct" | Visual 🖼️ |
|
|
90
|
+
| "Is the button centered on the page?" | Visual 🖼️ |
|
|
91
|
+
| "Read the article content" | Text-only ✅ |
|
|
92
|
+
| "Compare this page to the mockup" | Visual 🖼️ |
|
|
93
|
+
|
|
94
|
+
## Example Session
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
You: Open https://example.com and explore the page
|
|
98
|
+
|
|
99
|
+
→ browser_navigate(url="https://example.com")
|
|
100
|
+
|
|
101
|
+
Page: https://example.com/
|
|
102
|
+
Title: Example Domain
|
|
103
|
+
Viewport: 1920x1080
|
|
104
|
+
|
|
105
|
+
Elements (14 interactive of 82 total):
|
|
106
|
+
[3] <a> href="https://iana.org/domains/example" text="More information..."
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
OCR (full page screenshot):
|
|
110
|
+
Example Domain
|
|
111
|
+
This domain is for use in illustrative examples in documents.
|
|
112
|
+
...
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Requirements
|
|
116
|
+
|
|
117
|
+
- Node.js 18+
|
|
118
|
+
- Pi coding agent installed
|
|
119
|
+
- Playwright Chromium: `npx playwright install chromium`
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT © [nandal](https://github.com/nandal)
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
*Built by Agent, for Agents 🤖*
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bytesbrains/pi-textbrowser",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Headless browser for Pi — browse the web with DOM + OCR text maps. No image tokens, 10-50x cheaper than screenshot-based browsing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"browser",
|
|
9
|
+
"automation",
|
|
10
|
+
"playwright",
|
|
11
|
+
"ocr",
|
|
12
|
+
"tesseract",
|
|
13
|
+
"web-scraping"
|
|
14
|
+
],
|
|
15
|
+
"author": "nandal <nandal@users.noreply.github.com>",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/nandal/pi-ext",
|
|
19
|
+
"directory": "textbrowser"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/nandal/pi-ext/tree/main/textbrowser",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/nandal/pi-ext/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"main": "./src/index.ts",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src/",
|
|
32
|
+
"README.md",
|
|
33
|
+
"AGENTS.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"playwright": "^1.50.0",
|
|
38
|
+
"tesseract.js": "^6.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"typebox": "*"
|
|
43
|
+
},
|
|
44
|
+
"pi": {
|
|
45
|
+
"extensions": [
|
|
46
|
+
"./src/index.ts"
|
|
47
|
+
],
|
|
48
|
+
"image": "https://raw.githubusercontent.com/nandal/pi-ext/main/textbrowser/screenshot.png"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { Page, Browser, BrowserContext } from "playwright";
|
|
4
|
+
|
|
5
|
+
// ── Module-level browser state (one instance per Pi session) ──
|
|
6
|
+
|
|
7
|
+
let browser: Browser | null = null;
|
|
8
|
+
let context: BrowserContext | null = null;
|
|
9
|
+
let page: Page | null = null;
|
|
10
|
+
let ocrWorker: any = null;
|
|
11
|
+
let currentHeadless = false;
|
|
12
|
+
|
|
13
|
+
async function getPage(headless?: boolean): Promise<Page> {
|
|
14
|
+
headless = headless ?? currentHeadless;
|
|
15
|
+
if (!page || headless !== currentHeadless) {
|
|
16
|
+
if (browser) {
|
|
17
|
+
try { await browser.close(); } catch { /* ignore */ }
|
|
18
|
+
}
|
|
19
|
+
currentHeadless = headless;
|
|
20
|
+
const { chromium } = await import("playwright");
|
|
21
|
+
browser = await chromium.launch({ headless });
|
|
22
|
+
context = await browser.newContext({
|
|
23
|
+
viewport: { width: 1280, height: 800 },
|
|
24
|
+
userAgent:
|
|
25
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
26
|
+
});
|
|
27
|
+
page = await context.newPage();
|
|
28
|
+
}
|
|
29
|
+
return page;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function closeBrowser(): Promise<void> {
|
|
33
|
+
if (ocrWorker) {
|
|
34
|
+
try { await ocrWorker.terminate(); } catch { /* ignore */ }
|
|
35
|
+
ocrWorker = null;
|
|
36
|
+
}
|
|
37
|
+
if (browser) {
|
|
38
|
+
try { await browser.close(); } catch { /* ignore */ }
|
|
39
|
+
browser = null;
|
|
40
|
+
context = null;
|
|
41
|
+
page = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── OCR helper ──
|
|
46
|
+
|
|
47
|
+
async function getOcrWorker(): Promise<any | null> {
|
|
48
|
+
if (ocrWorker) return ocrWorker;
|
|
49
|
+
try {
|
|
50
|
+
const { createWorker } = await import("tesseract.js");
|
|
51
|
+
ocrWorker = await createWorker("eng");
|
|
52
|
+
return ocrWorker;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runOcr(buffer: Buffer): Promise<string> {
|
|
59
|
+
const worker = await getOcrWorker();
|
|
60
|
+
if (!worker) return "(OCR unavailable — install tesseract.js)";
|
|
61
|
+
const { data: { text } } = await worker.recognize(buffer);
|
|
62
|
+
return text.trim() || "(no text detected)";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Page map generator ──
|
|
66
|
+
|
|
67
|
+
interface ElementInfo {
|
|
68
|
+
index: number;
|
|
69
|
+
tag: string;
|
|
70
|
+
text?: string;
|
|
71
|
+
attributes: Record<string, string>;
|
|
72
|
+
bbox: { x: number; y: number; width: number; height: number } | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate a structured text map of the page.
|
|
77
|
+
*
|
|
78
|
+
* Modes:
|
|
79
|
+
* text-only (default): screenshot is captured → OCR runs → image is DISCARDED.
|
|
80
|
+
* Only DOM + OCR text is returned. Zero image tokens.
|
|
81
|
+
* visual: screenshot is captured → OCR text + base64 PNG image
|
|
82
|
+
* are both returned. Useful when layout/colors matter.
|
|
83
|
+
*/
|
|
84
|
+
async function generatePageMap(p: Page, visual = false): Promise<{ text: string; image?: string }> {
|
|
85
|
+
const url = p.url();
|
|
86
|
+
const title = await p.title().catch(() => "(no title)");
|
|
87
|
+
const viewport = p.viewportSize() ?? { width: 1920, height: 1080 };
|
|
88
|
+
|
|
89
|
+
// Extract interactive / visible elements with bounding boxes (traverses shadow DOM)
|
|
90
|
+
const elements: ElementInfo[] = await p.evaluate(() => {
|
|
91
|
+
const interactive = new Set([
|
|
92
|
+
"A", "BUTTON", "INPUT", "TEXTAREA", "SELECT", "OPTION", "LABEL", "DETAILS", "SUMMARY",
|
|
93
|
+
]);
|
|
94
|
+
const results: ElementInfo[] = [];
|
|
95
|
+
let index = 0;
|
|
96
|
+
const keepAttrs = ["id", "name", "type", "placeholder", "href", "src", "alt", "role", "aria-label", "value"];
|
|
97
|
+
|
|
98
|
+
function processElement(el: Element) {
|
|
99
|
+
const rect = el.getBoundingClientRect();
|
|
100
|
+
const style = window.getComputedStyle(el);
|
|
101
|
+
if (style.display === "none" || style.visibility === "hidden") return;
|
|
102
|
+
if (rect.width < 2 || rect.height < 2) return;
|
|
103
|
+
|
|
104
|
+
const tag = el.tagName.toLowerCase();
|
|
105
|
+
const isInteractive =
|
|
106
|
+
interactive.has(el.tagName) ||
|
|
107
|
+
(el as HTMLElement).onclick != null ||
|
|
108
|
+
el.getAttribute("role") === "button" ||
|
|
109
|
+
el.getAttribute("role") === "link";
|
|
110
|
+
|
|
111
|
+
if (!isInteractive && rect.width * rect.height < 400) return;
|
|
112
|
+
|
|
113
|
+
const text = el.textContent?.trim().slice(0, 200) || undefined;
|
|
114
|
+
const attrs: Record<string, string> = {};
|
|
115
|
+
for (const a of keepAttrs) {
|
|
116
|
+
const v = el.getAttribute(a);
|
|
117
|
+
if (v) attrs[a] = v;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
results.push({
|
|
121
|
+
index: ++index,
|
|
122
|
+
tag,
|
|
123
|
+
text,
|
|
124
|
+
attributes: attrs,
|
|
125
|
+
bbox: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Recurse into shadow DOM
|
|
129
|
+
if ((el as any).shadowRoot) {
|
|
130
|
+
walkTree((el as any).shadowRoot);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function walkTree(root: Document | ShadowRoot) {
|
|
135
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
136
|
+
while (walker.nextNode()) {
|
|
137
|
+
processElement(walker.currentNode as Element);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
walkTree(document);
|
|
142
|
+
return results;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Capture screenshot for OCR (and optionally for visual mode)
|
|
146
|
+
let screenshotBuffer: Buffer | null = null;
|
|
147
|
+
let ocrText: string;
|
|
148
|
+
try {
|
|
149
|
+
screenshotBuffer = Buffer.from(await p.screenshot({ type: "png", fullPage: false }));
|
|
150
|
+
ocrText = await runOcr(screenshotBuffer);
|
|
151
|
+
} catch {
|
|
152
|
+
ocrText = "(screenshot/OCR failed)";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build structured text output
|
|
156
|
+
const lines: string[] = [];
|
|
157
|
+
lines.push(`Page: ${url}`);
|
|
158
|
+
lines.push(`Title: ${title}`);
|
|
159
|
+
lines.push(`Viewport: ${viewport.width}x${viewport.height}`);
|
|
160
|
+
lines.push("");
|
|
161
|
+
|
|
162
|
+
const interactiveEls = elements.filter(
|
|
163
|
+
(e) =>
|
|
164
|
+
["a", "button", "input", "textarea", "select"].includes(e.tag) ||
|
|
165
|
+
e.attributes.role === "button" ||
|
|
166
|
+
e.attributes.role === "link"
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
lines.push(`Elements (${interactiveEls.length} interactive of ${elements.length} total):`);
|
|
170
|
+
for (const el of interactiveEls.slice(0, 60)) {
|
|
171
|
+
const attrStr = Object.entries(el.attributes)
|
|
172
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
173
|
+
.join(" ");
|
|
174
|
+
const bboxStr = el.bbox
|
|
175
|
+
? `bbox=(${el.bbox.x},${el.bbox.y},${el.bbox.x + el.bbox.width},${el.bbox.y + el.bbox.height})`
|
|
176
|
+
: "";
|
|
177
|
+
lines.push(` [${el.index}] <${el.tag}> ${attrStr}${attrStr ? " " : ""}${bboxStr}`);
|
|
178
|
+
if (el.text && el.text.length > 1) {
|
|
179
|
+
lines.push(` text: "${el.text.replace(/\n/g, " ")}"`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (interactiveEls.length > 60) {
|
|
183
|
+
lines.push(` ... (${interactiveEls.length - 60} more interactive elements hidden)`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push("OCR (full page screenshot):");
|
|
188
|
+
lines.push(ocrText || "(no text detected)");
|
|
189
|
+
|
|
190
|
+
const text = lines.join("\n");
|
|
191
|
+
|
|
192
|
+
// ── Mode: text-only ──
|
|
193
|
+
// Screenshot was captured only for OCR processing. Image is discarded.
|
|
194
|
+
// Zero image tokens reach the AI.
|
|
195
|
+
if (!visual) {
|
|
196
|
+
return { text };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Mode: visual ──
|
|
200
|
+
// Include the base64-encoded PNG so the AI can see colors, layout, etc.
|
|
201
|
+
const image = screenshotBuffer?.toString("base64");
|
|
202
|
+
return { text, image };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Extension ──
|
|
206
|
+
|
|
207
|
+
export default function (pi: ExtensionAPI) {
|
|
208
|
+
pi.registerTool({
|
|
209
|
+
name: "browser_navigate",
|
|
210
|
+
label: "Browser Navigate",
|
|
211
|
+
description: "Open a URL in the browser and return the page context (DOM + OCR). Default text-only mode captures screenshots only for OCR — no image tokens. Set visual=true to receive the actual PNG.",
|
|
212
|
+
parameters: Type.Object({
|
|
213
|
+
url: Type.String({ description: "URL to open" }),
|
|
214
|
+
headless: Type.Optional(Type.Boolean({ description: "Run headless (default false = visible window)" })),
|
|
215
|
+
visual: Type.Optional(Type.Boolean({ description: "Return base64 PNG screenshot alongside text (default false = text-only, zero image tokens)" })),
|
|
216
|
+
}),
|
|
217
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
218
|
+
const p = await getPage(params.headless === true);
|
|
219
|
+
await p.goto(params.url, { waitUntil: "load", timeout: 30000 });
|
|
220
|
+
const { text, image } = await generatePageMap(p, params.visual);
|
|
221
|
+
const content: any[] = [{ type: "text", text }];
|
|
222
|
+
if (image) {
|
|
223
|
+
content.push({ type: "image", source: { type: "base64", mediaType: "image/png", data: image } });
|
|
224
|
+
}
|
|
225
|
+
return { content, details: { url: p.url() } };
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
pi.registerTool({
|
|
230
|
+
name: "browser_click",
|
|
231
|
+
label: "Browser Click",
|
|
232
|
+
description: "Click an element by CSS selector, XPath, or visible text. Returns updated page context.",
|
|
233
|
+
parameters: Type.Object({
|
|
234
|
+
selector: Type.Optional(Type.String({ description: "CSS selector" })),
|
|
235
|
+
xpath: Type.Optional(Type.String({ description: "XPath expression" })),
|
|
236
|
+
text: Type.Optional(Type.String({ description: "Visible text content to match" })),
|
|
237
|
+
visual: Type.Optional(Type.Boolean({ description: "Return base64 PNG screenshot alongside text (default false = text-only, zero image tokens)" })),
|
|
238
|
+
}),
|
|
239
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
240
|
+
const p = await getPage();
|
|
241
|
+
if (params.selector) {
|
|
242
|
+
await p.locator(params.selector).first().click({ timeout: 10000, force: true });
|
|
243
|
+
} else if (params.xpath) {
|
|
244
|
+
await p.locator(`xpath=${params.xpath}`).first().click({ timeout: 10000, force: true });
|
|
245
|
+
} else if (params.text) {
|
|
246
|
+
await p.getByText(params.text, { exact: false }).first().click({ timeout: 10000, force: true });
|
|
247
|
+
} else {
|
|
248
|
+
return { content: [{ type: "text", text: "Error: provide selector, xpath, or text" }], isError: true, details: {} };
|
|
249
|
+
}
|
|
250
|
+
await p.waitForTimeout(500);
|
|
251
|
+
const { text, image } = await generatePageMap(p, params.visual);
|
|
252
|
+
const content: any[] = [{ type: "text", text }];
|
|
253
|
+
if (image) {
|
|
254
|
+
content.push({ type: "image", source: { type: "base64", mediaType: "image/png", data: image } });
|
|
255
|
+
}
|
|
256
|
+
return { content, details: {} };
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
pi.registerTool({
|
|
261
|
+
name: "browser_type",
|
|
262
|
+
label: "Browser Type",
|
|
263
|
+
description: "Type text into an input field. Returns updated page context.",
|
|
264
|
+
parameters: Type.Object({
|
|
265
|
+
selector: Type.String({ description: "CSS selector for the input" }),
|
|
266
|
+
text: Type.String({ description: "Text to type" }),
|
|
267
|
+
clear: Type.Optional(Type.Boolean({ description: "Clear existing content first (default true)" })),
|
|
268
|
+
visual: Type.Optional(Type.Boolean({ description: "Return base64 PNG screenshot alongside text (default false = text-only, zero image tokens)" })),
|
|
269
|
+
}),
|
|
270
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
271
|
+
const p = await getPage();
|
|
272
|
+
const locator = p.locator(params.selector).first();
|
|
273
|
+
if (params.clear !== false) {
|
|
274
|
+
await locator.fill(params.text);
|
|
275
|
+
} else {
|
|
276
|
+
await locator.pressSequentially(params.text);
|
|
277
|
+
}
|
|
278
|
+
const { text, image } = await generatePageMap(p, params.visual);
|
|
279
|
+
const content: any[] = [{ type: "text", text }];
|
|
280
|
+
if (image) {
|
|
281
|
+
content.push({ type: "image", source: { type: "base64", mediaType: "image/png", data: image } });
|
|
282
|
+
}
|
|
283
|
+
return { content, details: {} };
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
pi.registerTool({
|
|
288
|
+
name: "browser_scroll",
|
|
289
|
+
label: "Browser Scroll",
|
|
290
|
+
description: "Scroll the page or a specific element. Returns updated page context.",
|
|
291
|
+
parameters: Type.Object({
|
|
292
|
+
direction: Type.String({ description: "Direction: up, down, left, right, or 'to' (element)" }),
|
|
293
|
+
amount: Type.Optional(Type.Number({ description: "Pixels to scroll (default 800)" })),
|
|
294
|
+
selector: Type.Optional(Type.String({ description: "CSS selector to scroll into view" })),
|
|
295
|
+
visual: Type.Optional(Type.Boolean({ description: "Return base64 PNG screenshot alongside text (default false = text-only, zero image tokens)" })),
|
|
296
|
+
}),
|
|
297
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
298
|
+
const p = await getPage();
|
|
299
|
+
if (params.selector) {
|
|
300
|
+
await p.locator(params.selector).first().scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
301
|
+
} else {
|
|
302
|
+
const dir = params.direction ?? "down";
|
|
303
|
+
const amt = params.amount ?? 800;
|
|
304
|
+
const delta = dir === "up" || dir === "left" ? -amt : amt;
|
|
305
|
+
const isVertical = dir === "up" || dir === "down";
|
|
306
|
+
await p.evaluate(({ d, vertical }) => {
|
|
307
|
+
if (vertical) window.scrollBy(0, d);
|
|
308
|
+
else window.scrollBy(d, 0);
|
|
309
|
+
}, { d: delta, vertical: isVertical });
|
|
310
|
+
}
|
|
311
|
+
await p.waitForTimeout(300);
|
|
312
|
+
const { text, image } = await generatePageMap(p, params.visual);
|
|
313
|
+
const content: any[] = [{ type: "text", text }];
|
|
314
|
+
if (image) {
|
|
315
|
+
content.push({ type: "image", source: { type: "base64", mediaType: "image/png", data: image } });
|
|
316
|
+
}
|
|
317
|
+
return { content, details: {} };
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.registerTool({
|
|
322
|
+
name: "browser_screenshot",
|
|
323
|
+
label: "Browser Screenshot",
|
|
324
|
+
description: "Capture the current page and return an OCR-based text map. Set visual=true to also receive the PNG.",
|
|
325
|
+
parameters: Type.Object({
|
|
326
|
+
fullPage: Type.Optional(Type.Boolean({ description: "Capture full page height (default false)" })),
|
|
327
|
+
visual: Type.Optional(Type.Boolean({ description: "Return base64 PNG screenshot alongside text (default false = text-only, zero image tokens)" })),
|
|
328
|
+
}),
|
|
329
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
330
|
+
const p = await getPage();
|
|
331
|
+
const { text, image } = await generatePageMap(p, params.visual);
|
|
332
|
+
const content: any[] = [{ type: "text", text }];
|
|
333
|
+
if (image) {
|
|
334
|
+
content.push({ type: "image", source: { type: "base64", mediaType: "image/png", data: image } });
|
|
335
|
+
}
|
|
336
|
+
return { content, details: { fullPage: params.fullPage ?? false } };
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
pi.registerTool({
|
|
341
|
+
name: "browser_read",
|
|
342
|
+
label: "Browser Read",
|
|
343
|
+
description: "Get the current page context (DOM + OCR text map) without changing anything.",
|
|
344
|
+
parameters: Type.Object({
|
|
345
|
+
visual: Type.Optional(Type.Boolean({ description: "Return base64 PNG screenshot alongside text (default false = text-only, zero image tokens)" })),
|
|
346
|
+
}),
|
|
347
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
348
|
+
const p = await getPage();
|
|
349
|
+
const { text, image } = await generatePageMap(p, params.visual);
|
|
350
|
+
const content: any[] = [{ type: "text", text }];
|
|
351
|
+
if (image) {
|
|
352
|
+
content.push({ type: "image", source: { type: "base64", mediaType: "image/png", data: image } });
|
|
353
|
+
}
|
|
354
|
+
return { content, details: {} };
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
pi.registerTool({
|
|
359
|
+
name: "browser_evaluate",
|
|
360
|
+
label: "Browser Evaluate",
|
|
361
|
+
description: "Execute JavaScript in the page context and return the result. Restricted to safe DOM operations; no eval() or new Function().",
|
|
362
|
+
parameters: Type.Object({
|
|
363
|
+
script: Type.String({ description: "JavaScript code to run (must not contain eval, Function, import, or WebSocket)" }),
|
|
364
|
+
}),
|
|
365
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
366
|
+
// Validate: reject banned patterns to prevent arbitrary code execution
|
|
367
|
+
const banned = /\beval\b|\bFunction\b|\bimport\b|\bWebSocket\b|\bfetch\b\(|\bXMLHttpRequest\b|\bdocument\.write\b/i;
|
|
368
|
+
if (banned.test(params.script)) {
|
|
369
|
+
return {
|
|
370
|
+
content: [{ type: "text", text: "⚠️ Script rejected: contains banned keywords (eval, Function, import, WebSocket, fetch, XMLHttpRequest, document.write)." }],
|
|
371
|
+
isError: true,
|
|
372
|
+
details: {},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const p = await getPage();
|
|
376
|
+
// Execute in a sandboxed way — pass script as a string to Function constructor
|
|
377
|
+
// but only after the content check above
|
|
378
|
+
const result = await p.evaluate((script: string) => {
|
|
379
|
+
try {
|
|
380
|
+
const fn = new Function(`"use strict"; return (${script})`);
|
|
381
|
+
return fn();
|
|
382
|
+
} catch (e: any) {
|
|
383
|
+
return `Error: ${e.message}`;
|
|
384
|
+
}
|
|
385
|
+
}, params.script);
|
|
386
|
+
const text = typeof result === "object" ? JSON.stringify(result, null, 2) : String(result);
|
|
387
|
+
return { content: [{ type: "text", text }], details: { result } };
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
pi.on("session_shutdown", async () => {
|
|
392
|
+
await closeBrowser();
|
|
393
|
+
});
|
|
394
|
+
}
|