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
@@ -0,0 +1,127 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { callApi, cancelled, InboxError, membersOf } from './mail-api.js';
4
+ import { bodyText, extractCode, extractLinks, matchesFilters } from './message.js';
5
+ export { InboxError } from './mail-api.js';
6
+ /** The mail.tm-compatible providers, mail.tm first; the tools take a name, never a URL. */
7
+ export const PROVIDERS = { 'mail.tm': 'https://api.mail.tm', 'mail.gw': 'https://api.mail.gw' };
8
+ const POLL_INTERVAL_MS = 3_000;
9
+ const FAILED_POLLS_LIMIT = 3;
10
+ /** An inbox from stored credentials, with the password hidden from serialization. */
11
+ export const inboxFrom = ({ address, password, api }) => Object.defineProperty({ address, api }, 'password', { value: password, enumerable: false });
12
+ /** `mail.tm` for its API URL; the URL itself for any other. */
13
+ export const providerOf = (api) => Object.keys(PROVIDERS).find((name) => PROVIDERS[name] === api) ?? api;
14
+ const summaryOf = (message) => ({
15
+ id: message.id ?? '',
16
+ from: message.from?.address ?? '',
17
+ subject: message.subject ?? '',
18
+ received_at: message.createdAt ?? '',
19
+ });
20
+ const receivedOf = (message) => {
21
+ const code = extractCode(message);
22
+ return { ...summaryOf(message), ...(code && { code }), links: extractLinks(message), text: bodyText(message) };
23
+ };
24
+ const createOn = async (api, client) => {
25
+ const domains = membersOf(await callApi(client, api, '/domains'));
26
+ const domain = domains.find((entry) => entry.isActive !== false)?.domain;
27
+ if (!domain)
28
+ throw new InboxError('provider_rejected', `${api} offers no active domain`, 'Try the other provider.');
29
+ const address = `test-${randomBytes(5).toString('hex')}@${domain}`;
30
+ const password = randomBytes(18).toString('base64url');
31
+ await callApi(client, api, '/accounts', { method: 'POST', body: { address, password }, secrets: [password] });
32
+ return inboxFrom({ address, password, api });
33
+ };
34
+ /**
35
+ * A new inbox on `api`, or on mail.tm and then, if mail.tm is down, offers no active domain or refuses the account,
36
+ * on mail.gw once. Both failing is `inbox_unavailable`.
37
+ *
38
+ * @example
39
+ * const inbox = await createInbox(); // { address: 'test-0a1b2c3d4e@<domain>', api: 'https://api.mail.tm' }
40
+ */
41
+ export const createInbox = async ({ api, ...client } = {}) => {
42
+ if (api)
43
+ return createOn(api, client);
44
+ try {
45
+ return await createOn(PROVIDERS['mail.tm'], client);
46
+ }
47
+ catch (first) {
48
+ if (!(first instanceof InboxError))
49
+ throw first;
50
+ try {
51
+ return await createOn(PROVIDERS['mail.gw'], client);
52
+ }
53
+ catch (second) {
54
+ throw new InboxError('inbox_unavailable', `No inbox could be made: mail.tm failed (${first.message}), then mail.gw failed (${second.message}).`, 'Both disposable inbox providers are down or refusing new accounts. Ask the user for a test mailbox of their own.');
55
+ }
56
+ }
57
+ };
58
+ const fetchToken = async (inbox, client, signal) => (await callApi(client, inbox.api, '/token', {
59
+ method: 'POST',
60
+ body: { address: inbox.address, password: inbox.password },
61
+ secrets: [inbox.password],
62
+ signal,
63
+ })).token;
64
+ const listWith = async (inbox, client, token, signal) => membersOf(await callApi(client, inbox.api, '/messages', { token, signal, secrets: [inbox.password] }));
65
+ const readWith = async (inbox, client, token, id, signal) => {
66
+ try {
67
+ return receivedOf(await callApi(client, inbox.api, `/messages/${encodeURIComponent(id)}`, { token, signal, secrets: [inbox.password] }));
68
+ }
69
+ catch (error) {
70
+ if (error instanceof InboxError && error.status === 404) {
71
+ throw new InboxError('message_not_found', `No message "${id}" in ${inbox.address}`, 'List the inbox for its message ids.', 404);
72
+ }
73
+ throw error;
74
+ }
75
+ };
76
+ /** The inbox's messages, newest first, without their bodies. */
77
+ export const listMessages = async (inbox, { signal, ...client } = {}) => (await listWith(inbox, client, await fetchToken(inbox, client, signal), signal)).map(summaryOf);
78
+ /** One message in full, with its code and links. */
79
+ export const readMessage = async (inbox, id, { signal, ...client } = {}) => readWith(inbox, client, await fetchToken(inbox, client, signal), id, signal);
80
+ const defaultDelay = async (ms, signal) => {
81
+ try {
82
+ await sleep(ms, undefined, { signal });
83
+ }
84
+ catch {
85
+ throw cancelled();
86
+ }
87
+ };
88
+ /**
89
+ * Polls the inbox now and every 3 s until the newest message matching `from` and `subject` arrives, then reads it
90
+ * in full; undefined once `timeoutMs` has passed (with one last poll at the deadline). A 401 gets a new token; 3
91
+ * failed polls in a row end the wait with the provider's error. Aborting `signal` stops it with `cancelled`.
92
+ *
93
+ * @example
94
+ * await waitForMessage(inbox, { from: 'staylist', timeoutMs: 120_000 }) // { code: '482913', links: [...], … }
95
+ */
96
+ export const waitForMessage = async (inbox, { from, subject, timeoutMs, signal, onPoll, ...client }) => {
97
+ const now = client.now ?? Date.now;
98
+ const delay = client.delay ?? defaultDelay;
99
+ const started = now();
100
+ let token = await fetchToken(inbox, client, signal);
101
+ let failures = 0;
102
+ for (;;) {
103
+ if (signal?.aborted)
104
+ throw cancelled();
105
+ onPoll?.(now() - started);
106
+ try {
107
+ const match = (await listWith(inbox, client, token, signal)).find((message) => matchesFilters(message, { from, subject }));
108
+ if (match?.id)
109
+ return await readWith(inbox, client, token, match.id, signal);
110
+ failures = 0;
111
+ }
112
+ catch (error) {
113
+ if (!(error instanceof InboxError) || error.code === 'cancelled' || error.code === 'message_not_found')
114
+ throw error;
115
+ if (++failures >= FAILED_POLLS_LIMIT)
116
+ throw error;
117
+ if (error.status === 401) {
118
+ token = await fetchToken(inbox, client, signal);
119
+ continue;
120
+ }
121
+ }
122
+ const remaining = started + timeoutMs - now();
123
+ if (remaining <= 0)
124
+ return undefined;
125
+ await delay(Math.min(POLL_INTERVAL_MS, remaining), signal);
126
+ }
127
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A failed inbox call, with what to do in `hint`. Codes: `provider_unavailable` (network, 5xx, 429),
3
+ * `provider_rejected` (another 4xx), `inbox_unavailable` (both providers failed), `unknown_inbox`,
4
+ * `message_not_found`, `cancelled`.
5
+ */
6
+ export class InboxError extends Error {
7
+ code;
8
+ hint;
9
+ status;
10
+ constructor(code, message, hint, status) {
11
+ super(message);
12
+ this.code = code;
13
+ this.hint = hint;
14
+ this.status = status;
15
+ }
16
+ }
17
+ const RETRY_HINT = 'The provider is busy or down; try again in a minute, or ask the user for a test mailbox of their own.';
18
+ const scrub = (text, secrets) => secrets.filter(Boolean).reduce((clean, secret) => clean.split(secret).join('[secret]'), text);
19
+ export const cancelled = () => new InboxError('cancelled', 'Stopped waiting: cancelled by the caller.', 'Wait again when you need the email.');
20
+ /**
21
+ * One call to a mail.tm-compatible API, JSON in and out. Failures are `InboxError`s whose text never holds `secrets`
22
+ * (the password and token), even when the provider echoes them.
23
+ */
24
+ export const callApi = async (client, api, path, { method = 'GET', body, token, signal, secrets = [] } = {}) => {
25
+ const fetch = client.fetch ?? globalThis.fetch;
26
+ const where = `${method} ${api}${path}`;
27
+ let response;
28
+ try {
29
+ response = await fetch(`${api}${path}`, {
30
+ method,
31
+ headers: {
32
+ accept: 'application/ld+json',
33
+ ...(body && { 'content-type': 'application/json' }),
34
+ ...(token && { authorization: `Bearer ${token}` }),
35
+ },
36
+ body: body && JSON.stringify(body),
37
+ signal,
38
+ });
39
+ }
40
+ catch (error) {
41
+ if (signal?.aborted)
42
+ throw cancelled();
43
+ throw new InboxError('provider_unavailable', `${where} failed: ${scrub(error.message, [...secrets, token ?? ''])}`, RETRY_HINT);
44
+ }
45
+ if (response.ok)
46
+ return (await response.json());
47
+ const detail = scrub((await response.text().catch(() => '')).slice(0, 300), [...secrets, token ?? '']);
48
+ const transient = response.status >= 500 || response.status === 429;
49
+ throw new InboxError(transient ? 'provider_unavailable' : 'provider_rejected', `${where} → ${response.status} ${detail}`.trim(), transient ? RETRY_HINT : 'The provider refused the request; the message says why.', response.status);
50
+ };
51
+ /** A collection as `{ 'hydra:member': [...] }` (ld+json) or a bare array (plain JSON). */
52
+ export const membersOf = (collection) => Array.isArray(collection) ? collection : (collection['hydra:member'] ?? []);
@@ -0,0 +1,34 @@
1
+ const EXCERPT_CHARS = 600;
2
+ const htmlOf = (message) => [message.html ?? []].flat().join('\n');
3
+ const stripHtml = (html) => html
4
+ .replace(/<(style|script)[\s\S]*?<\/\1>/gi, '')
5
+ .replace(/<[^>]+>/g, ' ')
6
+ .replace(/\s+/g, ' ')
7
+ .trim();
8
+ /** The text part, or the html part without tags when there is no text. */
9
+ export const bodyText = (message) => message.text || stripHtml(htmlOf(message));
10
+ /**
11
+ * The first standalone 4–8 digit number in subject and body: not part of a longer number, a decimal, a date or an id.
12
+ *
13
+ * @example
14
+ * extractCode({ subject: 'Welcome', text: 'Your code is 482913.' }) // '482913'
15
+ */
16
+ export const extractCode = (message) => `${message.subject ?? ''}\n${bodyText(message)}`.match(/(?<![\w.,/-])\d{4,8}(?!\d|[.,/-]\d)/)?.[0];
17
+ // Doctype and namespace URLs and images (logos, tracking pixels) are never the link to follow.
18
+ const NOT_A_LINK = /^https?:\/\/www\.w3\.org\/|\.(png|jpe?g|gif|svg|webp|ico)(\?|$)/i;
19
+ /** Unique http(s) URLs a person could follow, from the text and html parts, `&amp;` decoded. */
20
+ export const extractLinks = (message) => {
21
+ const urls = `${message.text ?? ''}\n${htmlOf(message)}`.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? [];
22
+ return [...new Set(urls.map((url) => url.replace(/&amp;/g, '&')))].filter((url) => !NOT_A_LINK.test(url));
23
+ };
24
+ /** Whether the sender (name or address) and subject contain the filters, ignoring case; a missing filter passes. */
25
+ export const matchesFilters = (message, { from, subject }) => {
26
+ const sender = `${message.from?.name ?? ''} ${message.from?.address ?? ''}`.toLowerCase();
27
+ return ((!from || sender.includes(from.toLowerCase())) &&
28
+ (!subject || (message.subject ?? '').toLowerCase().includes(subject.toLowerCase())));
29
+ };
30
+ /** `text` with whitespace folded, cut at 600 characters with an ellipsis. */
31
+ export const excerpt = (text) => {
32
+ const folded = text.replace(/\s+/g, ' ').trim();
33
+ return folded.length <= EXCERPT_CHARS ? folded : `${folded.slice(0, EXCERPT_CHARS).trimEnd()}…`;
34
+ };
@@ -15,7 +15,6 @@ export class ReplayMiss extends Error {
15
15
  this.pageDiff = pageDiff;
16
16
  }
17
17
  }
18
- const DEFAULT_DIR = 'verify/out/replay/default';
19
18
  const IMAGE = '<image>';
20
19
  const LOCAL_ORIGIN = /\b(127\.0\.0\.1|localhost):\d+/g;
21
20
  /**
@@ -33,8 +32,13 @@ export const replayMode = () => {
33
32
  throw new Error(`JEVWAY_REPLAY must be record or replay, got "${mode}".`);
34
33
  return mode;
35
34
  };
36
- /** The recording folder: `JEVWAY_REPLAY_DIR`, or `verify/out/replay/default` under the working directory. */
37
- export const replayDir = () => resolve(process.env.JEVWAY_REPLAY_DIR?.trim() || DEFAULT_DIR);
35
+ /** The recording folder, `JEVWAY_REPLAY_DIR`: required whenever `JEVWAY_REPLAY` is set, so no run writes into a guessed folder. */
36
+ export const replayDir = () => {
37
+ const dir = process.env.JEVWAY_REPLAY_DIR?.trim();
38
+ if (!dir)
39
+ throw new Error('JEVWAY_REPLAY needs JEVWAY_REPLAY_DIR: the folder that holds the recordings.');
40
+ return resolve(dir);
41
+ };
38
42
  const isPlainObject = (value) => typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
39
43
  /**
40
44
  * The request as it is matched: keys sorted at every depth, every image (an SDK `Image`, an `image_url` part, a
@@ -0,0 +1,29 @@
1
+ /** A busy or flaky model API (5xx incl. 529 overload, 429, 499, timeouts, dropped connections): worth another try. */
2
+ const TRANSIENT_MODEL_ERROR = /^(5\d\d|429|499)\b|timeout|timed out|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up/i;
3
+ /**
4
+ * Whether a model call failed for a reason that may pass on its own.
5
+ *
6
+ * @example
7
+ * isTransientModelError(new Error('529 We are currently experiencing high traffic')) // true
8
+ */
9
+ export const isTransientModelError = (error) => error instanceof Error && TRANSIENT_MODEL_ERROR.test(error.message);
10
+ /**
11
+ * An `Ask` that tries again after a transient failure, waiting each of `delaysMs` in turn; any other failure, or the
12
+ * last one, is thrown as it came.
13
+ *
14
+ * @example
15
+ * const ask = retryingAsk(createAsk(profile), { delaysMs: [1000, 3000] });
16
+ */
17
+ export const retryingAsk = (ask, { delaysMs, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) => async (request) => {
18
+ for (const delay of delaysMs) {
19
+ try {
20
+ return await ask(request);
21
+ }
22
+ catch (error) {
23
+ if (!isTransientModelError(error))
24
+ throw error;
25
+ await wait(delay);
26
+ }
27
+ }
28
+ return ask(request);
29
+ };
@@ -29,15 +29,16 @@ const isImagePart = (value) => value.type === 'image_url';
29
29
  * caller marked secret never reaches the model or any output, including when the page echoes it (upper-cased, in an
30
30
  * address). Matching is case-insensitive and takes the longest form first, in one pass, so a marker is never scrubbed
31
31
  * again. Every non-empty value counts, even a short one: a garbled text is the safe failure, a leaked secret is not.
32
- * With nothing marked it is the identity.
32
+ * `more` adds secrets that are not supplied values, each under the key it stands for (an authenticator secret's
33
+ * base32, every code typed from it). With nothing marked it is the identity.
33
34
  *
34
35
  * @example
35
36
  * const scrub = scrubberFor({ token: 'ghp_x1' }, ['token']);
36
37
  * scrub({ page_text: 'Token GHP_X1 created' }) // { page_text: 'Token [secret:token] created' }
37
38
  */
38
- export const scrubberFor = (values, secretKeys = []) => {
39
+ export const scrubberFor = (values, secretKeys = [], more = []) => {
39
40
  const keyByForm = new Map();
40
- for (const [key, value] of secretEntries(values, secretKeys)) {
41
+ for (const [key, value] of [...secretEntries(values, secretKeys), ...more.filter(([, value]) => value.trim() !== '')]) {
41
42
  for (const form of formsOf(value))
42
43
  if (form && !keyByForm.has(form.toLowerCase()))
43
44
  keyByForm.set(form.toLowerCase(), key);
@@ -44,7 +44,8 @@ export const buildFlows = (steps) => {
44
44
  return [...flows.values()];
45
45
  };
46
46
  const parentOf = (name) => (name.includes('/') ? name.slice(0, name.lastIndexOf('/')) : null);
47
- const describeAction = (step) => {
47
+ /** A step's action as a person reads it: `Tap "Search"`, `Type into "Destination" → "Lisbon"`. */
48
+ export const describeAction = (step) => {
48
49
  const verb = { CLICK: 'Tap', TYPE_TEXT: 'Type into', SELECT: 'Choose' }[step.action.operation] ?? step.action.operation;
49
50
  return `${verb} "${step.action.label}"${step.action.value ? ` → "${step.action.value}"` : ''}`;
50
51
  };
@@ -10,6 +10,9 @@ import { buildFlows, hotspotOf } from './journey-doc.js';
10
10
  import { pickNextMove } from './pick-next-move.js';
11
11
  import { renderViewer } from './render-viewer.js';
12
12
  import { qaFindings, readScreenJudgments, screenQuestions, } from './screen-judgments.js';
13
+ import { screenFileName } from '../output/layout.js';
14
+ import { writeManifest } from '../output/manifest.js';
15
+ import { outsideHosts } from '../task/hosts.js';
13
16
  const EXPLORE_GOAL = `Explore this site the way a first-time mobile visitor would, one screen at a time.
14
17
  Open the next screen that matters most for the site's core user journeys: discovering, searching, comparing,
15
18
  and evaluating its main offering. Prefer primary navigation, search, results, and detail screens over legal,
@@ -24,7 +27,7 @@ footer, social, app-download, or external links. Dismiss blocking banners. Never
24
27
  * journey.report(); // screen s01 + Jev's suggested next tap
25
28
  * await journey.step({ next: 4, flow: 'Search', insight: 'Search is a full-screen sheet' });
26
29
  * await journey.autopilot({ screens: 5, flow: 'Search/Results' });
27
- * journey.finish(); // → journey.json, index.html, screens/*.png
30
+ * journey.finish(); // → journey.json, index.html, manifest.json, screens/s01-home.png …
28
31
  */
29
32
  export class JourneySession {
30
33
  doc;
@@ -227,7 +230,8 @@ export class JourneySession {
227
230
  continue;
228
231
  }
229
232
  const scope = this.#options.stayWithin ?? new URL(this.#options.startUrl).origin;
230
- if (!(await this.#tab.observe()).url.startsWith(scope)) {
233
+ const landed = (await this.#tab.observe()).url;
234
+ if (!landed.startsWith(scope) || this.#outsideHosts(landed)) {
231
235
  await this.#goBack();
232
236
  this.#logMove('left_scope', move.action.label);
233
237
  continue;
@@ -286,9 +290,15 @@ export class JourneySession {
286
290
  return this.report();
287
291
  }
288
292
  // Observe → one Jev request (next step + screen understanding) → dedupe → screenshot → QA → save.
293
+ #outsideHosts(url) {
294
+ return this.#options.hosts && outsideHosts(url, this.#options.hosts);
295
+ }
289
296
  async #capture(arrival) {
290
297
  await this.#diagnostics.waitForIdle();
291
298
  const page = await this.#settledPage();
299
+ const outside = this.#outsideHosts(page.url);
300
+ if (outside)
301
+ throw new Error(`The page is outside the server's hosts: ${outside}. Take \`back\` to return.`);
292
302
  const seesScreen = (this.#options.screenshots ?? 'off') !== 'off';
293
303
  const boxed = seesScreen && Boolean(this.#options.box);
294
304
  const decision = await decide(this.#ask, {
@@ -320,7 +330,7 @@ export class JourneySession {
320
330
  mergeFindings(this.#screen(existing).qa, findings);
321
331
  }
322
332
  else {
323
- const screenshot = `screens/${id}.png`;
333
+ const screenshot = screenFileName(id, judgments.type);
324
334
  writeFileSync(join(this.outDir, screenshot), Buffer.from(await this.#tab.screenshot(), 'base64'));
325
335
  this.doc.screens.push({
326
336
  id,
@@ -433,6 +443,7 @@ export class JourneySession {
433
443
  this.doc.flows = buildFlows(this.doc.steps);
434
444
  writeFileSync(join(this.outDir, 'journey.json'), JSON.stringify(this.doc, null, 2));
435
445
  writeFileSync(join(this.outDir, 'index.html'), renderViewer(this.doc));
446
+ writeManifest(this.outDir, 'journey');
436
447
  }
437
448
  }
438
449
  // "https://site/#/" and "https://site/" are the same route.