pi-browser-harness 0.14.1 → 0.16.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.
@@ -7,6 +7,11 @@ import { cdpCall, evalJs } from "./cdp-call";
7
7
  import { runOnElement } from "./element-call";
8
8
  import { elementFunctionSource } from "./page/inject";
9
9
  import { dispatchKeyEvent, type KeyEventSpec } from "./page/keyboard.page";
10
+ import { prepareTyping, readTypedValue, type TypePrep, type TypedValue } from "./page/fill.page";
11
+ import { nativeFill } from "./form";
12
+ import { interactiveDiff } from "./ref-resolve";
13
+ import { isRecord } from "../util/guards";
14
+ import type { BrowserClient } from "../client";
10
15
 
11
16
  const DISPATCH_KEY_FN = elementFunctionSource(dispatchKeyEvent);
12
17
 
@@ -19,53 +24,180 @@ const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
19
24
  F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123,
20
25
  };
21
26
 
22
- const virtualKeyCode = (key: string): number =>
23
- VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
27
+ // Windows virtual-key codes for letters and digits are the upper-case ASCII code ("a" and "A" are both 65).
28
+ export const virtualKeyCode = (key: string): number => {
29
+ const named = VIRTUAL_KEY_CODES[key];
30
+ if (named !== undefined) return named;
31
+ if (/^[a-z0-9]$/i.test(key)) return key.toUpperCase().charCodeAt(0);
32
+ return key.length === 1 ? key.charCodeAt(0) : 0;
33
+ };
34
+
35
+ // KeyboardEvent.code: the physical key. Pages that check `code` (shortcut handlers) need "KeyA", not "a".
36
+ export const physicalCode = (key: string): string => {
37
+ if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
38
+ if (/^[0-9]$/.test(key)) return `Digit${key}`;
39
+ if (key === " ") return "Space";
40
+ return key;
41
+ };
42
+
43
+ // Chrome only runs an editing shortcut sent over CDP when the event names the command (macOS does not map Cmd+A to
44
+ // select-all for synthetic events), so Ctrl/Cmd + A/C/X/V/Z/Y carry it explicitly.
45
+ export const editingCommands = (key: string, modifiers: number): ReadonlyArray<string> => {
46
+ const ctrlOrMeta = (modifiers & (2 | 4)) !== 0;
47
+ if (!ctrlOrMeta || (modifiers & 1) !== 0) return [];
48
+ const shift = (modifiers & 8) !== 0;
49
+ switch (key.toLowerCase()) {
50
+ case "a": return shift ? [] : ["selectAll"];
51
+ case "c": return shift ? [] : ["copy"];
52
+ case "x": return shift ? [] : ["cut"];
53
+ case "v": return shift ? [] : ["paste"];
54
+ case "z": return shift ? ["redo"] : ["undo"];
55
+ case "y": return shift ? [] : ["redo"];
56
+ default: return [];
57
+ }
58
+ };
24
59
 
25
60
  const TypeArgs = Type.Object({
26
- text: Type.String({ description: "Text to type" }),
61
+ ref: Type.Optional(
62
+ Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED. The field is focused for you." }),
63
+ ),
64
+ selector: Type.Optional(Type.String({ description: "CSS selector of the field (fallback when no ref)." })),
65
+ text: Type.String({ description: "Text to enter. With `clear` (the default when a field is named) it replaces the field's value; \"\" empties it." }),
66
+ clear: Type.Optional(
67
+ Type.Boolean({
68
+ description:
69
+ "Replace the field's current value (true) or add to it at the cursor (false). Default: true when ref/selector is given, false when typing into the already-focused element.",
70
+ }),
71
+ ),
27
72
  });
28
73
 
29
- type FocusProbe = { readonly ok: boolean; readonly tag: string | null };
74
+ type TypeTarget = { readonly ref?: string | undefined; readonly selector?: string | undefined };
30
75
 
31
- const isFocusProbe = (v: unknown): v is FocusProbe =>
32
- typeof v === "object" && v !== null
33
- && "ok" in v && typeof v.ok === "boolean"
34
- && "tag" in v && (typeof v.tag === "string" || v.tag === null);
76
+ const PREP_FN = elementFunctionSource(prepareTyping);
77
+ const READ_FN = elementFunctionSource(readTypedValue);
78
+
79
+ const isTypePrep = (v: unknown): v is TypePrep => {
80
+ if (!isRecord(v) || typeof v["ok"] !== "boolean") return false;
81
+ if (v["ok"]) return typeof v["kind"] === "string" && typeof v["value"] === "string" && typeof v["tag"] === "string";
82
+ return typeof v["code"] === "string" && typeof v["reason"] === "string";
83
+ };
84
+
85
+ const isTypedValue = (v: unknown): v is TypedValue =>
86
+ isRecord(v) && typeof v["value"] === "string" && typeof v["focused"] === "boolean";
87
+
88
+ const hasTarget = (t: TypeTarget): boolean => t.ref !== undefined || t.selector !== undefined;
89
+
90
+ const labelOf = (t: TypeTarget): string => (t.ref !== undefined ? `[${t.ref}]` : t.selector ?? "the focused element");
91
+
92
+ // Runs an element function on the named field, or on document.activeElement when none is named.
93
+ const onField = async (
94
+ client: BrowserClient,
95
+ t: TypeTarget,
96
+ fn: string,
97
+ args: ReadonlyArray<unknown>,
98
+ ): Promise<Result<unknown, ToolErr>> => {
99
+ if (hasTarget(t)) {
100
+ const r = await runOnElement(client, { ref: t.ref, selector: t.selector, functionDeclaration: fn, args });
101
+ if (!r.success) return r;
102
+ if (!r.data.found) return err({ kind: "invalid_state", message: `No element matched: ${labelOf(t)}` });
103
+ return ok(r.data.value);
104
+ }
105
+ return evalJs(client, "(" + fn + ").apply(document.activeElement, " + safeJs`${args}` + ")");
106
+ };
107
+
108
+ const prepErr = (t: TypeTarget, prep: Extract<TypePrep, { readonly ok: false }>): ToolErr => {
109
+ const where = labelOf(t);
110
+ const hint =
111
+ prep.code === "select" ? " For a <select> use browser_select_option."
112
+ : prep.code === "checkable" ? " For a checkbox or radio use browser_set_checked."
113
+ : prep.code === "not_element" ? " Pass the field's ref, or click it first so it has focus."
114
+ : "";
115
+ return { kind: "invalid_state", message: `Cannot type into ${where}: ${prep.reason}.${hint}`, details: { code: prep.code, ...(prep.tag !== undefined ? { tag: prep.tag } : {}) } };
116
+ };
117
+
118
+ // Backspace with the field's content selected deletes it, the way a user empties a field.
119
+ const pressBackspace = async (client: BrowserClient): Promise<Result<void, ToolErr>> => {
120
+ const key = { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8 };
121
+ const down = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyDown", ...key });
122
+ if (!down.success) return down;
123
+ const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", ...key });
124
+ if (!up.success) return up;
125
+ return ok(undefined);
126
+ };
35
127
 
36
128
  export const typeTool = defineBrowserTool({
37
129
  name: "browser_type",
38
130
  label: "Browser Type",
39
- description: "Type text into the currently focused element. Use browser_click first to focus an input field.",
40
- promptSnippet: "Type text into the focused element",
131
+ description:
132
+ "Enter text into a field the way a user types: focuses it, replaces its value (or appends with clear:false), fires real input events so autocomplete/suggestion lists open and React/Vue controlled inputs update, then reads the value back. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`; with neither, types into the focused element. If typing does not produce the value (date/masked inputs), it falls back to setting the value directly and says so.",
133
+ promptSnippet: "Type text into a field by ref (preferred), selector, or the focused element",
41
134
  promptGuidelines: [
42
- "For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
135
+ "One tool for all text entry: browser_type({ ref, text }) replaces the field's value; pass clear:false to append; text \"\" empties the field.",
136
+ "Check the value it reports — a page can reformat or reject what was typed.",
137
+ "Suggestion lists open as you type: after browser_type, run browser_snapshot and browser_click the option's ref.",
138
+ "For a <select> use browser_select_option; for a checkbox/radio use browser_set_checked.",
139
+ "For special keys (Enter, Tab, Escape, arrows) use browser_press_key.",
43
140
  ],
44
141
  parameters: TypeArgs,
45
142
  concurrency: "serialized",
46
143
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
47
- const focused = await evalJs(client, safeJs`
48
- (() => {
49
- const el = document.activeElement;
50
- const ok = !!el && el !== document.body &&
51
- (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
52
- return { ok, tag: el ? el.tagName : null };
53
- })()
54
- `);
55
- if (focused.success) {
56
- const f: FocusProbe = isFocusProbe(focused.data) ? focused.data : { ok: false, tag: null };
57
- if (!f.ok) {
144
+ const t: TypeTarget = { ref: args.ref, selector: args.selector };
145
+ const named = hasTarget(t);
146
+ const clear = args.clear ?? named;
147
+ const where = labelOf(t);
148
+
149
+ const prepRaw = await onField(client, t, PREP_FN, [clear]);
150
+ if (!prepRaw.success) return prepRaw;
151
+ if (!isTypePrep(prepRaw.data)) {
152
+ if (named) return err({ kind: "internal", message: "unexpected page result shape from browser_type", details: { target: where } });
153
+ return err({
154
+ kind: "invalid_state",
155
+ message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
156
+ });
157
+ }
158
+ const prep = prepRaw.data;
159
+ if (!prep.ok) {
160
+ if (!named && prep.code !== "disabled" && prep.code !== "readonly") {
58
161
  return err({
59
162
  kind: "invalid_state",
60
- message:
61
- "No editable element is focused — typed text would be lost. Click the field with browser_click first, or use browser_fill with a selector.",
62
- details: { activeElement: f.tag },
163
+ message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
164
+ details: { activeElement: prep.tag ?? null },
63
165
  });
64
166
  }
167
+ return err(prepErr(t, prep));
65
168
  }
66
- const r = await cdpCall(client, "Input.insertText", { text: args.text });
67
- if (!r.success) return r;
68
- return ok({ text: `Typed: "${args.text}"` });
169
+
170
+ const typed = clear && args.text === "" ? await pressBackspace(client) : await cdpCall(client, "Input.insertText", { text: args.text });
171
+ if (!typed.success) return typed;
172
+
173
+ const readRaw = await onField(client, t, READ_FN, []);
174
+ const after = readRaw.success && isTypedValue(readRaw.data) ? readRaw.data : undefined;
175
+ const expected = clear ? args.text : prep.value + args.text;
176
+ let value = after?.value;
177
+ let note = "";
178
+ if (value !== undefined && value !== expected && clear && named && prep.kind !== "contenteditable") {
179
+ // Typing did not produce the value (a date or masked input, or a handler that swallowed the keys): set it.
180
+ const filled = await nativeFill(client, t, args.text);
181
+ if (filled.success && "value" in filled.data) {
182
+ value = filled.data.value;
183
+ note = " (typing did not take; value set directly)";
184
+ }
185
+ }
186
+ if (value !== undefined && value !== expected) note += ` ⚠ the field holds ${JSON.stringify(value)}, not what was typed — the page reformatted or rejected it`;
187
+ if (after !== undefined && !after.focused && note === "") note = " (the field lost focus after typing)";
188
+
189
+ const diff = await interactiveDiff(client);
190
+ const shown = value !== undefined ? ` → value ${JSON.stringify(value)}` : "";
191
+ return ok({
192
+ text: `Typed "${args.text}" into ${where}${shown}${note}${diff}`,
193
+ details: {
194
+ ...(args.ref !== undefined ? { ref: args.ref } : {}),
195
+ ...(args.selector !== undefined ? { selector: args.selector } : {}),
196
+ text: args.text,
197
+ clear,
198
+ ...(value !== undefined ? { value } : {}),
199
+ },
200
+ });
69
201
  },
70
202
  });
71
203
 
@@ -100,23 +232,26 @@ export const pressKeyTool = defineBrowserTool({
100
232
  const code = virtualKeyCode(k);
101
233
  const modifiers = args.modifiers ?? 0;
102
234
  const isChar = k.length === 1;
235
+ const physical = physicalCode(k);
236
+ // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
237
+ const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
238
+ const commands = editingCommands(k, modifiers);
103
239
  const downParams: ParamsOf<"Input.dispatchKeyEvent"> = {
104
- type: "keyDown",
240
+ type: isChar && hasCommandModifier ? "rawKeyDown" : "keyDown",
105
241
  key: k,
106
- code: k,
242
+ code: physical,
107
243
  modifiers,
108
244
  ...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
109
- ...(isChar ? { text: k, unmodifiedText: k } : {}),
245
+ ...(isChar && !hasCommandModifier ? { text: k, unmodifiedText: k } : {}),
246
+ ...(commands.length > 0 ? { commands: [...commands] } : {}),
110
247
  };
111
248
  const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams);
112
249
  if (!down.success) return down;
113
- // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
114
- const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
115
250
  if (isChar && !hasCommandModifier) {
116
251
  const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });
117
252
  if (!charEv.success) return charEv;
118
253
  }
119
- const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
254
+ const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: physical, modifiers });
120
255
  if (!up.success) return up;
121
256
  return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
122
257
  },
@@ -153,7 +288,8 @@ export const dispatchKeyTool = defineBrowserTool({
153
288
  const eventType = args.eventType ?? "keydown";
154
289
  const target = args.ref ?? args.selector ?? "";
155
290
  // Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
156
- const code = virtualKeyCode(args.key);
291
+ // keypress carries the character code ("a" = 97); keydown/keyup carry the key code ("a" = 65).
292
+ const code = eventType === "keypress" && args.key.length === 1 ? args.key.charCodeAt(0) : virtualKeyCode(args.key);
157
293
  if (args.ref !== undefined) {
158
294
  // Through runOnElement so the resolved RemoteObject is released after the call.
159
295
  const spec: KeyEventSpec = { type: eventType, key: args.key, code };
@@ -0,0 +1,22 @@
1
+ // Page-side code for browser_click; see fill.page.ts for the rules every `*.page.ts` module follows.
2
+
3
+ // What sits under the click point, relative to the element the click was aimed at.
4
+ export type HitInfo = {
5
+ // The point lands on the target, inside it, or on an ancestor that wraps it (a label around an icon).
6
+ readonly onTarget: boolean;
7
+ // A short description of the element actually under the point, e.g. `div.modal-overlay "Close"`.
8
+ readonly hit: string;
9
+ };
10
+
11
+ // `target` is the element the click aims at (Runtime.callFunctionOn's `this`), `hit` the element hit-testing found.
12
+ export function hitInfo(target: Element | null, hit: Element | null): HitInfo {
13
+ if (hit?.nodeType !== 1) return { onTarget: false, hit: "nothing" };
14
+ const onTarget = target !== null && (target === hit || target.contains(hit) || hit.contains(target));
15
+ const tag = hit.tagName.toLowerCase();
16
+ const id = hit.id ? "#" + hit.id : "";
17
+ const cls = typeof hit.className === "string" && hit.className.trim() !== ""
18
+ ? "." + hit.className.trim().split(/\s+/).slice(0, 2).join(".")
19
+ : "";
20
+ const label = (hit.getAttribute("aria-label") ?? hit.textContent).replace(/\s+/g, " ").trim().slice(0, 40);
21
+ return { onTarget, hit: tag + id + cls + (label !== "" ? ' "' + label + '"' : "") };
22
+ }
@@ -10,7 +10,7 @@
10
10
  export type PageOption = { readonly value: string; readonly text: string };
11
11
 
12
12
  export type FillOptions = {
13
- // browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
13
+ // The recipe-only browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
14
14
  readonly rejectSelect: boolean;
15
15
  readonly focusFirst: boolean;
16
16
  };
@@ -37,6 +37,8 @@ export type FormControl = HTMLElement & {
37
37
  value?: string;
38
38
  checked?: boolean;
39
39
  disabled?: boolean;
40
+ readOnly?: boolean;
41
+ select?: () => void;
40
42
  options?: HTMLOptionsCollection;
41
43
  };
42
44
 
@@ -137,3 +139,70 @@ export function fillField(el: FormControl | null, value: unknown, opts: FillOpti
137
139
 
138
140
  return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")", tag: tagName };
139
141
  }
142
+
143
+ // What browser_type learns about its field before typing: whether it can take text, and its value right now.
144
+ export type TypePrep =
145
+ | { readonly ok: true; readonly kind: "input" | "textarea" | "contenteditable"; readonly value: string; readonly tag: string }
146
+ | {
147
+ readonly ok: false;
148
+ readonly code: "not_element" | "disabled" | "readonly" | "select" | "checkable" | "not_editable";
149
+ readonly reason: string;
150
+ readonly tag?: string;
151
+ };
152
+
153
+ // Focuses `el` and, when `clear` is set, selects everything in it, so the text typed next replaces the old value the
154
+ // way select-all + typing does for a user. Refuses fields that cannot take typed text, naming the tool that can.
155
+ export function prepareTyping(el: FormControl | null, clear: boolean): TypePrep {
156
+ if (el?.nodeType !== 1) return { ok: false, code: "not_element", reason: "no element to type into" };
157
+ const tagName = el.tagName;
158
+ const tag = tagName.toLowerCase();
159
+ const type = (el.type ?? "").toLowerCase();
160
+ if (tag === "select") return { ok: false, code: "select", reason: "element is a <select>", tag: tagName };
161
+ if (tag === "input" && (type === "checkbox" || type === "radio")) {
162
+ return { ok: false, code: "checkable", reason: "element is a " + type, tag: tagName };
163
+ }
164
+ const editableTag = (tag === "input" && type !== "button" && type !== "submit" && type !== "file" && type !== "image" && type !== "reset")
165
+ || tag === "textarea";
166
+ if (!editableTag && !el.isContentEditable) {
167
+ return { ok: false, code: "not_editable", reason: "element is not a text field (tag=" + tag + ")", tag: tagName };
168
+ }
169
+ if (el.disabled) return { ok: false, code: "disabled", reason: "element is disabled", tag: tagName };
170
+ if (editableTag && el.readOnly === true) {
171
+ return { ok: false, code: "readonly", reason: "element is read-only", tag: tagName };
172
+ }
173
+ try {
174
+ el.focus();
175
+ } catch {
176
+ /* focus throws on some detached or shadow nodes; the caller checks where the text landed */
177
+ }
178
+ if (editableTag) {
179
+ if (clear) {
180
+ try {
181
+ el.select?.();
182
+ } catch {
183
+ /* select() is a no-op or throws on a few input types (date, color); the caller verifies and falls back */
184
+ }
185
+ }
186
+ return { ok: true, kind: tag === "textarea" ? "textarea" : "input", value: el.value ?? "", tag: tagName };
187
+ }
188
+ if (clear) {
189
+ const sel = window.getSelection();
190
+ if (sel) {
191
+ sel.removeAllRanges();
192
+ const rng = document.createRange();
193
+ rng.selectNodeContents(el);
194
+ sel.addRange(rng);
195
+ }
196
+ }
197
+ return { ok: true, kind: "contenteditable", value: el.textContent, tag: tagName };
198
+ }
199
+
200
+ // The field's value after typing, and whether it still has focus (a blur handler may have moved it elsewhere).
201
+ export type TypedValue = { readonly value: string; readonly focused: boolean };
202
+
203
+ export function readTypedValue(el: FormControl | null): TypedValue {
204
+ if (el?.nodeType !== 1) return { value: "", focused: false };
205
+ const tag = el.tagName.toLowerCase();
206
+ const value = tag === "input" || tag === "textarea" ? (el.value ?? "") : el.textContent;
207
+ return { value, focused: document.activeElement === el };
208
+ }
@@ -1,4 +1,5 @@
1
- // Page-side code for browser_web_search: pulls the organic result anchors off a Google results page. See fill.page.ts for the rules.
1
+ // Page-side code for browser_web_search: pulls the organic result anchors off a search engine's results page, driven
2
+ // by the engine's selectors. See fill.page.ts for the rules.
2
3
 
3
4
  export type SerpAnchor = {
4
5
  readonly href: string;
@@ -12,29 +13,41 @@ export type SerpExtraction = {
12
13
  readonly pageText: string;
13
14
  };
14
15
 
16
+ export type SerpSelectors = {
17
+ readonly root: string;
18
+ readonly heading: string;
19
+ readonly containers: ReadonlyArray<string>;
20
+ readonly snippet?: string;
21
+ readonly cite: string;
22
+ };
23
+
15
24
  // Returns the extraction as JSON text (not an object) so the isolated-tab evaluator can keep its string guard.
16
- export function extractSerp(pageTextLimit: number): string {
17
- const main = document.querySelector<HTMLElement>("#search, #rso, #main") ?? document.body;
18
- const headings = Array.from(main.querySelectorAll<HTMLElement>("a h3"));
25
+ export function extractSerp(selectors: SerpSelectors, pageTextLimit: number): string {
26
+ const main = document.querySelector<HTMLElement>(selectors.root) ?? document.body;
27
+ const headings = Array.from(main.querySelectorAll<HTMLElement>(selectors.heading));
19
28
  const seen = new Set<string>();
20
29
  const anchors: SerpAnchor[] = [];
21
- for (const h3 of headings) {
22
- const a = h3.closest<HTMLAnchorElement>("a[href]");
30
+ for (const heading of headings) {
31
+ // The heading is the link (DuckDuckGo), sits inside it (Google, Brave) or wraps it (Bing).
32
+ const a = heading.closest<HTMLAnchorElement>("a[href]") ?? heading.querySelector<HTMLAnchorElement>("a[href]");
23
33
  if (!a) continue;
24
34
  const href = a.href;
25
35
  if (!href || seen.has(href)) continue;
26
36
  seen.add(href);
27
- const container: HTMLElement | null =
28
- a.closest<HTMLElement>("div.g") ??
29
- a.closest<HTMLElement>("div[data-hveid]") ??
30
- a.parentElement?.parentElement ??
31
- a.parentElement;
32
- const containerText = container ? container.innerText : "";
33
- const heading = h3.innerText || h3.textContent;
34
- const snippet = containerText.replace(heading, "").replace(/\s+/g, " ").trim().slice(0, 500);
35
- const citeEl = container ? container.querySelector<HTMLElement>("cite") : null;
37
+ let container: HTMLElement | null = null;
38
+ for (const selector of selectors.containers) {
39
+ container = a.closest<HTMLElement>(selector);
40
+ if (container) break;
41
+ }
42
+ container = container ?? a.parentElement?.parentElement ?? a.parentElement;
43
+ const title = heading.innerText || heading.textContent || "";
44
+ const snippetEl =
45
+ selectors.snippet !== undefined && container ? container.querySelector<HTMLElement>(selectors.snippet) : null;
46
+ const snippetSource = snippetEl ? snippetEl.innerText : container ? container.innerText.replace(title, "") : "";
47
+ const snippet = snippetSource.replace(/\s+/g, " ").trim().slice(0, 500);
48
+ const citeEl = container ? container.querySelector<HTMLElement>(selectors.cite) : null;
36
49
  const cite = citeEl ? (citeEl.innerText || citeEl.textContent || "").trim() : "";
37
- anchors.push({ href, heading, snippet, cite });
50
+ anchors.push({ href, heading: title, snippet, cite });
38
51
  }
39
52
  const pageText = document.body.innerText.slice(0, pageTextLimit);
40
53
  const extraction: SerpExtraction = { anchors, pageText };
@@ -91,7 +91,7 @@ export const waitForTool = defineBrowserTool({
91
91
  "Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
92
92
  promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
93
93
  promptGuidelines: [
94
- "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
94
+ "Use before browser_type / browser_click on dynamic pages so you act after the element renders.",
95
95
  ],
96
96
  parameters: WaitForArgs,
97
97
  concurrency: "parallel",
@@ -1,4 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import type { BrowserClient } from "../client";
4
+ import { imageWidth } from "../util/image-size";
5
+ import { evalJs } from "./cdp-call";
2
6
  import { Type } from "typebox";
3
7
  import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
8
  import { type Result, ok } from "../util/result";
@@ -20,6 +24,21 @@ const ScreenshotDetailsSchema = Type.Object({
20
24
 
21
25
  export type ScreenshotDetails = DetailsOf<typeof ScreenshotDetailsSchema>;
22
26
 
27
+ // Screenshots come out in device pixels (2x on a Retina display) and maxDim may shrink them again, so a point read
28
+ // off the image is not a browser_click coordinate. Say how to convert, whenever the two differ.
29
+ const scaleNote = async (client: BrowserClient, path: string, fullPage: boolean): Promise<string> => {
30
+ try {
31
+ const width = imageWidth(await readFile(path));
32
+ const css = await evalJs(client, fullPage ? "document.documentElement.scrollWidth" : "window.innerWidth");
33
+ if (width === undefined || !css.success || typeof css.data !== "number" || css.data <= 0) return "";
34
+ const ratio = width / css.data;
35
+ if (Math.abs(ratio - 1) < 0.05) return "";
36
+ return `\nImage scale: ${ratio.toFixed(2)} image px per CSS px — divide coordinates read off this image by ${ratio.toFixed(2)} before passing them to browser_click (or use refs from browser_snapshot).`;
37
+ } catch {
38
+ return "";
39
+ }
40
+ };
41
+
23
42
  export const screenshotTool = defineBrowserTool({
24
43
  name: "browser_screenshot",
25
44
  label: "Browser Screenshot",
@@ -40,8 +59,9 @@ export const screenshotTool = defineBrowserTool({
40
59
  const resized = await resizeIfNeeded(cap.data.path, args.maxDim);
41
60
  note = resized.note;
42
61
  }
62
+ const scale = await scaleNote(client, cap.data.path, args.fullPage ?? false);
43
63
  return ok({
44
- text: `Screenshot saved: ${cap.data.path}${note}`,
64
+ text: `Screenshot saved: ${cap.data.path}${note}${scale}`,
45
65
  details: { path: cap.data.path, format: cap.data.format, attached: false },
46
66
  });
47
67
  },
@@ -0,0 +1,47 @@
1
+ import { externalResult, isWebUrl, type SearchEngine, type SerpAnchor, urlFromCite } from "../serp";
2
+
3
+ const BING_PROPERTY = /(?:^|\.)bing\.(?:com|net)$/;
4
+ const isExternalResult = externalResult((host) => BING_PROPERTY.test(host));
5
+
6
+ // Bing wraps organic links as bing.com/ck/a?...&u=a1<base64url(target)>. The "a1" prefix tags the encoding.
7
+ export const decodeBingClick = (href: string): string | undefined => {
8
+ try {
9
+ const u = new URL(href);
10
+ if (!BING_PROPERTY.test(u.hostname.toLowerCase()) || u.pathname !== "/ck/a") return undefined;
11
+ const encoded = u.searchParams.get("u");
12
+ if (!encoded?.startsWith("a1")) return undefined;
13
+ const decoded = Buffer.from(encoded.slice(2), "base64url").toString("utf8");
14
+ return isWebUrl(decoded) ? decoded : undefined;
15
+ } catch {
16
+ return undefined;
17
+ }
18
+ };
19
+
20
+ const resultUrl = (anchor: SerpAnchor): string | undefined => {
21
+ const target = decodeBingClick(anchor.href) ?? anchor.href;
22
+ if (isExternalResult(target)) return target;
23
+ if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite, isExternalResult);
24
+ return undefined;
25
+ };
26
+
27
+ export const bing: SearchEngine = {
28
+ id: "bing",
29
+ name: "Bing",
30
+ buildUrl: (query, limit) =>
31
+ `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${limit}&setlang=en`,
32
+ selectors: {
33
+ root: "#b_results, #b_content",
34
+ heading: "li.b_algo h2",
35
+ containers: ["li.b_algo"],
36
+ snippet: ".b_caption p, p[class*='b_lineclamp'], .b_snippet",
37
+ cite: "cite",
38
+ },
39
+ captchaMarkers: [
40
+ "one last step",
41
+ "solve the challenge",
42
+ "verify you are a human",
43
+ "unusual traffic",
44
+ "are you a robot",
45
+ ],
46
+ resultUrl,
47
+ };
@@ -0,0 +1,23 @@
1
+ import { externalResult, type SearchEngine, type SerpAnchor } from "../serp";
2
+
3
+ const BRAVE_PROPERTY = /(?:^|\.)brave\.com$/;
4
+ const isExternalResult = externalResult((host) => BRAVE_PROPERTY.test(host));
5
+
6
+ // Brave links straight to the target; there is no redirect wrapper to undo.
7
+ const resultUrl = (anchor: SerpAnchor): string | undefined =>
8
+ isExternalResult(anchor.href) ? anchor.href : undefined;
9
+
10
+ export const brave: SearchEngine = {
11
+ id: "brave",
12
+ name: "Brave Search",
13
+ buildUrl: (query) => `https://search.brave.com/search?q=${encodeURIComponent(query)}&source=web`,
14
+ selectors: {
15
+ root: "#results, main",
16
+ heading: "[data-type='web'] a .title",
17
+ containers: ["[data-type='web']"],
18
+ snippet: ".generic-snippet .content, .snippet-description",
19
+ cite: "cite",
20
+ },
21
+ captchaMarkers: ["captcha", "verify you are human", "unusual activity", "not a robot"],
22
+ resultUrl,
23
+ };
@@ -0,0 +1,42 @@
1
+ import { externalResult, type SearchEngine, type SerpAnchor, urlFromCite } from "../serp";
2
+
3
+ const DDG_PROPERTY = /(?:^|\.)duckduckgo\.com$/;
4
+ const isExternalResult = externalResult((host) => DDG_PROPERTY.test(host));
5
+
6
+ // Results normally link straight to the target; a duckduckgo.com/l/?uddg=<encoded target> wrapper is undone, and ads
7
+ // (routed through /y.js) are dropped by the own-property filter.
8
+ export const unwrapDuckDuckGo = (href: string): string => {
9
+ try {
10
+ const u = new URL(href);
11
+ if (!DDG_PROPERTY.test(u.hostname.toLowerCase()) || u.pathname !== "/l/") return href;
12
+ return u.searchParams.get("uddg") ?? href;
13
+ } catch {
14
+ return href;
15
+ }
16
+ };
17
+
18
+ const resultUrl = (anchor: SerpAnchor): string | undefined => {
19
+ const target = unwrapDuckDuckGo(anchor.href);
20
+ if (isExternalResult(target)) return target;
21
+ if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite, isExternalResult);
22
+ return undefined;
23
+ };
24
+
25
+ // The main results page. It renders results with JavaScript after the load event, hence readySelector. (The no-JS
26
+ // html.duckduckgo.com endpoint serves a bot challenge even to a real Chrome, so it is not used.)
27
+ export const duckduckgo: SearchEngine = {
28
+ id: "duckduckgo",
29
+ name: "DuckDuckGo",
30
+ buildUrl: (query) => `https://duckduckgo.com/?q=${encodeURIComponent(query)}&kl=us-en&ia=web`,
31
+ selectors: {
32
+ // The page has two mainline containers and the first is empty, so the root is the results list itself.
33
+ root: ".react-results--main, #react-layout",
34
+ heading: "article[data-testid='result'] a[data-testid='result-title-a']",
35
+ containers: ["article[data-testid='result']"],
36
+ snippet: "[data-result='snippet']",
37
+ cite: "a[data-testid='result-extras-url-link']",
38
+ },
39
+ captchaMarkers: ["bots use duckduckgo too", "complete the following challenge"],
40
+ readySelector: "article[data-testid='result']",
41
+ resultUrl,
42
+ };