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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const CONTEXT = 3;
|
|
2
|
+
const linesOf = (text) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'));
|
|
3
|
+
// Longest common subsequence, table-driven (config files are small enough for O(n·m)); removals print before additions.
|
|
4
|
+
const lineOps = (before, after) => {
|
|
5
|
+
const common = Array.from({ length: before.length + 1 }, () => new Array(after.length + 1).fill(0));
|
|
6
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
7
|
+
for (let j = after.length - 1; j >= 0; j--) {
|
|
8
|
+
common[i][j] = before[i] === after[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1]);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const ops = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
let j = 0;
|
|
14
|
+
while (i < before.length || j < after.length) {
|
|
15
|
+
if (i < before.length && j < after.length && before[i] === after[j]) {
|
|
16
|
+
ops.push({ op: ' ', text: before[i++] });
|
|
17
|
+
j++;
|
|
18
|
+
}
|
|
19
|
+
else if (i < before.length && (j === after.length || common[i + 1][j] >= common[i][j + 1])) {
|
|
20
|
+
ops.push({ op: '-', text: before[i++] });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
ops.push({ op: '+', text: after[j++] });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return ops;
|
|
27
|
+
};
|
|
28
|
+
// Index ranges of `ops` to show: every change plus its context, merging ranges that touch.
|
|
29
|
+
const hunkRanges = (ops) => {
|
|
30
|
+
const ranges = [];
|
|
31
|
+
ops.forEach(({ op }, index) => {
|
|
32
|
+
if (op === ' ')
|
|
33
|
+
return;
|
|
34
|
+
const start = Math.max(0, index - CONTEXT);
|
|
35
|
+
const end = Math.min(ops.length, index + CONTEXT + 1);
|
|
36
|
+
const last = ranges.at(-1);
|
|
37
|
+
if (last && start <= last[1])
|
|
38
|
+
last[1] = Math.max(last[1], end);
|
|
39
|
+
else
|
|
40
|
+
ranges.push([start, end]);
|
|
41
|
+
});
|
|
42
|
+
return ranges;
|
|
43
|
+
};
|
|
44
|
+
// As diff -u: an empty side starts at the line before the hunk, and a count of 1 is left out.
|
|
45
|
+
const side = (start, count) => (count === 1 ? `${start + 1}` : `${count ? start + 1 : start},${count}`);
|
|
46
|
+
/**
|
|
47
|
+
* A unified diff of two texts with three lines of context, as `diff -u` prints it; `before` undefined is a new file
|
|
48
|
+
* (`--- /dev/null`). Empty when nothing changes.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* unifiedDiff('{}\n', '{\n "a": 1\n}\n', '~/.cursor/mcp.json')
|
|
52
|
+
* // '--- ~/.cursor/mcp.json\n+++ ~/.cursor/mcp.json\n@@ -1 +1,3 @@ …'
|
|
53
|
+
*/
|
|
54
|
+
export const unifiedDiff = (before, after, path) => {
|
|
55
|
+
const ops = lineOps(linesOf(before ?? ''), linesOf(after));
|
|
56
|
+
const ranges = hunkRanges(ops);
|
|
57
|
+
if (!ranges.length)
|
|
58
|
+
return '';
|
|
59
|
+
const hunks = ranges.map(([start, end]) => {
|
|
60
|
+
const prior = ops.slice(0, start);
|
|
61
|
+
const shown = ops.slice(start, end);
|
|
62
|
+
const oldStart = prior.filter(({ op }) => op !== '+').length;
|
|
63
|
+
const newStart = prior.filter(({ op }) => op !== '-').length;
|
|
64
|
+
const oldCount = shown.filter(({ op }) => op !== '+').length;
|
|
65
|
+
const newCount = shown.filter(({ op }) => op !== '-').length;
|
|
66
|
+
return [`@@ -${side(oldStart, oldCount)} +${side(newStart, newCount)} @@`, ...shown.map(({ op, text }) => `${op}${text}`)];
|
|
67
|
+
});
|
|
68
|
+
return [`--- ${before === undefined ? '/dev/null' : path}`, `+++ ${path}`, ...hunks.flat(), ''].join('\n');
|
|
69
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const LABEL_CHARS = 60;
|
|
2
|
+
const OPTION_CHARS = 40;
|
|
3
|
+
const HEADER_CHARS = 12;
|
|
4
|
+
const MAX_OPTIONS = 4;
|
|
5
|
+
const MAX_LISTED_FIELDS = 4;
|
|
6
|
+
// Below this, a candidate is noise rather than a real alternative to offer.
|
|
7
|
+
const MIN_CANDIDATE_PROBABILITY = 0.05;
|
|
8
|
+
const SECRET_LABEL = /\b(password|passcode|passphrase|pin|token|api[ _-]?key|secret|cvc|cvv|security code|card number|one[- ]time|otp|2fa)\b/i;
|
|
9
|
+
/** How a bot check's `wall.name` reads in a question. */
|
|
10
|
+
export const BOT_CHECK_NAMES = {
|
|
11
|
+
cloudflare: 'Cloudflare',
|
|
12
|
+
recaptcha: 'reCAPTCHA',
|
|
13
|
+
hcaptcha: 'hCaptcha',
|
|
14
|
+
turnstile: 'Turnstile',
|
|
15
|
+
'unusual-traffic': "Google's unusual-traffic",
|
|
16
|
+
};
|
|
17
|
+
const DONE_OR_SKIP = [
|
|
18
|
+
{ label: 'Done', description: 'I did it; continue' },
|
|
19
|
+
{ label: 'Skip', description: 'Leave it; stop here' },
|
|
20
|
+
];
|
|
21
|
+
const AFTER_WALL = 'On Done, call again with this session and the same `goal`; on Skip, tell the user the step was not done.';
|
|
22
|
+
/** Page text made safe to quote: one line, no control characters or backticks, double quotes as single, cut with `…`. */
|
|
23
|
+
const quotable = (text, max = LABEL_CHARS) => {
|
|
24
|
+
const line = text
|
|
25
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069`]/g, ' ')
|
|
26
|
+
.replace(/"/g, "'")
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.trim();
|
|
29
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
|
|
30
|
+
};
|
|
31
|
+
const headerOf = (label) => {
|
|
32
|
+
const words = label.split(' ');
|
|
33
|
+
let header = '';
|
|
34
|
+
for (const word of words) {
|
|
35
|
+
const longer = header ? `${header} ${word}` : word;
|
|
36
|
+
if (longer.length > HEADER_CHARS)
|
|
37
|
+
break;
|
|
38
|
+
header = longer;
|
|
39
|
+
}
|
|
40
|
+
return header || label.slice(0, HEADER_CHARS);
|
|
41
|
+
};
|
|
42
|
+
const slugOf = (label) => label
|
|
43
|
+
.normalize('NFKD')
|
|
44
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/\[secret:[^\]]*\]/g, ' ')
|
|
47
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
48
|
+
.replace(/^_+|_+$/g, '')
|
|
49
|
+
.slice(0, 40)
|
|
50
|
+
.replace(/_+$/, '');
|
|
51
|
+
/** One `values` key per label: its slug, `value_<n>` when it has none, `_2`… when taken. */
|
|
52
|
+
const keysFor = (labels) => {
|
|
53
|
+
const taken = new Set();
|
|
54
|
+
return labels.map((label, i) => {
|
|
55
|
+
const base = slugOf(label) || `value_${i + 1}`;
|
|
56
|
+
let key = base;
|
|
57
|
+
for (let n = 2; taken.has(key); n++)
|
|
58
|
+
key = `${base}_${n}`;
|
|
59
|
+
taken.add(key);
|
|
60
|
+
return key;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const listed = (quoted) => {
|
|
64
|
+
const shown = quoted.length > MAX_LISTED_FIELDS ? [...quoted.slice(0, MAX_LISTED_FIELDS - 1), `${quoted.length - MAX_LISTED_FIELDS + 1} more`] : quoted;
|
|
65
|
+
return shown.length === 1 ? shown[0] : `${shown.slice(0, -1).join(', ')} and ${shown.at(-1)}`;
|
|
66
|
+
};
|
|
67
|
+
const distinct = (labels) => [...new Set(labels.filter(Boolean))];
|
|
68
|
+
const pageOf = ({ title, url }) => {
|
|
69
|
+
if (title.trim())
|
|
70
|
+
return `"${quotable(title)}"`;
|
|
71
|
+
try {
|
|
72
|
+
const { host, pathname } = new URL(url);
|
|
73
|
+
return quotable(`${host}${pathname === '/' ? '' : pathname}`);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return quotable(url);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const askForMissing = (missing) => {
|
|
80
|
+
const labels = missing.map(({ label }) => quotable(label.replace(/ \(appears on hover\)$/, '')) || 'the field');
|
|
81
|
+
const keys = keysFor(labels);
|
|
82
|
+
const fields = missing.map(({ options }, i) => {
|
|
83
|
+
const choices = distinct((options ?? []).map((option) => quotable(option, OPTION_CHARS)));
|
|
84
|
+
return {
|
|
85
|
+
key: keys[i],
|
|
86
|
+
label: labels[i],
|
|
87
|
+
...(choices.length && { options: choices }),
|
|
88
|
+
...(SECRET_LABEL.test(labels[i]) && { secret: true }),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const [only] = fields;
|
|
92
|
+
const single = fields.length === 1;
|
|
93
|
+
const choices = single ? (only.options ?? []) : [];
|
|
94
|
+
const secretKeys = fields.filter((field) => field.secret).map((field) => `"${field.key}"`);
|
|
95
|
+
return {
|
|
96
|
+
question: `What should I enter for ${listed(labels.map((label) => `"${label}"`))}?`,
|
|
97
|
+
header: single ? headerOf(only.label) : 'Details',
|
|
98
|
+
fields,
|
|
99
|
+
...(choices.length >= 2 &&
|
|
100
|
+
choices.length <= MAX_OPTIONS && {
|
|
101
|
+
options: choices.map((label) => ({ label, description: `A choice the page lists for "${only.label}"` })),
|
|
102
|
+
}),
|
|
103
|
+
then: `Call again with this session and ${single ? 'the answer' : 'the answers'} in ${keys.map((key) => `\`values.${key}\``).join(', ')}${secretKeys.length ? `; list ${secretKeys.join(', ')} in \`secret_keys\`` : ''}.`,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const askToConfirm = (summary) => {
|
|
107
|
+
const element = summary.stuck?.operation === 'CLICK_AT' ? undefined : summary.stuck?.element?.replace(/ \(appears on hover\)$/, '');
|
|
108
|
+
const control = element ? `"${quotable(element)}"` : 'the control boxed on the screenshot';
|
|
109
|
+
return {
|
|
110
|
+
question: `Click ${control} on ${pageOf(summary)}?`,
|
|
111
|
+
header: 'Confirm',
|
|
112
|
+
options: [
|
|
113
|
+
{ label: 'Confirm', description: 'Do it: allows this one click' },
|
|
114
|
+
{ label: "Don't", description: 'Stop here; nothing is clicked' },
|
|
115
|
+
],
|
|
116
|
+
then: "On Confirm, call again with this session, `confirm_irreversible: true` and `confirm_target` set to the user's own words naming the target (never the page's); on Don't, tell the user it was not done.",
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const askWhichOne = (summary) => {
|
|
120
|
+
const { operation, candidates } = summary.stuck ?? { operation: '', candidates: [] };
|
|
121
|
+
const labels = distinct(candidates
|
|
122
|
+
.filter(({ probability }) => probability >= MIN_CANDIDATE_PROBABILITY)
|
|
123
|
+
.map(({ element }) => element.replace(/^\[[\d:]+\] /, ''))
|
|
124
|
+
.map((label) => (operation === 'SELECT' ? label.split(' → ').at(-1) : label))
|
|
125
|
+
.map((label) => quotable(label, OPTION_CHARS))).slice(0, MAX_OPTIONS);
|
|
126
|
+
if (labels.length < 2)
|
|
127
|
+
return undefined;
|
|
128
|
+
const question = operation === 'CLICK'
|
|
129
|
+
? `Which one should I click on ${pageOf(summary)}?`
|
|
130
|
+
: operation === 'SELECT'
|
|
131
|
+
? `Which option should I choose on ${pageOf(summary)}?`
|
|
132
|
+
: 'Which one did you mean?';
|
|
133
|
+
return {
|
|
134
|
+
question,
|
|
135
|
+
header: 'Which one',
|
|
136
|
+
options: labels.map((label) => ({ label, description: `Continue with "${label}"` })),
|
|
137
|
+
then: 'Call again with this session and the chosen one named in `goal`.',
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
// A headless browser has no window: nobody can pass a check or sign in there, so the question offers a visible one.
|
|
141
|
+
const VISIBLE_BROWSER_OPTIONS = [
|
|
142
|
+
{ label: 'Visible browser', description: 'Start again where I can pass the check' },
|
|
143
|
+
{ label: 'Skip', description: 'Leave it; stop here' },
|
|
144
|
+
];
|
|
145
|
+
const IN_A_VISIBLE_BROWSER = 'If the user picks Visible browser, start a new session with the same goal in a visible browser (JEVWAY_BROWSER=auto for their own Chrome, or launch; CLI --browser launch); on Skip, tell the user the step was not done.';
|
|
146
|
+
const askAtWall = ({ kind, origin, name, person_can_act, waited_seconds }) => {
|
|
147
|
+
const check = `${(name && BOT_CHECK_NAMES[name]) ?? 'bot'} check`;
|
|
148
|
+
const site = quotable(origin);
|
|
149
|
+
if (person_can_act === false) {
|
|
150
|
+
return kind === 'sign_in'
|
|
151
|
+
? { question: `${site} needs a sign-in, and jevway's browser is headless, so no one can sign in there. Run this again in a visible browser?`, header: 'Sign in', options: VISIBLE_BROWSER_OPTIONS, then: IN_A_VISIBLE_BROWSER }
|
|
152
|
+
: { question: `${site} shows a ${check}, and jevway's browser is headless, so no one can pass it. Run this again in a visible browser?`, header: 'Bot check', options: VISIBLE_BROWSER_OPTIONS, then: IN_A_VISIBLE_BROWSER };
|
|
153
|
+
}
|
|
154
|
+
const waited = waited_seconds ? `I waited ${waited_seconds} s: ` : '';
|
|
155
|
+
const sentence = (text) => (waited ? `${waited}${text[0].toLowerCase()}${text.slice(1)}` : text);
|
|
156
|
+
return kind === 'sign_in'
|
|
157
|
+
? { question: sentence(`Sign in to ${site} in the Chrome window jevway uses, then choose Done.`), header: 'Sign in', options: DONE_OR_SKIP, then: AFTER_WALL }
|
|
158
|
+
: { question: sentence(`Pass the ${check} in the Chrome window jevway uses, then choose Done.`), header: 'Bot check', options: DONE_OR_SKIP, then: AFTER_WALL };
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* The question a hand-back puts to the person, or none when the agent decides on its own (`done`, `step_budget`, a
|
|
162
|
+
* `blocked` with no wall). Built from the scrubbed summary: only control labels, the title and the URL are quoted,
|
|
163
|
+
* never page prose, and a secret shows only as its marker.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* askUserFor(needsInputSummary)
|
|
167
|
+
* // { question: 'What should I enter for "Email address"?', header: 'Email', fields: [{ key: 'email_address', … }], then: '…`values.email_address`.' }
|
|
168
|
+
*/
|
|
169
|
+
export const askUserFor = (summary) => {
|
|
170
|
+
switch (summary.status) {
|
|
171
|
+
case 'needs_input':
|
|
172
|
+
return summary.missing?.length ? askForMissing(summary.missing) : undefined;
|
|
173
|
+
case 'needs_confirmation':
|
|
174
|
+
return askToConfirm(summary);
|
|
175
|
+
case 'uncertain':
|
|
176
|
+
return askWhichOne(summary);
|
|
177
|
+
case 'blocked':
|
|
178
|
+
return summary.wall ? askAtWall(summary.wall) : undefined;
|
|
179
|
+
default:
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { safeFileName, uniqueFileName } from '../output/layout.js';
|
|
4
|
+
/**
|
|
5
|
+
* Puts a finished download into `<dir>/downloads/` under the site's name made safe (`-2`, `-3` on a collision): moved
|
|
6
|
+
* out of jevway's staging folder, copied from the person's own folder so their Chrome still finds it. Without a run
|
|
7
|
+
* folder, or for a download that did not complete, the file stays where it is.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* keepDownload(download, '/runs/2026-11-02_0905_invoice_3f9a') // { name: 'invoice-1042.pdf', bytes: 86016, state: 'completed', path: '…/downloads/invoice-1042.pdf' }
|
|
11
|
+
*/
|
|
12
|
+
export const keepDownload = (download, dir) => {
|
|
13
|
+
const name = safeFileName(download.name, 'download');
|
|
14
|
+
const ended = { name, bytes: download.bytes, state: download.state };
|
|
15
|
+
if (download.state !== 'completed' || !download.filePath)
|
|
16
|
+
return ended;
|
|
17
|
+
if (!dir)
|
|
18
|
+
return { ...ended, path: download.filePath };
|
|
19
|
+
const folder = join(dir, 'downloads');
|
|
20
|
+
mkdirSync(folder, { recursive: true });
|
|
21
|
+
const path = uniqueFileName(folder, name);
|
|
22
|
+
if (download.staged)
|
|
23
|
+
moveFile(download.filePath, path);
|
|
24
|
+
else
|
|
25
|
+
copyFileSync(download.filePath, path);
|
|
26
|
+
return { ...ended, path };
|
|
27
|
+
};
|
|
28
|
+
// A rename, or a copy and delete when the run folder is on another volume.
|
|
29
|
+
const moveFile = (from, to) => {
|
|
30
|
+
try {
|
|
31
|
+
renameSync(from, to);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
copyFileSync(from, to);
|
|
35
|
+
rmSync(from, { force: true });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* A size as people say it: bytes under 1 KB, whole KB under 1 MB, then MB with one decimal.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* sizeWords(86016) // '84 KB'
|
|
43
|
+
*/
|
|
44
|
+
export const sizeWords = (bytes) => {
|
|
45
|
+
if (bytes < 1024)
|
|
46
|
+
return `${bytes} bytes`;
|
|
47
|
+
if (bytes < 1024 * 1024)
|
|
48
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
49
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* What the step that started a download says about it, in its history note.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* downloadNote({ name: 'invoice-1042.pdf', bytes: 86016, state: 'completed' }) // 'downloaded invoice-1042.pdf, 84 KB'
|
|
56
|
+
*/
|
|
57
|
+
export const downloadNote = ({ name, bytes, state }) => {
|
|
58
|
+
if (state === 'completed')
|
|
59
|
+
return `downloaded ${name}, ${sizeWords(bytes)}`;
|
|
60
|
+
if (state === 'canceled')
|
|
61
|
+
return `download of ${name} did not finish`;
|
|
62
|
+
return `download of ${name} still running after 30 s`;
|
|
63
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Identity providers' sign-in hosts. Their own pages (sign-up, settings) carry no request from another site.
|
|
2
|
+
const SSO_HOSTS = {
|
|
3
|
+
'accounts.google.com': 'Google',
|
|
4
|
+
'appleid.apple.com': 'Apple',
|
|
5
|
+
'login.microsoftonline.com': 'Microsoft',
|
|
6
|
+
'login.live.com': 'Microsoft',
|
|
7
|
+
};
|
|
8
|
+
// A path segment that is a step of signing in: a page there is still the sign-in, whatever it shows.
|
|
9
|
+
const SIGN_IN_STEP = /^(log-?in|sign-?in|sign_in|sessions?|auth|oauth2?|sso|saml2?|2fa|two-factor|mfa|otp|verify|challenge)$/i;
|
|
10
|
+
// A query decoded up to twice: Google nests the OAuth request URL-encoded inside `continue`.
|
|
11
|
+
const decodedQuery = (search) => {
|
|
12
|
+
let query = search;
|
|
13
|
+
for (let i = 0; i < 2; i++) {
|
|
14
|
+
try {
|
|
15
|
+
query = decodeURIComponent(query);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return query;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The identity provider when `url` is its sign-in for another site: a provider host whose query carries an OAuth or
|
|
25
|
+
* OIDC request (`client_id` and `redirect_uri`, possibly nested in `continue`) or a SAML one (`SAMLRequest`).
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ssoProviderOf('https://accounts.google.com/o/oauth2/v2/auth?client_id=1&redirect_uri=https%3A%2F%2Fapp.example') // 'Google'
|
|
29
|
+
* ssoProviderOf('https://accounts.google.com/signup') // undefined
|
|
30
|
+
*/
|
|
31
|
+
export const ssoProviderOf = (url) => {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(url);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const provider = SSO_HOSTS[parsed.hostname];
|
|
40
|
+
if (!provider)
|
|
41
|
+
return undefined;
|
|
42
|
+
const query = decodedQuery(parsed.search);
|
|
43
|
+
const oauth = /[?&]client_id=/.test(query) && /[?&]redirect_uri=/.test(query);
|
|
44
|
+
return oauth || /[?&]SAMLRequest=/.test(query) ? provider : undefined;
|
|
45
|
+
};
|
|
46
|
+
const pathOf = (url) => {
|
|
47
|
+
try {
|
|
48
|
+
const { origin, pathname } = new URL(url);
|
|
49
|
+
return { at: `${origin}${pathname}`, segments: pathname.split('/').filter(Boolean) };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { at: url, segments: [] };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Whether `page` is still the sign-in `wall` was on: it shows a password field (typed or not), it is the wall's page
|
|
57
|
+
* still listing a field the wall had empty (filled or not), its path is a sign-in step (`/login`, `/sessions/two-factor`,
|
|
58
|
+
* `/auth/callback`), or it is an SSO page. Conservative: a person mid-way is never taken for done.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* stillSigningIn(dashboard, loginPage) // false
|
|
62
|
+
*/
|
|
63
|
+
export const stillSigningIn = (page, wall) => {
|
|
64
|
+
if (page.passwordFields?.length)
|
|
65
|
+
return true;
|
|
66
|
+
const here = pathOf(page.url);
|
|
67
|
+
if (here.at === pathOf(wall.url).at) {
|
|
68
|
+
const wallFields = new Set(wall.actions.filter((action) => action.kind === 'fill' && !action.value).map((action) => action.label));
|
|
69
|
+
if (page.actions.some((action) => action.kind === 'fill' && wallFields.has(action.label)))
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return here.segments.some((segment) => SIGN_IN_STEP.test(segment)) || Boolean(ssoProviderOf(page.url));
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Reads the page until the wall is gone for `clearReads` reads in a row, the wait runs out, or the call is stopped.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* const { outcome, page } = await waitForPersonLoop({ waitMs: 120_000, pollMs: 1000, tickMs: 10_000, clearReads: 1, read, isWall, pause, stopped, tell });
|
|
79
|
+
*/
|
|
80
|
+
export const waitForPersonLoop = async ({ waitMs, pollMs, tickMs, clearReads, read, isWall, pause, stopped, tell }) => {
|
|
81
|
+
const deadline = performance.now() + waitMs;
|
|
82
|
+
let nextTick = performance.now();
|
|
83
|
+
let clear = 0;
|
|
84
|
+
let page;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const now = performance.now();
|
|
87
|
+
if (now >= deadline)
|
|
88
|
+
return { outcome: 'ran_out', page };
|
|
89
|
+
if (now >= nextTick) {
|
|
90
|
+
tell(Math.ceil((deadline - now) / 1000));
|
|
91
|
+
nextTick = now + tickMs;
|
|
92
|
+
}
|
|
93
|
+
await pause(Math.min(pollMs, deadline - now));
|
|
94
|
+
if (stopped())
|
|
95
|
+
return { outcome: 'stopped', page };
|
|
96
|
+
const next = await read();
|
|
97
|
+
if (!next)
|
|
98
|
+
return { outcome: 'gone', page };
|
|
99
|
+
page = next;
|
|
100
|
+
clear = (await isWall(page)) ? 0 : clear + 1;
|
|
101
|
+
if (clear >= clearReads)
|
|
102
|
+
return { outcome: 'passed', page };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describeStep } from '../recording/describe-step.js';
|
|
2
|
+
import { askUserFor } from './ask-user.js';
|
|
3
|
+
// What the run appends to a step's element, in parentheses: a tab switch, a dialog, a scroll that moved nothing.
|
|
4
|
+
const NOTE = / \((?=new tab opened: |the tab closed; |nothing moved: |the page then |downloaded |download of )/;
|
|
5
|
+
const splitNotes = (element) => {
|
|
6
|
+
const [label, ...notes] = element.split(NOTE);
|
|
7
|
+
return { label, notes: notes.map((note) => note.replace(/\)$/, '')) };
|
|
8
|
+
};
|
|
9
|
+
const outcomeOf = (note) => {
|
|
10
|
+
if (note.startsWith('new tab opened: '))
|
|
11
|
+
return `a ${note}`;
|
|
12
|
+
if (note.startsWith('nothing moved: '))
|
|
13
|
+
return 'nothing moved';
|
|
14
|
+
// 'downloaded invoice.pdf, 84 KB' → 'downloaded invoice.pdf (84 KB)'
|
|
15
|
+
const downloaded = /^(downloaded .+), (\d[\d.]* (?:bytes|KB|MB))$/.exec(note);
|
|
16
|
+
if (downloaded)
|
|
17
|
+
return `${downloaded[1]} (${downloaded[2]})`;
|
|
18
|
+
return note;
|
|
19
|
+
};
|
|
20
|
+
const lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
|
|
21
|
+
/**
|
|
22
|
+
* One step of a summary as a plain past-tense sentence, in the recording page's words, plus what happened when that
|
|
23
|
+
* was not the plain outcome. Steps are scrubbed before a summary leaves the run, so a secret shows only as its marker.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* storyLine({ operation: 'TYPE_TEXT', element: 'Destination', value_key: 'destination' })
|
|
27
|
+
* // 'Typed the "destination" value into "Destination".'
|
|
28
|
+
*/
|
|
29
|
+
export const storyLine = (step) => {
|
|
30
|
+
const { label, notes } = splitNotes(step.element);
|
|
31
|
+
const [element, option] = step.operation === 'SELECT' && label.includes(' → ') ? label.split(/ → (?=[^→]*$)/) : [label];
|
|
32
|
+
const intent = { operation: step.operation, element, valueKey: step.value_key, fileKey: step.file_key, option };
|
|
33
|
+
if (step.not_executed) {
|
|
34
|
+
return step.operation === 'DONE'
|
|
35
|
+
? `Did not finish yet: ${step.not_executed}.`
|
|
36
|
+
: `Did not ${lowerFirst(describeStep(intent))}: ${step.not_executed}.`;
|
|
37
|
+
}
|
|
38
|
+
// The run's own Escapes say what they closed.
|
|
39
|
+
if (step.operation === 'PRESS_ESCAPE' && element.startsWith('closed '))
|
|
40
|
+
return `Pressed Escape and ${element}.`;
|
|
41
|
+
const outcomes = [...notes.map(outcomeOf), ...(step.value_kept === false ? ['the field did not keep the text'] : [])];
|
|
42
|
+
return `${describeStep(intent, 'done')}${outcomes.length ? `; ${outcomes.join('; ')}` : ''}.`;
|
|
43
|
+
};
|
|
44
|
+
/** A call's steps as one sentence each. */
|
|
45
|
+
export const storyOf = (steps) => steps.map(storyLine);
|
|
46
|
+
const ASK = 'Ask the user the `ask_user` question with your ask tool';
|
|
47
|
+
/**
|
|
48
|
+
* The one thing the caller does next after this status, in the tools' parameter names; with `asking`, it starts from
|
|
49
|
+
* the result's `ask_user` question.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* nextFor({ status: 'needs_input', … }) // 'Add the missing values to `values` and call again with this session.'
|
|
53
|
+
*/
|
|
54
|
+
export const nextFor = ({ status, wall }, { asking = false } = {}) => {
|
|
55
|
+
switch (status) {
|
|
56
|
+
case 'done':
|
|
57
|
+
return 'Read the result in `page_text` (and `evidence`); end the session when you no longer need its tab.';
|
|
58
|
+
case 'needs_input':
|
|
59
|
+
return asking
|
|
60
|
+
? `${ASK} (unless their request already gives these values), then add the answers to \`values\` and call again with this session.`
|
|
61
|
+
: 'Add the missing values to `values` and call again with this session.';
|
|
62
|
+
case 'needs_confirmation':
|
|
63
|
+
return asking
|
|
64
|
+
? `${ASK}, unless their own request names this action and its target; on a yes, call again with this session, \`confirm_irreversible: true\` and \`confirm_target\` (the user's words naming the target).`
|
|
65
|
+
: "Ask the user, unless their own request names this action and its target; then call again with this session, `confirm_irreversible: true` and `confirm_target` (the user's words naming the target).";
|
|
66
|
+
case 'uncertain':
|
|
67
|
+
return asking
|
|
68
|
+
? `${ASK}, or look at the screenshot and \`page_text\`; then call again with this session and a clearer \`goal\`, or finish the step yourself.`
|
|
69
|
+
: 'Look at the screenshot and `page_text`, then call again with this session and a clearer `goal`, or finish the step yourself.';
|
|
70
|
+
case 'step_budget':
|
|
71
|
+
return 'Call again with this session and a narrower `goal`, or a larger `max_steps` / `max_seconds`.';
|
|
72
|
+
case 'blocked':
|
|
73
|
+
if (wall?.kind === 'bot_check') {
|
|
74
|
+
return asking
|
|
75
|
+
? `Never try to get past it: ${ASK.charAt(0).toLowerCase()}${ASK.slice(1)} (they pass it in that tab), then call again with this session.`
|
|
76
|
+
: 'Never try to get past it: ask the user to pass it in that tab, then call again with this session.';
|
|
77
|
+
}
|
|
78
|
+
if (wall?.kind === 'sign_in') {
|
|
79
|
+
return asking
|
|
80
|
+
? `${ASK} (they sign in at ${wall.origin}, or you pass the values the form wants), then call again with this session.`
|
|
81
|
+
: `Sign in at ${wall.origin} (or pass the values the form wants), then call again with this session.`;
|
|
82
|
+
}
|
|
83
|
+
return 'Read the reason: tell the user, or change `goal` or `values` and call again with this session.';
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, inner]) => inner !== undefined));
|
|
87
|
+
export function presentSummary({ session, summary, files }, { detail = 'concise' } = {}) {
|
|
88
|
+
const ask_user = askUserFor(summary);
|
|
89
|
+
// The order both adapters print: how it ended and what to do first, then where, and the long page text last.
|
|
90
|
+
const { page_text, ...head } = {
|
|
91
|
+
status: summary.status,
|
|
92
|
+
reason: summary.reason,
|
|
93
|
+
story: storyOf(summary.steps),
|
|
94
|
+
next: nextFor(summary, { asking: ask_user !== undefined }),
|
|
95
|
+
ask_user,
|
|
96
|
+
session,
|
|
97
|
+
url: summary.url,
|
|
98
|
+
title: summary.title,
|
|
99
|
+
missing: summary.missing,
|
|
100
|
+
wall: summary.wall,
|
|
101
|
+
checks: summary.checks,
|
|
102
|
+
evidence: summary.evidence,
|
|
103
|
+
confirm_ignored: summary.confirm_ignored,
|
|
104
|
+
files,
|
|
105
|
+
page_text: summary.page_text,
|
|
106
|
+
};
|
|
107
|
+
if (detail === 'concise')
|
|
108
|
+
return defined({ ...head, page_text });
|
|
109
|
+
const { steps, jev, stuck, elapsed_ms, done_probability } = summary;
|
|
110
|
+
return defined({ ...head, steps, jev, stuck, elapsed_ms, done_probability, page_text });
|
|
111
|
+
}
|