@browserwright/pi 0.0.1

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/index.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * @browserwright/pi — `web_fetch` and `web_search` for pi, backed by
3
+ * declarative providers that drive browserwright.
4
+ *
5
+ * A provider is a JSON file in providers/; adding one needs no code change.
6
+ * This package ships only the browserwright rungs, which are the ones that
7
+ * carry the user's login state. Drop your own JSON in to add a cheaper or
8
+ * anonymous rung ahead of them. See README.md for the contract.
9
+ */
10
+
11
+ import { Type } from "typebox";
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { makeExecutor, runChain } from "./core/chain.ts";
14
+ import { EXTENSION_DIR, loadConfig, loadProviders } from "./core/config.ts";
15
+ import { renderFailure, renderResults, renderSuccess } from "./core/format.ts";
16
+ import { inspectSearch, inspectText, providersForRole } from "./core/predicates.ts";
17
+ import { formatProbeReport, loadProbeCases, runProbe, saveProbeEvidence } from "./core/probe.ts";
18
+ import type { PiConfig, Provider, Role, SearchPayload } from "./core/types.ts";
19
+
20
+ /**
21
+ * A chain failure is reported by THROWING, not by returning a flag.
22
+ *
23
+ * `AgentToolResult` has no `isError` field: pi's agent loop hardcodes
24
+ * `isError: false` on the normal return path and only sets it in the catch
25
+ * around `execute`. Returning `{isError: true}` therefore records a failed call
26
+ * as a successful one — the TUI does not mark it, and observers of the
27
+ * `tool_result` event see `isError: false`.
28
+ */
29
+ class ToolFailure extends Error {}
30
+
31
+ export default function (pi: ExtensionAPI) {
32
+ const config = loadConfig();
33
+ const providers = loadProviders();
34
+
35
+ const namesFor = (role: Role): string[] => [...providersForRole(providers, role).keys()];
36
+ const fetchNames = namesFor("fetch");
37
+ const searchNames = namesFor("search");
38
+
39
+ if (fetchNames.length === 0 && searchNames.length === 0) {
40
+ console.error("[browserwright-pi] no provider declarations found in providers/ — both tools will always fail");
41
+ }
42
+
43
+ // setStatus is TUI/RPC only; print and json modes have no UI to update.
44
+ const statusReporter =
45
+ (ctx: ExtensionContext, onUpdate?: (partial: { content: Array<{ type: "text"; text: string }> }) => void) =>
46
+ (text: string) => {
47
+ if (ctx.hasUI) ctx.ui.setStatus("browserwright", text);
48
+ onUpdate?.({ content: [{ type: "text", text: `*${text}*` }] });
49
+ };
50
+
51
+ // ---- web_fetch ---------------------------------------------------------
52
+
53
+ pi.registerTool({
54
+ name: "web_fetch",
55
+ label: "Fetch Web Page",
56
+ description:
57
+ "Fetch a URL and return its content as Markdown. " +
58
+ `Tries providers in order until one returns usable content: ${config.order.fetch.join(" → ")}. ` +
59
+ "The response header states which provider answered and what format the body is in. " +
60
+ "Output over 50KB is truncated and the full text written to a temp file whose path is given.",
61
+ promptSnippet: "Fetch a URL as markdown, through the user's real browser",
62
+ promptGuidelines: [
63
+ "Prefer `web_fetch` over curl or a shell HTTP client for reading web pages — it renders JavaScript " +
64
+ "and carries the user's login state, so it can read pages an anonymous request cannot.",
65
+ ],
66
+ parameters: Type.Object({
67
+ url: Type.String({ description: "HTTP(S) URL to fetch" }),
68
+ provider: Type.Optional(
69
+ Type.String({
70
+ description:
71
+ `Force one provider instead of the automatic chain (${fetchNames.join(", ") || "none declared"}). ` +
72
+ "Forcing disables fallback.",
73
+ }),
74
+ ),
75
+ }),
76
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
77
+ const url = /^https?:\/\//i.test(params.url) ? params.url : `https://${params.url}`;
78
+ const setStatus = statusReporter(ctx, onUpdate);
79
+
80
+ const result = await runChain<string>({
81
+ providers,
82
+ config,
83
+ role: "fetch",
84
+ subject: url,
85
+ inspect: inspectText,
86
+ forced: params.provider,
87
+ executor: makeExecutor<string>(config, { dir: EXTENSION_DIR, role: "fetch", signal }),
88
+ onAttempt: (provider, index, total) =>
89
+ setStatus(`🌐 ${provider.label ?? provider.name} (${index + 1}/${total})`),
90
+ });
91
+
92
+ if (ctx.hasUI) ctx.ui.setStatus("browserwright", "");
93
+
94
+ if (!result.ok) {
95
+ throw new ToolFailure(renderFailure(result, url, { tool: "web_fetch", alternatives: fetchNames }));
96
+ }
97
+
98
+ return {
99
+ content: [
100
+ {
101
+ type: "text" as const,
102
+ text: renderSuccess(result, { url, maxBytes: config.maxBytes, maxLines: config.maxLines }),
103
+ },
104
+ ],
105
+ details: {
106
+ url,
107
+ provider: result.provider,
108
+ format: result.format,
109
+ chars: result.content?.length ?? 0,
110
+ attempts: result.attempts,
111
+ },
112
+ };
113
+ },
114
+ });
115
+
116
+ // ---- web_search --------------------------------------------------------
117
+
118
+ pi.registerTool({
119
+ name: "web_search",
120
+ label: "Search the Web",
121
+ description:
122
+ "Search the web and return ranked results as title, URL, snippet and date. " +
123
+ `Providers in order: ${config.order.search.join(" → ")}. ` +
124
+ "Also returns the engine's own AI Overview, knowledge panel, 'people also ask' and " +
125
+ "related searches when that query triggered them. Returns links, never page bodies — " +
126
+ "call web_fetch on the ones worth reading.",
127
+ promptSnippet: "Search the web and get back ranked links",
128
+ promptGuidelines: [
129
+ "`web_search` returns links, not page contents. After searching, call `web_fetch` on the one or two " +
130
+ "results actually worth reading rather than fetching all of them.",
131
+ ],
132
+ parameters: Type.Object({
133
+ query: Type.String({ description: "What to search for" }),
134
+ provider: Type.Optional(
135
+ Type.String({
136
+ description:
137
+ `Force one provider instead of the automatic chain (${searchNames.join(", ") || "none declared"}). ` +
138
+ "Forcing disables fallback.",
139
+ }),
140
+ ),
141
+ }),
142
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
143
+ const query = params.query.trim();
144
+ if (!query) throw new ToolFailure("web_search needs a non-empty query");
145
+ const setStatus = statusReporter(ctx, onUpdate);
146
+
147
+ const result = await runChain<SearchPayload>({
148
+ providers,
149
+ config,
150
+ role: "search",
151
+ subject: query,
152
+ inspect: inspectSearch,
153
+ forced: params.provider,
154
+ executor: makeExecutor<SearchPayload>(config, {
155
+ dir: EXTENSION_DIR,
156
+ role: "search",
157
+ signal,
158
+ // Only module providers stream, and the search rung is why
159
+ // that capability exists: it is slow enough that the user
160
+ // deserves to see which phase it is in.
161
+ onProgress: (text) => setStatus(`🔎 ${text}`),
162
+ }),
163
+ onAttempt: (provider, index, total) =>
164
+ setStatus(`🔎 ${provider.label ?? provider.name} (${index + 1}/${total})`),
165
+ });
166
+
167
+ if (ctx.hasUI) ctx.ui.setStatus("browserwright", "");
168
+
169
+ if (!result.ok) {
170
+ throw new ToolFailure(renderFailure(result, query, { tool: "web_search", alternatives: searchNames }));
171
+ }
172
+
173
+ return {
174
+ content: [{ type: "text" as const, text: renderResults(result, query) }],
175
+ details: {
176
+ query,
177
+ provider: result.provider,
178
+ count: result.content?.results.length ?? 0,
179
+ features: {
180
+ answerBox: Boolean(result.content?.answerBox),
181
+ knowledgeGraph: Boolean(result.content?.knowledgeGraph),
182
+ peopleAlsoAsk: result.content?.peopleAlsoAsk?.length ?? 0,
183
+ relatedSearches: result.content?.relatedSearches?.length ?? 0,
184
+ },
185
+ attempts: result.attempts,
186
+ },
187
+ };
188
+ },
189
+ });
190
+
191
+ // ---- /browserwright ----------------------------------------------------
192
+
193
+ pi.registerCommand("browserwright", {
194
+ description:
195
+ "Inspect providers (/browserwright list) or probe one against real URLs (/browserwright probe <provider>)",
196
+ handler: async (args, ctx) => {
197
+ const [subcommand, target] = args.trim().split(/\s+/);
198
+
199
+ if (!subcommand || subcommand === "list") {
200
+ ctx.ui.notify(describeProviders(config, providers), "info");
201
+ return;
202
+ }
203
+
204
+ if (subcommand !== "probe") {
205
+ ctx.ui.notify(`Unknown subcommand "${subcommand}". Use "list" or "probe <provider>".`, "error");
206
+ return;
207
+ }
208
+
209
+ // Probing only makes sense for fetch providers — the cases are URLs.
210
+ const chosen = target ? [target] : fetchNames;
211
+ const unknown = chosen.filter((name) => !fetchNames.includes(name));
212
+ if (unknown.length > 0) {
213
+ ctx.ui.notify(`Not a probeable fetch provider: ${unknown.join(", ")}`, "error");
214
+ return;
215
+ }
216
+
217
+ // Probe hits real sites and opens tabs in the user's daily Chrome.
218
+ // It never runs without an explicit yes.
219
+ if (!ctx.hasUI) return;
220
+ const cases = loadProbeCases();
221
+ const ok = await ctx.ui.confirm(
222
+ "Run probe?",
223
+ `Hits ${cases.length} real URLs for: ${chosen.join(", ")}.\n\nThis opens tabs in your daily Chrome.`,
224
+ );
225
+ if (!ok) return;
226
+
227
+ for (const name of chosen) {
228
+ const provider = providers.get(name) as Provider;
229
+ ctx.ui.setStatus("browserwright", `🔬 probing ${name}…`);
230
+ const rows = await runProbe(provider, cases, {
231
+ config,
232
+ dir: EXTENSION_DIR,
233
+ onCase: (probeCase, index) =>
234
+ ctx.ui.setStatus("browserwright", `🔬 ${name} ${index + 1}/${cases.length}: ${probeCase.name}`),
235
+ });
236
+ const path = saveProbeEvidence(name, rows);
237
+ // deliverAs "steer" so the report renders as soon as the probe
238
+ // finishes. "nextTurn" would queue it invisibly until the user
239
+ // typed something else, which defeats the point of a report.
240
+ pi.sendMessage(
241
+ {
242
+ customType: "browserwright-probe",
243
+ content: `${formatProbeReport(name, rows)}\n\nEvidence written to ${path}`,
244
+ display: true,
245
+ },
246
+ { deliverAs: "steer" },
247
+ );
248
+ }
249
+
250
+ ctx.ui.setStatus("browserwright", "");
251
+ },
252
+ });
253
+ }
254
+
255
+ function describeProviders(config: PiConfig, providers: Map<string, Provider>): string {
256
+ const lines: string[] = [];
257
+ for (const role of ["fetch", "search"] as Role[]) {
258
+ const scoped = providersForRole(providers, role);
259
+ lines.push(`${role}:`);
260
+ if (scoped.size === 0) {
261
+ lines.push(" (none declared)");
262
+ continue;
263
+ }
264
+ const order = config.order[role] ?? [];
265
+ const names = [...order.filter((n) => scoped.has(n)), ...[...scoped.keys()].filter((n) => !order.includes(n))];
266
+ for (const [index, name] of names.entries()) {
267
+ const provider = scoped.get(name);
268
+ if (!provider) continue;
269
+ const flags = [provider.kind, `returns=${provider.returns}`];
270
+ if (provider.enabled === false) flags.push("disabled");
271
+ if (provider.when) flags.push("conditional");
272
+ lines.push(` ${index + 1}. ${name} (${flags.join(", ")})`);
273
+ }
274
+ }
275
+ return lines.join("\n");
276
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@browserwright/pi",
3
+ "version": "0.0.1",
4
+ "description": "web_fetch and web_search for the pi coding agent, driving the user's real browser through browserwright",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "browserwright",
11
+ "web-fetch",
12
+ "web-search",
13
+ "browser-automation"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/broven/browserwright.git",
18
+ "directory": "pi-extension"
19
+ },
20
+ "homepage": "https://github.com/broven/browserwright/tree/main/pi-extension#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/broven/browserwright/issues"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./index.ts"
27
+ ]
28
+ },
29
+ "files": [
30
+ "index.ts",
31
+ "verify.ts",
32
+ "probe-run.ts",
33
+ "core/",
34
+ "!core/*.test.ts",
35
+ "providers/",
36
+ "config.json",
37
+ "probe-cases.json",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=22.0.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "typebox": "*"
47
+ },
48
+ "scripts": {
49
+ "test": "node --test 'core/*.test.ts'"
50
+ }
51
+ }
@@ -0,0 +1,22 @@
1
+ [
2
+ { "name": "article", "url": "https://en.wikipedia.org/wiki/Markdown", "expose": "normal long article" },
3
+ { "name": "spa-shell", "url": "https://web.telegram.org/", "expose": "client-rendered shell, no server HTML" },
4
+ { "name": "bot-wall", "url": "https://www.g2.com/", "expose": "bot challenge / interstitial" },
5
+ { "name": "login-wall", "url": "https://x.com/elonmusk", "expose": "needs the user's session cookies" },
6
+ { "name": "not-found", "url": "https://example.com/definitely-not-here", "expose": "404 handling" },
7
+ {
8
+ "name": "huge",
9
+ "url": "https://en.wikipedia.org/wiki/List_of_Latin_phrases_(full)",
10
+ "expose": "page far past the 50KB truncation limit"
11
+ },
12
+ {
13
+ "name": "pdf",
14
+ "url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
15
+ "expose": "non-HTML content type"
16
+ },
17
+ {
18
+ "name": "localhost",
19
+ "url": "http://localhost:3000/",
20
+ "expose": "local dev server — remote providers cannot reach it, curl and browserwright can"
21
+ }
22
+ ]
package/probe-run.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Headless probe runner — the same code path as `/webfetch probe`, without pi's
3
+ * confirmation dialog. Use it when you want the evidence matrix captured to a
4
+ * file rather than rendered into a session.
5
+ *
6
+ * node probe-run.ts # every provider
7
+ * node probe-run.ts jina curl # only these
8
+ */
9
+
10
+ import { EXTENSION_DIR, loadConfig, loadProviders } from "./core/config.ts";
11
+ import { formatProbeReport, loadProbeCases, runProbe, saveProbeEvidence } from "./core/probe.ts";
12
+
13
+ const config = loadConfig();
14
+ const providers = loadProviders();
15
+ const cases = loadProbeCases();
16
+ const requested = process.argv.slice(2);
17
+ const names = requested.length > 0 ? requested : [...providers.keys()];
18
+
19
+ console.log(`probing ${names.join(", ")} against ${cases.length} real URLs\n`);
20
+
21
+ for (const name of names) {
22
+ const provider = providers.get(name);
23
+ if (!provider) {
24
+ console.log(`unknown provider: ${name}\n`);
25
+ continue;
26
+ }
27
+ const rows = await runProbe(provider, cases, {
28
+ config,
29
+ dir: EXTENSION_DIR,
30
+ onCase: (probeCase, index) => console.error(` [${name}] ${index + 1}/${cases.length} ${probeCase.name}`),
31
+ });
32
+ console.log(formatProbeReport(name, rows));
33
+ console.log(`evidence: ${saveProbeEvidence(name, rows)}\n`);
34
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "browserwright-search",
3
+ "label": "Google via your Chrome",
4
+ "role": "search",
5
+ "kind": "module",
6
+ "module": "./providers/browserwright-search.ts",
7
+ "returns": "results",
8
+ "timeoutMs": 120000,
9
+ "options": {
10
+ "limit": 10,
11
+ "searchUrl": "https://www.google.com/search?q={queryEncoded}&hl=en&num={limit}"
12
+ },
13
+ "failWhen": {
14
+ "matches": [],
15
+ "minResults": 1
16
+ },
17
+ "_note": [
18
+ "kind: \"module\" rather than \"command\" because a search is a lifecycle, not",
19
+ "one shot at a subprocess: mint a session, navigate, extract, retry once on an",
20
+ "executor death, tear down in a finally. See the header of the .ts runner for",
21
+ "the six measured executor behaviours it is designed around.",
22
+ "timeoutMs is 120s, well above the measured cost. Google through a real",
23
+ "browser took 10-21s warm on 2026-08-09 (page.goto's own settle wait accounts",
24
+ "for ~2.5s of that; the rest is Google's page never going network-quiet).",
25
+ "Slow is accepted here — this rung exists for correctness and login state, not",
26
+ "for throughput.",
27
+ "failWhen.matches is [] because the extraction returns rows, not prose: the",
28
+ "default needles ('captcha', 'just a moment') would be matched against result",
29
+ "titles and snippets, where they appear legitimately in any query about them.",
30
+ "The runner detects real interstitials from the page HTML instead and reports",
31
+ "them as a reason.",
32
+ "minResults 1 is the real guard. A captcha or consent wall parses perfectly",
33
+ "and yields an empty list, which is the failure mode that would otherwise be",
34
+ "indistinguishable from an honestly empty search.",
35
+ "searchUrl is a template so the engine can be swapped without touching code.",
36
+ "{queryEncoded} and {limit} are the only substitutions.",
37
+ "SERP features (measured 2026-08-10, see docs/adr/0008). The page is server-",
38
+ "rendered for organic rows, but the AI Overview body is NOT in the initial HTML",
39
+ "at all — it streams in afterwards. That is why extraction runs against the",
40
+ "live DOM and not the document response. browserwright's page.goto already",
41
+ "waits long enough: both organic and the AI Overview were present 0.02s after",
42
+ "it returned.",
43
+ "Each extractor is independently guarded, because the realistic failure is one",
44
+ "restyled container taking the whole search down with it. Anchors used, worst",
45
+ "to best: AI Overview has no stable hook at all, so it anchors on the",
46
+ "accessible heading and walks up; the knowledge panel uses data-attrid, which",
47
+ "is semantic markup rather than a styling class and is the sturdiest hook on",
48
+ "the page; PAA uses data-q (which also carries the query itself, so that is",
49
+ "filtered); related searches are scoped to #botstuff because the same href",
50
+ "pattern at the top of the page is Google's own tab bar.",
51
+ "Known noise that is cleaned: snippets end with the 'Read more' expander text,",
52
+ "dates are prefixed onto snippets with either an em dash or a middot, the AI",
53
+ "Overview splices citation counters ('+2') into its prose, and pagination",
54
+ "renders 'Next' through the related-searches selector."
55
+ ]
56
+ }