pi-browser-harness 0.8.3 → 0.9.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 +28 -0
- package/README.md +28 -7
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +8 -0
- package/src/cdp/discovery.ts +171 -9
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +55 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +52 -9
- 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 +121 -0
- package/src/domains/navigate.ts +25 -10
- 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/index.ts +17 -7
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +26 -10
- package/src/state.ts +1 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated-attach tab lifecycle (ADR-0002 v1). Opens a harness-owned tab,
|
|
3
|
+
* attaches to it by its own CDP sessionId, and drives it via callOnTarget /
|
|
4
|
+
* evaluateJs(expr, sid) WITHOUT ever touching the global "current" tab — so a
|
|
5
|
+
* search or page-read never disturbs the tab the user is looking at, even when
|
|
6
|
+
* several subagents run concurrently.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the create/attach sequence in browser_open_urls
|
|
9
|
+
* (src/domains/navigate.ts:84-139). Every page evaluation is bounded by a
|
|
10
|
+
* timeout, so a JS dialog on the (uninstrumented) isolated tab surfaces as a
|
|
11
|
+
* typed timeout rather than hanging — see the dialog-hang caveat in plan.md.
|
|
12
|
+
*/
|
|
13
|
+
import type { BrowserClient } from "../client";
|
|
14
|
+
import { type Result, err, ok } from "../util/result";
|
|
15
|
+
import { safeJs } from "../util/js-template";
|
|
16
|
+
import { bindHarnessWindowId } from "../cdp/window-binding";
|
|
17
|
+
import type { ToolErr } from "../util/tool";
|
|
18
|
+
|
|
19
|
+
/** A tab attached by its own sessionId, ready to drive via callOnTarget. */
|
|
20
|
+
export type IsolatedTab = {
|
|
21
|
+
readonly targetId: string;
|
|
22
|
+
readonly sessionId: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EVAL_TIMEOUT_MS = 15_000;
|
|
26
|
+
const READY_POLL_INTERVAL_MS = 50;
|
|
27
|
+
|
|
28
|
+
const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolErr => ({ kind, message });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Open a harness-owned tab and attach to it, returning its targetId + private
|
|
32
|
+
* sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done.
|
|
33
|
+
*/
|
|
34
|
+
export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
|
|
35
|
+
const hw = client.ownership().harnessWindow();
|
|
36
|
+
const createParams: Record<string, unknown> = hw
|
|
37
|
+
? { url: "about:blank", openerId: hw }
|
|
38
|
+
: { url: "about:blank", newWindow: true };
|
|
39
|
+
|
|
40
|
+
const created = await client.session().callBrowser("Target.createTarget", createParams);
|
|
41
|
+
if (!created.success) return err(toToolErr(created.error.message));
|
|
42
|
+
const { targetId } = created.data as { targetId: string };
|
|
43
|
+
|
|
44
|
+
if (createParams["newWindow"]) {
|
|
45
|
+
client.ownership().setHarnessWindow(targetId);
|
|
46
|
+
await bindHarnessWindowId(client, targetId);
|
|
47
|
+
}
|
|
48
|
+
client.ownership().add(targetId);
|
|
49
|
+
|
|
50
|
+
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
|
|
51
|
+
if (!attached.success) {
|
|
52
|
+
await client.closeTab(targetId);
|
|
53
|
+
return err(toToolErr(attached.error.message));
|
|
54
|
+
}
|
|
55
|
+
const { sessionId } = attached.data as { sessionId: string };
|
|
56
|
+
|
|
57
|
+
const enabled = await client.session().callOnTarget("Page.enable", {}, sessionId);
|
|
58
|
+
if (!enabled.success) {
|
|
59
|
+
await client.closeTab(targetId);
|
|
60
|
+
return err(toToolErr(enabled.error.message));
|
|
61
|
+
}
|
|
62
|
+
return ok({ targetId, sessionId });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Navigate an isolated tab to a URL (does not wait for load). */
|
|
66
|
+
export const navigateIsolatedTab = async (
|
|
67
|
+
client: BrowserClient,
|
|
68
|
+
tab: IsolatedTab,
|
|
69
|
+
url: string,
|
|
70
|
+
): Promise<Result<void, ToolErr>> => {
|
|
71
|
+
const nav = await client.session().callOnTarget("Page.navigate", { url }, tab.sessionId, {
|
|
72
|
+
timeoutMs: EVAL_TIMEOUT_MS,
|
|
73
|
+
});
|
|
74
|
+
if (!nav.success) return err(toToolErr(nav.error.message));
|
|
75
|
+
return ok(undefined);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Poll the isolated tab until document.readyState === 'complete' or timeout. */
|
|
79
|
+
export const waitForIsolatedLoad = async (
|
|
80
|
+
client: BrowserClient,
|
|
81
|
+
tab: IsolatedTab,
|
|
82
|
+
timeoutMs: number,
|
|
83
|
+
signal?: AbortSignal,
|
|
84
|
+
): Promise<Result<void, ToolErr>> => {
|
|
85
|
+
const start = Date.now();
|
|
86
|
+
while (Date.now() - start < timeoutMs) {
|
|
87
|
+
if (signal?.aborted) return err(toToolErr("aborted", "internal"));
|
|
88
|
+
const r = await client.evaluateJs(safeJs`document.readyState`, tab.sessionId);
|
|
89
|
+
if (r.success && r.data === "complete") return ok(undefined);
|
|
90
|
+
await new Promise((res) => setTimeout(res, READY_POLL_INTERVAL_MS));
|
|
91
|
+
}
|
|
92
|
+
return err(toToolErr(`page did not finish loading in ${Math.round(timeoutMs / 1000)}s`, "timeout"));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Evaluate an expression in the isolated tab and return the raw value. Bounds
|
|
97
|
+
* the eval with a timeout so a blocking dialog cannot hang the tool.
|
|
98
|
+
*/
|
|
99
|
+
export const evalInIsolatedTab = async (
|
|
100
|
+
client: BrowserClient,
|
|
101
|
+
tab: IsolatedTab,
|
|
102
|
+
expression: string,
|
|
103
|
+
): Promise<Result<unknown, ToolErr>> => {
|
|
104
|
+
const r = await client.session().callOnTarget(
|
|
105
|
+
"Runtime.evaluate",
|
|
106
|
+
{ expression, returnByValue: true, awaitPromise: true },
|
|
107
|
+
tab.sessionId,
|
|
108
|
+
{ timeoutMs: EVAL_TIMEOUT_MS },
|
|
109
|
+
);
|
|
110
|
+
if (!r.success) return err(toToolErr(r.error.message, r.error.kind === "timeout" ? "timeout" : "cdp_error"));
|
|
111
|
+
const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
|
|
112
|
+
if (data.exceptionDetails) {
|
|
113
|
+
return err(toToolErr(`page evaluation failed: ${JSON.stringify(data.exceptionDetails)}`));
|
|
114
|
+
}
|
|
115
|
+
return ok(data.result?.value);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Close an isolated tab and drop its ownership. Best-effort; never throws. */
|
|
119
|
+
export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => {
|
|
120
|
+
await client.closeTab(tab.targetId).catch(() => {});
|
|
121
|
+
};
|
package/src/domains/navigate.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type Result, err, ok } from "../util/result";
|
|
|
4
4
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
5
|
import { applyTruncation } from "../util/truncate";
|
|
6
6
|
import { safeJs } from "../util/js-template";
|
|
7
|
+
import { bindHarnessWindowId } from "../cdp/window-binding";
|
|
7
8
|
|
|
8
9
|
const NavigateArgs = Type.Object({
|
|
9
10
|
url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
|
|
@@ -75,7 +76,7 @@ export const openUrlsTool = defineBrowserTool({
|
|
|
75
76
|
description: "Open multiple URLs in parallel new tabs. Returns per-URL outcomes.",
|
|
76
77
|
promptSnippet: "Open multiple URLs in new tabs (parallel)",
|
|
77
78
|
promptGuidelines: [
|
|
78
|
-
"Use after
|
|
79
|
+
"Use after browser_web_search to open citations in parallel.",
|
|
79
80
|
"After opening, use browser_list_tabs / browser_switch_tab to navigate, then browser_snapshot to inspect each tab.",
|
|
80
81
|
"Use browser_wait_for_load on a tab before extracting data from SPAs.",
|
|
81
82
|
],
|
|
@@ -83,17 +84,31 @@ export const openUrlsTool = defineBrowserTool({
|
|
|
83
84
|
renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
|
|
84
85
|
async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
|
|
85
86
|
const total = args.urls.length;
|
|
86
|
-
// Phase 1: create tabs in
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
// Phase 1: create tabs. All must land in ONE window. If the harness window
|
|
88
|
+
// already exists, open every url as its child in parallel. If it doesn't
|
|
89
|
+
// (fresh/closed), create the FIRST url as a new dedicated window (binding
|
|
90
|
+
// its windowId) and open the rest as children of it — a parallel
|
|
91
|
+
// newWindow:true per url would otherwise scatter tabs across N windows.
|
|
92
|
+
let seed = client.ownership().harnessWindow();
|
|
93
|
+
let seedResult: TabResult | undefined;
|
|
94
|
+
if (!seed) {
|
|
95
|
+
const url0 = args.urls[0];
|
|
96
|
+
if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
|
|
97
|
+
const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
|
|
98
|
+
if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message });
|
|
99
|
+
seed = (r0.data as { targetId: string }).targetId;
|
|
100
|
+
client.ownership().setHarnessWindow(seed);
|
|
101
|
+
await bindHarnessWindowId(client, seed);
|
|
102
|
+
client.ownership().add(seed);
|
|
103
|
+
seedResult = { url: url0, targetId: seed, ok: true };
|
|
104
|
+
}
|
|
105
|
+
const seededWindow = seed;
|
|
106
|
+
const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
|
|
107
|
+
// Reuse the seed tab (created above) for the first url instead of duplicating it.
|
|
108
|
+
if (i === 0 && seedResult) return seedResult;
|
|
109
|
+
const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow });
|
|
94
110
|
if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
|
|
95
111
|
const c = r.data as { targetId: string };
|
|
96
|
-
if (params["newWindow"]) client.ownership().setHarnessWindow(c.targetId);
|
|
97
112
|
client.ownership().add(c.targetId);
|
|
98
113
|
return { url, targetId: c.targetId, ok: true };
|
|
99
114
|
}));
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
`;
|
|
@@ -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
|
+
};
|