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.
Files changed (74) hide show
  1. package/README.md +227 -149
  2. package/SETUP.md +212 -0
  3. package/dist/browser/downloads.js +103 -0
  4. package/dist/browser/frames.js +21 -1
  5. package/dist/browser/snapshot.js +53 -0
  6. package/dist/browser/tab.js +59 -10
  7. package/dist/cli/daemon-call.js +78 -0
  8. package/dist/cli/dispatch.js +76 -0
  9. package/dist/cli/flags.js +152 -0
  10. package/dist/cli/help.js +46 -0
  11. package/dist/cli/inbox-command.js +199 -0
  12. package/dist/cli/journey-command.js +162 -0
  13. package/dist/cli/look-command.js +54 -0
  14. package/dist/cli/main.js +64 -0
  15. package/dist/cli/output.js +27 -0
  16. package/dist/cli/run-command.js +221 -0
  17. package/dist/cli/session-commands.js +132 -0
  18. package/dist/daemon/client.js +175 -0
  19. package/dist/daemon/paths.js +35 -0
  20. package/dist/daemon/protocol.js +28 -0
  21. package/dist/daemon/server.js +339 -0
  22. package/dist/inbox/inbox-file.js +43 -0
  23. package/dist/inbox/inbox-registry.js +33 -0
  24. package/dist/inbox/inbox.js +127 -0
  25. package/dist/inbox/mail-api.js +52 -0
  26. package/dist/inbox/message.js +34 -0
  27. package/dist/jev/replay.js +7 -3
  28. package/dist/jev/retry.js +29 -0
  29. package/dist/jev/secrets.js +4 -3
  30. package/dist/journeys/journey-doc.js +2 -1
  31. package/dist/journeys/journey-session.js +14 -3
  32. package/dist/journeys/render-viewer.js +163 -150
  33. package/dist/mcp/inbox-tools.js +82 -0
  34. package/dist/mcp/instructions.js +27 -0
  35. package/dist/mcp/journey-tools.js +117 -0
  36. package/dist/mcp/results.js +53 -0
  37. package/dist/mcp/server.js +57 -0
  38. package/dist/mcp/task-tools.js +213 -0
  39. package/dist/mcp.js +10 -337
  40. package/dist/notify/desktop.js +40 -0
  41. package/dist/notify/person-messages.js +50 -0
  42. package/dist/output/layout.js +86 -0
  43. package/dist/output/manifest.js +86 -0
  44. package/dist/output/run-folder.js +80 -0
  45. package/dist/recording/describe-step.js +69 -0
  46. package/dist/{task → recording}/recording-page.js +164 -18
  47. package/dist/{task → recording}/recording.js +33 -91
  48. package/dist/recording/run-page.js +155 -0
  49. package/dist/session/session-service.js +213 -0
  50. package/dist/setup/atomic-write.js +13 -0
  51. package/dist/setup/clients.js +180 -0
  52. package/dist/setup/command-output.js +15 -0
  53. package/dist/setup/doctor-command.js +16 -7
  54. package/dist/setup/doctor.js +17 -2
  55. package/dist/setup/env-file.js +22 -0
  56. package/dist/setup/setup-command.js +104 -0
  57. package/dist/setup/setup-wizard.js +216 -0
  58. package/dist/setup/skill-command.js +124 -0
  59. package/dist/setup/terminal-prompter.js +51 -0
  60. package/dist/setup/unified-diff.js +69 -0
  61. package/dist/task/ask-user.js +182 -0
  62. package/dist/task/downloads.js +63 -0
  63. package/dist/task/person-wait.js +104 -0
  64. package/dist/task/present.js +111 -0
  65. package/dist/task/run-task.js +332 -32
  66. package/dist/task/start-task.js +70 -11
  67. package/dist/task/totp.js +124 -0
  68. package/dist/task/upload-files.js +237 -5
  69. package/package.json +10 -8
  70. package/skill/jevway/SKILL.md +54 -15
  71. package/skill/jevway-inbox/SKILL.md +61 -0
  72. package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
  73. package/INSTALL-FOR-AGENTS.md +0 -138
  74. 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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, 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.
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 full-size link sits outside the button.
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">Full size</a></span>${text}</span></div>`;
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
- --held: oklch(0.55 0.13 150);
112
- --reached: oklch(0.64 0.14 70);
113
- --missed: oklch(0.56 0.19 27);
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 { BASE_STYLE, clock, dollars, escapeHtml, fact, FONT_LINK, playerMarkup, plural, PLAYER_SCRIPT, seconds, statusWord, thousands, } from './recording-page.js';
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
- // 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)}"`;
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 `Click the control at ${target} by its on-screen position`;
29
+ return `click ${element}`;
39
30
  case 'TYPE_TEXT':
40
- return `Type the "${valueKey}" value into ${target}`;
31
+ return `type ${valueKey ?? element}`;
32
+ case 'UPLOAD':
33
+ return `upload ${fileKey ?? element}`;
41
34
  case 'SELECT':
42
- return option ? `Choose "${option}" in ${target}` : `Choose an option in ${target}`;
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 'Wait for the page to finish updating';
41
+ return operation.replace('_', ' ');
53
42
  default:
54
- return `${operation} ${target}`;
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, recordDir: 'runs/langfuse' });
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 = `${String(this.steps.length + 1).padStart(2, '0')}.jpg`;
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
- 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));
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
- };