@poncho-ai/browser 0.6.26 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +13 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +112 -0
- package/package.json +1 -1
- package/src/session.ts +83 -0
- package/src/tools.ts +49 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/browser@0.
|
|
2
|
+
> @poncho-ai/browser@0.7.0 build /home/runner/work/poncho-ai/poncho-ai/packages/browser
|
|
3
3
|
> tsup src/index.ts --format esm --dts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
[34mCLI[39m tsup v8.5.1
|
|
8
8
|
[34mCLI[39m Target: es2022
|
|
9
9
|
[34mESM[39m Build start
|
|
10
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
11
|
-
[32mESM[39m ⚡️ Build success in
|
|
10
|
+
[32mESM[39m [1mdist/index.js [22m[32m53.08 KB[39m
|
|
11
|
+
[32mESM[39m ⚡️ Build success in 63ms
|
|
12
12
|
[34mDTS[39m Build start
|
|
13
|
-
[32mDTS[39m ⚡️ Build success in
|
|
14
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
13
|
+
[32mDTS[39m ⚡️ Build success in 5156ms
|
|
14
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m14.54 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @poncho-ai/browser
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#184](https://github.com/cesr/poncho-ai/pull/184) [`12ce2be`](https://github.com/cesr/poncho-ai/commit/12ce2be01c9d98b1d9aa634d4d8051c4c0094a44) Thanks [@cesr](https://github.com/cesr)! - Add `browser_download` so the agent can save files from the browser into the
|
|
8
|
+
VFS. The tool fetches a file using the page's logged-in session (so it works
|
|
9
|
+
for files behind a login) and writes the bytes straight to the tenant's VFS via
|
|
10
|
+
`ToolContext.vfs` — never through the model. `url` defaults to the current page,
|
|
11
|
+
or pass a same-origin link's href. The fetch runs inside the page (`evaluate`),
|
|
12
|
+
so it works identically for local and remote/cloud browsers (bytes return over
|
|
13
|
+
CDP). Capped at 25 MB. The harness browser system prompt now documents it under
|
|
14
|
+
a "Saving files" section.
|
|
15
|
+
|
|
3
16
|
## 0.6.26
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -144,6 +144,21 @@ declare class BrowserSession {
|
|
|
144
144
|
url: string;
|
|
145
145
|
title: string;
|
|
146
146
|
}>;
|
|
147
|
+
/**
|
|
148
|
+
* Fetch a file using the page's own (logged-in) session and return its
|
|
149
|
+
* bytes, so the host can persist it (e.g. to a VFS). `url` defaults to the
|
|
150
|
+
* current page. The fetch runs INSIDE the page via `evaluate`, so it carries
|
|
151
|
+
* the site's cookies and works the same whether the browser is local or a
|
|
152
|
+
* remote/cloud provider (the bytes come back over CDP). Because it's a page
|
|
153
|
+
* `fetch`, same-origin and CORS-permissive URLs work; a cross-origin URL the
|
|
154
|
+
* site doesn't allow CORS for will fail — navigate to the file first (so it's
|
|
155
|
+
* same-origin) or pass its direct URL while on that site.
|
|
156
|
+
*/
|
|
157
|
+
download(conversationId: string, url?: string): Promise<{
|
|
158
|
+
data: Buffer;
|
|
159
|
+
contentType: string;
|
|
160
|
+
filename: string;
|
|
161
|
+
}>;
|
|
147
162
|
scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void>;
|
|
148
163
|
clickText(conversationId: string, text: string, exact?: boolean): Promise<void>;
|
|
149
164
|
executeJs(conversationId: string, script: string): Promise<unknown>;
|
package/dist/index.js
CHANGED
|
@@ -244,6 +244,28 @@ var SAME_TAB_INIT_SCRIPT = `
|
|
|
244
244
|
} catch {}
|
|
245
245
|
})();
|
|
246
246
|
`;
|
|
247
|
+
function sanitizeName(name) {
|
|
248
|
+
const cleaned = name.trim().replace(/[/\\]/g, "_").replace(/\0/g, "");
|
|
249
|
+
return cleaned || "download";
|
|
250
|
+
}
|
|
251
|
+
function filenameFromDownload(disposition, url) {
|
|
252
|
+
const star = /filename\*=(?:UTF-8'')?["']?([^"';]+)/i.exec(disposition);
|
|
253
|
+
if (star?.[1]) {
|
|
254
|
+
try {
|
|
255
|
+
return sanitizeName(decodeURIComponent(star[1]));
|
|
256
|
+
} catch {
|
|
257
|
+
return sanitizeName(star[1]);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const plain = /filename=["']?([^"';]+)/i.exec(disposition);
|
|
261
|
+
if (plain?.[1]) return sanitizeName(plain[1]);
|
|
262
|
+
try {
|
|
263
|
+
const base = new URL(url).pathname.split("/").filter(Boolean).pop();
|
|
264
|
+
if (base) return sanitizeName(decodeURIComponent(base));
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
return "download";
|
|
268
|
+
}
|
|
247
269
|
var BrowserSession = class {
|
|
248
270
|
config;
|
|
249
271
|
sessionId;
|
|
@@ -693,6 +715,55 @@ var BrowserSession = class {
|
|
|
693
715
|
this.unlock();
|
|
694
716
|
}
|
|
695
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* Fetch a file using the page's own (logged-in) session and return its
|
|
720
|
+
* bytes, so the host can persist it (e.g. to a VFS). `url` defaults to the
|
|
721
|
+
* current page. The fetch runs INSIDE the page via `evaluate`, so it carries
|
|
722
|
+
* the site's cookies and works the same whether the browser is local or a
|
|
723
|
+
* remote/cloud provider (the bytes come back over CDP). Because it's a page
|
|
724
|
+
* `fetch`, same-origin and CORS-permissive URLs work; a cross-origin URL the
|
|
725
|
+
* site doesn't allow CORS for will fail — navigate to the file first (so it's
|
|
726
|
+
* same-origin) or pass its direct URL while on that site.
|
|
727
|
+
*/
|
|
728
|
+
async download(conversationId, url) {
|
|
729
|
+
await this.lock();
|
|
730
|
+
try {
|
|
731
|
+
const mgr = await this.ensureManager();
|
|
732
|
+
await this.switchToConversation(mgr, conversationId);
|
|
733
|
+
const page = mgr.getPage();
|
|
734
|
+
const target = url && url.trim() ? url.trim() : page.url();
|
|
735
|
+
if (!target || target === "about:blank") {
|
|
736
|
+
throw new Error("no URL to download (open the file's page first, or pass a url)");
|
|
737
|
+
}
|
|
738
|
+
const MAX_BYTES = 25 * 1024 * 1024;
|
|
739
|
+
const expr = `(async () => {
|
|
740
|
+
const res = await fetch(${JSON.stringify(target)}, { credentials: "include" });
|
|
741
|
+
if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
|
|
742
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
743
|
+
if (buf.length > ${MAX_BYTES}) throw new Error("file too large: " + buf.length + " bytes (max ${MAX_BYTES})");
|
|
744
|
+
let bin = "";
|
|
745
|
+
const CH = 0x8000;
|
|
746
|
+
for (let i = 0; i < buf.length; i += CH) {
|
|
747
|
+
bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH));
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
base64: btoa(bin),
|
|
751
|
+
contentType: res.headers.get("content-type") || "",
|
|
752
|
+
disposition: res.headers.get("content-disposition") || "",
|
|
753
|
+
finalUrl: res.url || ${JSON.stringify(target)},
|
|
754
|
+
};
|
|
755
|
+
})()`;
|
|
756
|
+
const r = await page.evaluate(expr);
|
|
757
|
+
const data = Buffer.from(r.base64, "base64");
|
|
758
|
+
return {
|
|
759
|
+
data,
|
|
760
|
+
contentType: r.contentType,
|
|
761
|
+
filename: filenameFromDownload(r.disposition, r.finalUrl)
|
|
762
|
+
};
|
|
763
|
+
} finally {
|
|
764
|
+
this.unlock();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
696
767
|
async scroll(conversationId, direction, amount) {
|
|
697
768
|
await this.lock();
|
|
698
769
|
try {
|
|
@@ -1261,6 +1332,47 @@ function createBrowserTools(getSession) {
|
|
|
1261
1332
|
return { url: result.url, title: result.title, text: result.text };
|
|
1262
1333
|
}
|
|
1263
1334
|
},
|
|
1335
|
+
{
|
|
1336
|
+
name: "browser_download",
|
|
1337
|
+
description: "Download a file from the browser and save it into the user's virtual filesystem (VFS). Fetches the file using the browser's logged-in session, so it works for files behind a login \u2014 use it to keep a PDF, CSV, image, or other file the page offers. It fetches `url` (or the current page if you omit it), so for a download link on the page, grab its href from a snapshot first; for a file that opens in the browser, navigate to it and call this with no url. The fetch runs in the page, so the url should be same-origin with the current page (navigate to the file's site first if needed). Returns the saved VFS path and byte size \u2014 the bytes go straight to the VFS, not through the chat.",
|
|
1338
|
+
inputSchema: {
|
|
1339
|
+
type: "object",
|
|
1340
|
+
properties: {
|
|
1341
|
+
path: {
|
|
1342
|
+
type: "string",
|
|
1343
|
+
description: "Destination in the VFS. Include a filename (e.g. /downloads/report.pdf); parent folders are created as needed. End with '/' (e.g. /downloads/) to keep the file's own name."
|
|
1344
|
+
},
|
|
1345
|
+
url: {
|
|
1346
|
+
type: "string",
|
|
1347
|
+
description: "URL of the file to download. Optional \u2014 defaults to the current page's URL."
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
required: ["path"]
|
|
1351
|
+
},
|
|
1352
|
+
handler: async (input, context) => {
|
|
1353
|
+
const session = getSession();
|
|
1354
|
+
const vfs = context.vfs;
|
|
1355
|
+
if (!vfs) throw new Error("VFS is not available in this environment");
|
|
1356
|
+
const dest0 = String(input.path ?? "").trim();
|
|
1357
|
+
if (!dest0) throw new Error("path is required");
|
|
1358
|
+
const url = input.url != null ? String(input.url) : void 0;
|
|
1359
|
+
const { data, contentType, filename } = await session.download(
|
|
1360
|
+
context.conversationId ?? "__default__",
|
|
1361
|
+
url
|
|
1362
|
+
);
|
|
1363
|
+
let dest = dest0.startsWith("/") ? dest0 : `/${dest0}`;
|
|
1364
|
+
if (dest.endsWith("/")) dest = `${dest}${filename}`;
|
|
1365
|
+
const slash = dest.lastIndexOf("/");
|
|
1366
|
+
if (slash > 0) {
|
|
1367
|
+
try {
|
|
1368
|
+
await vfs.mkdir(dest.slice(0, slash), { recursive: true });
|
|
1369
|
+
} catch {
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
await vfs.writeFile(dest, new Uint8Array(data), contentType || void 0);
|
|
1373
|
+
return { path: dest, bytes: data.length, ...contentType ? { contentType } : {} };
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1264
1376
|
{
|
|
1265
1377
|
name: "browser_screenshot",
|
|
1266
1378
|
description: "Take a screenshot of the current page. Returns the image so you can see exactly what the page looks like. Use this when you need to see visual layout, verify actions, or read content that isn't in the accessibility tree.",
|
package/package.json
CHANGED
package/src/session.ts
CHANGED
|
@@ -146,6 +146,29 @@ interface ConversationTab {
|
|
|
146
146
|
lastUsed: number;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** Strip path separators / nulls so a derived name can't escape its folder. */
|
|
150
|
+
function sanitizeName(name: string): string {
|
|
151
|
+
const cleaned = name.trim().replace(/[/\\]/g, "_").replace(/\0/g, "");
|
|
152
|
+
return cleaned || "download";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Derive a filename from a Content-Disposition header, falling back to the
|
|
156
|
+
* URL's last path segment, then a generic "download". */
|
|
157
|
+
function filenameFromDownload(disposition: string, url: string): string {
|
|
158
|
+
const star = /filename\*=(?:UTF-8'')?["']?([^"';]+)/i.exec(disposition);
|
|
159
|
+
if (star?.[1]) {
|
|
160
|
+
try { return sanitizeName(decodeURIComponent(star[1])); }
|
|
161
|
+
catch { return sanitizeName(star[1]); }
|
|
162
|
+
}
|
|
163
|
+
const plain = /filename=["']?([^"';]+)/i.exec(disposition);
|
|
164
|
+
if (plain?.[1]) return sanitizeName(plain[1]);
|
|
165
|
+
try {
|
|
166
|
+
const base = new URL(url).pathname.split("/").filter(Boolean).pop();
|
|
167
|
+
if (base) return sanitizeName(decodeURIComponent(base));
|
|
168
|
+
} catch { /* not a parseable URL */ }
|
|
169
|
+
return "download";
|
|
170
|
+
}
|
|
171
|
+
|
|
149
172
|
export class BrowserSession {
|
|
150
173
|
private readonly config: BrowserConfig;
|
|
151
174
|
private readonly sessionId: string;
|
|
@@ -655,6 +678,66 @@ export class BrowserSession {
|
|
|
655
678
|
}
|
|
656
679
|
}
|
|
657
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Fetch a file using the page's own (logged-in) session and return its
|
|
683
|
+
* bytes, so the host can persist it (e.g. to a VFS). `url` defaults to the
|
|
684
|
+
* current page. The fetch runs INSIDE the page via `evaluate`, so it carries
|
|
685
|
+
* the site's cookies and works the same whether the browser is local or a
|
|
686
|
+
* remote/cloud provider (the bytes come back over CDP). Because it's a page
|
|
687
|
+
* `fetch`, same-origin and CORS-permissive URLs work; a cross-origin URL the
|
|
688
|
+
* site doesn't allow CORS for will fail — navigate to the file first (so it's
|
|
689
|
+
* same-origin) or pass its direct URL while on that site.
|
|
690
|
+
*/
|
|
691
|
+
async download(
|
|
692
|
+
conversationId: string,
|
|
693
|
+
url?: string,
|
|
694
|
+
): Promise<{ data: Buffer; contentType: string; filename: string }> {
|
|
695
|
+
await this.lock();
|
|
696
|
+
try {
|
|
697
|
+
const mgr = await this.ensureManager();
|
|
698
|
+
await this.switchToConversation(mgr, conversationId);
|
|
699
|
+
const page = mgr.getPage();
|
|
700
|
+
const target = url && url.trim() ? url.trim() : page.url();
|
|
701
|
+
if (!target || target === "about:blank") {
|
|
702
|
+
throw new Error("no URL to download (open the file's page first, or pass a url)");
|
|
703
|
+
}
|
|
704
|
+
const MAX_BYTES = 25 * 1024 * 1024;
|
|
705
|
+
// Build the in-page fetch. JSON.stringify safely escapes the URL into the
|
|
706
|
+
// evaluated source. Base64 in-page so the bytes survive the JSON channel.
|
|
707
|
+
const expr = `(async () => {
|
|
708
|
+
const res = await fetch(${JSON.stringify(target)}, { credentials: "include" });
|
|
709
|
+
if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
|
|
710
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
711
|
+
if (buf.length > ${MAX_BYTES}) throw new Error("file too large: " + buf.length + " bytes (max ${MAX_BYTES})");
|
|
712
|
+
let bin = "";
|
|
713
|
+
const CH = 0x8000;
|
|
714
|
+
for (let i = 0; i < buf.length; i += CH) {
|
|
715
|
+
bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH));
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
base64: btoa(bin),
|
|
719
|
+
contentType: res.headers.get("content-type") || "",
|
|
720
|
+
disposition: res.headers.get("content-disposition") || "",
|
|
721
|
+
finalUrl: res.url || ${JSON.stringify(target)},
|
|
722
|
+
};
|
|
723
|
+
})()`;
|
|
724
|
+
const r = (await page.evaluate(expr)) as {
|
|
725
|
+
base64: string;
|
|
726
|
+
contentType: string;
|
|
727
|
+
disposition: string;
|
|
728
|
+
finalUrl: string;
|
|
729
|
+
};
|
|
730
|
+
const data = Buffer.from(r.base64, "base64");
|
|
731
|
+
return {
|
|
732
|
+
data,
|
|
733
|
+
contentType: r.contentType,
|
|
734
|
+
filename: filenameFromDownload(r.disposition, r.finalUrl),
|
|
735
|
+
};
|
|
736
|
+
} finally {
|
|
737
|
+
this.unlock();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
658
741
|
async scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void> {
|
|
659
742
|
await this.lock();
|
|
660
743
|
try {
|
package/src/tools.ts
CHANGED
|
@@ -176,6 +176,55 @@ export function createBrowserTools(
|
|
|
176
176
|
return { url: result.url, title: result.title, text: result.text };
|
|
177
177
|
},
|
|
178
178
|
},
|
|
179
|
+
{
|
|
180
|
+
name: "browser_download",
|
|
181
|
+
description:
|
|
182
|
+
"Download a file from the browser and save it into the user's virtual filesystem (VFS). " +
|
|
183
|
+
"Fetches the file using the browser's logged-in session, so it works for files behind a login — " +
|
|
184
|
+
"use it to keep a PDF, CSV, image, or other file the page offers. " +
|
|
185
|
+
"It fetches `url` (or the current page if you omit it), so for a download link on the page, grab its href from a snapshot first; " +
|
|
186
|
+
"for a file that opens in the browser, navigate to it and call this with no url. " +
|
|
187
|
+
"The fetch runs in the page, so the url should be same-origin with the current page (navigate to the file's site first if needed). " +
|
|
188
|
+
"Returns the saved VFS path and byte size — the bytes go straight to the VFS, not through the chat.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {
|
|
192
|
+
path: {
|
|
193
|
+
type: "string",
|
|
194
|
+
description:
|
|
195
|
+
"Destination in the VFS. Include a filename (e.g. /downloads/report.pdf); parent folders are created as needed. " +
|
|
196
|
+
"End with '/' (e.g. /downloads/) to keep the file's own name.",
|
|
197
|
+
},
|
|
198
|
+
url: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description:
|
|
201
|
+
"URL of the file to download. Optional — defaults to the current page's URL.",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
required: ["path"],
|
|
205
|
+
},
|
|
206
|
+
handler: async (input: BrowserToolInput, context: ToolContext) => {
|
|
207
|
+
const session = getSession();
|
|
208
|
+
const vfs = context.vfs;
|
|
209
|
+
if (!vfs) throw new Error("VFS is not available in this environment");
|
|
210
|
+
const dest0 = String(input.path ?? "").trim();
|
|
211
|
+
if (!dest0) throw new Error("path is required");
|
|
212
|
+
const url = input.url != null ? String(input.url) : undefined;
|
|
213
|
+
const { data, contentType, filename } = await session.download(
|
|
214
|
+
context.conversationId ?? "__default__",
|
|
215
|
+
url,
|
|
216
|
+
);
|
|
217
|
+
// A trailing slash (or bare folder) means "use the file's own name".
|
|
218
|
+
let dest = dest0.startsWith("/") ? dest0 : `/${dest0}`;
|
|
219
|
+
if (dest.endsWith("/")) dest = `${dest}${filename}`;
|
|
220
|
+
const slash = dest.lastIndexOf("/");
|
|
221
|
+
if (slash > 0) {
|
|
222
|
+
try { await vfs.mkdir(dest.slice(0, slash), { recursive: true }); } catch { /* exists */ }
|
|
223
|
+
}
|
|
224
|
+
await vfs.writeFile(dest, new Uint8Array(data), contentType || undefined);
|
|
225
|
+
return { path: dest, bytes: data.length, ...(contentType ? { contentType } : {}) };
|
|
226
|
+
},
|
|
227
|
+
},
|
|
179
228
|
{
|
|
180
229
|
name: "browser_screenshot",
|
|
181
230
|
description:
|