pi-browser-harness 0.10.2 → 0.12.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -1,355 +0,0 @@
1
- import { Type } from "typebox";
2
- import type { BrowserClient } from "../client";
3
- import { type Result, err, ok } from "../util/result";
4
- import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
-
6
- // ── Page-side functions ──────────────────────────────────────────────────────
7
- // These run inside the page via Runtime.callFunctionOn with `this` bound to the
8
- // resolved element. They are NOT string-interpolated with user data — the value
9
- // is passed as a structured `arguments` entry, so safeJs is unnecessary here.
10
-
11
- /**
12
- * Universal field setter. Auto-detects the element type and applies the right
13
- * strategy, then reads the resulting value back so the caller can confirm.
14
- *
15
- * The <input>/<textarea> branch uses the "native setter" trick: React (and
16
- * other frameworks) overload the element's `value` setter to track state, so a
17
- * plain `el.value = x` updates the DOM but not the framework. Calling the
18
- * prototype's original setter and then dispatching a bubbling `input` event is
19
- * what makes React's onChange fire.
20
- */
21
- const FILL_FN = `
22
- function(value) {
23
- const el = this;
24
- if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" };
25
- if (el.disabled) return { ok: false, reason: "element is disabled" };
26
- const tag = (el.tagName || "").toLowerCase();
27
- const type = (el.type || "").toLowerCase();
28
- try { el.focus(); } catch (e) {}
29
- const fire = function(t) { el.dispatchEvent(new Event(t, { bubbles: true })); };
30
-
31
- if (tag === "select") {
32
- const want = String(value);
33
- let matched = null;
34
- for (let i = 0; i < el.options.length; i++) {
35
- const o = el.options[i];
36
- if (o.value === want || o.label === want || o.text === want) { matched = o; break; }
37
- }
38
- if (!matched) {
39
- const opts = [];
40
- for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
41
- return { ok: false, reason: "no matching option", kind: "select", options: opts };
42
- }
43
- el.value = matched.value;
44
- fire("input"); fire("change");
45
- return { ok: true, kind: "select", value: el.value, text: matched.text };
46
- }
47
-
48
- if (tag === "input" && (type === "checkbox" || type === "radio")) {
49
- const want = value === true || value === "true" || value === "on" || value === 1;
50
- if (el.checked !== want) {
51
- try { el.click(); } catch (e) {}
52
- if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); }
53
- }
54
- return { ok: true, kind: type, checked: el.checked };
55
- }
56
-
57
- if (el.isContentEditable) {
58
- el.textContent = String(value);
59
- fire("input"); fire("change");
60
- return { ok: true, kind: "contenteditable", value: el.textContent };
61
- }
62
-
63
- if (tag === "input" || tag === "textarea") {
64
- const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
65
- const desc = Object.getOwnPropertyDescriptor(proto, "value");
66
- if (desc && desc.set) desc.set.call(el, String(value));
67
- else el.value = String(value);
68
- fire("input"); fire("change");
69
- return { ok: true, kind: tag, value: el.value };
70
- }
71
-
72
- return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")" };
73
- }`;
74
-
75
- /** Specialized <select> setter — matches by value first, then by label/text. */
76
- const SELECT_FN = `
77
- function(value, label) {
78
- const el = this;
79
- if (!el || (el.tagName || "").toLowerCase() !== "select") return { ok: false, reason: "ref is not a <select> element" };
80
- if (el.disabled) return { ok: false, reason: "select is disabled" };
81
- let matched = null;
82
- for (let i = 0; i < el.options.length; i++) {
83
- const o = el.options[i];
84
- if (value != null && o.value === String(value)) { matched = o; break; }
85
- if (label != null && (o.text === String(label) || o.label === String(label))) { matched = o; break; }
86
- }
87
- if (!matched) {
88
- const opts = [];
89
- for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
90
- return { ok: false, reason: "no matching option", options: opts };
91
- }
92
- try { el.focus(); } catch (e) {}
93
- el.value = matched.value;
94
- el.dispatchEvent(new Event("input", { bubbles: true }));
95
- el.dispatchEvent(new Event("change", { bubbles: true }));
96
- return { ok: true, value: el.value, text: matched.text };
97
- }`;
98
-
99
- /** Specialized checkbox/radio setter — only acts when the state differs. */
100
- const CHECK_FN = `
101
- function(checked) {
102
- const el = this;
103
- const tag = (el.tagName || "").toLowerCase();
104
- const type = (el.type || "").toLowerCase();
105
- if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
106
- if (el.disabled) return { ok: false, reason: "element is disabled" };
107
- const want = checked === true;
108
- let changed = false;
109
- if (el.checked !== want) {
110
- try { el.click(); } catch (e) {}
111
- if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
112
- changed = true;
113
- }
114
- return { ok: true, checked: el.checked, changed: changed };
115
- }`;
116
-
117
- // ── Resolver ─────────────────────────────────────────────────────────────────
118
-
119
- /** Shape returned by the page-side functions above. */
120
- type PageResult = {
121
- readonly ok: boolean;
122
- readonly reason?: string;
123
- readonly kind?: string;
124
- readonly value?: unknown;
125
- readonly text?: string;
126
- readonly checked?: boolean;
127
- readonly changed?: boolean;
128
- readonly options?: ReadonlyArray<{ value: string; text: string }>;
129
- };
130
-
131
- /**
132
- * Resolve a ref (CDP backendDOMNodeId) to a live JS handle and invoke a
133
- * page-side function on it. DOM + Runtime are enabled on every session
134
- * (see cdp/session.ts enableDomains), so no extra setup is required.
135
- */
136
- const resolveAndCall = async (
137
- client: BrowserClient,
138
- ref: number,
139
- functionDeclaration: string,
140
- args: ReadonlyArray<unknown>,
141
- ): Promise<Result<PageResult, ToolErr>> => {
142
- const session = client.session();
143
-
144
- const resolved = await session.call("DOM.resolveNode", { backendNodeId: ref });
145
- if (!resolved.success) {
146
- return err({
147
- kind: "invalid_state",
148
- message: `ref ${ref} could not be resolved — the page may have changed. Re-run browser_snapshot to get fresh refs.`,
149
- details: { ref },
150
- });
151
- }
152
- const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
153
- if (objectId === undefined) {
154
- return err({
155
- kind: "invalid_state",
156
- message: `ref ${ref} resolved to no JS object — re-run browser_snapshot to get fresh refs.`,
157
- details: { ref },
158
- });
159
- }
160
-
161
- try {
162
- const called = await session.call("Runtime.callFunctionOn", {
163
- objectId,
164
- functionDeclaration,
165
- arguments: args.map((v) => ({ value: v })),
166
- returnByValue: true,
167
- awaitPromise: true,
168
- });
169
- if (!called.success) return err({ kind: "cdp_error", message: called.error.message, details: { ref } });
170
- const data = called.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
171
- if (data.exceptionDetails !== undefined) {
172
- return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(data.exceptionDetails)}`, details: { ref } });
173
- }
174
- const value = data.result?.value;
175
- if (typeof value !== "object" || value === null) {
176
- return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
177
- }
178
- return ok(value as PageResult);
179
- } finally {
180
- // Best-effort: release the remote handle so it can be GC'd. Failure is
181
- // harmless (the page may have navigated), so we ignore the result.
182
- await session.call("Runtime.releaseObject", { objectId });
183
- }
184
- };
185
-
186
- /** Drop the `ok` flag before stashing a page result in tool `details` — the
187
- * tool-result envelope reserves `ok` for its own success discriminant. */
188
- const detailsOf = (ref: number, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
189
- const { ok: _ok, ...rest } = res;
190
- return { ref, ...rest, ...(extra ?? {}) };
191
- };
192
-
193
- // ── Tools ────────────────────────────────────────────────────────────────────
194
-
195
- const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
196
- description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
197
- });
198
-
199
- const FillArgs = Type.Object({
200
- ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }),
201
- value: FieldValue,
202
- });
203
-
204
- export const fillTool = defineBrowserTool({
205
- name: "browser_fill",
206
- label: "Browser Fill",
207
- description:
208
- "Fill a single form field by ref. Auto-detects input/textarea/select/checkbox/radio/contenteditable, clears then sets the value, fires input+change events (drives React/Vue controlled inputs correctly), and reads the value back to confirm. Get the ref from browser_snapshot's [ref=N].",
209
- promptSnippet: "Fill a form field by ref (text, select, checkbox) — React-safe, self-confirming",
210
- promptGuidelines: [
211
- "Get the ref from browser_snapshot's [ref=N] for the target field. No click-to-focus needed.",
212
- "value is a string for text fields/selects/contenteditable, or a boolean for checkboxes/radios.",
213
- "The result reports the value the field actually holds after filling — check it matches what you intended.",
214
- "For autocomplete/typeahead that needs real keystrokes, click the field's @(x,y) then use browser_type instead.",
215
- "If a ref no longer resolves, re-run browser_snapshot to get fresh refs.",
216
- ],
217
- parameters: FillArgs,
218
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
219
- const r = await resolveAndCall(client, args.ref, FILL_FN, [args.value]);
220
- if (!r.success) return r;
221
- const res = r.data;
222
- if (!res.ok) {
223
- return err({ kind: "invalid_state", message: `Could not fill ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
224
- }
225
- const wanted = typeof args.value === "boolean" ? undefined : String(args.value);
226
- const got = res.value !== undefined ? String(res.value) : undefined;
227
- const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
228
- let text: string;
229
- if (mismatch) {
230
- text = `Filled ref ${args.ref}, but it now reads ${JSON.stringify(got)} (wanted ${JSON.stringify(wanted)})`;
231
- } else if (got !== undefined) {
232
- text = `Filled ref ${args.ref} = ${JSON.stringify(got)}`;
233
- } else if (res.checked !== undefined) {
234
- text = `Filled ref ${args.ref} (checked=${res.checked})`;
235
- } else {
236
- text = `Filled ref ${args.ref}`;
237
- }
238
- return ok({ text, details: detailsOf(args.ref, res, { mismatch }) });
239
- },
240
- });
241
-
242
- const FillFormArgs = Type.Object({
243
- fields: Type.Array(
244
- Type.Object({
245
- ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }),
246
- value: FieldValue,
247
- }),
248
- { minItems: 1, description: "Fields to fill in one batch, each by ref." },
249
- ),
250
- });
251
-
252
- export const fillFormTool = defineBrowserTool({
253
- name: "browser_fill_form",
254
- label: "Browser Fill Form",
255
- description:
256
- "Fill multiple form fields in one call — the efficient way to complete a form. Each field is { ref, value }; refs come from browser_snapshot's [ref=N]. Handles text inputs, textareas, selects, checkboxes, radios, and contenteditable, drives React/Vue controlled inputs correctly, and reports the resulting value of every field.",
257
- promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
258
- promptGuidelines: [
259
- "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
260
- "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
261
- "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
262
- "Re-run browser_snapshot afterwards to verify the form state if needed.",
263
- ],
264
- parameters: FillFormArgs,
265
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
266
- const results: Array<Readonly<Record<string, unknown>>> = [];
267
- let okCount = 0;
268
- const issues: string[] = [];
269
- for (const f of args.fields) {
270
- const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
271
- if (!r.success) {
272
- results.push({ ref: f.ref, ok: false, error: r.error.message });
273
- issues.push(`ref ${f.ref} (${r.error.message})`);
274
- continue;
275
- }
276
- const res = r.data;
277
- const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
278
- const got = res.value !== undefined ? String(res.value) : undefined;
279
- const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
280
- const fieldOk = res.ok === true && !mismatch;
281
- if (fieldOk) okCount++;
282
- else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
283
- results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
284
- }
285
- const text =
286
- `Filled ${okCount}/${args.fields.length} fields` +
287
- (issues.length > 0 ? `; issues: ${issues.join(", ")}` : "");
288
- return ok({ text, details: { results } });
289
- },
290
- });
291
-
292
- const SelectOptionArgs = Type.Object({
293
- ref: Type.Integer({ description: "Element ref of a <select> from browser_snapshot ([ref=N])." }),
294
- value: Type.Optional(Type.String({ description: "Option value attribute to select." })),
295
- label: Type.Optional(Type.String({ description: "Visible option text/label to select (used if value is omitted)." })),
296
- });
297
-
298
- export const selectOptionTool = defineBrowserTool({
299
- name: "browser_select_option",
300
- label: "Browser Select Option",
301
- description:
302
- "Select an option in a native <select> dropdown by ref. Matches by option value, then by visible label/text, and fires change. Native selects cannot be driven by clicking coordinates — use this instead. Get the ref from browser_snapshot's [ref=N].",
303
- promptSnippet: "Choose an option in a <select> dropdown by ref",
304
- promptGuidelines: [
305
- "Provide value (the option's value attribute) or label (the visible text). value wins if both given.",
306
- "On no match, the result lists the available options so you can retry with a valid one.",
307
- "Use this for native <select> elements — clicking coordinates does not work for OS dropdowns.",
308
- ],
309
- parameters: SelectOptionArgs,
310
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
311
- if (args.value === undefined && args.label === undefined) {
312
- return err({ kind: "invalid_state", message: "Provide value or label to select an option." });
313
- }
314
- const r = await resolveAndCall(client, args.ref, SELECT_FN, [args.value ?? null, args.label ?? null]);
315
- if (!r.success) return r;
316
- const res = r.data;
317
- if (!res.ok) {
318
- return err({ kind: "invalid_state", message: `Could not select option on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
319
- }
320
- return ok({
321
- text: `Selected ${JSON.stringify(res.text)} (value=${JSON.stringify(res.value)}) on ref ${args.ref}`,
322
- details: detailsOf(args.ref, res),
323
- });
324
- },
325
- });
326
-
327
- const SetCheckedArgs = Type.Object({
328
- ref: Type.Integer({ description: "Element ref of a checkbox or radio from browser_snapshot ([ref=N])." }),
329
- checked: Type.Boolean({ description: "Desired checked state." }),
330
- });
331
-
332
- export const setCheckedTool = defineBrowserTool({
333
- name: "browser_set_checked",
334
- label: "Browser Set Checked",
335
- description:
336
- "Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [ref=N].",
337
- promptSnippet: "Set a checkbox/radio checked state by ref",
338
- promptGuidelines: [
339
- "checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
340
- "For radios, set checked:true on the option you want; the group deselects the others.",
341
- ],
342
- parameters: SetCheckedArgs,
343
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
344
- const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]);
345
- if (!r.success) return r;
346
- const res = r.data;
347
- if (!res.ok) {
348
- return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
349
- }
350
- return ok({
351
- text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
352
- details: detailsOf(args.ref, res),
353
- });
354
- },
355
- });
@@ -1,46 +0,0 @@
1
- /**
2
- * The JavaScript source evaluated in a loaded page to capture candidate content
3
- * blocks + metadata for the pure readability extractor (readability.ts). Crosses
4
- * the CDP boundary via Runtime.evaluate (returnByValue) and returns a JSON
5
- * string shaped like PageCapture.
6
- *
7
- * The DOM walk lives here (unavoidable); all scoring/selection is pure and lives
8
- * in readability.ts, so the decision logic stays unit-testable.
9
- */
10
-
11
- /** Max bytes of body-text fallback captured for thin/structure-less pages. */
12
- const BODY_TEXT_LIMIT = 20_000;
13
-
14
- /** Build the capture expression. No interpolated values ⇒ no injection surface. */
15
- export const buildPageCaptureExpr = (): string => `
16
- (() => {
17
- const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
18
- const inBoilerplate = (el) => {
19
- for (let n = el; n; n = n.parentElement) {
20
- if (BOILERPLATE.has(n.tagName)) return true;
21
- const role = n.getAttribute && n.getAttribute('role');
22
- if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true;
23
- }
24
- return false;
25
- };
26
- const kindOf = (tag) => {
27
- if (/^H[1-6]$/.test(tag)) return 'heading';
28
- if (tag === 'LI') return 'listitem';
29
- if (tag === 'BLOCKQUOTE') return 'blockquote';
30
- if (tag === 'P') return 'paragraph';
31
- return 'other';
32
- };
33
- const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';
34
- const root = document.querySelector('article, main, [role=main]') || document.body;
35
- const blocks = [];
36
- for (const el of root.querySelectorAll(selector)) {
37
- const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
38
- if (!text) continue;
39
- let linkTextLength = 0;
40
- for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length;
41
- blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) });
42
- }
43
- const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT});
44
- return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText });
45
- })()
46
- `;
@@ -1,44 +0,0 @@
1
- /**
2
- * The JavaScript source evaluated inside the Google SERP page to capture raw
3
- * anchors + page text. This string crosses the CDP boundary via
4
- * Runtime.evaluate (returnByValue) and returns a JSON string that
5
- * `parseGoogleSerp` / `classifySerp` consume on the harness side.
6
- *
7
- * ADR-0001: anchors are selected structurally (a link carrying an <h3>), not by
8
- * Google CSS class names, so cosmetic markup churn does not break extraction.
9
- */
10
-
11
- /** Max bytes of page text captured for CAPTCHA / no-results classification. */
12
- const PAGE_TEXT_LIMIT = 4000;
13
-
14
- /**
15
- * Build the extraction expression. Takes no interpolated values, so it is a
16
- * fixed source string (no injection surface). Returns
17
- * `JSON.stringify({ anchors, pageText })`.
18
- */
19
- export const buildSerpExtractionExpr = (): string => `
20
- (() => {
21
- const main = document.querySelector('#search, #rso, #main') || document.body;
22
- const headings = Array.from(main.querySelectorAll('a h3'));
23
- const seen = new Set();
24
- const anchors = [];
25
- for (const h3 of headings) {
26
- const a = h3.closest('a[href]');
27
- if (!a) continue;
28
- const href = a.href;
29
- if (!href || seen.has(href)) continue;
30
- seen.add(href);
31
- const container =
32
- a.closest('div.g') ||
33
- a.closest('div[data-hveid]') ||
34
- a.parentElement?.parentElement ||
35
- a.parentElement;
36
- const containerText = container ? container.innerText || '' : '';
37
- const heading = h3.innerText || h3.textContent || '';
38
- const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500);
39
- anchors.push({ href, heading, snippet });
40
- }
41
- const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT});
42
- return JSON.stringify({ anchors, pageText });
43
- })()
44
- `;
@@ -1,73 +0,0 @@
1
- /**
2
- * The profile picker, shared by /browser-profile and first-run setup so both
3
- * show the user exactly the same list.
4
- *
5
- * Rendering rules:
6
- * - one row per profile, labelled "Name (email)" — or "Name (Profile 3)" when
7
- * the profile has no signed-in account, so every row keeps its shape
8
- * - the pinned profile is marked with a check
9
- * - a final row clears the selection, since a pin must be undoable
10
- *
11
- * `ctx.ui.select` resolves to undefined when the user presses Escape AND in
12
- * non-interactive modes (pi's print/RPC UI context returns undefined for every
13
- * dialog), so callers must treat "no answer" as "change nothing".
14
- */
15
-
16
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
17
- import type { BrowserProfile } from "./list";
18
- import type { ProfilePin } from "./store";
19
-
20
- export const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
21
-
22
- export type ProfileChoice =
23
- | { readonly kind: "profile"; readonly profile: BrowserProfile }
24
- | { readonly kind: "clear" }
25
- | { readonly kind: "cancelled" };
26
-
27
- /**
28
- * Two profiles can legitimately share a name and account (the same person
29
- * signed into two profiles). Labels must stay distinct or the selection cannot
30
- * be mapped back, so a repeated label gains its directory.
31
- */
32
- const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
33
- const counts = new Map<string, number>();
34
- for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
35
- return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
36
- };
37
-
38
- /**
39
- * Show the picker and report what the user chose. Returns "cancelled" when
40
- * there is no UI or the user dismissed the dialog.
41
- */
42
- export const promptForProfile = async (
43
- ctx: ExtensionContext,
44
- profiles: ReadonlyArray<BrowserProfile>,
45
- currentPin: ProfilePin | null,
46
- title = "Select browser profile",
47
- ): Promise<ProfileChoice> => {
48
- if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
49
-
50
- const labels = disambiguate(profiles);
51
- const rows = labels.map((label, i) => {
52
- const isPinned = currentPin?.profileDir === profiles[i]?.dir;
53
- return isPinned ? `✓ ${label}` : ` ${label}`;
54
- });
55
- const options = [...rows, CLEAR_SELECTION_LABEL];
56
-
57
- const answer = await ctx.ui.select(title, options);
58
- if (answer === undefined) return { kind: "cancelled" };
59
- if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
60
-
61
- const index = options.indexOf(answer);
62
- const profile = index >= 0 ? profiles[index] : undefined;
63
- if (!profile) return { kind: "cancelled" };
64
- return { kind: "profile", profile };
65
- };
66
-
67
- /** Build the durable pin for a chosen profile. */
68
- export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
69
- userDataDir,
70
- profileDir: profile.dir,
71
- label: profile.label,
72
- savedAt: new Date().toISOString(),
73
- });
@@ -1,10 +0,0 @@
1
- const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
2
- Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32,
3
- ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
4
- Home: 36, End: 35, PageUp: 33, PageDown: 34,
5
- };
6
-
7
- export const SPECIAL_KEYS: ReadonlyArray<string> = Object.keys(VIRTUAL_KEY_CODES);
8
-
9
- export const virtualKeyCode = (key: string): number =>
10
- VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
package/src/util/time.ts DELETED
@@ -1,29 +0,0 @@
1
- export const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
2
- new Promise((resolve, reject) => {
3
- if (signal?.aborted) {
4
- reject(signal.reason ?? new Error("aborted"));
5
- return;
6
- }
7
- const onAbort = (): void => {
8
- clearTimeout(timer);
9
- reject(signal?.reason ?? new Error("aborted"));
10
- };
11
- const timer = setTimeout(() => {
12
- signal?.removeEventListener("abort", onAbort);
13
- resolve();
14
- }, ms);
15
- signal?.addEventListener("abort", onAbort, { once: true });
16
- });
17
-
18
- export type Deadline = {
19
- readonly remainingMs: () => number;
20
- readonly expired: () => boolean;
21
- };
22
-
23
- export const deadline = (totalMs: number): Deadline => {
24
- const end = Date.now() + totalMs;
25
- return {
26
- remainingMs: () => Math.max(0, end - Date.now()),
27
- expired: () => Date.now() >= end,
28
- };
29
- };