jevway 0.0.0-stage → 0.1.2

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 (43) hide show
  1. package/INSTALL-FOR-AGENTS.md +138 -0
  2. package/LICENSE +11 -0
  3. package/LICENSE.jev-ultrafast +21 -0
  4. package/README.md +218 -2
  5. package/dist/browser/bot-wall.js +105 -0
  6. package/dist/browser/cdp.js +112 -0
  7. package/dist/browser/chrome.js +147 -0
  8. package/dist/browser/devices.js +20 -0
  9. package/dist/browser/frames.js +516 -0
  10. package/dist/browser/snapshot.js +611 -0
  11. package/dist/browser/tab.js +938 -0
  12. package/dist/browser/words.js +51 -0
  13. package/dist/cli.js +112 -0
  14. package/dist/jev/action-space.js +48 -0
  15. package/dist/jev/box.js +58 -0
  16. package/dist/jev/decide.js +405 -0
  17. package/dist/jev/instructions.js +67 -0
  18. package/dist/jev/model.js +189 -0
  19. package/dist/jev/replay.js +213 -0
  20. package/dist/jev/secrets.js +60 -0
  21. package/dist/journeys/diagnostics.js +85 -0
  22. package/dist/journeys/journey-doc.js +50 -0
  23. package/dist/journeys/journey-session.js +503 -0
  24. package/dist/journeys/pick-next-move.js +59 -0
  25. package/dist/journeys/render-viewer.js +153 -0
  26. package/dist/journeys/screen-boxes.js +49 -0
  27. package/dist/journeys/screen-judgments.js +149 -0
  28. package/dist/mcp.js +361 -0
  29. package/dist/setup/doctor-command.js +81 -0
  30. package/dist/setup/doctor.js +163 -0
  31. package/dist/setup/env-file.js +50 -0
  32. package/dist/task/checks.js +156 -0
  33. package/dist/task/evidence.js +96 -0
  34. package/dist/task/hosts.js +91 -0
  35. package/dist/task/look.js +17 -0
  36. package/dist/task/recording-page.js +310 -0
  37. package/dist/task/recording.js +232 -0
  38. package/dist/task/run-limit.js +66 -0
  39. package/dist/task/run-task.js +1607 -0
  40. package/dist/task/start-task.js +91 -0
  41. package/dist/task/upload-files.js +43 -0
  42. package/package.json +80 -4
  43. package/skill/jevway/SKILL.md +73 -0
@@ -0,0 +1,310 @@
1
+ export const escapeHtml = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
2
+ const escape = escapeHtml;
3
+ /** Switzer from Fontshare; the system stack takes over when it can't load (offline, file://). */
4
+ export const FONT_LINK = 'https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap';
5
+ // --- words and numbers a reader compares
6
+ export const statusWord = (status) => status.replaceAll('_', ' ');
7
+ export const clock = (s) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`;
8
+ export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
9
+ export const indexLabel = (i) => String(i + 1).padStart(2, '0');
10
+ export const seconds = (ms) => `${(ms / 1000).toFixed(1)} s`;
11
+ export const thousands = (n) => `${(n / 1000).toFixed(1)}k`;
12
+ // Two significant digits: a run costs fractions of a cent.
13
+ export const dollars = (usd) => (usd === 0 ? '$0' : usd < 1 ? `$${usd.toPrecision(2)}` : `$${usd.toFixed(2)}`);
14
+ export const pathOf = (url) => {
15
+ try {
16
+ const { pathname, search } = new URL(url);
17
+ return pathname + search;
18
+ }
19
+ catch {
20
+ return url;
21
+ }
22
+ };
23
+ /** A run's Finished shots carry the status (lowercase) as their operation; actions are uppercase. */
24
+ export const isFinished = (step) => step.operation === step.operation.toLowerCase();
25
+ /** One hairline row of a fact list: the label in column 1, the body across columns 2–4. */
26
+ export const fact = (label, body) => `<div class="fact grid4"><dt>${label}</dt><dd class="c2s3">${body}</dd></div>`;
27
+ /**
28
+ * The video frame's opening tag, shaped like the recording: its own aspect, a portrait (phone) one capped in height and
29
+ * centred so it never fills a whole screen or sits in a wide empty band. Unknown (an old recording): the video takes
30
+ * its own size.
31
+ *
32
+ * @example
33
+ * screenFrame({ width: 393, height: 852 }) // '<div class="screen portrait" style="aspect-ratio:393 / 852">'
34
+ */
35
+ export const screenFrame = (viewport) => {
36
+ if (!viewport)
37
+ return '<div class="screen intrinsic">';
38
+ return `<div class="screen${viewport.height > viewport.width ? ' portrait' : ''}" style="aspect-ratio:${viewport.width} / ${viewport.height}">`;
39
+ };
40
+ /** A step strip's opening tag: its shots take the recording's aspect, a portrait strip fits twice as many. */
41
+ export const stripOpening = (label, viewport) => {
42
+ const shape = viewport ? `${viewport.height > viewport.width ? ' portrait' : ''}" style="--shot-aspect:${viewport.width} / ${viewport.height}` : '';
43
+ return `<div class="strip${shape}" aria-label="Steps of ${label}">`;
44
+ };
45
+ /**
46
+ * One step as a card: the shot, then its number, video time and confidence, what happened, and where. The run's
47
+ * Finished shots (lowercase operation, the status) read "Finished: <status>", their reason in the alt text. With
48
+ * `href` the whole card is a link (the overview); otherwise it seeks the player it sits in.
49
+ */
50
+ export const shotCard = (step, i, dir, open) => {
51
+ const finished = isFinished(step);
52
+ const label = finished ? `Finished: ${statusWord(step.operation)}` : step.description;
53
+ const timed = 'video' in open && open.video && step.video_at !== undefined;
54
+ const meta = [
55
+ indexLabel(i),
56
+ ...(step.video_at !== undefined && ('href' in open || timed) ? [clock(step.video_at)] : []),
57
+ ...(step.unreachable ? [`<span class="missed">not reached${(step.attempts ?? 1) > 1 ? ` ×${step.attempts}` : ''}: covered</span>`] : step.confidence !== undefined && !finished ? [step.confidence.toFixed(2)] : []),
58
+ ].join(' · ');
59
+ const src = `${dir}${step.file}`;
60
+ const image = `<img src="${escape(src)}" alt="${escape(step.description)}" loading="lazy" decoding="async" width="1120" height="780">`;
61
+ const text = `<span class="what">${escape(label)}</span><span class="where">${escape(pathOf(step.url))}</span>`;
62
+ const className = `shot${finished ? ' finished' : ''}`;
63
+ if ('href' in open)
64
+ return `<a class="${className}" href="${escape(open.href)}">${image}<span class="caption"><span class="meta num">${meta}</span>${text}</span></a>`;
65
+ // In a player the whole card seeks (the button gives it a keyboard stop); the full-size link sits outside the button.
66
+ const data = timed ? ` data-at="${step.video_at}"` : ` data-src="${escape(src)}"`;
67
+ return `<div class="${className}" data-index="${i}"${data}><button type="button" class="seek" aria-label="Show step ${i + 1}">${image}</button>
68
+ <span class="caption"><span class="meta-line"><span class="meta num">${meta}</span><a class="full-size" href="${escape(src)}" target="_blank" rel="noopener">Full size</a></span>${text}</span></div>`;
69
+ };
70
+ /**
71
+ * The video (or the first shot when there is no video), its timeline with one tick per step, the `bar` (a run switcher
72
+ * and the clock), then the step strip, all shaped like the recording's `viewport`. Only a `featured` player fetches its poster;
73
+ * the others hold it in `data-poster` until shown.
74
+ *
75
+ * @example
76
+ * playerMarkup({ dir: 'run-1/', video: 'run.mp4', steps, label: 'run 1', featured: true, viewport: { width: 1120, height: 780 }, bar })
77
+ */
78
+ export const playerMarkup = ({ dir, video, steps, label, featured, viewport, bar, preload = 'none', }) => {
79
+ const screen = screenFrame(viewport);
80
+ const strip = stripOpening(label, viewport);
81
+ const poster = `${dir}${steps[0].file}`;
82
+ const timed = steps.filter((s) => s.video_at !== undefined);
83
+ if (!video || !timed.length) {
84
+ return `${screen}<img data-frame src="${escape(poster)}" alt="${escape(steps[0].description)}"></div>
85
+ <p class="small screen-note">No video for this run (it needs ffmpeg): pick a step to see its shot.</p>
86
+ ${bar}
87
+ ${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false })).join('')}</div>`;
88
+ }
89
+ // The last frame holds 1.5 s (recording.ts), so this is the video's length until its metadata says otherwise.
90
+ const duration = Math.max(...timed.map((s) => s.video_at)) + 1.5;
91
+ // Wide enough that the two closest ticks don't overlap (about 1.75rem apart); a long one scrolls inside its own box.
92
+ const times = timed.map((s) => s.video_at).sort((a, b) => a - b);
93
+ const closest = Math.min(...times.slice(1).map((t, i) => t - times[i]).filter((gap) => gap > 0), duration);
94
+ const width = Math.min(200, Math.max(steps.length * 2, (1.75 * duration) / closest));
95
+ const ticks = steps
96
+ .map((s, i) => (s.video_at === undefined ? '' : `<button type="button" class="tick" aria-label="Play from step ${i + 1}" data-at="${s.video_at}" data-index="${i}" style="left:${((s.video_at / duration) * 100).toFixed(2)}%">${i + 1}</button>`))
97
+ .join('');
98
+ const posterAttribute = featured ? ` poster="${escape(poster)}"` : ` data-poster="${escape(poster)}"`;
99
+ return `${screen}<video src="${escape(`${dir}${video}`)}" preload="${preload}"${posterAttribute} controls muted playsinline aria-label="Screencast of ${label}"></video></div>
100
+ <div class="timeline-scroll"><div class="timeline" style="min-width:${width.toFixed(1)}rem"><div class="track"></div><div class="played"></div><div class="ticks">${ticks}</div></div></div>
101
+ ${bar}
102
+ ${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: true })).join('')}</div>`;
103
+ };
104
+ // --- the stylesheet: tokens, the grid, type, the player and fact rows
105
+ export const BASE_STYLE = `
106
+ :root {
107
+ --background: oklch(1 0 0);
108
+ --foreground: oklch(0.205 0 0);
109
+ --muted: oklch(0.922 0 0);
110
+ --muted-foreground: oklch(0.47 0 0);
111
+ --held: oklch(0.55 0.13 150);
112
+ --reached: oklch(0.64 0.14 70);
113
+ --missed: oklch(0.56 0.19 27);
114
+ --font-sans: "Switzer", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
115
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
116
+ --wrap: 85rem;
117
+ --gap: 1rem;
118
+ color-scheme: light;
119
+ }
120
+ @media (prefers-color-scheme: dark) {
121
+ :root:not([data-theme="light"]) {
122
+ --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0);
123
+ --muted-foreground: oklch(0.708 0 0); --held: oklch(0.72 0.14 150); --reached: oklch(0.8 0.14 80);
124
+ --missed: oklch(0.7 0.17 27); color-scheme: dark;
125
+ }
126
+ }
127
+ :root[data-theme="dark"] {
128
+ --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0);
129
+ --muted-foreground: oklch(0.708 0 0); --held: oklch(0.72 0.14 150); --reached: oklch(0.8 0.14 80);
130
+ --missed: oklch(0.7 0.17 27); color-scheme: dark;
131
+ }
132
+ *, *::before, *::after { box-sizing: border-box; }
133
+ html { -webkit-text-size-adjust: 100%; }
134
+ body { margin: 0; background: var(--background); color: var(--foreground); font: 400 1rem/1.5 var(--font-sans); -webkit-font-smoothing: antialiased; }
135
+ h1, h2, h3, p, dl, dd, ul, figure { margin: 0; }
136
+ ul { padding: 0; list-style: none; }
137
+ a { color: inherit; text-decoration: none; }
138
+ a:hover { color: var(--muted-foreground); }
139
+ button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; text-align: inherit; }
140
+ img, video { display: block; max-width: 100%; }
141
+ code { font-family: var(--font-mono); font-size: 0.8125rem; }
142
+ :focus-visible { outline: 2px solid var(--foreground); outline-offset: 3px; border-radius: 0.25rem; }
143
+ [hidden] { display: none !important; }
144
+
145
+ /* Frame and the one grid */
146
+ .wrapper { max-width: var(--wrap); margin-inline: auto; padding-left: max(1rem, env(safe-area-inset-left)); padding-right: max(1rem, env(safe-area-inset-right)); }
147
+ .grid4 { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.5rem var(--gap); }
148
+ .grid4 > * { min-width: 0; }
149
+ @media (min-width: 64rem) {
150
+ .wrapper { padding-left: max(2rem, env(safe-area-inset-left)); padding-right: max(2rem, env(safe-area-inset-right)); }
151
+ .grid4 { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: baseline; }
152
+ .c1 { grid-column: 1; } .c2 { grid-column: 2; } .c3 { grid-column: 3; } .c4 { grid-column: 4; }
153
+ .c2s2 { grid-column: 2 / span 2; } .c3s2 { grid-column: 3 / span 2; } .c2s3 { grid-column: 2 / span 3; } .c1s3 { grid-column: 1 / span 3; }
154
+ .end { justify-self: end; text-align: right; }
155
+ }
156
+
157
+ /* Type: the theme's low end, one display size */
158
+ .meta { font-size: 0.75rem; line-height: 1rem; color: var(--muted-foreground); text-transform: uppercase; letter-spacing: 0.02em; }
159
+ .small { font-size: 0.875rem; line-height: 1.375rem; color: var(--muted-foreground); overflow-wrap: anywhere; }
160
+ .job-title { font-size: clamp(1.75rem, 1.3rem + 1.6vw, 2.5rem); font-weight: 500; line-height: 1.15; letter-spacing: -0.025em; text-wrap: balance; }
161
+ .section-title { font-size: clamp(1.125rem, 1rem + 0.6vw, 1.5rem); font-weight: 500; line-height: 1.375; letter-spacing: -0.02em; }
162
+ .num, .figure, .clock { font-variant-numeric: tabular-nums; }
163
+ .held { color: var(--held); } .reached { color: var(--reached); } .missed { color: var(--missed); } .unrun { color: var(--muted-foreground); }
164
+ .marks { letter-spacing: 0.15em; text-transform: none; }
165
+ .textlinks { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; font-size: 0.75rem; line-height: 1rem; color: var(--muted-foreground); }
166
+ .textlinks button:hover, .textlinks [aria-pressed="true"] { color: var(--foreground); }
167
+ .textlinks [aria-pressed="true"] { text-decoration: underline; text-underline-offset: 4px; }
168
+
169
+ /* Step strip: real shots, one per grid column on wide screens */
170
+ .strip { position: relative; display: flex; gap: var(--gap); overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; padding-bottom: 0.75rem; scrollbar-width: thin; }
171
+ .shot { flex: 0 0 72%; display: flex; flex-direction: column; gap: 0.625rem; scroll-snap-align: start; min-width: 0; color: var(--foreground); }
172
+ @media (min-width: 40rem) { .shot { flex-basis: calc((100% - 2 * var(--gap)) / 3); } }
173
+ @media (min-width: 64rem) { .shot { flex-basis: calc((100% - 3 * var(--gap)) / 4); } }
174
+ .strip.portrait .shot { flex-basis: calc((100% - var(--gap)) / 2.6); }
175
+ @media (min-width: 40rem) { .strip.portrait .shot { flex-basis: calc((100% - 4 * var(--gap)) / 5); } }
176
+ @media (min-width: 64rem) { .strip.portrait .shot { flex-basis: calc((100% - 7 * var(--gap)) / 8); } }
177
+ .shot img { width: 100%; height: auto; aspect-ratio: var(--shot-aspect, 1120 / 780); object-fit: cover; object-position: top; border-radius: 0.75rem; background: var(--muted); transition: opacity 160ms ease; }
178
+ .caption { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; }
179
+ .caption .meta, .caption .where { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
180
+ .meta-line { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; min-width: 0; }
181
+ .full-size { flex: none; font-size: 0.75rem; line-height: 1rem; color: var(--muted-foreground); }
182
+ .full-size:hover { color: var(--foreground); }
183
+ .seek { display: block; width: 100%; border-radius: 0.75rem; }
184
+ .player .shot { cursor: pointer; }
185
+ .caption .where { font-size: 0.75rem; line-height: 1rem; color: var(--muted-foreground); }
186
+ .what { font-size: 0.875rem; line-height: 1.25rem; height: 2.5rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
187
+ .shot.finished .what { color: var(--muted-foreground); }
188
+ a.shot:hover img { opacity: 0.85; }
189
+ .player .shot img { opacity: 0.55; }
190
+ .player .shot[aria-current="step"] img, .player .shot:hover img { opacity: 1; }
191
+ .player .shot[aria-current="step"] .meta { color: var(--foreground); }
192
+
193
+ /* A view: its head, the player, the facts */
194
+ .job-head { padding-block: 2rem 0; }
195
+ .job-head .story { margin-top: 0.75rem; }
196
+ .back { display: inline-flex; align-items: center; gap: 0.375rem; color: var(--foreground); }
197
+ .screen { margin-top: 2rem; width: 100%; aspect-ratio: 16 / 10; border-radius: 2rem; overflow: hidden; background: var(--muted); }
198
+ .screen video, .screen img { width: 100%; height: 100%; object-fit: contain; background: var(--muted); }
199
+ /* A phone recording: as wide as its own aspect needs at a capped height, centred in the grid */
200
+ .screen.portrait { width: auto; max-width: 100%; height: min(72svh, 48rem); margin-inline: auto; }
201
+ .screen.intrinsic { aspect-ratio: auto; width: fit-content; max-width: 100%; margin-inline: auto; }
202
+ .screen.intrinsic video, .screen.intrinsic img { width: auto; height: auto; max-height: min(72svh, 48rem); }
203
+ .screen.quiet { aspect-ratio: auto; max-height: none; display: grid; align-content: center; gap: 0.5rem; min-height: 14rem; padding: 2rem; }
204
+ .screen.quiet p { max-width: 40rem; }
205
+ .screen-note { margin-top: 0.75rem; }
206
+ .timeline-scroll { overflow-x: auto; margin-top: 1rem; scrollbar-width: none; }
207
+ .timeline { position: relative; height: 1.75rem; }
208
+ .timeline .track, .timeline .played { position: absolute; left: 0; top: 50%; height: 1px; }
209
+ .timeline .track { right: 0; background: var(--muted); }
210
+ .timeline .played { width: 0; background: var(--foreground); }
211
+ .timeline .ticks { position: absolute; inset: 0 0.875rem; }
212
+ .tick { position: absolute; top: 50%; transform: translate(-50%, -50%); min-width: 1.5rem; padding: 0.25rem 0.3rem; border-radius: 99px; border: 1px solid var(--muted); background: var(--background); color: var(--muted-foreground); font-size: 0.625rem; line-height: 1; text-align: center; font-variant-numeric: tabular-nums; }
213
+ .tick:hover { color: var(--foreground); border-color: var(--foreground); }
214
+ .tick[aria-current="step"] { color: var(--background); background: var(--foreground); border-color: var(--foreground); }
215
+ .player-bar { margin-top: 0.75rem; }
216
+ .player .strip { margin-top: 2rem; }
217
+ .run-head { margin-top: 4rem; margin-bottom: 2rem; }
218
+ .fact { border-top: 1px solid var(--muted); padding-block: 1.25rem; }
219
+ .fact dt { font-size: 0.75rem; line-height: 1.375rem; color: var(--muted-foreground); text-transform: uppercase; letter-spacing: 0.02em; }
220
+ .fact dd, .fact p { font-size: 0.9375rem; line-height: 1.5rem; overflow-wrap: anywhere; }
221
+ .kv { color: var(--muted-foreground); margin-right: 0.375rem; }
222
+ .address { color: var(--muted-foreground); font-family: var(--font-mono); font-size: 0.8125rem; overflow-wrap: anywhere; }
223
+
224
+ /* Footer */
225
+ footer .wrapper { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5rem 1rem; padding-block: 2rem; border-top: 1px solid var(--muted); font-size: 0.75rem; line-height: 1rem; color: var(--muted-foreground); }
226
+ footer span { overflow-wrap: anywhere; }
227
+
228
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
229
+ `;
230
+ /**
231
+ * Keeps each player (`.run`) on one step: its video, its timeline ticks and its step shots. Clicking a tick or a shot
232
+ * seeks (and plays, muted); playing marks the current step's tick and shot and scrolls the strip, never the page.
233
+ * Without a video, a shot shows in the frame. A `seek-step` event (`detail.index`) goes to a step without playing.
234
+ */
235
+ export const PLAYER_SCRIPT = `<script>
236
+ (() => {
237
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
238
+ const clock = (s) => Math.floor(s / 60) + ':' + (s % 60).toFixed(1).padStart(4, '0');
239
+ const wire = (panel) => {
240
+ const video = panel.querySelector('video');
241
+ const frame = panel.querySelector('.screen img[data-frame]');
242
+ const strip = panel.querySelector('.strip');
243
+ const timeline = panel.querySelector('.timeline-scroll');
244
+ const ticks = [...panel.querySelectorAll('.tick')];
245
+ const shots = [...panel.querySelectorAll('.strip .shot')];
246
+ const played = panel.querySelector('.played');
247
+ const clockText = panel.querySelector('.clock');
248
+ let current = -1;
249
+ const reveal = (shot) => {
250
+ if (!shot || !strip) return;
251
+ const left = shot.offsetLeft - (strip.clientWidth - shot.offsetWidth) / 2;
252
+ strip.scrollTo({ left: Math.max(0, left), behavior: reduceMotion ? 'auto' : 'smooth' });
253
+ };
254
+ const mark = (index) => {
255
+ if (index === current) return;
256
+ current = index;
257
+ for (const list of [ticks, shots]) list.forEach((el, n) => (n === index ? el.setAttribute('aria-current', 'step') : el.removeAttribute('aria-current')));
258
+ reveal(shots[index]);
259
+ const tick = ticks[index];
260
+ if (tick && timeline && timeline.scrollWidth > timeline.clientWidth) {
261
+ timeline.scrollTo({ left: Math.max(0, tick.offsetLeft - timeline.clientWidth / 2), behavior: reduceMotion ? 'auto' : 'smooth' });
262
+ }
263
+ };
264
+ const stepAt = (time) => {
265
+ let index = 0;
266
+ shots.forEach((shot, n) => { if (shot.dataset.at !== undefined && time + 0.05 >= Number(shot.dataset.at)) index = n; });
267
+ return index;
268
+ };
269
+ const seek = (index, play) => {
270
+ mark(index);
271
+ const shot = shots[index];
272
+ if (frame && shot && shot.dataset.src) frame.src = shot.dataset.src;
273
+ if (!video || !shot || shot.dataset.at === undefined) return;
274
+ video.currentTime = Number(shot.dataset.at);
275
+ if (play) video.play().catch(() => {});
276
+ // A paused video shows its poster until it has data: show the linked step's own shot meanwhile.
277
+ else {
278
+ if (video.readyState < 2) video.poster = shot.querySelector('img').getAttribute('src');
279
+ if (clockText) clockText.textContent = clock(Number(shot.dataset.at));
280
+ }
281
+ };
282
+ // A reader's seek: plays from there, and the address names the step so it can be shared (#<view>-r<run>-s<step>).
283
+ const choose = (index) => {
284
+ seek(index, true);
285
+ const view = panel.closest('.job-view');
286
+ if (view && panel.dataset.run) history.replaceState(null, '', '#' + view.id + '-r' + panel.dataset.run + '-s' + (index + 1));
287
+ };
288
+ ticks.forEach((tick) => tick.addEventListener('click', () => choose(Number(tick.dataset.index))));
289
+ // A click anywhere on a card seeks, except on its full-size link.
290
+ shots.forEach((shot, n) => shot.addEventListener('click', (event) => { if (!event.target.closest('a')) choose(n); }));
291
+ // A link to a step (the router): go there without playing.
292
+ panel.addEventListener('seek-step', (event) => seek(event.detail.index, false));
293
+ if (!video) return;
294
+ const place = () => {
295
+ if (!video.duration || !isFinite(video.duration)) return;
296
+ for (const tick of ticks) tick.style.left = Math.min(100, (Number(tick.dataset.at) / video.duration) * 100) + '%';
297
+ };
298
+ video.addEventListener('loadedmetadata', place);
299
+ const follow = () => {
300
+ const time = video.currentTime;
301
+ mark(stepAt(time));
302
+ if (clockText) clockText.textContent = clock(time) + (video.duration && isFinite(video.duration) ? ' / ' + clock(video.duration) : '');
303
+ if (played && video.duration) played.style.width = Math.min(100, (time / video.duration) * 100) + '%';
304
+ };
305
+ video.addEventListener('timeupdate', follow);
306
+ video.addEventListener('seeked', follow);
307
+ };
308
+ document.querySelectorAll('.run').forEach(wire);
309
+ })();
310
+ </script>`;
@@ -0,0 +1,232 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { BASE_STYLE, clock, dollars, escapeHtml, fact, FONT_LINK, playerMarkup, plural, PLAYER_SCRIPT, seconds, statusWord, thousands, } from './recording-page.js';
6
+ const VIDEO = 'run.mp4';
7
+ // The video shows what changed, not how long anyone waited: no frame holds longer than this.
8
+ const LONGEST_FRAME_S = 1.5;
9
+ const SHORTEST_FRAME_S = 1 / 30;
10
+ /**
11
+ * How long each screencast frame stays on screen: until the next one, clamped so idle stretches
12
+ * (a slow request, the agent between calls) don't freeze the video. The last frame holds the longest.
13
+ *
14
+ * @example
15
+ * frameDurations([10, 10.2, 19]) // [0.2, 1.5, 1.5]
16
+ */
17
+ export const frameDurations = (times) => times.map((at, i) => i === times.length - 1 ? LONGEST_FRAME_S : Math.min(LONGEST_FRAME_S, Math.max(SHORTEST_FRAME_S, times[i + 1] - at)));
18
+ /** Seconds into the encoded video at which the wall-clock moment `at` appears. */
19
+ export const videoOffset = (times, durations, at) => times.reduce((offset, time, i) => (time < at ? offset + durations[i] : offset), 0);
20
+ // Row labels carry a whole table row; a sentence needs its first words.
21
+ const shorten = (label, max = 70) => {
22
+ const text = label.replace(/\s+/g, ' ').trim();
23
+ return text.length > max ? `${text.slice(0, max).replace(/\s+\S*$/, '')}…` : text;
24
+ };
25
+ /**
26
+ * A step as one plain sentence for people reading a recording.
27
+ *
28
+ * @example
29
+ * describeStep({ operation: 'TYPE_TEXT', element: 'Search traces', valueKey: 'search_query' })
30
+ * // 'Type the "search_query" value into "Search traces"'
31
+ */
32
+ export const describeStep = ({ operation, element = '', valueKey, option }) => {
33
+ const target = `"${shorten(element)}"`;
34
+ switch (operation) {
35
+ case 'CLICK':
36
+ return `Click ${target}`;
37
+ case 'CLICK_AT':
38
+ return `Click the control at ${target} by its on-screen position`;
39
+ case 'TYPE_TEXT':
40
+ return `Type the "${valueKey}" value into ${target}`;
41
+ case 'SELECT':
42
+ return option ? `Choose "${option}" in ${target}` : `Choose an option in ${target}`;
43
+ case 'PRESS_ENTER':
44
+ return `Press Enter in ${target} to submit it`;
45
+ case 'PRESS_ESCAPE':
46
+ return 'Press Escape to close the open menu or popup';
47
+ case 'SCROLL_DOWN':
48
+ return 'Scroll down to reveal more of the page';
49
+ case 'SCROLL_UP':
50
+ return 'Scroll back up';
51
+ case 'WAIT':
52
+ return 'Wait for the page to finish updating';
53
+ default:
54
+ return `${operation} ${target}`;
55
+ }
56
+ };
57
+ /**
58
+ * A human-facing record of a run: a screencast video (`run.mp4`, needs ffmpeg), a sharp screenshot before every
59
+ * executed action with its target outlined and a one-sentence description, the final page, `summary.json`
60
+ * and an `index.html` viewer. Separate from what the model sees.
61
+ *
62
+ * @example
63
+ * const task = new BrowserTask(tab, ask, { goal, recordDir: 'runs/langfuse' });
64
+ * await task.run(); // runs/langfuse/run.mp4, 01.jpg … index.html
65
+ */
66
+ export class Recording {
67
+ steps = [];
68
+ summaries = [];
69
+ #dir;
70
+ #frames = [];
71
+ /** The page size the screencast reports (CSS pixels): the run page and the reports shape the video's frame by it. */
72
+ #viewport;
73
+ #stopListening;
74
+ /** Takes the caller's secret values out of what is written (labels and addresses can echo them); screenshots are pixels and show what the page shows. */
75
+ #scrub;
76
+ constructor(dir, scrub = (text) => text) {
77
+ this.#dir = dir;
78
+ this.#scrub = scrub;
79
+ mkdirSync(dir, { recursive: true });
80
+ }
81
+ /** Starts the screencast; each frame is acknowledged at once, or Chrome stops sending them. */
82
+ async start(tab) {
83
+ if (this.#stopListening)
84
+ return;
85
+ this.#stopListening = tab.onEvent((method, params) => {
86
+ if (method !== 'Page.screencastFrame')
87
+ return;
88
+ this.#frames.push({ jpeg: params.data, at: params.metadata.timestamp });
89
+ const { deviceWidth, deviceHeight } = params.metadata;
90
+ if (!this.#viewport && deviceWidth > 0 && deviceHeight > 0)
91
+ this.#viewport = { width: Math.round(deviceWidth), height: Math.round(deviceHeight) };
92
+ tab.send('Page.screencastFrameAck', { sessionId: params.sessionId }).catch(() => undefined);
93
+ });
94
+ await tab.send('Page.enable');
95
+ await tab.send('Page.startScreencast', { format: 'jpeg', quality: 85, maxWidth: 2560, maxHeight: 1600 });
96
+ }
97
+ async step(tab, page, intent, target) {
98
+ const marks = target ? [{ label: intent.operation, rect: target }] : [];
99
+ const file = `${String(this.steps.length + 1).padStart(2, '0')}.jpg`;
100
+ writeFileSync(join(this.#dir, file), Buffer.from(await tab.markedScreenshot(marks, page, { sharp: true }), 'base64'));
101
+ this.steps.push({
102
+ file,
103
+ operation: intent.operation,
104
+ description: this.#scrub(describeStep(intent)),
105
+ ...(intent.confidence !== undefined && { confidence: intent.confidence }),
106
+ url: this.#scrub(page.url),
107
+ at: Date.now() / 1000,
108
+ });
109
+ }
110
+ /** The action shot just taken never ran (the page moved first); the retry takes its place. */
111
+ discardLast() {
112
+ this.steps.pop();
113
+ }
114
+ /** The action shot just taken never ran: its target couldn't be reached. Repeats of the same miss fold into one. */
115
+ markUnreachable() {
116
+ const [previous, last] = this.steps.slice(-2);
117
+ if (!last)
118
+ return;
119
+ if (previous?.unreachable && previous.description === last.description) {
120
+ previous.attempts = (previous.attempts ?? 1) + 1;
121
+ this.steps.pop();
122
+ return;
123
+ }
124
+ last.unreachable = true;
125
+ last.attempts = 1;
126
+ }
127
+ // A session can run several times (missing value, confirmation); each run ends with its own final shot,
128
+ // and the video is re-encoded to cover every run so far.
129
+ async finish(tab, summary) {
130
+ const page = await tab.observe();
131
+ await this.step(tab, page, { operation: summary.status });
132
+ this.steps.at(-1).description = `Finished: ${summary.status}. ${summary.reason}`;
133
+ this.summaries.push(summary);
134
+ await tab.send('Page.stopScreencast').catch(() => undefined);
135
+ this.#stopListening?.();
136
+ this.#stopListening = undefined;
137
+ const video = (await this.#encode()) ?? null;
138
+ const viewport = this.#viewport;
139
+ writeFileSync(join(this.#dir, 'summary.json'), JSON.stringify({ summaries: this.summaries, video, steps: this.steps, ...(viewport && { viewport }) }, null, 1));
140
+ writeFileSync(join(this.#dir, 'index.html'), renderSteps(this.steps, this.summaries, video, viewport));
141
+ }
142
+ // Frames → H.264 through ffmpeg's concat list, each frame held for its duration. Without ffmpeg: no video.
143
+ async #encode() {
144
+ if (!this.#frames.length)
145
+ return undefined;
146
+ const framesDir = join(this.#dir, '.frames');
147
+ mkdirSync(framesDir, { recursive: true });
148
+ try {
149
+ const times = this.#frames.map((f) => f.at);
150
+ const durations = frameDurations(times);
151
+ const list = this.#frames.map((frame, i) => {
152
+ const file = join(framesDir, `${String(i).padStart(5, '0')}.jpg`);
153
+ writeFileSync(file, Buffer.from(frame.jpeg, 'base64'));
154
+ return `file '${file}'\nduration ${durations[i].toFixed(3)}`;
155
+ });
156
+ // The concat demuxer ignores the last entry's duration unless that file is listed once more.
157
+ writeFileSync(join(framesDir, 'list.txt'), `${list.join('\n')}\n${list.at(-1).split('\n')[0]}\n`);
158
+ await promisify(execFile)('ffmpeg', [
159
+ '-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', join(framesDir, 'list.txt'),
160
+ '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p', '-r', '30',
161
+ '-c:v', 'libx264', '-crf', '20', '-movflags', '+faststart', join(this.#dir, VIDEO),
162
+ ]);
163
+ for (const step of this.steps)
164
+ step.video_at = Number(videoOffset(times, durations, step.at).toFixed(2));
165
+ return VIDEO;
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ finally {
171
+ rmSync(framesDir, { recursive: true, force: true });
172
+ }
173
+ }
174
+ }
175
+ export { escapeHtml };
176
+ // What a run's calls cost, summed: requests, input tokens, time, and the estimate unless a call has none.
177
+ const costLine = (summaries) => {
178
+ const sum = (pick) => summaries.reduce((total, s) => total + pick(s), 0);
179
+ const cost = summaries.reduce((total, s) => (total === null || s.jev.est_cost_usd == null ? null : total + s.jev.est_cost_usd), 0);
180
+ return `${plural(sum((s) => s.jev.requests), 'Jev request')} · ${thousands(sum((s) => s.jev.input_tokens))} input tokens · ${seconds(sum((s) => s.elapsed_ms))} · ${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}`;
181
+ };
182
+ // epoch seconds → "2026-11-01 18:39 UTC"
183
+ const recordedAt = (at) => `${new Date(at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
184
+ /**
185
+ * A run's own page (`index.html` in its record folder), in the reports' layout: how it ended, then the screencast with
186
+ * its steps on a timeline and a strip kept in sync, then the facts. One self-contained file next to its shots and
187
+ * video; it reads without JS (the script only syncs the player).
188
+ *
189
+ * @example
190
+ * writeFileSync('runs/a/index.html', renderSteps(steps, summaries, 'run.mp4', { width: 1120, height: 780 }));
191
+ */
192
+ export const renderSteps = (steps, summaries, video = null, viewport) => {
193
+ const last = summaries.at(-1);
194
+ const ending = last ? `Finished: ${statusWord(last.status)}` : 'jevway run';
195
+ const actions = summaries.reduce((total, s) => total + s.steps.length, 0);
196
+ const bar = `<div class="player-bar grid4"><p class="meta c1s3">${plural(steps.length, 'step')}${video ? ' · pick one to play from there' : ''}</p><span class="meta clock c4 end">${video ? clock(0) : ''}</span></div>`;
197
+ const player = steps.length
198
+ ? playerMarkup({ dir: '', video, steps, label: 'this run', featured: true, viewport, bar, preload: 'metadata' })
199
+ : '<div class="screen quiet"><p class="small">No step was recorded.</p></div>';
200
+ const facts = [
201
+ ...(summaries.length ? [fact('Calls', `${escapeHtml(summaries.map((s) => statusWord(s.status)).join(' → '))} · ${plural(actions, 'action')}`)] : []),
202
+ ...(last ? [fact('Final reason', escapeHtml(last.reason)), fact('Final address', `<span class="address">${escapeHtml(last.url)}</span>`), fact('Page title', escapeHtml(last.title))] : []),
203
+ ...(summaries.length ? [fact('Cost', `<span class="num">${costLine(summaries)}</span>`)] : []),
204
+ fact('Files', `<a href="summary.json">summary.json</a>${video ? ` · <a href="${escapeHtml(video)}">${escapeHtml(video)}</a>` : ''}`),
205
+ ];
206
+ return `<!doctype html>
207
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
208
+ <title>${escapeHtml(`jevway run: ${last ? statusWord(last.status) : 'recorded'}`)}</title>
209
+ <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
210
+ <link rel="stylesheet" href="${FONT_LINK}">
211
+ <style>${BASE_STYLE}
212
+ .run-page { padding-bottom: 4rem; }
213
+ .run-page .facts { margin-top: 4rem; }
214
+ </style>
215
+ </head><body>
216
+ <main class="wrapper run-page">
217
+ <header class="job-head grid4">
218
+ <p class="meta">jevway run<br>${steps.length ? recordedAt(steps[0].at) : ''}</p>
219
+ <h1 class="job-title c2s2">${escapeHtml(ending)}</h1>
220
+ <p class="meta c4 end">${plural(summaries.length, 'call')} · ${plural(actions, 'action')}</p>
221
+ ${last ? `<p class="small story c2s2">${escapeHtml(last.reason)}</p>` : ''}
222
+ </header>
223
+ <div class="run player current">
224
+ ${player}
225
+ </div>
226
+ <dl class="facts">${facts.join('\n')}</dl>
227
+ </main>
228
+ <footer><div class="wrapper"><span>Recorded by jevway for people to read; the model never sees these shots.</span><span>${plural(steps.length, 'step')}${video ? ` · ${escapeHtml(video)}` : ' · no video (needs ffmpeg)'}</span></div></footer>
229
+ ${PLAYER_SCRIPT}
230
+ </body></html>
231
+ `;
232
+ };
@@ -0,0 +1,66 @@
1
+ /** Why a call must end now: the caller cancelled it, or its time budget passed. */
2
+ export class RunStopped extends Error {
3
+ status;
4
+ reason;
5
+ constructor(status, reason) {
6
+ super(reason);
7
+ this.status = status;
8
+ this.reason = reason;
9
+ }
10
+ }
11
+ /**
12
+ * One call's stop signal: it aborts when the caller's signal does (`blocked`, "Cancelled by the caller") or when
13
+ * `maxSeconds` pass (`step_budget`). Waits and model requests take `signal`; inputs never do, so an input in flight
14
+ * always finishes and the loop stops at its next check. `dispose()` clears the timer when the call ends.
15
+ *
16
+ * @example
17
+ * const limit = startRunLimit({ signal: extra.signal, maxSeconds: 300, actions: () => executed });
18
+ * await limit.pause(300); // returns early on a stop
19
+ * const halt = limit.stopped(); // RunStopped | undefined
20
+ */
21
+ export const startRunLimit = ({ signal, maxSeconds, actions }) => {
22
+ const controller = new AbortController();
23
+ const stopWith = (status, reason) => {
24
+ if (!controller.signal.aborted)
25
+ controller.abort(new RunStopped(status, reason));
26
+ };
27
+ const cancel = () => stopWith('blocked', 'Cancelled by the caller');
28
+ const timer = setTimeout(() => stopWith('step_budget', `Reached the time budget of ${maxSeconds} s (max_seconds) after ${actions()} actions without finishing`), maxSeconds * 1000);
29
+ // Unref'd: a CLI run must not stay alive just for its budget timer.
30
+ timer.unref?.();
31
+ if (signal?.aborted)
32
+ cancel();
33
+ else
34
+ signal?.addEventListener('abort', cancel, { once: true });
35
+ const stopped = () => (controller.signal.aborted ? controller.signal.reason : undefined);
36
+ return {
37
+ signal: controller.signal,
38
+ stopped,
39
+ /** Sleeps `ms`, or less when the run is stopped meanwhile; never throws. */
40
+ pause: (ms) => new Promise((resolve) => {
41
+ if (controller.signal.aborted)
42
+ return resolve();
43
+ const done = () => {
44
+ clearTimeout(wait);
45
+ controller.signal.removeEventListener('abort', done);
46
+ resolve();
47
+ };
48
+ const wait = setTimeout(done, ms);
49
+ controller.signal.addEventListener('abort', done, { once: true });
50
+ }),
51
+ /** Settles like `work`, or rejects with the stop as soon as the run is stopped, whether or not `work` heeds the signal. */
52
+ within: (work) => new Promise((resolve, reject) => {
53
+ // `work` is always handled, even when the stop wins: a request rejected after the stop must not go unhandled.
54
+ const onStop = () => reject(stopped());
55
+ work.then(resolve, reject).finally(() => controller.signal.removeEventListener('abort', onStop));
56
+ if (controller.signal.aborted)
57
+ onStop();
58
+ else
59
+ controller.signal.addEventListener('abort', onStop, { once: true });
60
+ }),
61
+ dispose: () => {
62
+ clearTimeout(timer);
63
+ signal?.removeEventListener('abort', cancel);
64
+ },
65
+ };
66
+ };