jev-cdp 0.1.5 → 0.1.7
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/README.md +7 -7
- package/dist/cli.js +330 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,11 +27,11 @@ This is an early experimental port. See [NOTICE.md](NOTICE.md) for source attrib
|
|
|
27
27
|
Run the published CLI without adding it to a project. Bun must be installed for either command:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
bunx jev-cdp@0.1.
|
|
31
|
-
npx -y jev-cdp@0.1.
|
|
30
|
+
bunx jev-cdp@0.1.7 help run
|
|
31
|
+
npx -y jev-cdp@0.1.7 help run
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Chrome with a CDP endpoint and `TYPESAFE_API_KEY` are required for browser runs. FFmpeg is required for `--recording`.
|
|
34
|
+
Chrome with a CDP endpoint and `TYPESAFE_API_KEY` are required for browser runs. FFmpeg with H.264 encoding (`libx264`) is required for `--recording`. Jev selects `-fps_mode vfr` when FFmpeg supports it and falls back to `-vsync vfr` for older builds. Run `jev-cdp doctor` to check the installed encoder with a short MP4 encode and decode.
|
|
35
35
|
|
|
36
36
|
For source development:
|
|
37
37
|
|
|
@@ -96,13 +96,13 @@ bun run run -- run \
|
|
|
96
96
|
--final-state
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
`--recording` captures Chrome's compositor screencast stream for the full goal and renders
|
|
99
|
+
`--recording` captures Chrome's compositor screencast stream for the full goal and renders a 1280×720 H.264 MP4. A 40-pixel browser bar shows the active tab's URL above the page. Chrome uses a 1506×800 viewport, scaled to fit the video beneath the bar, so controls remain at their observed coordinates while more of the page fits on screen. Because Chrome's native pointer is not part of the stream, the adapter draws a high-contrast cursor that starts at the viewport center, glides to each target, and pulses on clicks. `--interaction-pauses` adds a deterministic delay after opening or loading a page, changing the URL within a page, or moving to a click target before pressing the mouse. Jev chooses during page pauses, and the adapter waits only for any time left before acting. Jev does not choose or observe those delays. When an action opens a new tab, the recording shows a "New tab opened" notice over the previous tab for the full configured pause, then switches to the new tab. With zero pause, the notice appears for one frame without delaying Jev. `--screenshot` saves a 1280×720 image with the same browser bar, even when recording is off. If a new tab opened, the final screenshot also includes the notice.
|
|
100
100
|
|
|
101
101
|
`jev-cdp run` writes JSON Lines to standard output: one `type:"action"` object per executed action, followed by one `type:"result"` object with the final status and action budget. Action objects include elapsed execution time, the operation, page and tab URLs, the viewport, and a CSS selector, role, name, frame, and coordinates for the element. Fill actions include the entered text; password fields and `--field-value-env` values are redacted and must be supplied separately for replay. Select and scroll actions include their option value or wheel delta. The CSS selector and page URL can be used as Playwright replay targets, with the coordinates as a fallback at the recorded viewport size. `--final-state` adds the final semantic page snapshot: a frame tree with URLs and loading state, actionable elements with frame identity, screen bounds, nearby text, region, clickability, and any element covering the click point, plus new-tab and redirect transitions. Automatic navigation and embedded-content waits use `--wait-budget-ms` (15 seconds by default) and do not consume `--max-steps`; a timeout returns `wait_timeout`, its pending condition and elapsed wait time, and the latest semantic state. Runtime failures emit a `type:"result"` object with `status:"error"`; diagnostic text uses standard error.
|
|
102
102
|
|
|
103
103
|
Each action object includes `consoleErrors` observed during that step. The result object includes `initialConsoleErrors` already present when attaching to the tab and `consoleErrors` for the full run. These fields include browser console errors, uncaught exceptions, and error-level DevTools log entries from the page and its frames. Review them alongside the semantic state; a console error does not by itself establish that the goal failed.
|
|
104
104
|
|
|
105
|
-
The snapshot includes visible controls in nested iframes, including cross-origin frames. Clicking a link there uses its frame coordinates and checks that the observed control is still current. If that click opens a new tab, Jev switches to it, returns its target ID for the next goal, and keeps the
|
|
105
|
+
The snapshot includes visible controls in nested iframes, including cross-origin frames. Clicking a link there uses its frame coordinates and checks that the observed control is still current. If that click opens a new tab, Jev switches to it, returns its target ID for the next goal, and keeps the 1506×800 viewport consistent across the switch.
|
|
106
106
|
|
|
107
107
|
## Supply known field values without another LLM
|
|
108
108
|
|
|
@@ -184,9 +184,9 @@ The context is disposed after evidence capture by default. Combine it with `--ke
|
|
|
184
184
|
| `--cdp URL` | `CHROME_CDP_URL` | `http://127.0.0.1:9222` | Chrome DevTools HTTP endpoint |
|
|
185
185
|
| `--tab TARGET_ID` | — | create a new tab | Attach to one exact existing Chrome page target |
|
|
186
186
|
| `tabs` | — | — | Print target IDs, titles, and URLs for open page tabs |
|
|
187
|
-
| `--recording PATH.mp4` | — | off | Record
|
|
187
|
+
| `--recording PATH.mp4` | — | off | Record a 1280×720 video with a URL bar, animated cursor, and tab notices |
|
|
188
188
|
| `--interaction-pauses MS` | — | `0` | Pause after page loads and before clicks, overlapping page pauses with Jev decisions |
|
|
189
|
-
| `--screenshot PATH.jpg` | — | off | Save the
|
|
189
|
+
| `--screenshot PATH.jpg` | — | off | Save a 1280×720 image with the URL bar and any new-tab notice |
|
|
190
190
|
| `--final-state` | — | off | Include the final semantic page state in stdout JSON |
|
|
191
191
|
| `--field-value LABEL=VALUE` | — | Luna fallback | Type caller-provided test data into the exactly labeled field |
|
|
192
192
|
| `--field-value-env LABEL=NAME` | — | off | Read a sensitive field value from an environment variable |
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "jev-cdp",
|
|
6
|
-
version: "0.1.
|
|
6
|
+
version: "0.1.7",
|
|
7
7
|
description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
|
|
8
8
|
type: "module",
|
|
9
9
|
license: "MIT",
|
|
@@ -38,9 +38,9 @@ var package_default = {
|
|
|
38
38
|
|
|
39
39
|
// src/browser.ts
|
|
40
40
|
import { createHash } from "crypto";
|
|
41
|
-
import { mkdir, mkdtemp, rm } from "fs/promises";
|
|
42
|
-
import { tmpdir } from "os";
|
|
43
|
-
import { dirname, join, resolve } from "path";
|
|
41
|
+
import { mkdir, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
42
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
43
|
+
import { dirname, join as join2, resolve } from "path";
|
|
44
44
|
|
|
45
45
|
// src/cdp.ts
|
|
46
46
|
async function listChromeTargets(cdpUrl) {
|
|
@@ -143,6 +143,164 @@ class CdpClient {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// src/ffmpeg.ts
|
|
147
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
148
|
+
import { tmpdir } from "os";
|
|
149
|
+
import { join } from "path";
|
|
150
|
+
function selectFrameSyncOption(help) {
|
|
151
|
+
if (/^\s*-fps_mode(?:\[:<stream_spec>\])?\s/m.test(help))
|
|
152
|
+
return "-fps_mode";
|
|
153
|
+
if (/^\s*-vsync\s/m.test(help))
|
|
154
|
+
return "-vsync";
|
|
155
|
+
throw new Error("FFmpeg supports neither -fps_mode nor -vsync");
|
|
156
|
+
}
|
|
157
|
+
async function run(command) {
|
|
158
|
+
const process2 = Bun.spawn(command, { stdout: "ignore", stderr: "pipe" });
|
|
159
|
+
const stderr = await new Response(process2.stderr).text();
|
|
160
|
+
return { code: await process2.exited, stderr };
|
|
161
|
+
}
|
|
162
|
+
async function recordingEncoder(ffmpeg = Bun.which("ffmpeg")) {
|
|
163
|
+
if (!ffmpeg)
|
|
164
|
+
throw new Error("FFmpeg not found on PATH");
|
|
165
|
+
const process2 = Bun.spawn([ffmpeg, "-hide_banner", "-h", "full"], { stdout: "pipe", stderr: "pipe" });
|
|
166
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
167
|
+
new Response(process2.stdout).text(),
|
|
168
|
+
new Response(process2.stderr).text(),
|
|
169
|
+
process2.exited
|
|
170
|
+
]);
|
|
171
|
+
if (code !== 0)
|
|
172
|
+
throw new Error(`Could not inspect FFmpeg options: ${stderr.slice(-500)}`);
|
|
173
|
+
return { path: ffmpeg, sync: selectFrameSyncOption(stdout + stderr) };
|
|
174
|
+
}
|
|
175
|
+
function recordingCommand(encoder, manifest, output) {
|
|
176
|
+
return [
|
|
177
|
+
encoder.path,
|
|
178
|
+
"-y",
|
|
179
|
+
"-f",
|
|
180
|
+
"concat",
|
|
181
|
+
"-safe",
|
|
182
|
+
"0",
|
|
183
|
+
"-i",
|
|
184
|
+
manifest,
|
|
185
|
+
encoder.sync,
|
|
186
|
+
"vfr",
|
|
187
|
+
"-c:v",
|
|
188
|
+
"libx264",
|
|
189
|
+
"-pix_fmt",
|
|
190
|
+
"yuv420p",
|
|
191
|
+
"-movflags",
|
|
192
|
+
"+faststart",
|
|
193
|
+
output
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
async function renderRecording(encoder, manifest, output) {
|
|
197
|
+
const result = await run(recordingCommand(encoder, manifest, output));
|
|
198
|
+
if (result.code !== 0)
|
|
199
|
+
throw new Error(`Could not render recording: ${result.stderr.slice(-800)}`);
|
|
200
|
+
}
|
|
201
|
+
async function checkRecordingEncoder() {
|
|
202
|
+
const encoder = await recordingEncoder();
|
|
203
|
+
const directory = await mkdtemp(join(tmpdir(), "jev-cdp-ffmpeg-check-"));
|
|
204
|
+
try {
|
|
205
|
+
const frame = join(directory, "frame.ppm");
|
|
206
|
+
const manifest = join(directory, "frames.ffconcat");
|
|
207
|
+
const output = join(directory, "check.mp4");
|
|
208
|
+
await Bun.write(frame, Buffer.concat([Buffer.from(`P6
|
|
209
|
+
16 16
|
|
210
|
+
255
|
|
211
|
+
`), Buffer.alloc(16 * 16 * 3, 90)]));
|
|
212
|
+
const quoted = frame.replaceAll("'", "'\\''");
|
|
213
|
+
await Bun.write(manifest, `ffconcat version 1.0
|
|
214
|
+
file '${quoted}'
|
|
215
|
+
duration 0.12
|
|
216
|
+
file '${quoted}'
|
|
217
|
+
`);
|
|
218
|
+
await renderRecording(encoder, manifest, output);
|
|
219
|
+
const decoded = await run([encoder.path, "-v", "error", "-i", output, "-f", "null", "-"]);
|
|
220
|
+
if (decoded.code !== 0)
|
|
221
|
+
throw new Error(`Could not decode recording: ${decoded.stderr.slice(-500)}`);
|
|
222
|
+
return `FFmpeg at ${encoder.path}; ${encoder.sync} vfr and libx264 encode/decode passed`;
|
|
223
|
+
} finally {
|
|
224
|
+
await rm(directory, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/recording-visual.ts
|
|
229
|
+
var VIDEO_WIDTH = 1280;
|
|
230
|
+
var VIDEO_HEIGHT = 720;
|
|
231
|
+
var BAR_HEIGHT = 40;
|
|
232
|
+
var PAGE_WIDTH = 1506;
|
|
233
|
+
var PAGE_HEIGHT = 800;
|
|
234
|
+
function composeFrameExpression(jpeg, url, newTabUrl) {
|
|
235
|
+
return `(async () => {
|
|
236
|
+
const bytes = Uint8Array.from(atob(${JSON.stringify(jpeg)}), character => character.charCodeAt(0));
|
|
237
|
+
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
|
|
238
|
+
const canvas = document.createElement('canvas');
|
|
239
|
+
canvas.width = ${VIDEO_WIDTH}; canvas.height = ${VIDEO_HEIGHT};
|
|
240
|
+
const context = canvas.getContext('2d');
|
|
241
|
+
if (!context) throw new Error('Could not create recording canvas');
|
|
242
|
+
context.fillStyle = '#f5f7fa';
|
|
243
|
+
context.fillRect(0, 0, canvas.width, ${BAR_HEIGHT});
|
|
244
|
+
context.fillStyle = '#ffffff';
|
|
245
|
+
context.beginPath();
|
|
246
|
+
context.roundRect(12, 6, canvas.width - 24, 28, 7);
|
|
247
|
+
context.fill();
|
|
248
|
+
context.strokeStyle = '#d7dce4';
|
|
249
|
+
context.lineWidth = 1;
|
|
250
|
+
context.stroke();
|
|
251
|
+
context.fillStyle = '#617185';
|
|
252
|
+
context.beginPath(); context.arc(28, 20, 4, 0, Math.PI * 2); context.fill();
|
|
253
|
+
const fit = (value, maxWidth) => {
|
|
254
|
+
if (context.measureText(value).width <= maxWidth) return value;
|
|
255
|
+
while (value.length && context.measureText(value + '\u2026').width > maxWidth) value = value.slice(0, -1);
|
|
256
|
+
return value + '\u2026';
|
|
257
|
+
};
|
|
258
|
+
const url = ${JSON.stringify(url)};
|
|
259
|
+
context.font = '14px system-ui, sans-serif';
|
|
260
|
+
context.fillStyle = '#263343';
|
|
261
|
+
context.textBaseline = 'middle';
|
|
262
|
+
context.fillText(fit(url, canvas.width - 72), 42, 20);
|
|
263
|
+
const chromeBorder = '#61738a';
|
|
264
|
+
context.fillStyle = chromeBorder;
|
|
265
|
+
context.fillRect(0, ${BAR_HEIGHT - 2}, canvas.width, 2);
|
|
266
|
+
context.drawImage(bitmap, 0, ${BAR_HEIGHT}, canvas.width, canvas.height - ${BAR_HEIGHT});
|
|
267
|
+
bitmap.close();
|
|
268
|
+
const newTabUrl = ${JSON.stringify(newTabUrl)};
|
|
269
|
+
if (newTabUrl) {
|
|
270
|
+
const cardWidth = 620, cardHeight = 104, radius = 12, x = (canvas.width - cardWidth) / 2;
|
|
271
|
+
const cardPath = () => {
|
|
272
|
+
context.beginPath();
|
|
273
|
+
context.moveTo(x, 0);
|
|
274
|
+
context.lineTo(x + cardWidth, 0);
|
|
275
|
+
context.lineTo(x + cardWidth, cardHeight - radius);
|
|
276
|
+
context.quadraticCurveTo(x + cardWidth, cardHeight, x + cardWidth - radius, cardHeight);
|
|
277
|
+
context.lineTo(x + radius, cardHeight);
|
|
278
|
+
context.quadraticCurveTo(x, cardHeight, x, cardHeight - radius);
|
|
279
|
+
context.closePath();
|
|
280
|
+
};
|
|
281
|
+
context.save();
|
|
282
|
+
context.shadowColor = 'rgba(15, 23, 42, .24)';
|
|
283
|
+
context.shadowBlur = 14;
|
|
284
|
+
context.shadowOffsetY = 5;
|
|
285
|
+
context.fillStyle = '#ffffff';
|
|
286
|
+
cardPath(); context.fill();
|
|
287
|
+
context.restore();
|
|
288
|
+
context.strokeStyle = chromeBorder;
|
|
289
|
+
context.lineWidth = 1.5;
|
|
290
|
+
cardPath(); context.stroke();
|
|
291
|
+
context.fillStyle = '#2563eb';
|
|
292
|
+
context.beginPath(); context.arc(x + 28, 35, 7, 0, Math.PI * 2); context.fill();
|
|
293
|
+
context.fillStyle = '#172033';
|
|
294
|
+
context.font = '600 19px system-ui, sans-serif';
|
|
295
|
+
context.fillText('New tab opened', x + 48, 35);
|
|
296
|
+
context.fillStyle = '#536277';
|
|
297
|
+
context.font = '14px system-ui, sans-serif';
|
|
298
|
+
context.fillText(fit(newTabUrl, cardWidth - 52), x + 24, 72);
|
|
299
|
+
}
|
|
300
|
+
return canvas.toDataURL('image/jpeg', .88).split(',')[1];
|
|
301
|
+
})()`;
|
|
302
|
+
}
|
|
303
|
+
|
|
146
304
|
// src/snapshot.js
|
|
147
305
|
var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1ad2 under the MIT License.
|
|
148
306
|
(() => {
|
|
@@ -335,6 +493,12 @@ class Browser {
|
|
|
335
493
|
#recordingStartedAt = 0;
|
|
336
494
|
#recordingSequence = 0;
|
|
337
495
|
#recordingWrites = Promise.resolve();
|
|
496
|
+
#activeUrl = "about:blank";
|
|
497
|
+
#recordingUrlEvents = [];
|
|
498
|
+
#newTabNoticeUntil = 0;
|
|
499
|
+
#newTabNoticeUrl = null;
|
|
500
|
+
#newTabNoticeWindows = [];
|
|
501
|
+
#hadNewTab = false;
|
|
338
502
|
#stopRecordingEvents;
|
|
339
503
|
#stopPageEvents = [];
|
|
340
504
|
#mainFrameId;
|
|
@@ -430,6 +594,8 @@ class Browser {
|
|
|
430
594
|
}
|
|
431
595
|
if (!info?.targetId || !info.url || info.url === "about:blank")
|
|
432
596
|
return;
|
|
597
|
+
if (info.targetId === browser.#targetId)
|
|
598
|
+
browser.setActiveUrl(info.url);
|
|
433
599
|
const urls = browser.#targetUrls.get(info.targetId) ?? [];
|
|
434
600
|
if (urls.at(-1) !== info.url)
|
|
435
601
|
urls.push(info.url);
|
|
@@ -455,8 +621,8 @@ class Browser {
|
|
|
455
621
|
await browser.watchConsole();
|
|
456
622
|
await browser.call("Page.enable");
|
|
457
623
|
await browser.call("Emulation.setDeviceMetricsOverride", {
|
|
458
|
-
width:
|
|
459
|
-
height:
|
|
624
|
+
width: PAGE_WIDTH,
|
|
625
|
+
height: PAGE_HEIGHT,
|
|
460
626
|
deviceScaleFactor: 1,
|
|
461
627
|
mobile: false
|
|
462
628
|
});
|
|
@@ -465,6 +631,7 @@ class Browser {
|
|
|
465
631
|
if (options.url)
|
|
466
632
|
await browser.call("Page.navigate", { url: options.url });
|
|
467
633
|
await browser.waitForReady();
|
|
634
|
+
browser.#activeUrl = await browser.evaluate("location.href") ?? browser.#activeUrl;
|
|
468
635
|
browser.#pauseUntil = performance.now() + browser.#interactionPauses;
|
|
469
636
|
await browser.startRecording();
|
|
470
637
|
return browser;
|
|
@@ -481,6 +648,18 @@ class Browser {
|
|
|
481
648
|
get targetId() {
|
|
482
649
|
return this.#targetId;
|
|
483
650
|
}
|
|
651
|
+
setActiveUrl(url) {
|
|
652
|
+
if (!url || url === this.#activeUrl)
|
|
653
|
+
return;
|
|
654
|
+
this.#activeUrl = url;
|
|
655
|
+
if (this.#recordingDirectory && this.#recordingFrames.length) {
|
|
656
|
+
this.#recordingUrlEvents.push({
|
|
657
|
+
elapsedMs: performance.now() - this.#recordingStartedAt,
|
|
658
|
+
targetId: this.#targetId,
|
|
659
|
+
url
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
484
663
|
takeConsoleErrors() {
|
|
485
664
|
return this.#consoleErrors.splice(0);
|
|
486
665
|
}
|
|
@@ -524,6 +703,8 @@ class Browser {
|
|
|
524
703
|
await this.call("Page.enable");
|
|
525
704
|
const tree = await this.call("Page.getFrameTree");
|
|
526
705
|
this.#mainFrameId = tree.frameTree.frame.id;
|
|
706
|
+
if (tree.frameTree.frame.url)
|
|
707
|
+
this.setActiveUrl(tree.frameTree.frame.url);
|
|
527
708
|
await this.call("Runtime.addBinding", { name: "__jevDomChanged" });
|
|
528
709
|
const observeDom = `(() => {
|
|
529
710
|
if (window.__jevDomWatching) return;
|
|
@@ -560,8 +741,11 @@ class Browser {
|
|
|
560
741
|
this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
|
|
561
742
|
const frame = params.frame;
|
|
562
743
|
this.signalChange();
|
|
563
|
-
if (frame && frame.id === this.#mainFrameId && !frame.parentId)
|
|
744
|
+
if (frame && frame.id === this.#mainFrameId && !frame.parentId) {
|
|
745
|
+
if (frame.url)
|
|
746
|
+
this.setActiveUrl(frame.url);
|
|
564
747
|
loading({ frameId: frame.id });
|
|
748
|
+
}
|
|
565
749
|
}), this.#cdp.on("Page.loadEventFired", this.#sessionId, loaded), this.#cdp.on("Page.frameStoppedLoading", this.#sessionId, (params) => {
|
|
566
750
|
if (typeof params.frameId === "string")
|
|
567
751
|
this.#loadingFrames.delete(params.frameId);
|
|
@@ -571,6 +755,8 @@ class Browser {
|
|
|
571
755
|
}), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
|
|
572
756
|
this.signalChange();
|
|
573
757
|
if (params.frameId === this.#mainFrameId) {
|
|
758
|
+
if (typeof params.url === "string")
|
|
759
|
+
this.setActiveUrl(params.url);
|
|
574
760
|
this.#pauseUntil = performance.now() + this.#interactionPauses;
|
|
575
761
|
this.resetRecordingCursor().catch(() => {
|
|
576
762
|
return;
|
|
@@ -631,11 +817,10 @@ class Browser {
|
|
|
631
817
|
async startRecording() {
|
|
632
818
|
if (!this.#recordingPath)
|
|
633
819
|
return;
|
|
634
|
-
|
|
635
|
-
throw new Error("--recording requires ffmpeg on PATH");
|
|
820
|
+
await recordingEncoder();
|
|
636
821
|
const firstSegment = !this.#recordingDirectory;
|
|
637
822
|
if (firstSegment)
|
|
638
|
-
this.#recordingDirectory = await
|
|
823
|
+
this.#recordingDirectory = await mkdtemp2(join2(tmpdir2(), "jev-cdp-recording-"));
|
|
639
824
|
await this.call("Page.enable");
|
|
640
825
|
await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
|
|
641
826
|
const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
|
|
@@ -643,32 +828,68 @@ class Browser {
|
|
|
643
828
|
throw new Error("Could not read the recording viewport");
|
|
644
829
|
await this.animateCursor(viewport.width / 2, viewport.height / 2);
|
|
645
830
|
const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
|
|
646
|
-
const initialPath =
|
|
831
|
+
const initialPath = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
647
832
|
await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
|
|
648
833
|
if (firstSegment)
|
|
649
834
|
this.#recordingStartedAt = performance.now();
|
|
650
|
-
|
|
835
|
+
const initialElapsedMs = firstSegment ? 0 : performance.now() - this.#recordingStartedAt;
|
|
836
|
+
this.#recordingFrames.push({
|
|
837
|
+
path: initialPath,
|
|
838
|
+
elapsedMs: initialElapsedMs,
|
|
839
|
+
targetId: this.#targetId,
|
|
840
|
+
url: this.#activeUrl,
|
|
841
|
+
newTab: false
|
|
842
|
+
});
|
|
651
843
|
this.#stopRecordingEvents = this.#cdp.on("Page.screencastFrame", this.#sessionId, (params) => {
|
|
652
844
|
const frame = params;
|
|
653
845
|
this.call("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {
|
|
654
846
|
return;
|
|
655
847
|
});
|
|
656
848
|
const sequence = this.#recordingSequence++;
|
|
657
|
-
const path =
|
|
658
|
-
const
|
|
849
|
+
const path = join2(this.#recordingDirectory, `${String(sequence).padStart(6, "0")}.jpg`);
|
|
850
|
+
const capturedAt = performance.now();
|
|
851
|
+
const elapsedMs = sequence ? capturedAt - this.#recordingStartedAt : 0;
|
|
852
|
+
const url = this.#activeUrl;
|
|
853
|
+
const targetId = this.#targetId;
|
|
854
|
+
const notice = capturedAt < this.#newTabNoticeUntil;
|
|
855
|
+
const noticeUrl = notice ? this.#newTabNoticeUrl ?? undefined : undefined;
|
|
659
856
|
this.#recordingWrites = this.#recordingWrites.then(async () => {
|
|
660
857
|
await Bun.write(path, Buffer.from(frame.data, "base64"));
|
|
661
|
-
this.#recordingFrames.push({
|
|
858
|
+
this.#recordingFrames.push({
|
|
859
|
+
path,
|
|
860
|
+
elapsedMs,
|
|
861
|
+
targetId,
|
|
862
|
+
url,
|
|
863
|
+
newTab: notice,
|
|
864
|
+
...noticeUrl ? { noticeUrl } : {}
|
|
865
|
+
});
|
|
662
866
|
});
|
|
663
867
|
});
|
|
664
868
|
await this.call("Page.startScreencast", {
|
|
665
869
|
format: "jpeg",
|
|
666
870
|
quality: 70,
|
|
667
|
-
maxWidth:
|
|
668
|
-
maxHeight:
|
|
871
|
+
maxWidth: PAGE_WIDTH,
|
|
872
|
+
maxHeight: PAGE_HEIGHT,
|
|
669
873
|
everyNthFrame: 3
|
|
670
874
|
});
|
|
671
875
|
}
|
|
876
|
+
async recordNewTabNotice(url) {
|
|
877
|
+
if (!this.#recordingPath || !this.#recordingDirectory)
|
|
878
|
+
return;
|
|
879
|
+
const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
|
|
880
|
+
const elapsedMs = performance.now() - this.#recordingStartedAt;
|
|
881
|
+
const path = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
882
|
+
await Bun.write(path, Buffer.from(capture.data, "base64"));
|
|
883
|
+
this.#recordingFrames.push({
|
|
884
|
+
path,
|
|
885
|
+
elapsedMs,
|
|
886
|
+
targetId: this.#targetId,
|
|
887
|
+
url: this.#activeUrl,
|
|
888
|
+
newTab: true,
|
|
889
|
+
noticeUrl: url
|
|
890
|
+
});
|
|
891
|
+
return elapsedMs;
|
|
892
|
+
}
|
|
672
893
|
async finishRecording() {
|
|
673
894
|
if (!this.#recordingPath || !this.#recordingDirectory)
|
|
674
895
|
return;
|
|
@@ -681,8 +902,53 @@ class Browser {
|
|
|
681
902
|
await this.#recordingWrites;
|
|
682
903
|
if (!this.#recordingFrames.length)
|
|
683
904
|
throw new Error("Recording produced no browser frames");
|
|
905
|
+
const stoppedAt = performance.now() - this.#recordingStartedAt;
|
|
906
|
+
this.#recordingFrames.sort((a, b) => a.elapsedMs - b.elapsedMs);
|
|
907
|
+
for (const event of this.#recordingUrlEvents) {
|
|
908
|
+
let index = this.#recordingFrames.length - 1;
|
|
909
|
+
while (index >= 0 && (this.#recordingFrames[index].elapsedMs >= event.elapsedMs || this.#recordingFrames[index].targetId !== event.targetId))
|
|
910
|
+
index--;
|
|
911
|
+
const before = this.#recordingFrames[index];
|
|
912
|
+
if (!before || before.url === event.url)
|
|
913
|
+
continue;
|
|
914
|
+
const path = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
915
|
+
await Bun.write(path, Bun.file(before.path));
|
|
916
|
+
const frame = {
|
|
917
|
+
path,
|
|
918
|
+
elapsedMs: event.elapsedMs,
|
|
919
|
+
targetId: event.targetId,
|
|
920
|
+
url: event.url,
|
|
921
|
+
newTab: this.#newTabNoticeWindows.some((window) => event.elapsedMs >= window.startMs && event.elapsedMs < window.endMs),
|
|
922
|
+
noticeUrl: before.noticeUrl
|
|
923
|
+
};
|
|
924
|
+
this.#recordingFrames.push(frame);
|
|
925
|
+
this.#recordingFrames.sort((a, b) => a.elapsedMs - b.elapsedMs);
|
|
926
|
+
}
|
|
927
|
+
for (const { endMs } of this.#newTabNoticeWindows) {
|
|
928
|
+
if (this.#interactionPauses > 0)
|
|
929
|
+
continue;
|
|
930
|
+
let index = this.#recordingFrames.length - 1;
|
|
931
|
+
while (index >= 0 && this.#recordingFrames[index].elapsedMs >= endMs)
|
|
932
|
+
index--;
|
|
933
|
+
const before = this.#recordingFrames[index];
|
|
934
|
+
if (!before?.newTab || this.#recordingFrames[index + 1]?.elapsedMs === endMs)
|
|
935
|
+
continue;
|
|
936
|
+
const path = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
937
|
+
await Bun.write(path, Bun.file(before.path));
|
|
938
|
+
this.#recordingFrames.splice(index + 1, 0, {
|
|
939
|
+
path,
|
|
940
|
+
elapsedMs: endMs,
|
|
941
|
+
targetId: before.targetId,
|
|
942
|
+
url: before.url,
|
|
943
|
+
newTab: false
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
for (const frame of this.#recordingFrames) {
|
|
947
|
+
const jpeg = Buffer.from(await Bun.file(frame.path).arrayBuffer()).toString("base64");
|
|
948
|
+
await Bun.write(frame.path, Buffer.from(await this.composeFrame(jpeg, frame.url, frame.newTab ? frame.noticeUrl ?? frame.url : null), "base64"));
|
|
949
|
+
}
|
|
684
950
|
await mkdir(dirname(this.#recordingPath), { recursive: true });
|
|
685
|
-
const finishedAt =
|
|
951
|
+
const finishedAt = Math.max(stoppedAt, this.#recordingFrames.at(-1).elapsedMs + 40);
|
|
686
952
|
const quoted = (path) => path.replaceAll("'", "'\\''");
|
|
687
953
|
const lines = ["ffconcat version 1.0"];
|
|
688
954
|
for (let index = 0;index < this.#recordingFrames.length; index++) {
|
|
@@ -692,49 +958,27 @@ class Browser {
|
|
|
692
958
|
lines.push(`file '${quoted(frame.path)}'`, `duration ${duration.toFixed(4)}`);
|
|
693
959
|
}
|
|
694
960
|
lines.push(`file '${quoted(this.#recordingFrames.at(-1).path)}'`);
|
|
695
|
-
const manifest =
|
|
961
|
+
const manifest = join2(this.#recordingDirectory, "frames.ffconcat");
|
|
696
962
|
await Bun.write(manifest, `${lines.join(`
|
|
697
963
|
`)}
|
|
698
964
|
`);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
"-y",
|
|
702
|
-
"-f",
|
|
703
|
-
"concat",
|
|
704
|
-
"-safe",
|
|
705
|
-
"0",
|
|
706
|
-
"-i",
|
|
707
|
-
manifest,
|
|
708
|
-
"-vsync",
|
|
709
|
-
"vfr",
|
|
710
|
-
"-c:v",
|
|
711
|
-
"libx264",
|
|
712
|
-
"-pix_fmt",
|
|
713
|
-
"yuv420p",
|
|
714
|
-
"-movflags",
|
|
715
|
-
"+faststart",
|
|
716
|
-
this.#recordingPath
|
|
717
|
-
], { stdout: "ignore", stderr: "pipe" });
|
|
718
|
-
const stderr = await new Response(process2.stderr).text();
|
|
719
|
-
if (await process2.exited !== 0)
|
|
720
|
-
throw new Error(`Could not render recording: ${stderr.slice(-800)}`);
|
|
721
|
-
await rm(this.#recordingDirectory, { recursive: true, force: true });
|
|
965
|
+
await renderRecording(await recordingEncoder(), manifest, this.#recordingPath);
|
|
966
|
+
await rm2(this.#recordingDirectory, { recursive: true, force: true });
|
|
722
967
|
this.#recordingDirectory = undefined;
|
|
723
968
|
}
|
|
724
969
|
async saveFinalScreenshot() {
|
|
725
970
|
if (!this.#screenshotPath)
|
|
726
971
|
return;
|
|
727
972
|
await mkdir(dirname(this.#screenshotPath), { recursive: true });
|
|
728
|
-
if (this.#recordingDirectory) {
|
|
729
|
-
await this.#recordingWrites;
|
|
730
|
-
const finalFrame = this.#recordingFrames.at(-1);
|
|
731
|
-
if (finalFrame) {
|
|
732
|
-
await Bun.write(this.#screenshotPath, Bun.file(finalFrame.path));
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
973
|
const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 90 });
|
|
737
|
-
await
|
|
974
|
+
const url = await this.evaluate("location.href") ?? this.#activeUrl;
|
|
975
|
+
await Bun.write(this.#screenshotPath, Buffer.from(await this.composeFrame(capture.data, url, this.#hadNewTab ? url : null), "base64"));
|
|
976
|
+
}
|
|
977
|
+
async composeFrame(jpeg, url, newTabUrl) {
|
|
978
|
+
const composed = await this.evaluate(composeFrameExpression(jpeg, url, newTabUrl), true);
|
|
979
|
+
if (!composed)
|
|
980
|
+
throw new Error("Could not compose browser recording frame");
|
|
981
|
+
return composed;
|
|
738
982
|
}
|
|
739
983
|
async animateCursor(x, y, click = false) {
|
|
740
984
|
if (!this.#recordingPath)
|
|
@@ -881,13 +1125,25 @@ class Browser {
|
|
|
881
1125
|
const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
|
|
882
1126
|
try {
|
|
883
1127
|
await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
|
|
884
|
-
width:
|
|
885
|
-
height:
|
|
1128
|
+
width: PAGE_WIDTH,
|
|
1129
|
+
height: PAGE_HEIGHT,
|
|
886
1130
|
deviceScaleFactor: 1,
|
|
887
1131
|
mobile: false
|
|
888
1132
|
}, attached.sessionId);
|
|
889
1133
|
} finally {
|
|
890
1134
|
if (this.#switchOnPopup) {
|
|
1135
|
+
const noticeStart = await this.recordNewTabNotice(target.url);
|
|
1136
|
+
const noticeStartAt = noticeStart === undefined ? performance.now() : this.#recordingStartedAt + noticeStart;
|
|
1137
|
+
this.#newTabNoticeUrl = target.url;
|
|
1138
|
+
this.#newTabNoticeUntil = this.#interactionPauses > 0 ? Infinity : 0;
|
|
1139
|
+
const noticeWindow = noticeStart === undefined ? undefined : {
|
|
1140
|
+
startMs: noticeStart,
|
|
1141
|
+
endMs: noticeStart + Math.max(40, this.#interactionPauses)
|
|
1142
|
+
};
|
|
1143
|
+
if (noticeWindow)
|
|
1144
|
+
this.#newTabNoticeWindows.push(noticeWindow);
|
|
1145
|
+
if (this.#interactionPauses > 0)
|
|
1146
|
+
await Bun.sleep(Math.max(0, noticeStartAt + this.#interactionPauses - performance.now()));
|
|
891
1147
|
if (this.#recordingPath) {
|
|
892
1148
|
await this.call("Page.stopScreencast").catch(() => {
|
|
893
1149
|
return;
|
|
@@ -896,13 +1152,20 @@ class Browser {
|
|
|
896
1152
|
this.#stopRecordingEvents = undefined;
|
|
897
1153
|
await this.#recordingWrites;
|
|
898
1154
|
}
|
|
1155
|
+
if (noticeWindow && this.#interactionPauses > 0)
|
|
1156
|
+
noticeWindow.endMs = performance.now() - this.#recordingStartedAt;
|
|
1157
|
+
this.#newTabNoticeUrl = null;
|
|
1158
|
+
this.#newTabNoticeUntil = 0;
|
|
899
1159
|
this.#sessionId = attached.sessionId;
|
|
900
1160
|
this.#targetId = target.id;
|
|
1161
|
+
this.#activeUrl = target.url;
|
|
901
1162
|
this.#loadingFrames.clear();
|
|
902
1163
|
await this.watchConsole();
|
|
903
1164
|
await this.watchPageLoads();
|
|
904
1165
|
this.#switchOnPopup = false;
|
|
1166
|
+
this.#hadNewTab = true;
|
|
905
1167
|
await this.startRecording();
|
|
1168
|
+
this.#pauseUntil = performance.now();
|
|
906
1169
|
} else {
|
|
907
1170
|
await this.#cdp.command("Target.detachFromTarget", { sessionId: attached.sessionId });
|
|
908
1171
|
}
|
|
@@ -1080,7 +1343,7 @@ ${child.text}`.slice(0, 6000);
|
|
|
1080
1343
|
const page = { ...info, frames, transitions: [...this.#transitions], fingerprint: fingerprint(info) };
|
|
1081
1344
|
if (screenshot) {
|
|
1082
1345
|
const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
|
|
1083
|
-
page.screenshot = capture.data;
|
|
1346
|
+
page.screenshot = await this.composeFrame(capture.data, info.url, performance.now() < this.#newTabNoticeUntil ? this.#newTabNoticeUrl : null);
|
|
1084
1347
|
}
|
|
1085
1348
|
return page;
|
|
1086
1349
|
}
|
|
@@ -1307,9 +1570,9 @@ ${child.text}`.slice(0, 6000);
|
|
|
1307
1570
|
}
|
|
1308
1571
|
|
|
1309
1572
|
// src/model.ts
|
|
1310
|
-
import { mkdtemp as
|
|
1311
|
-
import { tmpdir as
|
|
1312
|
-
import { join as
|
|
1573
|
+
import { mkdtemp as mkdtemp3, readFile, rm as rm3 } from "fs/promises";
|
|
1574
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1575
|
+
import { join as join3 } from "path";
|
|
1313
1576
|
|
|
1314
1577
|
// src/questions.ts
|
|
1315
1578
|
var NEXT_ACTION = `Advance the user's entire goal from the CURRENT page using one operation.
|
|
@@ -1549,9 +1812,9 @@ async function codexFieldText(context) {
|
|
|
1549
1812
|
if (!["none", "low", "medium", "high", "xhigh", "max"].includes(reasoning)) {
|
|
1550
1813
|
throw new Error("Unsupported Codex reasoning effort");
|
|
1551
1814
|
}
|
|
1552
|
-
const folder = await
|
|
1553
|
-
const outputPath =
|
|
1554
|
-
const schemaPath =
|
|
1815
|
+
const folder = await mkdtemp3(join3(tmpdir3(), "jev-codex-text-"));
|
|
1816
|
+
const outputPath = join3(folder, "output.json");
|
|
1817
|
+
const schemaPath = join3(folder, "schema.json");
|
|
1555
1818
|
await Bun.write(schemaPath, JSON.stringify(text_value_schema_default));
|
|
1556
1819
|
const childEnvironment = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !["TYPESAFE_API_KEY", "TEXT_MODEL_API_KEY"].includes(name)).map(([name, value]) => [name, value]));
|
|
1557
1820
|
const command = [
|
|
@@ -1614,7 +1877,7 @@ ${JSON.stringify(context)}`;
|
|
|
1614
1877
|
usage: {}
|
|
1615
1878
|
}];
|
|
1616
1879
|
} finally {
|
|
1617
|
-
await
|
|
1880
|
+
await rm3(folder, { recursive: true, force: true });
|
|
1618
1881
|
}
|
|
1619
1882
|
}
|
|
1620
1883
|
async function apiFieldText(context) {
|
|
@@ -1985,7 +2248,7 @@ Known field values:
|
|
|
1985
2248
|
Evidence and output:
|
|
1986
2249
|
--recording <file.mp4> Record the complete goal with an animated cursor.
|
|
1987
2250
|
Requires FFmpeg.
|
|
1988
|
-
--screenshot <file.jpg> Save the
|
|
2251
|
+
--screenshot <file.jpg> Save a 1280x720 image with the URL bar.
|
|
1989
2252
|
--final-state Add the final semantic page state to stdout JSON.
|
|
1990
2253
|
-h, --help Show this help and exit.
|
|
1991
2254
|
|
|
@@ -2307,8 +2570,11 @@ async function doctor(args) {
|
|
|
2307
2570
|
required: true
|
|
2308
2571
|
});
|
|
2309
2572
|
}
|
|
2310
|
-
|
|
2311
|
-
|
|
2573
|
+
try {
|
|
2574
|
+
checks.push({ name: "recording", status: "ok", detail: await checkRecordingEncoder(), required: false });
|
|
2575
|
+
} catch (error) {
|
|
2576
|
+
checks.push({ name: "recording", status: "warning", detail: `${error instanceof Error ? error.message : String(error)}; --recording will be unavailable`, required: false });
|
|
2577
|
+
}
|
|
2312
2578
|
if (process.env.TEXT_MODEL_PROVIDER === "api") {
|
|
2313
2579
|
checks.push(process.env.TEXT_MODEL_API_KEY ? { name: "text-helper", status: "ok", detail: `API helper configured (${process.env.TEXT_MODEL ?? "deepseek-chat"})`, required: false } : { name: "text-helper", status: "warning", detail: "TEXT_MODEL_PROVIDER=api but TEXT_MODEL_API_KEY is not set", required: false });
|
|
2314
2580
|
} else {
|