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,67 @@
1
+ import { Type } from "typebox";
2
+ import { type Result, ok } from "../util/result";
3
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { cdpCall } from "./cdp-call";
5
+
6
+ const DragArgs = Type.Object({
7
+ startX: Type.Number(),
8
+ startY: Type.Number(),
9
+ endX: Type.Number(),
10
+ endY: Type.Number(),
11
+ dataTransfer: Type.Optional(
12
+ Type.Record(Type.String(), Type.String(), {
13
+ description: "Optional MIME → data map for DataTransfer (e.g., { 'text/plain': 'hello' })",
14
+ }),
15
+ ),
16
+ });
17
+
18
+ export const dragAndDropTool = defineBrowserTool({
19
+ name: "browser_drag_and_drop",
20
+ label: "Browser Drag & Drop",
21
+ description: "Drag from (startX, startY) to (endX, endY) using CDP Input.dispatchDragEvent.",
22
+ promptSnippet: "Drag and drop between two coordinates",
23
+ promptGuidelines: [
24
+ "Coordinates in CSS pixels.",
25
+ "dataTransfer is an optional MIME→data map (e.g., { 'text/plain': 'hello' }).",
26
+ ],
27
+ parameters: DragArgs,
28
+ concurrency: "serialized",
29
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
30
+ const data = args.dataTransfer
31
+ ? {
32
+ items: Object.entries(args.dataTransfer).map(([mimeType, payload]) => ({
33
+ mimeType,
34
+ data: Buffer.from(payload).toString("base64"),
35
+ })),
36
+ dragOperationsMask: 1,
37
+ }
38
+ : { items: [], dragOperationsMask: 1 };
39
+ type InputCall = readonly ["Input.dispatchMouseEvent" | "Input.dispatchDragEvent", Record<string, unknown>];
40
+ const calls: ReadonlyArray<InputCall> = [
41
+ ["Input.dispatchMouseEvent", { type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }],
42
+ ["Input.dispatchDragEvent", { type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }],
43
+ ...Array.from({ length: 5 }, (_, i): InputCall => {
44
+ const t = (i + 1) / 5;
45
+ return [
46
+ "Input.dispatchDragEvent",
47
+ {
48
+ type: "dragOver",
49
+ x: Math.round(args.startX + (args.endX - args.startX) * t),
50
+ y: Math.round(args.startY + (args.endY - args.startY) * t),
51
+ data,
52
+ modifiers: 0,
53
+ },
54
+ ];
55
+ }),
56
+ ["Input.dispatchDragEvent", { type: "drop", x: args.endX, y: args.endY, data, modifiers: 0 }],
57
+ ["Input.dispatchMouseEvent", { type: "mouseReleased", x: args.endX, y: args.endY, button: "left", clickCount: 1 }],
58
+ ];
59
+ for (const [method, params] of calls) {
60
+ const r = await cdpCall(client, method, params);
61
+ if (!r.success) return r;
62
+ }
63
+ return ok({
64
+ text: `Dragged from (${args.startX},${args.startY}) to (${args.endX},${args.endY})`,
65
+ });
66
+ },
67
+ });
@@ -0,0 +1,117 @@
1
+ import type { BrowserClient } from "../client";
2
+ import { safeJs } from "../util/js-template";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import type { ToolErr } from "../util/tool";
5
+ import { cdpCall, evalJs } from "./cdp-call";
6
+ import { resolveRefToObjectId } from "./ref-resolve";
7
+
8
+ export type PageResult = {
9
+ readonly ok: boolean;
10
+ readonly reason?: string;
11
+ readonly kind?: string;
12
+ readonly value?: unknown;
13
+ readonly text?: string;
14
+ readonly checked?: boolean;
15
+ readonly changed?: boolean;
16
+ readonly tag?: string;
17
+ readonly options?: ReadonlyArray<{ value: string; text: string }>;
18
+ };
19
+
20
+ const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
21
+ typeof v === "object" && v !== null
22
+ && "value" in v && typeof v.value === "string"
23
+ && "text" in v && typeof v.text === "string";
24
+
25
+ export const isPageResult = (v: unknown): v is PageResult => {
26
+ if (typeof v !== "object" || v === null) return false;
27
+ if (!("ok" in v) || typeof v.ok !== "boolean") return false;
28
+ if ("reason" in v && typeof v.reason !== "string") return false;
29
+ if ("kind" in v && typeof v.kind !== "string") return false;
30
+ if ("text" in v && typeof v.text !== "string") return false;
31
+ if ("checked" in v && typeof v.checked !== "boolean") return false;
32
+ if ("changed" in v && typeof v.changed !== "boolean") return false;
33
+ if ("tag" in v && typeof v.tag !== "string") return false;
34
+ if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
35
+ return true;
36
+ };
37
+
38
+ export const runOnElement = async (
39
+ client: BrowserClient,
40
+ opts: {
41
+ ref?: string | undefined;
42
+ selector?: string | undefined;
43
+ fnBody: string;
44
+ arg: unknown;
45
+ notFound?: unknown;
46
+ },
47
+ ): Promise<Result<unknown, ToolErr>> => {
48
+ if (opts.ref !== undefined) {
49
+ const objectId = await resolveRefToObjectId(client, opts.ref);
50
+ if (!objectId.success) return objectId;
51
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
52
+ objectId: objectId.data,
53
+ functionDeclaration: `function (arg) { ${opts.fnBody} }`,
54
+ arguments: [{ value: opts.arg }],
55
+ returnByValue: true,
56
+ });
57
+ if (!r.success) return r;
58
+ if (r.data.exceptionDetails !== undefined) {
59
+ return err({
60
+ kind: "cdp_error",
61
+ message: `page function threw: ${r.data.exceptionDetails.text}`,
62
+ details: { ref: opts.ref },
63
+ });
64
+ }
65
+ return ok(r.data.result.value);
66
+ }
67
+ if (opts.selector === undefined) {
68
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
69
+ }
70
+ // fnBody is harness-authored, not user input; only the selector and value are interpolated, and those go through safeJs.
71
+ const prelude = safeJs`
72
+ (() => {
73
+ const el = document.querySelector(${opts.selector});
74
+ if (!el) return ${opts.notFound ?? { status: "not_found" }};
75
+ const __arg = ${opts.arg};
76
+ return (function (arg) {`;
77
+ const expr = `${prelude} ${opts.fnBody} }).call(el, __arg); })()`;
78
+ const r = await evalJs(client, expr);
79
+ if (!r.success) return r;
80
+ return ok(r.data);
81
+ };
82
+
83
+ export const resolveAndCall = async (
84
+ client: BrowserClient,
85
+ ref: string,
86
+ functionDeclaration: string,
87
+ args: ReadonlyArray<unknown>,
88
+ ): Promise<Result<PageResult, ToolErr>> => {
89
+ const objectId = await resolveRefToObjectId(client, ref);
90
+ if (!objectId.success) return objectId;
91
+ const handle = objectId.data;
92
+ try {
93
+ const called = await cdpCall(client, "Runtime.callFunctionOn", {
94
+ objectId: handle,
95
+ functionDeclaration,
96
+ arguments: args.map((v) => ({ value: v })),
97
+ returnByValue: true,
98
+ awaitPromise: true,
99
+ });
100
+ if (!called.success) return err({ ...called.error, details: { ref } });
101
+ if (called.data.exceptionDetails !== undefined) {
102
+ return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
103
+ }
104
+ const value = called.data.result.value;
105
+ if (!isPageResult(value)) {
106
+ return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
107
+ }
108
+ return ok(value);
109
+ } finally {
110
+ await cdpCall(client, "Runtime.releaseObject", { objectId: handle });
111
+ }
112
+ };
113
+
114
+ export const detailsOf = (ref: string, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
115
+ const { ok: _ok, ...rest } = res;
116
+ return { ref, ...rest, ...(extra ?? {}) };
117
+ };
@@ -0,0 +1,219 @@
1
+ import { mkdir, access, constants, stat, readFile, writeFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { Type } from "typebox";
4
+ import type { BrowserClient } from "../client";
5
+ import { type Result, err, ok } from "../util/result";
6
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
+ import { safeJs } from "../util/js-template";
8
+ import { pdfPath } from "../util/paths";
9
+ import { cdpCall, cdpCallBrowser, evalJs } from "./cdp-call";
10
+ import { resolveRefToBackendId } from "./ref-resolve";
11
+
12
+ const UploadArgs = Type.Object({
13
+ ref: Type.Optional(
14
+ Type.String({ description: "Stable ref of the file <input> from browser_snapshot. PREFERRED over selector." }),
15
+ ),
16
+ selector: Type.Optional(Type.String({ description: "CSS selector of the file <input> (fallback when no ref)" })),
17
+ filePath: Type.String({ description: "Absolute path to the file to upload" }),
18
+ });
19
+
20
+ const tryRefUpload = async (
21
+ client: BrowserClient,
22
+ backendId: number,
23
+ filePath: string,
24
+ ): Promise<Result<void, ToolErr>> => {
25
+ const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
26
+ if (!set.success) return set;
27
+ return ok(undefined);
28
+ };
29
+
30
+ const verifyReadable = async (filePath: string): Promise<Result<void, ToolErr>> => {
31
+ try {
32
+ await access(filePath, constants.R_OK);
33
+ return ok(undefined);
34
+ } catch (e) {
35
+ return err({
36
+ kind: "io_error",
37
+ message: `Cannot read file: ${filePath} (${e instanceof Error ? e.message : String(e)})`,
38
+ });
39
+ }
40
+ };
41
+
42
+ const tryCdpUpload = async (
43
+ client: BrowserClient,
44
+ selector: string,
45
+ filePath: string,
46
+ ): Promise<Result<void, ToolErr>> => {
47
+ const doc = await cdpCall(client, "DOM.getDocument", { depth: -1 });
48
+ if (!doc.success) return doc;
49
+ const q = await cdpCall(client, "DOM.querySelector", { nodeId: doc.data.root.nodeId, selector });
50
+ if (!q.success) return q;
51
+ const nodeId = q.data.nodeId;
52
+ if (!nodeId) return err({ kind: "invalid_state", message: `Selector matched 0 file inputs: ${selector}` });
53
+ const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], nodeId });
54
+ if (!set.success) return set;
55
+ const verify = await evalJs(client, safeJs`document.querySelector(${selector})?.files?.length || 0`);
56
+ if (!verify.success) return verify;
57
+ if (Number(verify.data ?? 0) === 0) {
58
+ return err({ kind: "invalid_state", message: "CDP upload reported success but file count is 0" });
59
+ }
60
+ return ok(undefined);
61
+ };
62
+
63
+ const inferMime = (name: string): string => {
64
+ if (name.endsWith(".png")) return "image/png";
65
+ if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg";
66
+ if (name.endsWith(".pdf")) return "application/pdf";
67
+ if (name.endsWith(".json")) return "application/json";
68
+ return "text/plain";
69
+ };
70
+
71
+ const jsFallbackUpload = async (
72
+ client: BrowserClient,
73
+ selector: string,
74
+ filePath: string,
75
+ ): Promise<Result<void, ToolErr>> => {
76
+ const buf = await readFile(filePath);
77
+ const st = await stat(filePath);
78
+ const name = basename(filePath);
79
+ const mime = inferMime(name);
80
+ const expr = safeJs`
81
+ (() => {
82
+ const input = document.querySelector(${selector});
83
+ if (!input || input.type !== 'file') throw new Error('File input not found');
84
+ const bin = atob(${buf.toString("base64")});
85
+ const bytes = new Uint8Array(bin.length);
86
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
87
+ const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.mtimeMs} });
88
+ const dt = new DataTransfer();
89
+ dt.items.add(file);
90
+ input.files = dt.files;
91
+ input.dispatchEvent(new Event('change', { bubbles: true }));
92
+ return input.files.length;
93
+ })()
94
+ `;
95
+ const r = await evalJs(client, expr);
96
+ if (!r.success) return r;
97
+ if (!Number(r.data ?? 0)) return err({ kind: "invalid_state", message: "JS fallback set 0 files" });
98
+ return ok(undefined);
99
+ };
100
+
101
+ export const uploadFileTool = defineBrowserTool({
102
+ name: "browser_upload_file",
103
+ label: "Browser Upload File",
104
+ description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
105
+ promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
106
+ promptGuidelines: [
107
+ "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders. (File inputs are often hidden; snapshot still surfaces them.)",
108
+ "File path must be absolute and readable.",
109
+ ],
110
+ parameters: UploadArgs,
111
+ concurrency: "serialized",
112
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
113
+ const readable = await verifyReadable(args.filePath);
114
+ if (!readable.success) return readable;
115
+
116
+ if (args.ref !== undefined) {
117
+ const backendId = resolveRefToBackendId(client, args.ref);
118
+ if (!backendId.success) return backendId;
119
+ const up = await tryRefUpload(client, backendId.data, args.filePath);
120
+ if (!up.success) return up;
121
+ return ok({ text: `Uploaded ${args.filePath} via CDP (ref ${args.ref})`, details: { mode: "cdp", ref: args.ref, filePath: args.filePath } });
122
+ }
123
+
124
+ if (args.selector === undefined) {
125
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
126
+ }
127
+ const cdp = await tryCdpUpload(client, args.selector, args.filePath);
128
+ if (cdp.success) {
129
+ return ok({
130
+ text: `Uploaded ${args.filePath} via CDP`,
131
+ details: { mode: "cdp", filePath: args.filePath },
132
+ });
133
+ }
134
+ const js = await jsFallbackUpload(client, args.selector, args.filePath);
135
+ if (js.success) {
136
+ return ok({
137
+ text: `Uploaded ${args.filePath} via JS DataTransfer`,
138
+ details: { mode: "js", filePath: args.filePath },
139
+ });
140
+ }
141
+ return err({
142
+ kind: "cdp_error",
143
+ message: `Both CDP and JS fallback failed. CDP: ${cdp.error.message}; JS: ${js.error.message}`,
144
+ });
145
+ },
146
+ });
147
+
148
+ const DownloadArgs = Type.Object({
149
+ downloadPath: Type.String({ description: "Absolute path to a writable directory where downloads should be saved" }),
150
+ });
151
+
152
+ export const downloadTool = defineBrowserTool({
153
+ name: "browser_download",
154
+ label: "Browser Download",
155
+ description: "Configure Chrome's download behavior: set the save directory and disable the save-as prompt.",
156
+ promptSnippet: "Configure download directory",
157
+ promptGuidelines: [
158
+ "Pass an absolute path to an existing writable directory.",
159
+ "Affects all subsequent downloads on this browser.",
160
+ ],
161
+ parameters: DownloadArgs,
162
+ concurrency: "serialized",
163
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
164
+ try {
165
+ await mkdir(args.downloadPath, { recursive: true });
166
+ } catch (e) {
167
+ return err({
168
+ kind: "io_error",
169
+ message: `Cannot create download directory: ${args.downloadPath} (${e instanceof Error ? e.message : String(e)})`,
170
+ });
171
+ }
172
+ try {
173
+ const s = await stat(args.downloadPath);
174
+ if (!s.isDirectory()) {
175
+ return err({ kind: "io_error", message: `Not a directory: ${args.downloadPath}` });
176
+ }
177
+ await access(args.downloadPath, constants.W_OK);
178
+ } catch (e) {
179
+ return err({
180
+ kind: "io_error",
181
+ message: `Download path unusable: ${e instanceof Error ? e.message : String(e)}`,
182
+ });
183
+ }
184
+ const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", {
185
+ behavior: "allow",
186
+ downloadPath: args.downloadPath,
187
+ eventsEnabled: true,
188
+ });
189
+ if (!r.success) return r;
190
+ return ok({
191
+ text: `Downloads will save to: ${args.downloadPath}`,
192
+ details: { downloadPath: args.downloadPath },
193
+ });
194
+ },
195
+ });
196
+
197
+ const PrintPdfArgs = Type.Object({
198
+ outputPath: Type.Optional(Type.String({ description: "Where to save the PDF. Default: tmpdir + uuid." })),
199
+ });
200
+
201
+ export const printToPdfTool = defineBrowserTool({
202
+ name: "browser_print_to_pdf",
203
+ label: "Browser Print to PDF",
204
+ description: "Print the current page to a PDF file using Chrome's Page.printToPDF.",
205
+ promptSnippet: "Print the current page to PDF",
206
+ promptGuidelines: ["Default output path is in tmpdir; pass outputPath to control."],
207
+ parameters: PrintPdfArgs,
208
+ concurrency: "serialized",
209
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
210
+ const r = await cdpCall(client, "Page.printToPDF", {
211
+ printBackground: true,
212
+ preferCSSPageSize: true,
213
+ });
214
+ if (!r.success) return r;
215
+ const path = args.outputPath ?? pdfPath(client.namespace);
216
+ await writeFile(path, Buffer.from(r.data.data, "base64"));
217
+ return ok({ text: `PDF saved: ${path}`, details: { path } });
218
+ },
219
+ });
@@ -0,0 +1,80 @@
1
+ export type FillOptions = {
2
+ readonly rejectSelect: boolean;
3
+ readonly focusFirst: boolean;
4
+ };
5
+
6
+ const ENGINE = `
7
+ const el = this;
8
+ if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" };
9
+ const tagName = el.tagName || "";
10
+ const tag = tagName.toLowerCase();
11
+ const type = (el.type || "").toLowerCase();
12
+
13
+ if (__rejectSelect && tag === "select") {
14
+ return { ok: false, reason: "element is a <select>", kind: "select", tag: tagName };
15
+ }
16
+ if (el.disabled) return { ok: false, reason: "element is disabled", tag: tagName };
17
+
18
+ const fire = function (t) { el.dispatchEvent(new Event(t, { bubbles: true })); };
19
+ if (__focusFirst) { try { el.focus(); } catch (e) {} }
20
+
21
+ if (tag === "select") {
22
+ const want = String(value);
23
+ let matched = null;
24
+ for (let i = 0; i < el.options.length; i++) {
25
+ const o = el.options[i];
26
+ if (o.value === want || o.label === want || o.text === want) { matched = o; break; }
27
+ }
28
+ if (!matched) {
29
+ const opts = [];
30
+ for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
31
+ return { ok: false, reason: "no matching option", kind: "select", options: opts, tag: tagName };
32
+ }
33
+ el.value = matched.value;
34
+ fire("input"); fire("change");
35
+ return { ok: true, kind: "select", value: el.value, text: matched.text, tag: tagName };
36
+ }
37
+
38
+ if (tag === "input" && (type === "checkbox" || type === "radio")) {
39
+ const want = value === true || value === "true" || value === "on" || value === 1;
40
+ let changed = false;
41
+ if (el.checked !== want) {
42
+ try { el.click(); } catch (e) {}
43
+ if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); }
44
+ changed = true;
45
+ }
46
+ return { ok: true, kind: type, checked: el.checked, changed: changed, tag: tagName };
47
+ }
48
+
49
+ if (el.isContentEditable) {
50
+ try { el.focus(); } catch (e) {}
51
+ let done = false;
52
+ try {
53
+ const sel = window.getSelection();
54
+ if (sel) { sel.removeAllRanges(); const rng = document.createRange(); rng.selectNodeContents(el); sel.addRange(rng); }
55
+ done = document.execCommand("insertText", false, String(value));
56
+ } catch (e) { done = false; }
57
+ if (!done) {
58
+ el.textContent = String(value);
59
+ el.dispatchEvent(new InputEvent("input", { bubbles: true }));
60
+ }
61
+ return { ok: true, kind: "contenteditable", value: el.textContent || "", tag: tagName };
62
+ }
63
+
64
+ if (tag === "input" || tag === "textarea") {
65
+ const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
66
+ const desc = Object.getOwnPropertyDescriptor(proto, "value");
67
+ if (desc && desc.set) desc.set.call(el, String(value));
68
+ else el.value = String(value);
69
+ fire("input"); fire("change");
70
+ return { ok: true, kind: el.type || tag, value: el.value, tag: tagName };
71
+ }
72
+
73
+ return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")", tag: tagName };
74
+ `;
75
+
76
+ export const fillBody = (opts: FillOptions): string =>
77
+ `const __rejectSelect = ${opts.rejectSelect}; const __focusFirst = ${opts.focusFirst};\n${ENGINE}`;
78
+
79
+ export const fillDeclaration = (opts: FillOptions): string =>
80
+ `function (value) { ${fillBody(opts)} }`;
@@ -0,0 +1,65 @@
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 { fillDeclaration } from "./fill-engine";
5
+ import { detailsOf, resolveAndCall } from "./element-call";
6
+
7
+ const FILL_FN = fillDeclaration({ rejectSelect: false, focusFirst: true });
8
+
9
+ const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
10
+ description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
11
+ });
12
+
13
+ const FillFormArgs = Type.Object({
14
+ fields: Type.Array(
15
+ Type.Object({
16
+ ref: Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7')." }),
17
+ value: FieldValue,
18
+ }),
19
+ { minItems: 1, description: "Fields to fill in one batch, each by ref." },
20
+ ),
21
+ });
22
+
23
+ export const fillFormTool = defineBrowserTool({
24
+ name: "browser_fill_form",
25
+ label: "Browser Fill Form",
26
+ description:
27
+ "Fill multiple form fields in one call — the efficient way to complete a form. Each field is { ref, value }; refs come from browser_snapshot's [eN]. Handles text inputs, textareas, selects, checkboxes, radios, and contenteditable, drives React/Vue controlled inputs correctly, and reports the resulting value of every field.",
28
+ promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
29
+ promptGuidelines: [
30
+ "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
31
+ "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
32
+ "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
33
+ "Re-run browser_snapshot afterwards to verify the form state if needed.",
34
+ ],
35
+ parameters: FillFormArgs,
36
+ concurrency: "serialized",
37
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
38
+ const results: Array<Readonly<Record<string, unknown>>> = [];
39
+ let okCount = 0;
40
+ const issues: string[] = [];
41
+ for (const f of args.fields) {
42
+ const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
43
+ if (!r.success) {
44
+ results.push({ ref: f.ref, ok: false, error: r.error.message });
45
+ issues.push(`ref ${f.ref} (${r.error.message})`);
46
+ continue;
47
+ }
48
+ const res = r.data;
49
+ const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
50
+ const got = res.value !== undefined ? String(res.value) : undefined;
51
+ const mismatch = wanted !== undefined && got !== undefined && got !== wanted && res.text !== wanted;
52
+ const fieldOk = res.ok === true && !mismatch;
53
+ if (fieldOk) okCount++;
54
+ else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
55
+ results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
56
+ }
57
+ const text =
58
+ `Filled ${okCount}/${args.fields.length} fields` +
59
+ (issues.length > 0 ? `; issues: ${issues.join(", ")}` : "");
60
+ if (okCount === 0) {
61
+ return err({ kind: "invalid_state", message: text, details: { results } });
62
+ }
63
+ return ok({ text, details: { results } });
64
+ },
65
+ });