jevway 0.1.2 → 0.3.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/README.md +227 -149
- package/SETUP.md +212 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +21 -1
- package/dist/browser/snapshot.js +53 -0
- package/dist/browser/tab.js +59 -10
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +76 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +54 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +221 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +339 -0
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/jev/secrets.js +4 -3
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +27 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/task-tools.js +213 -0
- package/dist/mcp.js +10 -337
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +86 -0
- package/dist/output/manifest.js +86 -0
- package/dist/output/run-folder.js +80 -0
- package/dist/recording/describe-step.js +69 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +213 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +124 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +111 -0
- package/dist/task/run-task.js +332 -32
- package/dist/task/start-task.js +70 -11
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +10 -8
- package/skill/jevway/SKILL.md +54 -15
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
- package/INSTALL-FOR-AGENTS.md +0 -138
- package/dist/cli.js +0 -112
|
@@ -1,9 +1,42 @@
|
|
|
1
|
+
// The design every recording page shares (Prima Persona: one 4-column grid, monochrome, hairline rows, Switzer):
|
|
2
|
+
// a run's own index.html, the jobs report and the agent report. The player (the video, its timeline and the step strip,
|
|
3
|
+
// kept on the same step) is here too, so a run looks the same wherever it is shown.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { storyLine } from '../task/present.js';
|
|
6
|
+
import { pastTense } from './describe-step.js';
|
|
1
7
|
export const escapeHtml = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
2
8
|
const escape = escapeHtml;
|
|
3
9
|
/** Switzer from Fontshare; the system stack takes over when it can't load (offline, file://). */
|
|
4
10
|
export const FONT_LINK = 'https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap';
|
|
11
|
+
// --- what every page's head carries
|
|
12
|
+
/** The installed jevway's version (`package.json` sits two folders up from here in `src/` and in `dist/`). */
|
|
13
|
+
export const JEVWAY_VERSION = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return 'unknown';
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
/** Names the page's maker for an agent that fetched it: `<meta name="generator" content="jevway 0.4.0">`. */
|
|
22
|
+
export const GENERATOR_META = `<meta name="generator" content="jevway ${JEVWAY_VERSION}">`;
|
|
23
|
+
/** The web font, optional: fetched without holding the first paint, so an offline page draws at once in the system stack. */
|
|
24
|
+
export const FONT_STYLESHEET = `<link rel="stylesheet" href="${FONT_LINK}" media="print" onload="this.media='all'">`;
|
|
25
|
+
/**
|
|
26
|
+
* Data an agent reads without scraping the markup: JSON in an inert script element, `<` escaped so no string in it can
|
|
27
|
+
* close the element.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* embeddedJson('jevway-summary', record) // '<script type="application/json" id="jevway-summary">{…}</script>'
|
|
31
|
+
*/
|
|
32
|
+
export const embeddedJson = (id, data) => `<script type="application/json" id="${escape(id)}">${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`;
|
|
5
33
|
// --- words and numbers a reader compares
|
|
6
34
|
export const statusWord = (status) => status.replaceAll('_', ' ');
|
|
35
|
+
const STATUS_LABELS = { done: 'Done', needs_input: 'Needs input', needs_confirmation: 'Needs confirmation', uncertain: 'Uncertain', blocked: 'Blocked', step_budget: 'Out of budget' };
|
|
36
|
+
/** A status as a person says it: "Needs input", "Out of budget". */
|
|
37
|
+
export const statusLabel = (status) => STATUS_LABELS[status] ?? statusWord(status).replace(/^./, (c) => c.toUpperCase());
|
|
38
|
+
/** How a status reads at a glance: done held, a hand-back is the caller's turn (reached), a stop missed. */
|
|
39
|
+
export const statusTone = (status) => (status === 'done' ? 'held' : ['needs_input', 'needs_confirmation', 'uncertain'].includes(status) ? 'reached' : 'missed');
|
|
7
40
|
export const clock = (s) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`;
|
|
8
41
|
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
9
42
|
export const indexLabel = (i) => String(i + 1).padStart(2, '0');
|
|
@@ -22,6 +55,78 @@ export const pathOf = (url) => {
|
|
|
22
55
|
};
|
|
23
56
|
/** A run's Finished shots carry the status (lowercase) as their operation; actions are uppercase. */
|
|
24
57
|
export const isFinished = (step) => step.operation === step.operation.toLowerCase();
|
|
58
|
+
const lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
|
|
59
|
+
const withoutStop = (text) => text.replace(/\.$/, '');
|
|
60
|
+
// A recorded shot's own wording when no summary step tells it: past tense, or what kept it from running.
|
|
61
|
+
const toldFromShot = (step) => step.unreachable
|
|
62
|
+
? `Did not ${lowerFirst(step.description)}: something covered its target${(step.attempts ?? 1) > 1 ? ` (${step.attempts} tries)` : ''}.`
|
|
63
|
+
: pastTense(step.description);
|
|
64
|
+
// A shot and a summary step are the same step when their operation and wording agree: the summary's sentence starts
|
|
65
|
+
// with the shot's description in the past tense (or "Did not …" for a target that was never reached).
|
|
66
|
+
const sameStep = (shot, step, sentence) => {
|
|
67
|
+
if (shot.operation !== step.operation)
|
|
68
|
+
return false;
|
|
69
|
+
if (shot.unreachable)
|
|
70
|
+
return sentence.startsWith(`Did not ${lowerFirst(shot.description)}`);
|
|
71
|
+
if (sentence.startsWith('Did not '))
|
|
72
|
+
return false;
|
|
73
|
+
return shot.operation === 'PRESS_ESCAPE' || sentence.startsWith(withoutStop(pastTense(shot.description)));
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The recorded steps told the way a summary's `story` tells them (`storyLine`), one story per call: the recording's
|
|
77
|
+
* Finished shots split the calls, and each shot takes the sentence of the summary step it matches, in order. A summary
|
|
78
|
+
* step with no shot (a rejected done, a covered target fed back) keeps its place without one; a shot no summary step
|
|
79
|
+
* matches is told from its own description.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* storyOfRecording(steps, summaries)[0].entries[0] // { sentence: 'Clicked "New issue".', shot: 0 }
|
|
83
|
+
*/
|
|
84
|
+
export const storyOfRecording = (steps, summaries) => {
|
|
85
|
+
const calls = [{ shots: [] }];
|
|
86
|
+
steps.forEach((step, i) => {
|
|
87
|
+
const call = calls.at(-1);
|
|
88
|
+
if (isFinished(step)) {
|
|
89
|
+
call.end = i;
|
|
90
|
+
calls.push({ shots: [] });
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
call.shots.push(i);
|
|
94
|
+
});
|
|
95
|
+
if (!calls.at(-1).shots.length && calls.length > 1)
|
|
96
|
+
calls.pop();
|
|
97
|
+
return calls.map(({ shots, end }, n) => {
|
|
98
|
+
const told = summaries[n]?.steps ?? [];
|
|
99
|
+
// A summary saved by older code may lack a step's element.
|
|
100
|
+
const sentences = told.map((step) => storyLine({ ...step, element: step.element ?? '' }));
|
|
101
|
+
const entries = [];
|
|
102
|
+
let cursor = 0;
|
|
103
|
+
for (const shot of shots) {
|
|
104
|
+
const match = told.findIndex((step, j) => j >= cursor && sameStep(steps[shot], step, sentences[j]));
|
|
105
|
+
if (match < 0) {
|
|
106
|
+
entries.push({ sentence: toldFromShot(steps[shot]), shot });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
entries.push(...sentences.slice(cursor, match).map((sentence) => ({ sentence })), { sentence: sentences[match], shot });
|
|
110
|
+
cursor = match + 1;
|
|
111
|
+
}
|
|
112
|
+
entries.push(...sentences.slice(cursor).map((sentence) => ({ sentence })));
|
|
113
|
+
if (end === undefined)
|
|
114
|
+
return { entries };
|
|
115
|
+
const status = steps[end].operation;
|
|
116
|
+
const reason = summaries[n]?.reason ?? steps[end].description.replace(/^Finished: [a-z_]+\.\s*/, '');
|
|
117
|
+
entries.push({ sentence: `${statusLabel(status)}: ${reason}`, shot: end });
|
|
118
|
+
return { status, entries };
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
/** Each recorded step's sentence, by its index: what its card and its alt text say. */
|
|
122
|
+
export const sentencesOf = (stories) => {
|
|
123
|
+
const sentences = [];
|
|
124
|
+
for (const { entries } of stories)
|
|
125
|
+
for (const { sentence, shot } of entries)
|
|
126
|
+
if (shot !== undefined)
|
|
127
|
+
sentences[shot] = sentence;
|
|
128
|
+
return sentences;
|
|
129
|
+
};
|
|
25
130
|
/** One hairline row of a fact list: the label in column 1, the body across columns 2–4. */
|
|
26
131
|
export const fact = (label, body) => `<div class="fact grid4"><dt>${label}</dt><dd class="c2s3">${body}</dd></div>`;
|
|
27
132
|
/**
|
|
@@ -43,13 +148,14 @@ export const stripOpening = (label, viewport) => {
|
|
|
43
148
|
return `<div class="strip${shape}" aria-label="Steps of ${label}">`;
|
|
44
149
|
};
|
|
45
150
|
/**
|
|
46
|
-
* One step as a card: the shot, then its number, video time and confidence, what happened
|
|
47
|
-
* Finished shots (lowercase operation, the status) read
|
|
48
|
-
* `href` the whole card is a link (the overview); otherwise it seeks the
|
|
151
|
+
* One step as a card: the shot, then its number, video time and confidence, what happened (`sentence`, the story's
|
|
152
|
+
* wording, else the recorded description), and where. Finished shots (lowercase operation, the status) read
|
|
153
|
+
* "Finished: <status>" without a sentence. With `href` the whole card is a link (the overview); otherwise it seeks the
|
|
154
|
+
* player it sits in, and a link named after the shot's file opens it full size.
|
|
49
155
|
*/
|
|
50
|
-
export const shotCard = (step, i, dir, open) => {
|
|
156
|
+
export const shotCard = (step, i, dir, open, sentence) => {
|
|
51
157
|
const finished = isFinished(step);
|
|
52
|
-
const label = finished ? `Finished: ${statusWord(step.operation)}` : step.description;
|
|
158
|
+
const label = sentence ?? (finished ? `Finished: ${statusWord(step.operation)}` : step.description);
|
|
53
159
|
const timed = 'video' in open && open.video && step.video_at !== undefined;
|
|
54
160
|
const meta = [
|
|
55
161
|
indexLabel(i),
|
|
@@ -57,15 +163,16 @@ export const shotCard = (step, i, dir, open) => {
|
|
|
57
163
|
...(step.unreachable ? [`<span class="missed">not reached${(step.attempts ?? 1) > 1 ? ` ×${step.attempts}` : ''}: covered</span>`] : step.confidence !== undefined && !finished ? [step.confidence.toFixed(2)] : []),
|
|
58
164
|
].join(' · ');
|
|
59
165
|
const src = `${dir}${step.file}`;
|
|
60
|
-
const image = `<img src="${escape(src)}" alt="${escape(step.description)}" loading="lazy" decoding="async" width="1120" height="780">`;
|
|
166
|
+
const image = `<img src="${escape(src)}" alt="${escape(sentence ?? step.description)}" loading="lazy" decoding="async" width="1120" height="780">`;
|
|
61
167
|
const text = `<span class="what">${escape(label)}</span><span class="where">${escape(pathOf(step.url))}</span>`;
|
|
62
168
|
const className = `shot${finished ? ' finished' : ''}`;
|
|
63
169
|
if ('href' in open)
|
|
64
170
|
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
|
|
171
|
+
// In a player the whole card seeks (the button gives it a keyboard stop); the file's own link sits outside the button.
|
|
66
172
|
const data = timed ? ` data-at="${step.video_at}"` : ` data-src="${escape(src)}"`;
|
|
173
|
+
const name = step.file.split('/').pop();
|
|
67
174
|
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"
|
|
175
|
+
<span class="caption"><span class="meta-line"><span class="meta num">${meta}</span><a class="full-size" href="${escape(src)}" target="_blank" rel="noopener" title="Open the shot full size">${escape(name)}</a></span>${text}</span></div>`;
|
|
69
176
|
};
|
|
70
177
|
/**
|
|
71
178
|
* The video (or the first shot when there is no video), its timeline with one tick per step, the `bar` (a run switcher
|
|
@@ -75,16 +182,16 @@ export const shotCard = (step, i, dir, open) => {
|
|
|
75
182
|
* @example
|
|
76
183
|
* playerMarkup({ dir: 'run-1/', video: 'run.mp4', steps, label: 'run 1', featured: true, viewport: { width: 1120, height: 780 }, bar })
|
|
77
184
|
*/
|
|
78
|
-
export const playerMarkup = ({ dir, video, steps, label, featured, viewport, bar, preload = 'none', }) => {
|
|
185
|
+
export const playerMarkup = ({ dir, video, steps, label, featured, viewport, bar, preload = 'none', sentences = [], }) => {
|
|
79
186
|
const screen = screenFrame(viewport);
|
|
80
187
|
const strip = stripOpening(label, viewport);
|
|
81
188
|
const poster = `${dir}${steps[0].file}`;
|
|
82
189
|
const timed = steps.filter((s) => s.video_at !== undefined);
|
|
83
190
|
if (!video || !timed.length) {
|
|
84
|
-
return `${screen}<img data-frame src="${escape(poster)}" alt="${escape(steps[0].description)}"></div>
|
|
191
|
+
return `${screen}<img data-frame src="${escape(poster)}" alt="${escape(sentences[0] ?? steps[0].description)}"></div>
|
|
85
192
|
<p class="small screen-note">No video for this run (it needs ffmpeg): pick a step to see its shot.</p>
|
|
86
193
|
${bar}
|
|
87
|
-
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false })).join('')}</div>`;
|
|
194
|
+
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false }, sentences[i])).join('')}</div>`;
|
|
88
195
|
}
|
|
89
196
|
// The last frame holds 1.5 s (recording.ts), so this is the video's length until its metadata says otherwise.
|
|
90
197
|
const duration = Math.max(...timed.map((s) => s.video_at)) + 1.5;
|
|
@@ -99,7 +206,7 @@ ${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false })).join('')}</
|
|
|
99
206
|
return `${screen}<video src="${escape(`${dir}${video}`)}" preload="${preload}"${posterAttribute} controls muted playsinline aria-label="Screencast of ${label}"></video></div>
|
|
100
207
|
<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
208
|
${bar}
|
|
102
|
-
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: true })).join('')}</div>`;
|
|
209
|
+
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: true }, sentences[i])).join('')}</div>`;
|
|
103
210
|
};
|
|
104
211
|
// --- the stylesheet: tokens, the grid, type, the player and fact rows
|
|
105
212
|
export const BASE_STYLE = `
|
|
@@ -108,9 +215,10 @@ export const BASE_STYLE = `
|
|
|
108
215
|
--foreground: oklch(0.205 0 0);
|
|
109
216
|
--muted: oklch(0.922 0 0);
|
|
110
217
|
--muted-foreground: oklch(0.47 0 0);
|
|
111
|
-
|
|
112
|
-
--
|
|
113
|
-
--
|
|
218
|
+
/* Text-safe on the background: at least 4.5:1 */
|
|
219
|
+
--held: oklch(0.5 0.13 150);
|
|
220
|
+
--reached: oklch(0.52 0.12 65);
|
|
221
|
+
--missed: oklch(0.5 0.19 27);
|
|
114
222
|
--font-sans: "Switzer", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
115
223
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
116
224
|
--wrap: 85rem;
|
|
@@ -132,8 +240,8 @@ export const BASE_STYLE = `
|
|
|
132
240
|
*, *::before, *::after { box-sizing: border-box; }
|
|
133
241
|
html { -webkit-text-size-adjust: 100%; }
|
|
134
242
|
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; }
|
|
243
|
+
h1, h2, h3, p, dl, dd, ul, ol, figure { margin: 0; }
|
|
244
|
+
ul, ol { padding: 0; list-style: none; }
|
|
137
245
|
a { color: inherit; text-decoration: none; }
|
|
138
246
|
a:hover { color: var(--muted-foreground); }
|
|
139
247
|
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; text-align: inherit; }
|
|
@@ -221,10 +329,27 @@ a.shot:hover img { opacity: 0.85; }
|
|
|
221
329
|
.kv { color: var(--muted-foreground); margin-right: 0.375rem; }
|
|
222
330
|
.address { color: var(--muted-foreground); font-family: var(--font-mono); font-size: 0.8125rem; overflow-wrap: anywhere; }
|
|
223
331
|
|
|
332
|
+
/* The top bar: the page's name and its in-page links */
|
|
333
|
+
nav.top { position: sticky; top: 0; z-index: 5; background: var(--background); }
|
|
334
|
+
nav.top .wrapper { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-block: 0.75rem; font-size: 0.75rem; }
|
|
335
|
+
nav.top .links { display: flex; gap: 1rem; color: var(--muted-foreground); }
|
|
336
|
+
|
|
224
337
|
/* Footer */
|
|
225
338
|
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
339
|
footer span { overflow-wrap: anywhere; }
|
|
227
340
|
|
|
341
|
+
/* Print: the story and the shots on paper, no player */
|
|
342
|
+
@media print {
|
|
343
|
+
:root { --background: #fff; --foreground: #000; --muted: #ccc; --muted-foreground: #444; color-scheme: light; }
|
|
344
|
+
video, .screen, .screen-note, .timeline-scroll, .player-bar, .full-size, nav.top, .needs-js { display: none !important; }
|
|
345
|
+
.strip { flex-wrap: wrap; overflow: visible; }
|
|
346
|
+
.shot { flex: 0 0 calc((100% - 3 * var(--gap)) / 4); break-inside: avoid; }
|
|
347
|
+
.player .shot img { opacity: 1; }
|
|
348
|
+
li, figure, article, .fact { break-inside: avoid; }
|
|
349
|
+
h1, h2, h3, .block-head { break-after: avoid; }
|
|
350
|
+
a { text-decoration: underline; }
|
|
351
|
+
}
|
|
352
|
+
|
|
228
353
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
|
|
229
354
|
`;
|
|
230
355
|
/**
|
|
@@ -244,6 +369,9 @@ export const PLAYER_SCRIPT = `<script>
|
|
|
244
369
|
const ticks = [...panel.querySelectorAll('.tick')];
|
|
245
370
|
const shots = [...panel.querySelectorAll('.strip .shot')];
|
|
246
371
|
const played = panel.querySelector('.played');
|
|
372
|
+
// A run page's story list: one entry per shot, marked with the shot it tells (outside the strip).
|
|
373
|
+
const entries = [...panel.querySelectorAll('li[data-shot]')];
|
|
374
|
+
const screen = panel.querySelector('.screen');
|
|
247
375
|
const clockText = panel.querySelector('.clock');
|
|
248
376
|
let current = -1;
|
|
249
377
|
const reveal = (shot) => {
|
|
@@ -255,6 +383,7 @@ export const PLAYER_SCRIPT = `<script>
|
|
|
255
383
|
if (index === current) return;
|
|
256
384
|
current = index;
|
|
257
385
|
for (const list of [ticks, shots]) list.forEach((el, n) => (n === index ? el.setAttribute('aria-current', 'step') : el.removeAttribute('aria-current')));
|
|
386
|
+
entries.forEach((el) => (Number(el.dataset.shot) === index ? el.setAttribute('aria-current', 'step') : el.removeAttribute('aria-current')));
|
|
258
387
|
reveal(shots[index]);
|
|
259
388
|
const tick = ticks[index];
|
|
260
389
|
if (tick && timeline && timeline.scrollWidth > timeline.clientWidth) {
|
|
@@ -279,17 +408,34 @@ export const PLAYER_SCRIPT = `<script>
|
|
|
279
408
|
if (clockText) clockText.textContent = clock(Number(shot.dataset.at));
|
|
280
409
|
}
|
|
281
410
|
};
|
|
282
|
-
// A reader's seek: plays from there, and the address names the step so it can be shared (#<view>-r<run>-s<step>
|
|
411
|
+
// A reader's seek: plays from there, and the address names the step so it can be shared (#<view>-r<run>-s<step>
|
|
412
|
+
// in a report, #s<step> on a run page).
|
|
283
413
|
const choose = (index) => {
|
|
284
414
|
seek(index, true);
|
|
285
415
|
const view = panel.closest('.job-view');
|
|
286
416
|
if (view && panel.dataset.run) history.replaceState(null, '', '#' + view.id + '-r' + panel.dataset.run + '-s' + (index + 1));
|
|
417
|
+
else if (panel.hasAttribute('data-step-links')) history.replaceState(null, '', '#s' + (index + 1));
|
|
287
418
|
};
|
|
288
419
|
ticks.forEach((tick) => tick.addEventListener('click', () => choose(Number(tick.dataset.index))));
|
|
289
420
|
// A click anywhere on a card seeks, except on its full-size link.
|
|
290
421
|
shots.forEach((shot, n) => shot.addEventListener('click', (event) => { if (!event.target.closest('a')) choose(n); }));
|
|
422
|
+
// A story entry's time: play from there, with the video in view.
|
|
423
|
+
panel.querySelectorAll('a.at[data-seek]').forEach((link) => link.addEventListener('click', (event) => {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
choose(Number(link.dataset.seek));
|
|
426
|
+
if (screen && screen.getBoundingClientRect().top < 0) screen.scrollIntoView({ block: 'start', behavior: reduceMotion ? 'auto' : 'smooth' });
|
|
427
|
+
}));
|
|
291
428
|
// A link to a step (the router): go there without playing.
|
|
292
429
|
panel.addEventListener('seek-step', (event) => seek(event.detail.index, false));
|
|
430
|
+
// A run page opened at #s<step> (a shared link): that step, without playing.
|
|
431
|
+
if (panel.hasAttribute('data-step-links')) {
|
|
432
|
+
const open = () => {
|
|
433
|
+
const step = /^s(\\d+)$/.exec(location.hash.slice(1));
|
|
434
|
+
if (step && shots[Number(step[1]) - 1]) seek(Number(step[1]) - 1, false);
|
|
435
|
+
};
|
|
436
|
+
addEventListener('hashchange', open);
|
|
437
|
+
open();
|
|
438
|
+
}
|
|
293
439
|
if (!video) return;
|
|
294
440
|
const place = () => {
|
|
295
441
|
if (!video.duration || !isFinite(video.duration)) return;
|
|
@@ -2,7 +2,11 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import {
|
|
5
|
+
import { safeSlug, stepFileName } from '../output/layout.js';
|
|
6
|
+
import { describeStep } from './describe-step.js';
|
|
7
|
+
import { escapeHtml } from './recording-page.js';
|
|
8
|
+
import { renderRunPage } from './run-page.js';
|
|
9
|
+
export { describeStep, escapeHtml };
|
|
6
10
|
const VIDEO = 'run.mp4';
|
|
7
11
|
// The video shows what changed, not how long anyone waited: no frame holds longer than this.
|
|
8
12
|
const LONGEST_FRAME_S = 1.5;
|
|
@@ -17,41 +21,26 @@ const SHORTEST_FRAME_S = 1 / 30;
|
|
|
17
21
|
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
22
|
/** Seconds into the encoded video at which the wall-clock moment `at` appears. */
|
|
19
23
|
export const videoOffset = (times, durations, at) => times.reduce((offset, time, i) => (time < at ? offset + durations[i] : offset), 0);
|
|
20
|
-
//
|
|
21
|
-
const
|
|
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)}"`;
|
|
24
|
+
// A step shot's name: the verb and what it acts on, short enough to read in a file list.
|
|
25
|
+
const stepWords = ({ operation, element = '', valueKey, fileKey, option }) => {
|
|
34
26
|
switch (operation) {
|
|
35
27
|
case 'CLICK':
|
|
36
|
-
return `Click ${target}`;
|
|
37
28
|
case 'CLICK_AT':
|
|
38
|
-
return `
|
|
29
|
+
return `click ${element}`;
|
|
39
30
|
case 'TYPE_TEXT':
|
|
40
|
-
return `
|
|
31
|
+
return `type ${valueKey ?? element}`;
|
|
32
|
+
case 'UPLOAD':
|
|
33
|
+
return `upload ${fileKey ?? element}`;
|
|
41
34
|
case 'SELECT':
|
|
42
|
-
return
|
|
35
|
+
return `select ${option ?? element}`;
|
|
43
36
|
case 'PRESS_ENTER':
|
|
44
|
-
return `Press Enter in ${target} to submit it`;
|
|
45
37
|
case 'PRESS_ESCAPE':
|
|
46
|
-
return 'Press Escape to close the open menu or popup';
|
|
47
38
|
case 'SCROLL_DOWN':
|
|
48
|
-
return 'Scroll down to reveal more of the page';
|
|
49
39
|
case 'SCROLL_UP':
|
|
50
|
-
return 'Scroll back up';
|
|
51
40
|
case 'WAIT':
|
|
52
|
-
return '
|
|
41
|
+
return operation.replace('_', ' ');
|
|
53
42
|
default:
|
|
54
|
-
return
|
|
43
|
+
return `finished ${operation}`;
|
|
55
44
|
}
|
|
56
45
|
};
|
|
57
46
|
/**
|
|
@@ -60,12 +49,14 @@ export const describeStep = ({ operation, element = '', valueKey, option }) => {
|
|
|
60
49
|
* and an `index.html` viewer. Separate from what the model sees.
|
|
61
50
|
*
|
|
62
51
|
* @example
|
|
63
|
-
* const task = new BrowserTask(tab, ask, { goal,
|
|
64
|
-
* await task.run(); // runs/langfuse/run.mp4, 01.jpg … index.html
|
|
52
|
+
* const task = new BrowserTask(tab, ask, { goal, output: { dir: 'runs/langfuse', record: true } });
|
|
53
|
+
* await task.run(); // runs/langfuse/run.mp4, steps/01-click-traces.jpg … index.html
|
|
65
54
|
*/
|
|
66
55
|
export class Recording {
|
|
67
56
|
steps = [];
|
|
68
57
|
summaries = [];
|
|
58
|
+
/** What each call was asked, scrubbed, one per summary. */
|
|
59
|
+
asked = [];
|
|
69
60
|
#dir;
|
|
70
61
|
#frames = [];
|
|
71
62
|
/** The page size the screencast reports (CSS pixels): the run page and the reports shape the video's frame by it. */
|
|
@@ -96,7 +87,8 @@ export class Recording {
|
|
|
96
87
|
}
|
|
97
88
|
async step(tab, page, intent, target) {
|
|
98
89
|
const marks = target ? [{ label: intent.operation, rect: target }] : [];
|
|
99
|
-
const file =
|
|
90
|
+
const file = stepFileName(this.steps.length + 1, safeSlug(stepWords(intent), this.#scrub));
|
|
91
|
+
mkdirSync(join(this.#dir, 'steps'), { recursive: true });
|
|
100
92
|
writeFileSync(join(this.#dir, file), Buffer.from(await tab.markedScreenshot(marks, page, { sharp: true }), 'base64'));
|
|
101
93
|
this.steps.push({
|
|
102
94
|
file,
|
|
@@ -109,7 +101,7 @@ export class Recording {
|
|
|
109
101
|
}
|
|
110
102
|
/** The action shot just taken never ran (the page moved first); the retry takes its place. */
|
|
111
103
|
discardLast() {
|
|
112
|
-
this.steps.pop();
|
|
104
|
+
this.#remove(this.steps.pop());
|
|
113
105
|
}
|
|
114
106
|
/** The action shot just taken never ran: its target couldn't be reached. Repeats of the same miss fold into one. */
|
|
115
107
|
markUnreachable() {
|
|
@@ -118,26 +110,34 @@ export class Recording {
|
|
|
118
110
|
return;
|
|
119
111
|
if (previous?.unreachable && previous.description === last.description) {
|
|
120
112
|
previous.attempts = (previous.attempts ?? 1) + 1;
|
|
121
|
-
this.steps.pop();
|
|
113
|
+
this.#remove(this.steps.pop());
|
|
122
114
|
return;
|
|
123
115
|
}
|
|
124
116
|
last.unreachable = true;
|
|
125
117
|
last.attempts = 1;
|
|
126
118
|
}
|
|
119
|
+
// A shot named for a step that never ran would sit in the folder with no step to explain it.
|
|
120
|
+
#remove(step) {
|
|
121
|
+
if (step)
|
|
122
|
+
rmSync(join(this.#dir, step.file), { force: true });
|
|
123
|
+
}
|
|
127
124
|
// A session can run several times (missing value, confirmation); each run ends with its own final shot,
|
|
128
125
|
// and the video is re-encoded to cover every run so far.
|
|
129
|
-
async finish(tab, summary) {
|
|
126
|
+
async finish(tab, summary, asked) {
|
|
130
127
|
const page = await tab.observe();
|
|
131
128
|
await this.step(tab, page, { operation: summary.status });
|
|
132
129
|
this.steps.at(-1).description = `Finished: ${summary.status}. ${summary.reason}`;
|
|
133
130
|
this.summaries.push(summary);
|
|
131
|
+
if (asked)
|
|
132
|
+
this.asked.push({ goal: this.#scrub(asked.goal), ...(asked.doneWhen && { done_when: this.#scrub(asked.doneWhen) }) });
|
|
134
133
|
await tab.send('Page.stopScreencast').catch(() => undefined);
|
|
135
134
|
this.#stopListening?.();
|
|
136
135
|
this.#stopListening = undefined;
|
|
137
136
|
const video = (await this.#encode()) ?? null;
|
|
138
137
|
const viewport = this.#viewport;
|
|
139
|
-
|
|
140
|
-
writeFileSync(join(this.#dir, '
|
|
138
|
+
const record = { summaries: this.summaries, video, steps: this.steps, ...(viewport && { viewport }), ...(this.asked.length && { asked: this.asked }) };
|
|
139
|
+
writeFileSync(join(this.#dir, 'summary.json'), JSON.stringify(record, null, 1));
|
|
140
|
+
writeFileSync(join(this.#dir, 'index.html'), renderRunPage(record));
|
|
141
141
|
}
|
|
142
142
|
// Frames → H.264 through ffmpeg's concat list, each frame held for its duration. Without ffmpeg: no video.
|
|
143
143
|
async #encode() {
|
|
@@ -172,61 +172,3 @@ export class Recording {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
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
|
-
};
|