pi-feats 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +24 -1
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/assets/pi-feats-preview.png +0 -0
  4. package/extensions/api-server/application-log-store.ts +31 -0
  5. package/extensions/api-server/application-runtime.ts +80 -18
  6. package/extensions/api-server/application-store.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +47 -20
  8. package/extensions/api-server/server.ts +182 -47
  9. package/extensions/browser-harness/cdp/commands.ts +260 -0
  10. package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
  11. package/extensions/browser-harness/cdp/discovery.ts +198 -0
  12. package/extensions/browser-harness/cdp/errors.ts +23 -0
  13. package/extensions/browser-harness/cdp/events.ts +170 -0
  14. package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
  15. package/extensions/browser-harness/cdp/ownership.ts +73 -0
  16. package/extensions/browser-harness/cdp/record-store.ts +61 -0
  17. package/extensions/browser-harness/cdp/session.ts +403 -0
  18. package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
  19. package/extensions/browser-harness/cdp/target-factory.ts +153 -0
  20. package/extensions/browser-harness/cdp/types.ts +82 -0
  21. package/extensions/browser-harness/client.ts +366 -0
  22. package/extensions/browser-harness/daemon/bridge.ts +531 -0
  23. package/extensions/browser-harness/daemon/index.ts +86 -0
  24. package/extensions/browser-harness/daemon/protocol.ts +110 -0
  25. package/extensions/browser-harness/daemon/queue.ts +85 -0
  26. package/extensions/browser-harness/daemon/server.ts +170 -0
  27. package/extensions/browser-harness/daemon/spawn.ts +124 -0
  28. package/extensions/browser-harness/daemon/transport.ts +178 -0
  29. package/extensions/browser-harness/domains/ax-tree.ts +179 -0
  30. package/extensions/browser-harness/domains/box.ts +51 -0
  31. package/extensions/browser-harness/domains/cdp-call.ts +55 -0
  32. package/extensions/browser-harness/domains/click.ts +97 -0
  33. package/extensions/browser-harness/domains/console.ts +219 -0
  34. package/extensions/browser-harness/domains/dialog.ts +34 -0
  35. package/extensions/browser-harness/domains/drag.ts +67 -0
  36. package/extensions/browser-harness/domains/element-call.ts +117 -0
  37. package/extensions/browser-harness/domains/files.ts +219 -0
  38. package/extensions/browser-harness/domains/fill-engine.ts +80 -0
  39. package/extensions/browser-harness/domains/form-batch.ts +65 -0
  40. package/extensions/browser-harness/domains/form.ts +283 -0
  41. package/extensions/browser-harness/domains/headers.ts +89 -0
  42. package/extensions/browser-harness/domains/history.ts +80 -0
  43. package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
  44. package/extensions/browser-harness/domains/js.ts +241 -0
  45. package/extensions/browser-harness/domains/keyboard.ts +197 -0
  46. package/extensions/browser-harness/domains/navigate.ts +161 -0
  47. package/extensions/browser-harness/domains/network.ts +301 -0
  48. package/extensions/browser-harness/domains/page.ts +154 -0
  49. package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
  50. package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
  51. package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
  52. package/extensions/browser-harness/domains/render.ts +43 -0
  53. package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
  54. package/extensions/browser-harness/domains/screenshot.ts +76 -0
  55. package/extensions/browser-harness/domains/scroll.ts +59 -0
  56. package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
  57. package/extensions/browser-harness/domains/search/web-search.ts +144 -0
  58. package/extensions/browser-harness/domains/setup.ts +35 -0
  59. package/extensions/browser-harness/domains/snapshot.ts +256 -0
  60. package/extensions/browser-harness/domains/tabs.ts +202 -0
  61. package/extensions/browser-harness/domains/target.ts +35 -0
  62. package/extensions/browser-harness/domains/viewport.ts +42 -0
  63. package/extensions/browser-harness/index.ts +88 -0
  64. package/extensions/browser-harness/profile/bind.ts +90 -0
  65. package/extensions/browser-harness/profile/browser-process.ts +320 -0
  66. package/extensions/browser-harness/profile/command.ts +189 -0
  67. package/extensions/browser-harness/profile/launch.ts +80 -0
  68. package/extensions/browser-harness/profile/list.ts +131 -0
  69. package/extensions/browser-harness/profile/paths.ts +103 -0
  70. package/extensions/browser-harness/profile/store.ts +84 -0
  71. package/extensions/browser-harness/prompt.ts +16 -0
  72. package/extensions/browser-harness/registry.ts +80 -0
  73. package/extensions/browser-harness/schemas/common.ts +11 -0
  74. package/extensions/browser-harness/schemas/parse.ts +23 -0
  75. package/extensions/browser-harness/setup.ts +47 -0
  76. package/extensions/browser-harness/state.ts +51 -0
  77. package/extensions/browser-harness/steel.ts +215 -0
  78. package/extensions/browser-harness/util/debug.ts +8 -0
  79. package/extensions/browser-harness/util/guards.ts +30 -0
  80. package/extensions/browser-harness/util/js-template.ts +12 -0
  81. package/extensions/browser-harness/util/mutex.ts +20 -0
  82. package/extensions/browser-harness/util/paths.ts +31 -0
  83. package/extensions/browser-harness/util/result.ts +9 -0
  84. package/extensions/browser-harness/util/sharp-shim.ts +42 -0
  85. package/extensions/browser-harness/util/tool.ts +185 -0
  86. package/extensions/browser-harness/util/truncate.ts +41 -0
  87. package/extensions/cli-resources.ts +35 -16
  88. package/extensions/composio/index.ts +163 -0
  89. package/extensions/context-memory/index.ts +58 -0
  90. package/extensions/lib/application-context.ts +22 -0
  91. package/extensions/lib/application-handler-templates.ts +17 -1
  92. package/extensions/lib/context-memory.ts +165 -0
  93. package/extensions/lib/profile-sandbox.ts +3 -0
  94. package/extensions/lib/workflow-harness.ts +238 -0
  95. package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
  96. package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
  97. package/extensions/pi-console-webui/app/layout.tsx +6 -1
  98. package/extensions/pi-console-webui/app/login/page.tsx +5 -9
  99. package/extensions/pi-console-webui/app/not-found.tsx +5 -0
  100. package/extensions/pi-console-webui/app/page.tsx +6 -0
  101. package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
  102. package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
  103. package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
  104. package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
  105. package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
  106. package/extensions/pi-console-webui/components/console-page.tsx +3 -1
  107. package/extensions/pi-console-webui/components/console.tsx +53 -7
  108. package/extensions/pi-console-webui/components/login-form.tsx +14 -0
  109. package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
  110. package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
  111. package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
  112. package/extensions/profiles.ts +18 -7
  113. package/extensions/pulse/store.ts +5 -2
  114. package/extensions/sequential-workflow.ts +340 -256
  115. package/extensions/skill-sources/store.ts +24 -3
  116. package/licenses/pi-browser-harness-MIT.txt +21 -0
  117. package/package.json +25 -4
  118. package/schemas/sequential-workflow-template.schema.json +62 -0
  119. package/skills/browser-harness/SKILL.md +33 -0
  120. package/skills/composio-connections/SKILL.md +75 -0
  121. package/skills/sequential-workflow/SKILL.md +70 -0
  122. package/skills/sequential-workflow-template/SKILL.md +44 -0
@@ -0,0 +1,301 @@
1
+ import { Type } from "typebox";
2
+ import { Markdown, Text } from "@earendil-works/pi-tui";
3
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
4
+ import { type Result, err, ok } from "../util/result";
5
+ import { formatBytes } from "../util/truncate";
6
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
+ import { cdpCall } from "./cdp-call";
8
+ import { applyTruncation } from "../util/truncate";
9
+ import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
10
+ import type { NetworkRecord } from "../cdp/network-buffer";
11
+
12
+ const HttpGetArgs = Type.Object({
13
+ url: Type.String({ description: "URL to GET" }),
14
+ headers: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Optional HTTP headers" })),
15
+ timeout: Type.Optional(
16
+ Type.Number({ default: 20, minimum: 1, maximum: 120, description: "Total seconds (covers headers AND body)" }),
17
+ ),
18
+ });
19
+
20
+ export const httpGetTool = defineBrowserTool({
21
+ name: "browser_http_get",
22
+ label: "Browser HTTP GET",
23
+ description:
24
+ "Fetch a URL outside the browser (faster than browser_navigate for APIs and static pages). Timeout covers headers AND body read.",
25
+ promptSnippet: "HTTP GET (outside browser; for APIs/static pages)",
26
+ promptGuidelines: [
27
+ "Faster than navigate+execute_js for JSON/HTML APIs.",
28
+ "No JS rendering — for SPAs use browser_navigate.",
29
+ ],
30
+ parameters: HttpGetArgs,
31
+ concurrency: "parallel",
32
+ ensureAlive: false,
33
+ async handler(args): Promise<Result<ToolOk, ToolErr>> {
34
+ const ac = new AbortController();
35
+ const timer = setTimeout(() => ac.abort(), (args.timeout ?? 20) * 1000);
36
+ try {
37
+ const fetchInit: RequestInit = { signal: ac.signal };
38
+ if (args.headers !== undefined) fetchInit.headers = args.headers;
39
+ const res = await fetch(args.url, fetchInit);
40
+ const body = await res.text();
41
+ clearTimeout(timer);
42
+ const ct = res.headers.get("content-type") ?? "";
43
+ const truncated = await applyTruncation(body, "http");
44
+ return ok({
45
+ text: `HTTP ${res.status} ${ct}\n${truncated.text}`,
46
+ details: truncated.fullOutputPath !== undefined
47
+ ? { status: res.status, contentType: ct, length: body.length, fullOutputPath: truncated.fullOutputPath, wasTruncated: truncated.wasTruncated }
48
+ : { status: res.status, contentType: ct, length: body.length, wasTruncated: truncated.wasTruncated },
49
+ });
50
+ } catch (e) {
51
+ clearTimeout(timer);
52
+ const msg = e instanceof Error ? e.message : String(e);
53
+ const kind: ToolErr["kind"] = msg.toLowerCase().includes("abort") ? "timeout" : "io_error";
54
+ return err({ kind, message: msg });
55
+ }
56
+ },
57
+ });
58
+
59
+ const NetworkRequestsArgs = Type.Object({
60
+ urlPattern: Type.Optional(
61
+ Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /api\\\\.example/) for regex." }),
62
+ ),
63
+ methodFilter: Type.Optional(
64
+ Type.Array(Type.String(), { description: 'HTTP methods to include, e.g. ["GET","POST"].' }),
65
+ ),
66
+ statusFilter: Type.Optional(
67
+ Type.Object(
68
+ {
69
+ min: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
70
+ max: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
71
+ },
72
+ { description: "Inclusive HTTP status range." },
73
+ ),
74
+ ),
75
+ resourceTypes: Type.Optional(
76
+ Type.Array(Type.String(), { description: 'CDP resource types: "Document","Stylesheet","Image","Media","Font","Script","TextTrack","XHR","Fetch","EventSource","WebSocket","Manifest","SignedExchange","Ping","CSPViolationReport","Preflight","Other".' }),
77
+ ),
78
+ sinceMs: Type.Optional(
79
+ Type.Integer({ minimum: 0, description: "Only requests started in the last N ms." }),
80
+ ),
81
+ limit: Type.Optional(
82
+ Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
83
+ ),
84
+ includeResponseBodies: Type.Optional(
85
+ Type.Boolean({ default: false, description: "Fetch response bodies via Network.getResponseBody. Costs one CDP call per matched request." }),
86
+ ),
87
+ });
88
+
89
+ const BODY_BUDGET_MS = 5_000;
90
+ const PER_BODY_CAP = 50_000;
91
+
92
+ const renderNetworkMarkdown = (
93
+ records: ReadonlyArray<NetworkRecord>,
94
+ opts: { includeBodies: boolean; total: number; bufferOverflowed: boolean; expanded: boolean },
95
+ ): string => {
96
+ if (records.length === 0) {
97
+ if (opts.bufferOverflowed) return "_No matching requests. (Buffer overflowed since last drain — some events were dropped.)_";
98
+ return "_No matching requests captured yet._";
99
+ }
100
+
101
+ const failed = records.filter((r) => r.failed || (r.status !== undefined && r.status >= 500)).length;
102
+ const totalBytes = records.reduce((s, r) => s + (r.responseBodySize ?? 0), 0);
103
+ const showWindow = (() => {
104
+ const finished = records.filter((r) => r.durationMs !== undefined);
105
+ if (finished.length === 0) return "";
106
+ const span = Math.max(...records.map((r) => r.requestStartedMs)) - Math.min(...records.map((r) => r.requestStartedMs));
107
+ return ` · ${(span / 1000).toFixed(1)}s window`;
108
+ })();
109
+
110
+ const headerLines: string[] = [
111
+ `**${records.length} requests** · ${failed} failed · ${formatBytes(totalBytes)}${showWindow}`,
112
+ ];
113
+ if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older events dropped");
114
+ if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
115
+
116
+ const compactRows = (records.length > 5 && !opts.expanded ? records.slice(0, 5) : records).map((r) => {
117
+ const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
118
+ const warn = r.failed || (r.status !== undefined && r.status >= 500) ? " ⚠" : "";
119
+ const ms = r.durationMs !== undefined ? `${r.durationMs} ms` : "—";
120
+ const url = r.url.length > 60 ? r.url.slice(0, 57) + "…" : r.url;
121
+ return `\`${r.method.padEnd(4)} ${status.padStart(3)}\` ${url} _${ms}_${warn}`;
122
+ });
123
+
124
+ const rows = compactRows.join("\n");
125
+
126
+ if (!opts.expanded) {
127
+ const more = records.length > 5 ? `\n … ${records.length - 5} more` : "";
128
+ return `${headerLines.join("\n")}\n\n${rows}${more}`;
129
+ }
130
+
131
+ const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`;
132
+ const tableRows = records
133
+ .map((r, i) => {
134
+ const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
135
+ const ms = r.durationMs !== undefined ? String(r.durationMs) : "—";
136
+ const url = r.url.length > 80 ? r.url.slice(0, 77) + "…" : r.url;
137
+ return `| ${i + 1} | ${r.method} | ${status} | \`${url}\` | ${r.type} | ${ms} |`;
138
+ })
139
+ .join("\n");
140
+
141
+ let bodySections = "";
142
+ if (opts.includeBodies) {
143
+ const sections: string[] = [];
144
+ records.forEach((r, i) => {
145
+ if (r.body === undefined) return;
146
+ const sizes = `${r.requestBodySize} B → ${r.responseBodySize ?? 0} B`;
147
+ const status = r.failed ? "failed" : r.status ?? "?";
148
+ const header = `── #${i + 1} ${r.method} ${r.url} (${status}, ${r.durationMs ?? "?"} ms, ${r.type}, ${sizes}) ──`;
149
+ const bodyText = r.body === null ? "_(body unavailable — already GC'd by Chrome)_" : "```\n" + r.body + "\n```";
150
+ sections.push(`${header}\n\n${bodyText}`);
151
+ });
152
+ if (sections.length > 0) bodySections = "\n\n" + sections.join("\n\n");
153
+ }
154
+
155
+ return [headerLines.join("\n"), "", tableHeader, tableRows, bodySections].filter((s) => s !== "").join("\n");
156
+ };
157
+
158
+ type NetworkDetails = {
159
+ total: number;
160
+ returned: number;
161
+ bufferOverflowed: boolean;
162
+ truncated: boolean;
163
+ fullOutputPath?: string;
164
+ requests: ReadonlyArray<NetworkRecord>;
165
+ includeBodies: boolean;
166
+ };
167
+
168
+ const asNetworkRecord = (v: unknown): NetworkRecord | undefined => {
169
+ const o = asRecord(v);
170
+ if (o === undefined) return undefined;
171
+ const method = asString(o["method"]);
172
+ const url = asString(o["url"]);
173
+ const type = asString(o["type"]);
174
+ if (method === undefined || url === undefined || type === undefined) return undefined;
175
+ const status = asNumber(o["status"]);
176
+ const statusText = asString(o["statusText"]);
177
+ const mimeType = asString(o["mimeType"]);
178
+ const durationMs = asNumber(o["durationMs"]);
179
+ const responseBodySize = asNumber(o["responseBodySize"]);
180
+ const failed = asBoolean(o["failed"]);
181
+ const errorText = asString(o["errorText"]);
182
+ const bodyRaw = o["body"];
183
+ const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined;
184
+ return {
185
+ requestId: asString(o["requestId"]) ?? "",
186
+ method,
187
+ url,
188
+ type,
189
+ requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0,
190
+ requestBodySize: asNumber(o["requestBodySize"]) ?? 0,
191
+ ...(status !== undefined ? { status } : {}),
192
+ ...(statusText !== undefined ? { statusText } : {}),
193
+ ...(mimeType !== undefined ? { mimeType } : {}),
194
+ ...(durationMs !== undefined ? { durationMs } : {}),
195
+ ...(responseBodySize !== undefined ? { responseBodySize } : {}),
196
+ ...(failed !== undefined ? { failed } : {}),
197
+ ...(errorText !== undefined ? { errorText } : {}),
198
+ ...(body !== undefined ? { body } : {}),
199
+ };
200
+ };
201
+
202
+ const asNetworkDetails = (v: unknown): NetworkDetails | undefined => {
203
+ const o = asRecord(v);
204
+ if (o === undefined) return undefined;
205
+ const requests = asArrayOf(o["requests"], asNetworkRecord);
206
+ if (requests === undefined) return undefined;
207
+ const fullOutputPath = asString(o["fullOutputPath"]);
208
+ return {
209
+ total: asNumber(o["total"]) ?? requests.length,
210
+ returned: asNumber(o["returned"]) ?? requests.length,
211
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
212
+ truncated: asBoolean(o["truncated"]) ?? false,
213
+ requests,
214
+ includeBodies: asBoolean(o["includeBodies"]) ?? false,
215
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
216
+ };
217
+ };
218
+
219
+ export const networkRequestsTool = defineBrowserTool({
220
+ name: "browser_network_requests",
221
+ label: "Browser Network Requests",
222
+ description:
223
+ "List recent network requests captured on the current tab since attach. Filter by URL pattern, method, status, resource type, or recency. Optionally include response bodies.",
224
+ promptSnippet: "Inspect recent network requests on the current page",
225
+ promptGuidelines: [
226
+ "Buffer is reset on tab switch — only requests on the current tab since attach are visible.",
227
+ 'urlPattern is a substring; wrap in slashes for regex (e.g. "/\\\\.json$/").',
228
+ "includeResponseBodies:true costs an extra CDP call per matched request — use only when you need payloads.",
229
+ "If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
230
+ ],
231
+ parameters: NetworkRequestsArgs,
232
+ concurrency: "parallel",
233
+
234
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
235
+ const session = client.session();
236
+ const drained = session.drainNetworkBuffer({
237
+ ...(args.urlPattern !== undefined ? { urlPattern: args.urlPattern } : {}),
238
+ ...(args.methodFilter !== undefined ? { methodFilter: args.methodFilter } : {}),
239
+ ...(args.statusFilter !== undefined ? { statusFilter: args.statusFilter } : {}),
240
+ ...(args.resourceTypes !== undefined ? { resourceTypes: args.resourceTypes } : {}),
241
+ ...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
242
+ limit: args.limit ?? 50,
243
+ });
244
+
245
+ if (args.includeResponseBodies) {
246
+ const ac = new AbortController();
247
+ const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS);
248
+ const fills = drained.records.map(async (r) => {
249
+ if (ac.signal.aborted) return;
250
+ const bodyRes = await cdpCall(client, "Network.getResponseBody", { requestId: r.requestId });
251
+ if (!bodyRes.success) {
252
+ r.body = null;
253
+ return;
254
+ }
255
+ const b = bodyRes.data;
256
+ let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
257
+ if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
258
+ r.body = body;
259
+ });
260
+ await Promise.allSettled(fills);
261
+ clearTimeout(timer);
262
+ }
263
+
264
+ const text = renderNetworkMarkdown(drained.records, {
265
+ includeBodies: args.includeResponseBodies ?? false,
266
+ total: drained.total,
267
+ bufferOverflowed: drained.bufferOverflowed,
268
+ expanded: true,
269
+ });
270
+ const trunc = await applyTruncation(text, "network");
271
+
272
+ const details: NetworkDetails = {
273
+ total: drained.total,
274
+ returned: drained.records.length,
275
+ bufferOverflowed: drained.bufferOverflowed,
276
+ truncated: trunc.wasTruncated,
277
+ requests: drained.records,
278
+ includeBodies: args.includeResponseBodies ?? false,
279
+ };
280
+ if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
281
+
282
+ return ok({ text: trunc.text, details });
283
+ },
284
+
285
+ renderResult(result, expanded, theme) {
286
+ const details = asNetworkDetails(result.details);
287
+ if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
288
+
289
+ const md = renderNetworkMarkdown(details.requests, {
290
+ includeBodies: details.includeBodies && expanded,
291
+ total: details.total,
292
+ bufferOverflowed: details.bufferOverflowed,
293
+ expanded,
294
+ });
295
+ const tail = expanded
296
+ ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
297
+ : `\n\n${keyHint("app.tools.expand", "to expand")}`;
298
+
299
+ return new Markdown(md + tail, 0, 0, getMarkdownTheme());
300
+ },
301
+ });
@@ -0,0 +1,154 @@
1
+ import { Type } from "typebox";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { evalJs } from "./cdp-call";
5
+ import { safeJs } from "../util/js-template";
6
+
7
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
8
+ new Promise((resolve, reject) => {
9
+ if (signal?.aborted) {
10
+ reject(signal.reason ?? new Error("aborted"));
11
+ return;
12
+ }
13
+ const onAbort = (): void => {
14
+ clearTimeout(timer);
15
+ reject(signal?.reason ?? new Error("aborted"));
16
+ };
17
+ const timer = setTimeout(() => {
18
+ signal?.removeEventListener("abort", onAbort);
19
+ resolve();
20
+ }, ms);
21
+ signal?.addEventListener("abort", onAbort, { once: true });
22
+ });
23
+
24
+ export const pageInfoTool = defineBrowserTool({
25
+ name: "browser_page_info",
26
+ label: "Browser Page Info",
27
+ description:
28
+ "Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog is open, returns dialog info instead.",
29
+ promptSnippet: "Get current page URL, title, viewport, and scroll position",
30
+ promptGuidelines: [
31
+ "Use browser_page_info to quickly check what page you're on and whether a JS dialog is blocking interaction.",
32
+ "If browser_page_info returns a dialog, use browser_handle_dialog before any other browser actions.",
33
+ "JS dialogs freeze the page's JS thread, so no other interaction works until the dialog is handled.",
34
+ "browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
35
+ ],
36
+ parameters: Type.Object({}),
37
+ concurrency: "parallel",
38
+ async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
39
+ const r = await client.pageInfo();
40
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
41
+ if ("dialog" in r.data) {
42
+ const d = r.data.dialog;
43
+ return ok({
44
+ text: `⚠️ DIALOG OPEN: ${d.type}\nMessage: ${d.message}${d.defaultPrompt ? `\nDefault: ${d.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
45
+ details: { dialog: d },
46
+ });
47
+ }
48
+ const i = r.data;
49
+ return ok({
50
+ text: `URL: ${i.url}\nTitle: ${i.title}\nViewport: ${i.width}x${i.height}\nScroll: (${i.scrollX}, ${i.scrollY})\nPage size: ${i.pageWidth}x${i.pageHeight}`,
51
+ details: { ...i },
52
+ });
53
+ },
54
+ });
55
+
56
+ const WaitArgs = Type.Object({
57
+ seconds: Type.Number({ minimum: 0, maximum: 60, description: "Seconds to wait" }),
58
+ });
59
+
60
+ export const waitTool = defineBrowserTool({
61
+ name: "browser_wait",
62
+ label: "Browser Wait",
63
+ description: "Wait N seconds before continuing.",
64
+ promptSnippet: "Wait N seconds",
65
+ promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
66
+ parameters: WaitArgs,
67
+ concurrency: "parallel",
68
+ async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
69
+ try {
70
+ await sleep(Math.round(args.seconds * 1000), signal);
71
+ return ok({ text: `Waited ${args.seconds}s` });
72
+ } catch (e) {
73
+ return err({ kind: "internal", message: e instanceof Error ? e.message : String(e) });
74
+ }
75
+ },
76
+ });
77
+
78
+ const WaitForLoadArgs = Type.Object({
79
+ timeout: Type.Optional(Type.Number({ default: 15, minimum: 1, maximum: 120, description: "Max seconds to wait. Default: 15." })),
80
+ });
81
+
82
+ export const waitForLoadTool = defineBrowserTool({
83
+ name: "browser_wait_for_load",
84
+ label: "Browser Wait For Load",
85
+ description:
86
+ "Poll until the current page reports document.readyState === 'complete'. Returns a typed timeout error if the deadline elapses.",
87
+ promptSnippet: "Wait for the page to finish loading",
88
+ promptGuidelines: [
89
+ "Call after browser_navigate / browser_open_urls before extracting data.",
90
+ "Returns when readyState becomes 'complete' OR the timeout elapses.",
91
+ ],
92
+ parameters: WaitForLoadArgs,
93
+ concurrency: "serialized",
94
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
95
+ const timeoutMs = (args.timeout ?? 15) * 1000;
96
+ const start = Date.now();
97
+ while (Date.now() - start < timeoutMs) {
98
+ if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
99
+ const r = await evalJs(client, safeJs`document.readyState`);
100
+ if (r.success && r.data === "complete") {
101
+ const ms = Date.now() - start;
102
+ return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
103
+ }
104
+ await new Promise((res) => setTimeout(res, 50));
105
+ }
106
+ return err({ kind: "timeout", message: `Page did not finish loading in ${args.timeout ?? 15}s` });
107
+ },
108
+ });
109
+
110
+ const WaitForArgs = Type.Object({
111
+ selector: Type.Optional(Type.String({ description: "CSS selector to wait for (or wait to disappear with gone:true)" })),
112
+ text: Type.Optional(Type.String({ description: "Wait until this text appears anywhere in document.body" })),
113
+ gone: Type.Optional(Type.Boolean({ default: false, description: "If true with a selector, wait until it is absent instead of present" })),
114
+ timeout: Type.Optional(Type.Number({ default: 5, minimum: 1, maximum: 60, description: "Max seconds to wait. Default: 5." })),
115
+ });
116
+
117
+ export const waitForTool = defineBrowserTool({
118
+ name: "browser_wait_for",
119
+ label: "Browser Wait For",
120
+ description:
121
+ "Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
122
+ promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
123
+ promptGuidelines: [
124
+ "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
125
+ "Provide selector (default: wait until present; gone:true: wait until absent) or text.",
126
+ "Returns a typed timeout error if the condition isn't met within the timeout.",
127
+ ],
128
+ parameters: WaitForArgs,
129
+ concurrency: "parallel",
130
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
131
+ if (args.selector === undefined && args.text === undefined) {
132
+ return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
133
+ }
134
+ const gone = args.gone ?? false;
135
+ const timeoutMs = (args.timeout ?? 5) * 1000;
136
+ const expr =
137
+ args.selector !== undefined
138
+ ? safeJs`(() => { const found = !!document.querySelector(${args.selector}); return ${gone} ? !found : found; })()`
139
+ : safeJs`(() => { const t = document.body ? document.body.innerText : ""; return t.includes(${args.text}); })()`;
140
+ const start = Date.now();
141
+ while (Date.now() - start < timeoutMs) {
142
+ if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
143
+ const r = await evalJs(client, expr);
144
+ if (r.success && r.data === true) {
145
+ const ms = Date.now() - start;
146
+ const what = args.selector !== undefined ? `${args.selector}${gone ? " gone" : ""}` : `text "${args.text}"`;
147
+ return ok({ text: `Condition met (${what}) in ${Math.round(ms / 100) / 10}s`, details: { ms } });
148
+ }
149
+ await sleep(50, signal);
150
+ }
151
+ const what = args.selector !== undefined ? `${args.selector}${gone ? " to disappear" : ""}` : `text "${args.text}"`;
152
+ return err({ kind: "timeout", message: `Timed out after ${args.timeout ?? 5}s waiting for ${what}` });
153
+ },
154
+ });
@@ -0,0 +1,174 @@
1
+ import { Type } from "typebox";
2
+ import { Compile } from "typebox/compile";
3
+ import { parseJson } from "../../schemas/parse";
4
+ import { type Result, err, ok } from "../../util/result";
5
+ import { applyTruncation } from "../../util/truncate";
6
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
7
+ import { cdpCallBrowser, evalJs } from "../cdp-call";
8
+ import {
9
+ closeIsolatedTab,
10
+ evalInIsolatedTab,
11
+ isJsonText,
12
+ type IsolatedTab,
13
+ navigateIsolatedTab,
14
+ openIsolatedTab,
15
+ waitForIsolatedLoad,
16
+ } from "../isolated-tab";
17
+ import { renderExpandableText } from "../render";
18
+ import { extractReadable, type PageCapture, type ReadablePage } from "./readability";
19
+
20
+ const LOAD_TIMEOUT_MS = 15_000;
21
+
22
+ const ReadPageArgs = Type.Object({
23
+ url: Type.Optional(Type.String({ description: "URL to open in an isolated tab, read, then close." })),
24
+ targetId: Type.Optional(
25
+ Type.String({ description: "Read an already-open tab this session owns instead of opening a URL." }),
26
+ ),
27
+ });
28
+
29
+ const ContentBlockSchema = Type.Object({
30
+ kind: Type.Union([
31
+ Type.Literal("paragraph"),
32
+ Type.Literal("heading"),
33
+ Type.Literal("listitem"),
34
+ Type.Literal("blockquote"),
35
+ Type.Literal("other"),
36
+ ]),
37
+ text: Type.String(),
38
+ linkTextLength: Type.Number(),
39
+ inBoilerplate: Type.Boolean(),
40
+ });
41
+
42
+ const PageCaptureSchema = Type.Object({
43
+ url: Type.String(),
44
+ title: Type.String(),
45
+ blocks: Type.Array(ContentBlockSchema),
46
+ bodyText: Type.String(),
47
+ });
48
+
49
+ const pageCaptureValidator = Compile(PageCaptureSchema);
50
+
51
+ const BODY_TEXT_LIMIT = 20_000;
52
+
53
+ const buildPageCaptureExpr = (): string => `
54
+ (() => {
55
+ const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
56
+ const inBoilerplate = (el) => {
57
+ for (let n = el; n; n = n.parentElement) {
58
+ if (BOILERPLATE.has(n.tagName)) return true;
59
+ const role = n.getAttribute && n.getAttribute('role');
60
+ if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true;
61
+ }
62
+ return false;
63
+ };
64
+ const kindOf = (tag) => {
65
+ if (/^H[1-6]$/.test(tag)) return 'heading';
66
+ if (tag === 'LI') return 'listitem';
67
+ if (tag === 'BLOCKQUOTE') return 'blockquote';
68
+ if (tag === 'P') return 'paragraph';
69
+ return 'other';
70
+ };
71
+ const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';
72
+ const root = document.querySelector('article, main, [role=main]') || document.body;
73
+ const blocks = [];
74
+ for (const el of root.querySelectorAll(selector)) {
75
+ const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
76
+ if (!text) continue;
77
+ let linkTextLength = 0;
78
+ for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length;
79
+ blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) });
80
+ }
81
+ const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT});
82
+ return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText });
83
+ })()
84
+ `;
85
+
86
+ const toPageCapture = (raw: unknown): PageCapture | undefined => {
87
+ if (typeof raw === "string") {
88
+ const parsed = parseJson(raw, pageCaptureValidator);
89
+ return parsed.success ? parsed.data : undefined;
90
+ }
91
+ return pageCaptureValidator.Check(raw) ? raw : undefined;
92
+ };
93
+
94
+ const captureToResult = async (raw: unknown): Promise<Result<ToolOk, ToolErr>> => {
95
+ const parsed = toPageCapture(raw);
96
+ if (parsed === undefined) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
97
+ const page: ReadablePage = extractReadable(parsed);
98
+ const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`;
99
+ const truncated = await applyTruncation(header + page.text, "readpage");
100
+ const summary = `${page.title} · ${page.wordCount} words`;
101
+ return ok({
102
+ text: truncated.text,
103
+ details: {
104
+ title: page.title,
105
+ url: page.url,
106
+ wordCount: page.wordCount,
107
+ render: {
108
+ summary,
109
+ body: page.text,
110
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
111
+ },
112
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
113
+ },
114
+ });
115
+ };
116
+
117
+ const readOpenedUrl = async (
118
+ client: Parameters<typeof openIsolatedTab>[0],
119
+ url: string,
120
+ signal: AbortSignal | undefined,
121
+ ): Promise<Result<ToolOk, ToolErr>> => {
122
+ const opened = await openIsolatedTab(client);
123
+ if (!opened.success) return err(opened.error);
124
+ const tab: IsolatedTab = opened.data;
125
+ try {
126
+ const navigated = await navigateIsolatedTab(client, tab, url);
127
+ if (!navigated.success) return err(navigated.error);
128
+ const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
129
+ if (!loaded.success) return err(loaded.error);
130
+ const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr(), isJsonText);
131
+ if (!captured.success) return err(captured.error);
132
+ return captureToResult(captured.data);
133
+ } finally {
134
+ await closeIsolatedTab(client, tab);
135
+ }
136
+ };
137
+
138
+ const readOwnedTab = async (
139
+ client: Parameters<typeof openIsolatedTab>[0],
140
+ targetId: string,
141
+ ): Promise<Result<ToolOk, ToolErr>> => {
142
+ if (!client.owns(targetId)) {
143
+ return err({ kind: "invalid_state", message: `Tab ${targetId} is not owned by this session.` });
144
+ }
145
+ const attached = await cdpCallBrowser(client, "Target.attachToTarget", { targetId, flatten: true });
146
+ if (!attached.success) return attached;
147
+ const sessionId = attached.data.sessionId;
148
+ const captured = await evalJs(client, buildPageCaptureExpr(), sessionId);
149
+ if (!captured.success) return captured;
150
+ return captureToResult(captured.data);
151
+ };
152
+
153
+ export const readPageTool = defineBrowserTool({
154
+ name: "browser_read_page",
155
+ label: "Browser Read Page",
156
+ description:
157
+ "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.",
158
+ promptSnippet: "Read a page's main content as clean text",
159
+ promptGuidelines: [
160
+ "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.",
161
+ "Returns readable main-article text — use this over browser_snapshot/browser_execute_js when you want an article's content for reading or research.",
162
+ "Boilerplate-heavy or structure-less pages fall back to bounded body text rather than erroring.",
163
+ ],
164
+ parameters: ReadPageArgs,
165
+ concurrency: "parallel",
166
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
167
+ if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal);
168
+ if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId);
169
+ return err({ kind: "invalid_state", message: "provide either url or targetId" });
170
+ },
171
+ renderResult(result, expanded, theme) {
172
+ return renderExpandableText("read_page", result, expanded, theme);
173
+ },
174
+ });