@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 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
+ [![npm version](https://img.shields.io/npm/v/pi-textbrowser)](https://www.npmjs.com/package/pi-textbrowser)
4
+ [![license](https://img.shields.io/npm/l/pi-textbrowser)](./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
+ }