pi-browser-harness 0.8.3 → 0.10.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/CHANGELOG.md +50 -0
- package/README.md +242 -181
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +23 -0
- package/src/cdp/discovery.ts +183 -37
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +74 -0
- package/src/cdp/target-factory.ts +223 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +114 -27
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +46 -13
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +120 -0
- package/src/domains/navigate.ts +21 -13
- package/src/domains/readpage/capture.ts +46 -0
- package/src/domains/readpage/read-page.ts +119 -0
- package/src/domains/readpage/readability.ts +89 -0
- package/src/domains/render.ts +64 -0
- package/src/domains/search/extract.ts +44 -0
- package/src/domains/search/google-serp.ts +114 -0
- package/src/domains/search/web-search.ts +116 -0
- package/src/domains/setup.ts +4 -2
- package/src/index.ts +33 -7
- package/src/profile/bind.ts +138 -0
- package/src/profile/browser-process.ts +371 -0
- package/src/profile/command.ts +168 -0
- package/src/profile/launch.ts +90 -0
- package/src/profile/list.ts +171 -0
- package/src/profile/paths.ts +166 -0
- package/src/profile/picker.ts +73 -0
- package/src/profile/store.ts +105 -0
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +28 -44
- package/src/state.ts +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type Result, err, ok } from "../../util/result";
|
|
3
|
+
import { applyTruncation } from "../../util/truncate";
|
|
4
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
|
|
5
|
+
import {
|
|
6
|
+
closeIsolatedTab,
|
|
7
|
+
evalInIsolatedTab,
|
|
8
|
+
type IsolatedTab,
|
|
9
|
+
navigateIsolatedTab,
|
|
10
|
+
openIsolatedTab,
|
|
11
|
+
waitForIsolatedLoad,
|
|
12
|
+
} from "../isolated-tab";
|
|
13
|
+
import { renderExpandableText } from "../render";
|
|
14
|
+
import { buildPageCaptureExpr } from "./capture";
|
|
15
|
+
import { extractReadable, type PageCapture, type ReadablePage } from "./readability";
|
|
16
|
+
|
|
17
|
+
const LOAD_TIMEOUT_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
const ReadPageArgs = Type.Object({
|
|
20
|
+
url: Type.Optional(Type.String({ description: "URL to open in an isolated tab, read, then close." })),
|
|
21
|
+
targetId: Type.Optional(
|
|
22
|
+
Type.String({ description: "Read an already-open tab this session owns instead of opening a URL." }),
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const isPageCapture = (value: unknown): value is PageCapture => {
|
|
27
|
+
if (typeof value !== "object" || value === null) return false;
|
|
28
|
+
const v = value as Record<string, unknown>;
|
|
29
|
+
return typeof v["url"] === "string" && typeof v["title"] === "string" && Array.isArray(v["blocks"]) && typeof v["bodyText"] === "string";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const safeParse = (text: string): unknown => {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text) as unknown;
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const captureToResult = async (raw: unknown): Promise<Result<ToolOk, ToolErr>> => {
|
|
41
|
+
const parsed = typeof raw === "string" ? safeParse(raw) : raw;
|
|
42
|
+
if (!isPageCapture(parsed)) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
|
|
43
|
+
const page: ReadablePage = extractReadable(parsed);
|
|
44
|
+
const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`;
|
|
45
|
+
const truncated = await applyTruncation(header + page.text, "readpage");
|
|
46
|
+
const summary = `${page.title} · ${page.wordCount} words`;
|
|
47
|
+
return ok({
|
|
48
|
+
text: truncated.text,
|
|
49
|
+
details: {
|
|
50
|
+
title: page.title,
|
|
51
|
+
url: page.url,
|
|
52
|
+
wordCount: page.wordCount,
|
|
53
|
+
render: {
|
|
54
|
+
summary,
|
|
55
|
+
body: page.text,
|
|
56
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
57
|
+
},
|
|
58
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const readOpenedUrl = async (
|
|
64
|
+
client: Parameters<typeof openIsolatedTab>[0],
|
|
65
|
+
url: string,
|
|
66
|
+
signal: AbortSignal | undefined,
|
|
67
|
+
): Promise<Result<ToolOk, ToolErr>> => {
|
|
68
|
+
const opened = await openIsolatedTab(client);
|
|
69
|
+
if (!opened.success) return err(opened.error);
|
|
70
|
+
const tab: IsolatedTab = opened.data;
|
|
71
|
+
try {
|
|
72
|
+
const navigated = await navigateIsolatedTab(client, tab, url);
|
|
73
|
+
if (!navigated.success) return err(navigated.error);
|
|
74
|
+
const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
|
|
75
|
+
if (!loaded.success) return err(loaded.error);
|
|
76
|
+
const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr());
|
|
77
|
+
if (!captured.success) return err(captured.error);
|
|
78
|
+
return captureToResult(captured.data);
|
|
79
|
+
} finally {
|
|
80
|
+
await closeIsolatedTab(client, tab);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const readOwnedTab = async (
|
|
85
|
+
client: Parameters<typeof openIsolatedTab>[0],
|
|
86
|
+
targetId: string,
|
|
87
|
+
): Promise<Result<ToolOk, ToolErr>> => {
|
|
88
|
+
if (!client.owns(targetId)) {
|
|
89
|
+
return err({ kind: "invalid_state", message: `Tab ${targetId} is not owned by this session.` });
|
|
90
|
+
}
|
|
91
|
+
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
|
|
92
|
+
if (!attached.success) return err({ kind: "cdp_error", message: attached.error.message });
|
|
93
|
+
const sessionId = (attached.data as { sessionId: string }).sessionId;
|
|
94
|
+
const captured = await client.evaluateJs(buildPageCaptureExpr(), sessionId);
|
|
95
|
+
if (!captured.success) return err({ kind: "cdp_error", message: captured.error.message });
|
|
96
|
+
return captureToResult(captured.data);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const readPageTool = defineBrowserTool({
|
|
100
|
+
name: "browser_read_page",
|
|
101
|
+
label: "Browser Read Page",
|
|
102
|
+
description:
|
|
103
|
+
"Read a page as clean article text — main content with nav/ads/boilerplate stripped. Pass a url (opened in an isolated tab, read, then closed) or a targetId of an owned tab. Reader-mode counterpart to browser_web_search.",
|
|
104
|
+
promptSnippet: "Read a page's main content as clean text",
|
|
105
|
+
promptGuidelines: [
|
|
106
|
+
"Pass url to read an arbitrary page (opened + closed in its own tab, never disturbing your current tab), or targetId to read an already-open owned tab.",
|
|
107
|
+
"Returns readable main-article text — use this over browser_snapshot/browser_execute_js when you want an article's content for reading or research.",
|
|
108
|
+
"Boilerplate-heavy or structure-less pages fall back to bounded body text rather than erroring.",
|
|
109
|
+
],
|
|
110
|
+
parameters: ReadPageArgs,
|
|
111
|
+
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
|
|
112
|
+
if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal);
|
|
113
|
+
if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId);
|
|
114
|
+
return err({ kind: "invalid_state", message: "provide either url or targetId" });
|
|
115
|
+
},
|
|
116
|
+
renderResult(result, expanded, theme) {
|
|
117
|
+
return renderExpandableText("read_page", result, expanded, theme);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reader-mode extraction — no CDP, no DOM. The in-page script
|
|
3
|
+
* (see capture.ts) serializes candidate content blocks; these pure functions
|
|
4
|
+
* score them and assemble clean article text, so the selection logic is
|
|
5
|
+
* unit-testable against saved fixtures (test/deep-research/readability-test.ts).
|
|
6
|
+
*
|
|
7
|
+
* Dependency-free by design (no jsdom / Readability): the harness ships no new
|
|
8
|
+
* runtime deps. The heuristic favors blocks with high text density and low link
|
|
9
|
+
* density, which is what separates article body from nav/ads/boilerplate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One candidate content block captured in-page. */
|
|
13
|
+
export type ContentBlock = {
|
|
14
|
+
/** Semantic tag: paragraph | heading | listitem | blockquote | other. */
|
|
15
|
+
readonly kind: "paragraph" | "heading" | "listitem" | "blockquote" | "other";
|
|
16
|
+
readonly text: string;
|
|
17
|
+
/** Chars of text that sit inside <a> tags — high ratio ⇒ nav/menu, not prose. */
|
|
18
|
+
readonly linkTextLength: number;
|
|
19
|
+
/** True when an ancestor is nav/header/footer/aside — structural boilerplate. */
|
|
20
|
+
readonly inBoilerplate: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The JSON payload capture.ts returns from the page. */
|
|
24
|
+
export type PageCapture = {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly blocks: ReadonlyArray<ContentBlock>;
|
|
28
|
+
/** Bounded innerText fallback when block extraction finds nothing usable. */
|
|
29
|
+
readonly bodyText: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The tool's public output shape. */
|
|
33
|
+
export type ReadablePage = {
|
|
34
|
+
readonly title: string;
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly text: string;
|
|
37
|
+
readonly wordCount: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MIN_PARAGRAPH_CHARS = 25;
|
|
41
|
+
const MAX_LINK_DENSITY = 0.5;
|
|
42
|
+
const MIN_ARTICLE_WORDS = 40;
|
|
43
|
+
|
|
44
|
+
const linkDensity = (block: ContentBlock): number => {
|
|
45
|
+
if (block.text.length === 0) return 1;
|
|
46
|
+
return block.linkTextLength / block.text.length;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A block is article prose if it is substantive, text-dense, and not chrome. */
|
|
50
|
+
const isArticleBlock = (block: ContentBlock): boolean => {
|
|
51
|
+
if (block.inBoilerplate) return false;
|
|
52
|
+
if (linkDensity(block) > MAX_LINK_DENSITY) return false;
|
|
53
|
+
if (block.kind === "heading" || block.kind === "blockquote" || block.kind === "listitem") {
|
|
54
|
+
return block.text.trim().length > 0;
|
|
55
|
+
}
|
|
56
|
+
return block.text.trim().length >= MIN_PARAGRAPH_CHARS;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const render = (block: ContentBlock): string => {
|
|
60
|
+
const text = block.text.trim();
|
|
61
|
+
if (block.kind === "heading") return `\n## ${text}\n`;
|
|
62
|
+
if (block.kind === "listitem") return `- ${text}`;
|
|
63
|
+
if (block.kind === "blockquote") return `> ${text}`;
|
|
64
|
+
return text;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const wordCountOf = (text: string): number => {
|
|
68
|
+
const trimmed = text.trim();
|
|
69
|
+
return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Assemble readable article text from a page capture. Keeps text-dense,
|
|
74
|
+
* non-boilerplate blocks; falls back to the bounded body text when the article
|
|
75
|
+
* is too thin (e.g. a page with no clear content structure).
|
|
76
|
+
*/
|
|
77
|
+
export const extractReadable = (capture: PageCapture): ReadablePage => {
|
|
78
|
+
const articleText = capture.blocks.filter(isArticleBlock).map(render).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
79
|
+
|
|
80
|
+
const useFallback = wordCountOf(articleText) < MIN_ARTICLE_WORDS;
|
|
81
|
+
const text = useFallback ? capture.bodyText.trim() : articleText;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
title: capture.title.trim(),
|
|
85
|
+
url: capture.url,
|
|
86
|
+
text,
|
|
87
|
+
wordCount: wordCountOf(text),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TUI renderer for text-heavy tool output (browser_web_search,
|
|
3
|
+
* browser_read_page). Gives the native pi expand/collapse (Ctrl+O) behavior:
|
|
4
|
+
* a compact preview by default, the full body when expanded — mirroring the
|
|
5
|
+
* pattern in browser_execute_js (src/domains/js.ts:81-109).
|
|
6
|
+
*
|
|
7
|
+
* The full text always goes to the LLM via ToolOk.text; this only governs the
|
|
8
|
+
* human-facing TUI view.
|
|
9
|
+
*/
|
|
10
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
11
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
13
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
const COMPACT_PREVIEW_LINES = 6;
|
|
16
|
+
|
|
17
|
+
/** Structured payload a text-heavy tool stores so renderResult can rebuild views. */
|
|
18
|
+
export type ExpandableText = {
|
|
19
|
+
/** One-line summary shown above the body in both states (e.g. "5 results · google"). */
|
|
20
|
+
readonly summary: string;
|
|
21
|
+
/** The full rendered body (markdown/plain). */
|
|
22
|
+
readonly body: string;
|
|
23
|
+
/** Path to the on-disk full output when the LLM text was truncated, if any. */
|
|
24
|
+
readonly fullOutputPath?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isExpandableText = (value: unknown): value is ExpandableText => {
|
|
28
|
+
if (typeof value !== "object" || value === null) return false;
|
|
29
|
+
const v = value as Record<string, unknown>;
|
|
30
|
+
return typeof v["summary"] === "string" && typeof v["body"] === "string";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const extractRender = (details: unknown): unknown => {
|
|
34
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
35
|
+
return (details as Record<string, unknown>)["render"];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a renderResult over an ExpandableText stored in `details.render`.
|
|
40
|
+
* `label` names the tool for the error fallback (e.g. "web_search").
|
|
41
|
+
*/
|
|
42
|
+
export const renderExpandableText = (
|
|
43
|
+
label: string,
|
|
44
|
+
result: { readonly details?: unknown },
|
|
45
|
+
expanded: boolean,
|
|
46
|
+
theme: Theme,
|
|
47
|
+
): Component => {
|
|
48
|
+
const render = extractRender(result.details);
|
|
49
|
+
if (!isExpandableText(render)) return new Text(theme.fg("error", `${label}: no details`), 0, 0);
|
|
50
|
+
|
|
51
|
+
if (!expanded) {
|
|
52
|
+
const lines = render.body.split("\n");
|
|
53
|
+
const preview = lines.slice(0, COMPACT_PREVIEW_LINES).join("\n");
|
|
54
|
+
const more = lines.length > COMPACT_PREVIEW_LINES ? `\n… ${lines.length - COMPACT_PREVIEW_LINES} more lines` : "";
|
|
55
|
+
const md = `**${render.summary}**\n\n${preview}${more}\n\n${keyHint("app.tools.expand", "to expand")}`;
|
|
56
|
+
return new Markdown(md, 0, 0, getMarkdownTheme());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tail = render.fullOutputPath
|
|
60
|
+
? `\n\nFull output at \`${render.fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
|
|
61
|
+
: `\n\n${keyHint("app.tools.expand", "to collapse")}`;
|
|
62
|
+
const md = `**${render.summary}**\n\n${render.body}${tail}`;
|
|
63
|
+
return new Markdown(md, 0, 0, getMarkdownTheme());
|
|
64
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
`;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Google SERP parsing — no CDP, no browser. Everything here is a pure
|
|
3
|
+
* function over data captured from the page, so it is unit-testable against
|
|
4
|
+
* saved fixtures (see test/deep-research/serp-parser-test.ts).
|
|
5
|
+
*
|
|
6
|
+
* ADR-0001: results are extracted by structure/semantics (anchors carrying an
|
|
7
|
+
* <h3> heading in the main results column), never by brittle Google CSS class
|
|
8
|
+
* names, and Google redirect wrappers are unwound to the real target URL.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One raw anchor captured in-page: a link with a heading and nearby snippet. */
|
|
12
|
+
export type SerpAnchor = {
|
|
13
|
+
readonly href: string;
|
|
14
|
+
readonly heading: string;
|
|
15
|
+
readonly snippet: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The JSON payload the in-page extraction expression returns. */
|
|
19
|
+
export type SerpExtraction = {
|
|
20
|
+
readonly anchors: ReadonlyArray<SerpAnchor>;
|
|
21
|
+
readonly pageText: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A parsed, ranked search result — the tool's public output shape. */
|
|
25
|
+
export type SearchResult = {
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly snippet: string;
|
|
29
|
+
readonly rank: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Why a SERP yielded no usable results, or that it did. */
|
|
33
|
+
export type SerpVerdict = "ok" | "captcha" | "no_results";
|
|
34
|
+
|
|
35
|
+
const CAPTCHA_MARKERS: ReadonlyArray<string> = [
|
|
36
|
+
"unusual traffic",
|
|
37
|
+
"not a robot",
|
|
38
|
+
"recaptcha",
|
|
39
|
+
"detected unusual",
|
|
40
|
+
"systems have detected",
|
|
41
|
+
"before you continue to google",
|
|
42
|
+
"our systems have detected unusual traffic",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decode a Google redirect wrapper (`/url?q=<real>&sa=...`) to its target.
|
|
47
|
+
* Returns the input unchanged when it is not a wrapper.
|
|
48
|
+
*/
|
|
49
|
+
const unwrapRedirect = (href: string): string => {
|
|
50
|
+
const wrapper = href.match(/^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i);
|
|
51
|
+
if (!wrapper) return href;
|
|
52
|
+
const query = href.slice(href.indexOf("?") + 1);
|
|
53
|
+
const target = new URLSearchParams(query).get("q");
|
|
54
|
+
return target ?? href;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const isExternalResult = (url: string): boolean => {
|
|
58
|
+
if (!/^https?:\/\//i.test(url)) return false;
|
|
59
|
+
try {
|
|
60
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
61
|
+
if (host === "google.com" || host.endsWith(".google.com")) return false;
|
|
62
|
+
if (host === "googleusercontent.com" || host.endsWith(".googleusercontent.com")) return false;
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Normalized key for dedupe: origin + pathname, trailing slash and hash removed. */
|
|
70
|
+
const dedupeKey = (url: string): string => {
|
|
71
|
+
try {
|
|
72
|
+
const u = new URL(url);
|
|
73
|
+
return `${u.origin}${u.pathname.replace(/\/$/, "")}`.toLowerCase();
|
|
74
|
+
} catch {
|
|
75
|
+
return url.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse captured anchors into ranked results: unwrap redirects, keep only
|
|
81
|
+
* external http(s) links, dedupe by normalized url (first wins), rank by input
|
|
82
|
+
* order, cap to `limit`.
|
|
83
|
+
*/
|
|
84
|
+
export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: number): ReadonlyArray<SearchResult> => {
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
const results: SearchResult[] = [];
|
|
87
|
+
for (const anchor of anchors) {
|
|
88
|
+
if (results.length >= limit) break;
|
|
89
|
+
const url = unwrapRedirect(anchor.href);
|
|
90
|
+
if (!isExternalResult(url)) continue;
|
|
91
|
+
const key = dedupeKey(url);
|
|
92
|
+
if (seen.has(key)) continue;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
results.push({
|
|
95
|
+
title: anchor.heading.trim(),
|
|
96
|
+
url,
|
|
97
|
+
snippet: anchor.snippet.trim(),
|
|
98
|
+
rank: results.length + 1,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decide whether the SERP is usable, blocked by a CAPTCHA, or genuinely empty.
|
|
106
|
+
* `resultCount` is the number of parsed results; `pageText` is a bounded slice
|
|
107
|
+
* of the page's visible text.
|
|
108
|
+
*/
|
|
109
|
+
export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => {
|
|
110
|
+
if (resultCount > 0) return "ok";
|
|
111
|
+
const lower = pageText.toLowerCase();
|
|
112
|
+
if (CAPTCHA_MARKERS.some((marker) => lower.includes(marker))) return "captcha";
|
|
113
|
+
return "no_results";
|
|
114
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type Result, err, ok } from "../../util/result";
|
|
3
|
+
import { applyTruncation } from "../../util/truncate";
|
|
4
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
|
|
5
|
+
import {
|
|
6
|
+
closeIsolatedTab,
|
|
7
|
+
evalInIsolatedTab,
|
|
8
|
+
navigateIsolatedTab,
|
|
9
|
+
openIsolatedTab,
|
|
10
|
+
waitForIsolatedLoad,
|
|
11
|
+
} from "../isolated-tab";
|
|
12
|
+
import { renderExpandableText } from "../render";
|
|
13
|
+
import { buildSerpExtractionExpr } from "./extract";
|
|
14
|
+
import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp";
|
|
15
|
+
|
|
16
|
+
const LOAD_TIMEOUT_MS = 15_000;
|
|
17
|
+
|
|
18
|
+
const WebSearchArgs = Type.Object({
|
|
19
|
+
query: Type.String({ description: "The search query." }),
|
|
20
|
+
limit: Type.Optional(
|
|
21
|
+
Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }),
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const buildSerpUrl = (query: string, limit: number): string =>
|
|
26
|
+
`https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
|
|
27
|
+
|
|
28
|
+
const isSerpExtraction = (value: unknown): value is SerpExtraction => {
|
|
29
|
+
if (typeof value !== "object" || value === null) return false;
|
|
30
|
+
const v = value as Record<string, unknown>;
|
|
31
|
+
return Array.isArray(v["anchors"]) && typeof v["pageText"] === "string";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const formatResults = (query: string, results: ReadonlyArray<SearchResult>): string => {
|
|
35
|
+
const lines = results.map((r) => `${r.rank}. ${r.title}\n ${r.url}\n ${r.snippet}`);
|
|
36
|
+
return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const webSearchTool = defineBrowserTool({
|
|
40
|
+
name: "browser_web_search",
|
|
41
|
+
label: "Browser Web Search",
|
|
42
|
+
description:
|
|
43
|
+
"Search the web and return ranked results (title, url, snippet, rank). Scrapes a Google results page in the real Chrome — no API key. Returns links only; use browser_read_page to read a result's content.",
|
|
44
|
+
promptSnippet: "Search the web for a query",
|
|
45
|
+
promptGuidelines: [
|
|
46
|
+
"Returns ranked {title, url, snippet, rank} — links only, no page content. Follow up with browser_read_page.",
|
|
47
|
+
"On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl) — surface it to the user; do not retry in a tight loop.",
|
|
48
|
+
"On zero results it fails with details.reason:'no_results' — rephrase the query and try again.",
|
|
49
|
+
"Runs in its own isolated tab and never disturbs the user's current tab.",
|
|
50
|
+
],
|
|
51
|
+
parameters: WebSearchArgs,
|
|
52
|
+
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
|
|
53
|
+
const limit = args.limit ?? 10;
|
|
54
|
+
const serpUrl = buildSerpUrl(args.query, limit);
|
|
55
|
+
|
|
56
|
+
const opened = await openIsolatedTab(client);
|
|
57
|
+
if (!opened.success) return err(opened.error);
|
|
58
|
+
const tab = opened.data;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const navigated = await navigateIsolatedTab(client, tab, serpUrl);
|
|
62
|
+
if (!navigated.success) return err(navigated.error);
|
|
63
|
+
|
|
64
|
+
const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
|
|
65
|
+
if (!loaded.success) return err(loaded.error);
|
|
66
|
+
|
|
67
|
+
const extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr());
|
|
68
|
+
if (!extracted.success) return err(extracted.error);
|
|
69
|
+
|
|
70
|
+
const parsedJson = typeof extracted.data === "string" ? safeParse(extracted.data) : extracted.data;
|
|
71
|
+
if (!isSerpExtraction(parsedJson)) {
|
|
72
|
+
return err({ kind: "internal", message: "SERP extraction returned an unexpected shape" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const results = parseGoogleSerp(parsedJson.anchors, limit);
|
|
76
|
+
const verdict = classifySerp(parsedJson.pageText, results.length);
|
|
77
|
+
if (verdict !== "ok") {
|
|
78
|
+
return err({
|
|
79
|
+
kind: "invalid_state",
|
|
80
|
+
message: verdict === "captcha" ? "Google served a CAPTCHA / bot wall" : "no results found",
|
|
81
|
+
details: { reason: verdict, serpUrl },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const body = formatResults(args.query, results);
|
|
86
|
+
const truncated = await applyTruncation(body, "search");
|
|
87
|
+
return ok({
|
|
88
|
+
text: truncated.text,
|
|
89
|
+
details: {
|
|
90
|
+
results,
|
|
91
|
+
engine: "google",
|
|
92
|
+
query: args.query,
|
|
93
|
+
render: {
|
|
94
|
+
summary: `${results.length} result(s) · google · "${args.query}"`,
|
|
95
|
+
body,
|
|
96
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
97
|
+
},
|
|
98
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
} finally {
|
|
102
|
+
await closeIsolatedTab(client, tab);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
renderResult(result, expanded, theme) {
|
|
106
|
+
return renderExpandableText("web_search", result, expanded, theme);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const safeParse = (text: string): unknown => {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(text) as unknown;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
};
|
package/src/domains/setup.ts
CHANGED
|
@@ -31,8 +31,10 @@ export const setupTool = defineBrowserTool({
|
|
|
31
31
|
// ensureAlive:false — this tool IS the setup; it must bypass the socket guard
|
|
32
32
|
ensureAlive: false,
|
|
33
33
|
renderCall: () => new Text("🔧 Initializing browser...", 0, 0),
|
|
34
|
-
async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
35
|
-
|
|
34
|
+
async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
|
|
35
|
+
// extensionCtx carries the UI, so the agent calling browser_setup gets the
|
|
36
|
+
// same first-run profile picker the user gets from /browser-setup.
|
|
37
|
+
const result = await performSetup(client, extensionCtx);
|
|
36
38
|
if (result.success) {
|
|
37
39
|
return ok({ text: result.data });
|
|
38
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Commands:
|
|
14
14
|
* /browser-setup — guided setup wizard
|
|
15
|
+
* /browser-profile — choose which browser profile the agent uses
|
|
15
16
|
* /browser-status — show client status and current page
|
|
16
17
|
* /browser-reload-daemon — restart the browser client
|
|
18
|
+
* /deep-research <q> — research a question on the web, write a cited report
|
|
17
19
|
*
|
|
18
20
|
* Flags:
|
|
19
21
|
* --browser-namespace <name> — override namespace (default: auto)
|
|
@@ -24,6 +26,9 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
24
26
|
import { type BrowserClient, createBrowserClient } from "./client";
|
|
25
27
|
import { getBrowserSystemPrompt } from "./prompt";
|
|
26
28
|
import { registerSetupCommand } from "./setup";
|
|
29
|
+
import { registerProfileCommand } from "./profile/command";
|
|
30
|
+
import { readPin } from "./profile/store";
|
|
31
|
+
import { registerDeepResearchCommand } from "./deep-research";
|
|
27
32
|
import { type BrowserState, defaultState, persistState, restoreState } from "./state";
|
|
28
33
|
import { registerAllTools } from "./registry";
|
|
29
34
|
import { cleanupTempDirs } from "./util/truncate";
|
|
@@ -55,8 +60,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
55
60
|
return;
|
|
56
61
|
}
|
|
57
62
|
const s = client.status();
|
|
63
|
+
const pin = client.profilePin();
|
|
58
64
|
const lines = [
|
|
59
65
|
`Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`,
|
|
66
|
+
`Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`,
|
|
60
67
|
`Session: ${s.sessionId ?? "none"}`,
|
|
61
68
|
];
|
|
62
69
|
if (s.remoteBrowserId) lines.push(`Browser ID: ${s.remoteBrowserId}`);
|
|
@@ -100,6 +107,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
100
107
|
pi.on("session_start", async (_event, ctx) => {
|
|
101
108
|
state = restoreState(ctx, state.namespace);
|
|
102
109
|
|
|
110
|
+
// The pinned profile lives on disk, not in the session, so it survives
|
|
111
|
+
// session termination. Re-read it every session start: another pi session
|
|
112
|
+
// may have changed it since this client was created.
|
|
113
|
+
const profilePin = await readPin();
|
|
114
|
+
|
|
103
115
|
// Create the client lazily on first session. The daemon transport
|
|
104
116
|
// is created but NOT connected — that only happens when the user
|
|
105
117
|
// runs /browser-setup or a browser tool call finds an existing socket.
|
|
@@ -107,13 +119,15 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
107
119
|
if (!client) {
|
|
108
120
|
const transport = createDaemonTransport(state.namespace);
|
|
109
121
|
|
|
110
|
-
const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
122
|
+
const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
|
|
111
123
|
if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
|
|
112
124
|
if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
|
|
125
|
+
if (state.harnessWindowId !== undefined) initialOwnership.harnessWindowId = state.harnessWindowId;
|
|
113
126
|
|
|
114
127
|
client = createBrowserClient({
|
|
115
128
|
namespace: state.namespace,
|
|
116
129
|
transport,
|
|
130
|
+
profilePin,
|
|
117
131
|
...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
|
|
118
132
|
onOwnershipChange: (snap) => {
|
|
119
133
|
state = {
|
|
@@ -122,6 +136,9 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
122
136
|
...(snap.harnessWindowTargetId !== undefined
|
|
123
137
|
? { harnessWindowTargetId: snap.harnessWindowTargetId }
|
|
124
138
|
: {}),
|
|
139
|
+
...(snap.harnessWindowId !== undefined
|
|
140
|
+
? { harnessWindowId: snap.harnessWindowId }
|
|
141
|
+
: {}),
|
|
125
142
|
};
|
|
126
143
|
// Ownership changes can fire from CDP events at any time,
|
|
127
144
|
// including after session replacement when pi's ctx is stale.
|
|
@@ -130,6 +147,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
130
147
|
try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
|
|
131
148
|
},
|
|
132
149
|
});
|
|
150
|
+
} else {
|
|
151
|
+
// Client carried over from a previous session — adopt whatever pin is on
|
|
152
|
+
// disk now, which another pi session may have changed.
|
|
153
|
+
client.setProfilePin(profilePin);
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
// Do NOT call client.start() here — browser control is on-demand.
|
|
@@ -141,27 +162,32 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
|
|
|
141
162
|
toolsRegistered = true;
|
|
142
163
|
}
|
|
143
164
|
registerSetupCommand(pi, client);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
client.status().alive ? "🟢 Browser connected" : "🔴 Browser — run /browser-setup",
|
|
147
|
-
);
|
|
165
|
+
registerProfileCommand(pi, client);
|
|
166
|
+
registerDeepResearchCommand(pi);
|
|
148
167
|
});
|
|
149
168
|
|
|
150
169
|
pi.on("session_shutdown", async () => {
|
|
151
|
-
persistState(pi, state);
|
|
152
170
|
if (client) {
|
|
153
171
|
try {
|
|
154
172
|
// Detach from the page target (removes the "Chrome is being controlled"
|
|
155
173
|
// banner) but keep the transport alive for the next session.
|
|
156
174
|
await client.detach();
|
|
175
|
+
// Close every tab this session opened so no stale harness tabs linger
|
|
176
|
+
// in the user's browser between sessions. This also clears the persisted
|
|
177
|
+
// ownership + window binding via onOwnershipChange, so the next session
|
|
178
|
+
// starts with a fresh dedicated window rather than reattaching to leftovers.
|
|
179
|
+
await client.closeOwnedTabs();
|
|
157
180
|
} catch (e) {
|
|
158
|
-
console.warn("[pi-browser-harness]
|
|
181
|
+
console.warn("[pi-browser-harness] browser teardown failed during shutdown:", e);
|
|
159
182
|
}
|
|
160
183
|
// ponytail: keep the transport alive across sessions.
|
|
161
184
|
// Do NOT call client.stop() or null out client — the daemon connection
|
|
162
185
|
// persists and eliminates the per-session "Allow Remote Debugging" prompt.
|
|
163
186
|
// Only Chrome restart or daemon death triggers a new prompt.
|
|
164
187
|
}
|
|
188
|
+
// Persist last — closeOwnedTabs mutates `state` through onOwnershipChange,
|
|
189
|
+
// so this captures the cleared ownership set.
|
|
190
|
+
persistState(pi, state);
|
|
165
191
|
await cleanupTempDirs();
|
|
166
192
|
});
|
|
167
193
|
|