pi-agent-browser-native 0.2.38 → 0.2.40

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.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Purpose: Provide the optional Brave-backed `agent_browser_web_search` companion tool.
3
+ * Responsibilities: Define strict search input schema, resolve the configured Brave credential lazily, call Brave Search with cancellation/timeout, normalize compact results, and keep secrets out of content/details.
4
+ * Scope: Live web search only; browser automation remains in the `agent_browser` tool.
5
+ */
6
+
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import { defineTool } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import { resolveBraveApiKey, type AgentBrowserConfigState } from "./config.js";
11
+
12
+ export const AGENT_BROWSER_WEB_SEARCH_TOOL_NAME = "agent_browser_web_search";
13
+ export const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
14
+ export const DEFAULT_SEARCH_RESULT_COUNT = 5;
15
+ export const MAX_SEARCH_RESULT_COUNT = 10;
16
+ export const SEARCH_REQUEST_TIMEOUT_MS = 15_000;
17
+
18
+ export type BraveWebSearchResult = {
19
+ title?: unknown;
20
+ url?: unknown;
21
+ description?: unknown;
22
+ age?: unknown;
23
+ language?: unknown;
24
+ profile?: {
25
+ name?: unknown;
26
+ url?: unknown;
27
+ };
28
+ meta_url?: {
29
+ hostname?: unknown;
30
+ };
31
+ };
32
+
33
+ export type BraveWebSearchResponse = {
34
+ query?: {
35
+ original?: unknown;
36
+ altered?: unknown;
37
+ };
38
+ web?: {
39
+ results?: BraveWebSearchResult[];
40
+ };
41
+ };
42
+
43
+ export type NormalizedSearchResult = {
44
+ title: string;
45
+ url: string;
46
+ description?: string;
47
+ source?: string;
48
+ age?: string;
49
+ language?: string;
50
+ };
51
+
52
+ export const AgentBrowserWebSearchParams = Type.Object(
53
+ {
54
+ query: Type.String({
55
+ minLength: 1,
56
+ description: "Search query to run with Brave Search.",
57
+ }),
58
+ count: Type.Optional(
59
+ Type.Integer({
60
+ minimum: 1,
61
+ maximum: MAX_SEARCH_RESULT_COUNT,
62
+ description: `Number of web results to return. Defaults to ${DEFAULT_SEARCH_RESULT_COUNT}; max ${MAX_SEARCH_RESULT_COUNT}.`,
63
+ }),
64
+ ),
65
+ offset: Type.Optional(
66
+ Type.Integer({
67
+ minimum: 0,
68
+ maximum: 9,
69
+ description: "Zero-based result offset for pagination. Defaults to 0.",
70
+ }),
71
+ ),
72
+ country: Type.Optional(
73
+ Type.String({
74
+ pattern: "^[A-Za-z]{2}$",
75
+ description: "Optional 2-letter country code, such as US or GB.",
76
+ }),
77
+ ),
78
+ searchLang: Type.Optional(
79
+ Type.String({
80
+ minLength: 2,
81
+ maxLength: 8,
82
+ description: "Optional search language code, such as en or en-US.",
83
+ }),
84
+ ),
85
+ safesearch: Type.Optional(
86
+ StringEnum(["off", "moderate", "strict"] as const, {
87
+ description: "Optional Brave safe-search setting. Defaults to Brave's API default.",
88
+ }),
89
+ ),
90
+ freshness: Type.Optional(
91
+ StringEnum(["pd", "pw", "pm", "py"] as const, {
92
+ description: "Optional freshness window: pd=past day, pw=past week, pm=past month, py=past year.",
93
+ }),
94
+ ),
95
+ },
96
+ { additionalProperties: false },
97
+ );
98
+
99
+ const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
100
+ amp: "&",
101
+ apos: "'",
102
+ gt: ">",
103
+ lt: "<",
104
+ nbsp: " ",
105
+ quot: '"',
106
+ };
107
+
108
+ const HTML_TAG_NAMES_TO_STRIP = new Set([
109
+ "a",
110
+ "abbr",
111
+ "address",
112
+ "article",
113
+ "aside",
114
+ "audio",
115
+ "b",
116
+ "base",
117
+ "blockquote",
118
+ "body",
119
+ "br",
120
+ "button",
121
+ "canvas",
122
+ "code",
123
+ "div",
124
+ "em",
125
+ "embed",
126
+ "footer",
127
+ "form",
128
+ "h1",
129
+ "h2",
130
+ "h3",
131
+ "h4",
132
+ "h5",
133
+ "h6",
134
+ "head",
135
+ "header",
136
+ "html",
137
+ "i",
138
+ "iframe",
139
+ "img",
140
+ "input",
141
+ "li",
142
+ "link",
143
+ "main",
144
+ "mark",
145
+ "math",
146
+ "meta",
147
+ "nav",
148
+ "object",
149
+ "ol",
150
+ "option",
151
+ "p",
152
+ "pre",
153
+ "script",
154
+ "section",
155
+ "select",
156
+ "source",
157
+ "span",
158
+ "strong",
159
+ "style",
160
+ "svg",
161
+ "table",
162
+ "tbody",
163
+ "td",
164
+ "textarea",
165
+ "tfoot",
166
+ "th",
167
+ "thead",
168
+ "tr",
169
+ "u",
170
+ "ul",
171
+ "video",
172
+ ]);
173
+
174
+ function decodeHtmlEntity(entity: string): string {
175
+ const named = HTML_ENTITY_REPLACEMENTS[entity.toLowerCase()];
176
+ if (named !== undefined) return named;
177
+ const decimalMatch = /^#(\d+)$/.exec(entity);
178
+ const hexMatch = /^#x([0-9a-f]+)$/i.exec(entity);
179
+ const codePoint = decimalMatch ? Number.parseInt(decimalMatch[1] ?? "", 10) : hexMatch ? Number.parseInt(hexMatch[1] ?? "", 16) : undefined;
180
+ if (codePoint === undefined || !Number.isFinite(codePoint)) return `&${entity};`;
181
+ try {
182
+ return String.fromCodePoint(codePoint);
183
+ } catch {
184
+ return `&${entity};`;
185
+ }
186
+ }
187
+
188
+ export function decodeHtmlEntities(value: string): string {
189
+ return value.replace(/&([a-z][a-z0-9]+|#\d+|#x[0-9a-f]+);/gi, (_match, entity: string) => decodeHtmlEntity(entity));
190
+ }
191
+
192
+ function stripDecodedHtmlTags(value: string): string {
193
+ return value.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<\/?([a-z][a-z0-9-]*)(\s[^>]*)?>/gi, (match, tagName: string, attributes: string | undefined) => {
194
+ if (attributes || match.startsWith("</") || HTML_TAG_NAMES_TO_STRIP.has(tagName.toLowerCase())) return " ";
195
+ return match;
196
+ });
197
+ }
198
+
199
+ export function cleanSearchText(value: unknown, maxLength = 500): string | undefined {
200
+ if (typeof value !== "string") return undefined;
201
+ const cleaned = stripDecodedHtmlTags(decodeHtmlEntities(value.replace(/<[^>]*>/g, " ")))
202
+ .replace(/\s+/g, " ")
203
+ .trim();
204
+ if (!cleaned) return undefined;
205
+ if (cleaned.length <= maxLength) return cleaned;
206
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
207
+ }
208
+
209
+ export function normalizeSearchUrl(value: unknown): string | undefined {
210
+ if (typeof value !== "string") return undefined;
211
+ try {
212
+ const url = new URL(value);
213
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
214
+ return url.toString();
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ export function normalizeSearchResult(result: BraveWebSearchResult): NormalizedSearchResult | undefined {
221
+ const title = cleanSearchText(result.title, 180);
222
+ const url = normalizeSearchUrl(result.url);
223
+ if (!title || !url) return undefined;
224
+ return {
225
+ title,
226
+ url,
227
+ description: cleanSearchText(result.description, 320),
228
+ source: cleanSearchText(result.profile?.name, 120) ?? cleanSearchText(result.meta_url?.hostname, 120),
229
+ age: cleanSearchText(result.age, 80),
230
+ language: cleanSearchText(result.language, 40),
231
+ };
232
+ }
233
+
234
+ export function formatSearchResults(query: string, results: NormalizedSearchResult[]): string {
235
+ if (results.length === 0) {
236
+ return `No Brave web results found for: ${query}`;
237
+ }
238
+ const lines = [`Brave web search results for: ${query}`, ""];
239
+ results.forEach((result, index) => {
240
+ lines.push(`${index + 1}. ${result.title}`);
241
+ lines.push(` URL: ${result.url}`);
242
+ if (result.source) lines.push(` Source: ${result.source}`);
243
+ if (result.age) lines.push(` Age: ${result.age}`);
244
+ if (result.description) lines.push(` Summary: ${result.description}`);
245
+ lines.push("");
246
+ });
247
+ return lines.join("\n").trimEnd();
248
+ }
249
+
250
+ export function buildBraveSearchUrl(params: {
251
+ query: string;
252
+ count: number;
253
+ offset: number;
254
+ country?: string;
255
+ searchLang?: string;
256
+ safesearch?: "off" | "moderate" | "strict";
257
+ freshness?: "pd" | "pw" | "pm" | "py";
258
+ }): URL {
259
+ const url = new URL(BRAVE_SEARCH_ENDPOINT);
260
+ url.searchParams.set("q", params.query);
261
+ url.searchParams.set("count", String(params.count));
262
+ url.searchParams.set("offset", String(params.offset));
263
+ if (params.country) url.searchParams.set("country", params.country.toUpperCase());
264
+ if (params.searchLang) url.searchParams.set("search_lang", params.searchLang);
265
+ if (params.safesearch) url.searchParams.set("safesearch", params.safesearch);
266
+ if (params.freshness) url.searchParams.set("freshness", params.freshness);
267
+ return url;
268
+ }
269
+
270
+ function redactSearchSecret(text: string, apiKey: string): string {
271
+ return apiKey ? text.split(apiKey).join("[REDACTED]") : text;
272
+ }
273
+
274
+ export async function fetchBraveSearchJson(url: URL, apiKey: string, signal?: AbortSignal): Promise<BraveWebSearchResponse> {
275
+ const controller = new AbortController();
276
+ const timeout = setTimeout(() => controller.abort(new Error("Brave search timed out")), SEARCH_REQUEST_TIMEOUT_MS);
277
+ const abort = () => controller.abort(signal?.reason ?? new Error("Brave search cancelled"));
278
+ signal?.addEventListener("abort", abort, { once: true });
279
+ try {
280
+ const response = await fetch(url, {
281
+ headers: {
282
+ Accept: "application/json",
283
+ "X-Subscription-Token": apiKey,
284
+ },
285
+ signal: controller.signal,
286
+ });
287
+ const text = await response.text();
288
+ if (!response.ok) {
289
+ const errorPreview = cleanSearchText(redactSearchSecret(text, apiKey), 300);
290
+ throw new Error(`Brave search failed with HTTP ${response.status}: ${errorPreview ? redactSearchSecret(errorPreview, apiKey) : response.statusText}`);
291
+ }
292
+ try {
293
+ return JSON.parse(text) as BraveWebSearchResponse;
294
+ } catch (error) {
295
+ throw new Error(`Brave search returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
296
+ }
297
+ } finally {
298
+ clearTimeout(timeout);
299
+ signal?.removeEventListener("abort", abort);
300
+ }
301
+ }
302
+
303
+ export function createAgentBrowserWebSearchTool(configState: AgentBrowserConfigState) {
304
+ return defineTool({
305
+ name: AGENT_BROWSER_WEB_SEARCH_TOOL_NAME,
306
+ label: "Agent Browser Web Search",
307
+ description: `Search the web with Brave Search when configured. Returns up to ${MAX_SEARCH_RESULT_COUNT} concise web results.`,
308
+ promptSnippet: "Search the live web with Brave Search for current or external web information.",
309
+ promptGuidelines: [
310
+ "Use agent_browser_web_search when live web search would help answer the task, find current external information, or discover candidate URLs for agent_browser.",
311
+ "Prefer agent_browser_web_search over opening a search engine results page with agent_browser when a quick result list is enough.",
312
+ "After using agent_browser_web_search, cite result URLs in the final answer when web evidence informed the answer.",
313
+ ],
314
+ parameters: AgentBrowserWebSearchParams,
315
+ async execute(_toolCallId, params, signal) {
316
+ const resolvedCredential = await resolveBraveApiKey(configState, { signal });
317
+ if (!resolvedCredential) {
318
+ throw new Error("Brave Search credential source is configured but did not resolve. Run pi-agent-browser-config web-search status for setup details.");
319
+ }
320
+ const query = params.query.trim();
321
+ if (!query) throw new Error("query must not be blank");
322
+ const count = Math.min(Math.max(params.count ?? DEFAULT_SEARCH_RESULT_COUNT, 1), MAX_SEARCH_RESULT_COUNT);
323
+ const offset = Math.max(params.offset ?? 0, 0);
324
+ const url = buildBraveSearchUrl({
325
+ query,
326
+ count,
327
+ offset,
328
+ country: params.country,
329
+ searchLang: params.searchLang,
330
+ safesearch: params.safesearch,
331
+ freshness: params.freshness,
332
+ });
333
+ const data = await fetchBraveSearchJson(url, resolvedCredential.value, signal);
334
+ const results = (data.web?.results ?? [])
335
+ .map(normalizeSearchResult)
336
+ .filter((result): result is NormalizedSearchResult => Boolean(result));
337
+ const returnedQuery = cleanSearchText(data.query?.altered, 300) ?? cleanSearchText(data.query?.original, 300) ?? query;
338
+ return {
339
+ content: [{ type: "text", text: formatSearchResults(returnedQuery, results) }],
340
+ details: {
341
+ provider: "brave",
342
+ query,
343
+ returnedQuery,
344
+ count,
345
+ offset,
346
+ fetchedAt: new Date().toISOString(),
347
+ results,
348
+ },
349
+ };
350
+ },
351
+ });
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.38",
3
+ "version": "0.2.40",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
@@ -27,12 +27,17 @@
27
27
  "node": ">=22.19.0"
28
28
  },
29
29
  "bin": {
30
+ "pi-agent-browser-config": "scripts/config.mjs",
30
31
  "pi-agent-browser-doctor": "scripts/doctor.mjs"
31
32
  },
32
33
  "files": [
33
34
  "extensions",
35
+ "platform-smoke.config.mjs",
36
+ "scripts/config.mjs",
34
37
  "scripts/doctor.mjs",
35
38
  "scripts/agent-browser-capability-baseline.mjs",
39
+ "scripts/platform-smoke.mjs",
40
+ "scripts/platform-smoke",
36
41
  "README.md",
37
42
  "CHANGELOG.md",
38
43
  "LICENSE",
@@ -40,6 +45,7 @@
40
45
  "docs/COMMAND_REFERENCE.md",
41
46
  "docs/ELECTRON.md",
42
47
  "docs/RELEASE.md",
48
+ "docs/platform-smoke.md",
43
49
  "docs/REQUIREMENTS.md",
44
50
  "docs/SUPPORT_MATRIX.md",
45
51
  "docs/TOOL_CONTRACT.md"
@@ -71,6 +77,14 @@
71
77
  "docs": "node ./scripts/project.mjs docs",
72
78
  "doctor": "node ./scripts/doctor.mjs",
73
79
  "benchmark:agent-browser": "node ./scripts/agent-browser-efficiency-benchmark.mjs",
80
+ "check:platform-smoke": "node --check platform-smoke.config.mjs && node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/targets.mjs && node --check scripts/platform-smoke/artifacts.mjs && tsx --test test/platform-smoke.test.ts",
81
+ "smoke:platform": "node scripts/platform-smoke.mjs",
82
+ "smoke:platform:doctor": "node scripts/platform-smoke.mjs doctor",
83
+ "smoke:platform:ubuntu-image": "docker build -t pi-agent-browser-native-platform:node24-agent-browser0.27.1 --build-arg AGENT_BROWSER_VERSION=0.27.1 -f scripts/platform-smoke/linux-image/Dockerfile .",
84
+ "smoke:platform:macos": "node scripts/platform-smoke.mjs run --target macos",
85
+ "smoke:platform:ubuntu": "node scripts/platform-smoke.mjs run --target ubuntu",
86
+ "smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
87
+ "smoke:platform:all": "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native",
74
88
  "typecheck": "node ./scripts/project.mjs verify typecheck",
75
89
  "test": "tsx --test test/**/*.test.ts",
76
90
  "verify": "node ./scripts/project.mjs verify",
@@ -0,0 +1,18 @@
1
+ // Platform smoke configuration for pi-agent-browser-native.
2
+ // Crabbox owns the target lease/sync loop; this file is the project source of truth for release-blocking platform coverage.
3
+
4
+ import { CAPABILITY_BASELINE } from "./scripts/agent-browser-capability-baseline.mjs";
5
+
6
+ export default {
7
+ packageName: "pi-agent-browser-native",
8
+ artifactRoot: ".artifacts/platform-smoke",
9
+ requiredTargets: ["macos", "ubuntu", "windows-native"],
10
+ requiredSuites: ["platform-build", "browser-dogfood-smoke"],
11
+ requiredCrabbox: {
12
+ install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
13
+ minVersion: "0.24.0",
14
+ },
15
+ ubuntuContainerImage: "pi-agent-browser-native-platform:node24-agent-browser0.27.1",
16
+ nodeValidationMajor: 22,
17
+ agentBrowserVersion: CAPABILITY_BASELINE.targetVersion,
18
+ };
@@ -14,8 +14,8 @@ export const COMMAND_REFERENCE_BASELINE_BLOCK_IDS = Object.freeze(["upstream-bas
14
14
 
15
15
  const sourceEvidence = Object.freeze({
16
16
  repository: "vercel-labs/agent-browser",
17
- upstreamHead: "4ad284890cb59564af603e6de403dd75dd19e832",
18
- upstreamPackageVersion: "0.27.0",
17
+ upstreamHead: "90050f2913159875e2c3719e424746396ccb3cbf",
18
+ upstreamPackageVersion: "0.27.1",
19
19
  inspectedSources: Object.freeze([
20
20
  "agent-browser --version",
21
21
  "agent-browser --help",
@@ -349,7 +349,8 @@ const inventorySections = Object.freeze([
349
349
  "diff screenshot --baseline <file> --output <file> --threshold <0-1> --selector <sel> --full",
350
350
  "diff url <u1> <u2>",
351
351
  "diff url <u1> <u2> --screenshot --wait-until <strategy> --selector <sel> --compact --depth <n>",
352
- "trace start|stop [path]",
352
+ "trace start",
353
+ "trace stop [path]",
353
354
  "profiler start|stop [path]",
354
355
  "record start <path> [url]",
355
356
  "record restart <path> [url]",
@@ -386,7 +387,8 @@ const inventorySections = Object.freeze([
386
387
  root("storage <local|session>"),
387
388
  root("diff snapshot"),
388
389
  root("diff screenshot --baseline"),
389
- root("trace start|stop [path]"),
390
+ root("trace start"),
391
+ root("trace stop [path]"),
390
392
  root("profiler start|stop [path]"),
391
393
  root("record start <path> [url]"),
392
394
  root("record stop"),
@@ -422,7 +424,8 @@ const inventorySections = Object.freeze([
422
424
  ["diff help", "--threshold <0-1>"],
423
425
  ["diff help", "--wait-until <strategy>"],
424
426
  ["diff help", "diff screenshot --baseline <f>"],
425
- ["trace help", "trace <operation> [path]"],
427
+ ["trace help", "trace start"],
428
+ ["trace help", "trace stop [path]"],
426
429
  ["profiler help", "--categories <list>"],
427
430
  ["record help", "record restart <path.webm> [url]"],
428
431
  ["console help", "--clear"],
@@ -703,7 +706,7 @@ const inventorySections = Object.freeze([
703
706
  ]);
704
707
 
705
708
  export const CAPABILITY_BASELINE = Object.freeze({
706
- targetVersion: "0.27.0",
709
+ targetVersion: "0.27.1",
707
710
  sourceEvidence,
708
711
  helpCommands,
709
712
  inventorySections,