jevway 0.1.0 → 0.2.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/INSTALL-FOR-AGENTS.md +32 -38
- package/README.md +193 -150
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +74 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +47 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +173 -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 +320 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- 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/instructions.js +18 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +50 -0
- package/dist/mcp/task-tools.js +197 -0
- package/dist/mcp.js +12 -337
- package/dist/output/layout.js +50 -0
- package/dist/output/manifest.js +80 -0
- package/dist/output/run-folder.js +38 -0
- package/dist/recording/describe-step.js +66 -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 +193 -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 +101 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/present.js +90 -0
- package/dist/task/run-task.js +74 -11
- package/dist/task/start-task.js +21 -5
- package/package.json +12 -7
- package/skill/jevway/SKILL.md +50 -14
- package/dist/cli.js +0 -112
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { print, printError, printJson } from './command-output.js';
|
|
6
|
+
export const SKILL_USAGE = `Examples:
|
|
7
|
+
jevway skill
|
|
8
|
+
jevway skill --install ~/.claude/skills
|
|
9
|
+
|
|
10
|
+
jevway skill print the SKILL.md this version ships
|
|
11
|
+
jevway skill --install <dir> [--force] copy the skill folder into <dir>/jevway
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
jevway skill --install ~/.claude/skills
|
|
15
|
+
jevway skill --install ~/.codex/skills
|
|
16
|
+
|
|
17
|
+
--install prints { ok, status: installed|unchanged|replaced, path, files }. A different jevway skill already in
|
|
18
|
+
<dir>/jevway (or a symlink to one) is left alone unless --force is given.`;
|
|
19
|
+
/** The `skill/jevway/` folder of the installed package: two levels up from both `src/setup/` and `dist/setup/`. */
|
|
20
|
+
export const bundledSkillDir = () => fileURLToPath(new URL('../../skill/jevway/', import.meta.url));
|
|
21
|
+
/** Every file under `dir`, as sorted paths relative to it. */
|
|
22
|
+
const filesIn = (dir) => readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
23
|
+
.filter((entry) => entry.isFile())
|
|
24
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)))
|
|
25
|
+
.sort();
|
|
26
|
+
/** True when `target` holds exactly the files of `source`, byte for byte (following a symlinked `target`). */
|
|
27
|
+
const sameFolder = (source, target) => {
|
|
28
|
+
try {
|
|
29
|
+
const files = filesIn(source);
|
|
30
|
+
return (files.join('\n') === filesIn(target).join('\n') &&
|
|
31
|
+
files.every((file) => readFileSync(join(source, file)).equals(readFileSync(join(target, file)))));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** Whether `<parent>/jevway` is missing, the same as `source`, or something else (a different skill or a symlink to one). */
|
|
38
|
+
export const skillState = (source, parent) => {
|
|
39
|
+
const path = join(parent, 'jevway');
|
|
40
|
+
if (!existsSync(path) && !lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
41
|
+
return 'missing';
|
|
42
|
+
return sameFolder(source, path) ? 'same' : 'different';
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Copies `source` to `<parent>/jevway`. An identical copy is left as it is; a different folder or a symlink is
|
|
46
|
+
* replaced only with `force` (a symlink is removed, never its target). The copy lands under a temporary name first,
|
|
47
|
+
* so an interrupted install never leaves half a skill.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* installSkill(bundledSkillDir(), '/Users/ada/.claude/skills', { force: false })
|
|
51
|
+
* // { status: 'installed', path: '/Users/ada/.claude/skills/jevway', files: ['SKILL.md'] }
|
|
52
|
+
*/
|
|
53
|
+
export const installSkill = (source, parent, { force }) => {
|
|
54
|
+
const path = join(parent, 'jevway');
|
|
55
|
+
const files = filesIn(source);
|
|
56
|
+
const state = skillState(source, parent);
|
|
57
|
+
if (state === 'same')
|
|
58
|
+
return { status: 'unchanged', path, files };
|
|
59
|
+
if (state === 'different' && !force)
|
|
60
|
+
return { status: 'exists', path };
|
|
61
|
+
const present = state === 'different';
|
|
62
|
+
mkdirSync(parent, { recursive: true });
|
|
63
|
+
const incoming = join(parent, `.jevway-${process.pid}.incoming`);
|
|
64
|
+
cpSync(source, incoming, { recursive: true });
|
|
65
|
+
if (present)
|
|
66
|
+
rmSync(path, { recursive: true, force: true });
|
|
67
|
+
renameSync(incoming, path);
|
|
68
|
+
return { status: present ? 'replaced' : 'installed', path, files };
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* `jevway skill`: prints the bundled SKILL.md, or with `--install <dir>` copies the skill folder into `<dir>/jevway`.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* process.exit(await runSkill(process.argv.slice(3)));
|
|
75
|
+
*/
|
|
76
|
+
export const runSkill = async (argv) => {
|
|
77
|
+
let options;
|
|
78
|
+
try {
|
|
79
|
+
({ values: options } = parseArgs({
|
|
80
|
+
args: argv,
|
|
81
|
+
options: { install: { type: 'string' }, force: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return printError(2, 'usage', `jevway skill: ${error.message}`, 'jevway skill --install <dir> [--force]');
|
|
86
|
+
}
|
|
87
|
+
if (options.help) {
|
|
88
|
+
await print(`${SKILL_USAGE}\n`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (options.install === undefined) {
|
|
92
|
+
await print(readFileSync(join(bundledSkillDir(), 'SKILL.md'), 'utf8'));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
const result = installSkill(bundledSkillDir(), options.install, { force: Boolean(options.force) });
|
|
96
|
+
if (result.status === 'exists') {
|
|
97
|
+
return printError(1, 'skill_exists', `${result.path} already holds a different jevway skill (or a link to one); it was left as it is`, `Run again with --force to replace it with this version's skill: jevway skill --install ${options.install} --force`);
|
|
98
|
+
}
|
|
99
|
+
await printJson({ ok: true, ...result });
|
|
100
|
+
return 0;
|
|
101
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
/**
|
|
4
|
+
* The real terminal for setup. Hidden answers are read through readline with its echo sent to a muted stream, and
|
|
5
|
+
* readline keeps no history, so a key is never drawn on screen nor kept after the question. Ctrl-C (or Ctrl-D) exits
|
|
6
|
+
* 130 at once: setup writes every file whole, so nothing is left half-written.
|
|
7
|
+
*/
|
|
8
|
+
export const terminalPrompter = () => {
|
|
9
|
+
let muted = false;
|
|
10
|
+
let closing = false;
|
|
11
|
+
const echo = new Writable({
|
|
12
|
+
write(chunk, encoding, done) {
|
|
13
|
+
if (!muted)
|
|
14
|
+
process.stdout.write(chunk, encoding);
|
|
15
|
+
done();
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
Object.defineProperty(echo, 'columns', { get: () => process.stdout.columns });
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: echo, terminal: true, historySize: 0 });
|
|
20
|
+
const stop = () => {
|
|
21
|
+
if (closing)
|
|
22
|
+
return;
|
|
23
|
+
muted = false;
|
|
24
|
+
process.stdout.write('\nStopped. Every file is as it was before, or written whole.\n');
|
|
25
|
+
process.exit(130);
|
|
26
|
+
};
|
|
27
|
+
rl.on('SIGINT', stop);
|
|
28
|
+
rl.on('close', stop);
|
|
29
|
+
const io = {
|
|
30
|
+
say: (text) => void process.stdout.write(`${text}\n`),
|
|
31
|
+
ask: (question) => rl.question(question),
|
|
32
|
+
askHidden: async (question) => {
|
|
33
|
+
process.stdout.write(question);
|
|
34
|
+
muted = true;
|
|
35
|
+
try {
|
|
36
|
+
return await rl.question('');
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
muted = false;
|
|
40
|
+
process.stdout.write('\n');
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
io,
|
|
46
|
+
close: () => {
|
|
47
|
+
closing = true;
|
|
48
|
+
rl.close();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -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,90 @@
|
|
|
1
|
+
import { describeStep } from '../recording/describe-step.js';
|
|
2
|
+
// What the run appends to a step's element, in parentheses: a tab switch, a dialog, a scroll that moved nothing.
|
|
3
|
+
const NOTE = / \((?=new tab opened: |the tab closed; |nothing moved: |the page then )/;
|
|
4
|
+
const splitNotes = (element) => {
|
|
5
|
+
const [label, ...notes] = element.split(NOTE);
|
|
6
|
+
return { label, notes: notes.map((note) => note.replace(/\)$/, '')) };
|
|
7
|
+
};
|
|
8
|
+
const outcomeOf = (note) => {
|
|
9
|
+
if (note.startsWith('new tab opened: '))
|
|
10
|
+
return `a ${note}`;
|
|
11
|
+
if (note.startsWith('nothing moved: '))
|
|
12
|
+
return 'nothing moved';
|
|
13
|
+
return note;
|
|
14
|
+
};
|
|
15
|
+
const lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
|
|
16
|
+
/**
|
|
17
|
+
* One step of a summary as a plain past-tense sentence, in the recording page's words, plus what happened when that
|
|
18
|
+
* was not the plain outcome. Steps are scrubbed before a summary leaves the run, so a secret shows only as its marker.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* storyLine({ operation: 'TYPE_TEXT', element: 'Destination', value_key: 'destination' })
|
|
22
|
+
* // 'Typed the "destination" value into "Destination".'
|
|
23
|
+
*/
|
|
24
|
+
export const storyLine = (step) => {
|
|
25
|
+
const { label, notes } = splitNotes(step.element);
|
|
26
|
+
const [element, option] = step.operation === 'SELECT' && label.includes(' → ') ? label.split(/ → (?=[^→]*$)/) : [label];
|
|
27
|
+
const intent = { operation: step.operation, element, valueKey: step.value_key, fileKey: step.file_key, option };
|
|
28
|
+
if (step.not_executed) {
|
|
29
|
+
return step.operation === 'DONE'
|
|
30
|
+
? `Did not finish yet: ${step.not_executed}.`
|
|
31
|
+
: `Did not ${lowerFirst(describeStep(intent))}: ${step.not_executed}.`;
|
|
32
|
+
}
|
|
33
|
+
// The run's own Escapes say what they closed.
|
|
34
|
+
if (step.operation === 'PRESS_ESCAPE' && element.startsWith('closed '))
|
|
35
|
+
return `Pressed Escape and ${element}.`;
|
|
36
|
+
const outcomes = [...notes.map(outcomeOf), ...(step.value_kept === false ? ['the field did not keep the text'] : [])];
|
|
37
|
+
return `${describeStep(intent, 'done')}${outcomes.length ? `; ${outcomes.join('; ')}` : ''}.`;
|
|
38
|
+
};
|
|
39
|
+
/** A call's steps as one sentence each. */
|
|
40
|
+
export const storyOf = (steps) => steps.map(storyLine);
|
|
41
|
+
/**
|
|
42
|
+
* The one thing the caller does next after this status, in the tools' parameter names.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* nextFor({ status: 'needs_input', … }) // 'Add the missing values to `values` and call again with this session.'
|
|
46
|
+
*/
|
|
47
|
+
export const nextFor = ({ status, wall }) => {
|
|
48
|
+
switch (status) {
|
|
49
|
+
case 'done':
|
|
50
|
+
return 'Read the result in `page_text` (and `evidence`); end the session when you no longer need its tab.';
|
|
51
|
+
case 'needs_input':
|
|
52
|
+
return 'Add the missing values to `values` and call again with this session.';
|
|
53
|
+
case 'needs_confirmation':
|
|
54
|
+
return "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).";
|
|
55
|
+
case 'uncertain':
|
|
56
|
+
return 'Look at the screenshot and `page_text`, then call again with this session and a clearer `goal`, or finish the step yourself.';
|
|
57
|
+
case 'step_budget':
|
|
58
|
+
return 'Call again with this session and a narrower `goal`, or a larger `max_steps` / `max_seconds`.';
|
|
59
|
+
case 'blocked':
|
|
60
|
+
if (wall?.kind === 'bot_check')
|
|
61
|
+
return 'Never try to get past it: ask the user to pass it in that tab, then call again with this session.';
|
|
62
|
+
if (wall?.kind === 'sign_in')
|
|
63
|
+
return `Sign in at ${wall.origin} (or pass the values the form wants), then call again with this session.`;
|
|
64
|
+
return 'Read the reason: tell the user, or change `goal` or `values` and call again with this session.';
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, inner]) => inner !== undefined));
|
|
68
|
+
export function presentSummary({ session, summary, files }, { detail = 'concise' } = {}) {
|
|
69
|
+
// The order both adapters print: how it ended and what to do first, then where, and the long page text last.
|
|
70
|
+
const { page_text, ...head } = {
|
|
71
|
+
status: summary.status,
|
|
72
|
+
reason: summary.reason,
|
|
73
|
+
story: storyOf(summary.steps),
|
|
74
|
+
next: nextFor(summary),
|
|
75
|
+
session,
|
|
76
|
+
url: summary.url,
|
|
77
|
+
title: summary.title,
|
|
78
|
+
missing: summary.missing,
|
|
79
|
+
wall: summary.wall,
|
|
80
|
+
checks: summary.checks,
|
|
81
|
+
evidence: summary.evidence,
|
|
82
|
+
confirm_ignored: summary.confirm_ignored,
|
|
83
|
+
files,
|
|
84
|
+
page_text: summary.page_text,
|
|
85
|
+
};
|
|
86
|
+
if (detail === 'concise')
|
|
87
|
+
return defined({ ...head, page_text });
|
|
88
|
+
const { steps, jev, stuck, elapsed_ms, done_probability } = summary;
|
|
89
|
+
return defined({ ...head, steps, jev, stuck, elapsed_ms, done_probability, page_text });
|
|
90
|
+
}
|
package/dist/task/run-task.js
CHANGED
|
@@ -9,13 +9,17 @@ import { ANY_HOST, narrowHosts, outsideHosts } from './hosts.js';
|
|
|
9
9
|
import { scrubberFor } from '../jev/secrets.js';
|
|
10
10
|
import { describeFailure, runChecks } from './checks.js';
|
|
11
11
|
import { deepLink, quoteSpans } from './evidence.js';
|
|
12
|
-
import {
|
|
12
|
+
import { writeCall } from '../output/run-folder.js';
|
|
13
|
+
import { Recording } from '../recording/recording.js';
|
|
13
14
|
import { RunStopped, startRunLimit } from './run-limit.js';
|
|
14
15
|
import { checkUploadFiles, fileNames } from './upload-files.js';
|
|
16
|
+
import { isTransientModelError } from '../jev/retry.js';
|
|
15
17
|
// How much of the final page's visible text a summary carries: enough to read a list or a settings section, not a whole page.
|
|
16
18
|
const PAGE_TEXT_CHARS = 2500;
|
|
17
19
|
// An agent must know when the text was cut, to read the rest with another call or chrome-devtools.
|
|
18
20
|
export const pageTextFor = (text) => text.length > PAGE_TEXT_CHARS ? `${text.slice(0, PAGE_TEXT_CHARS)}\n…[${text.length - PAGE_TEXT_CHARS} more characters not shown]` : text;
|
|
21
|
+
// The widest end-page screenshot a run folder keeps, in pixels: enough to read, light enough to inline in a result.
|
|
22
|
+
const END_PAGE_WIDTH = 1280;
|
|
19
23
|
// How much of the page title a needs_confirmation reason quotes.
|
|
20
24
|
const CONFIRM_TITLE_CHARS = 80;
|
|
21
25
|
// How much of a page dialog's text a step or a repeat reason quotes.
|
|
@@ -104,7 +108,6 @@ const ESCAPE = { id: 'press_escape', kind: 'escape', label: 'Press Escape to clo
|
|
|
104
108
|
// Boxes on small targets land a row off (13 px checkboxes); pad before asking whether one covers the chosen control.
|
|
105
109
|
const BOX_PADDING = 16;
|
|
106
110
|
// A model call that fails like this (5xx, 499 "unexpected error", rate limit, network, timeout) is retried once; any other error is final.
|
|
107
|
-
const TRANSIENT_MODEL_ERROR = /^(5\d\d|429|499)\b|timeout|timed out|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up/i;
|
|
108
111
|
// Pause before that one retry, so a rate limit or a restarting upstream has a moment to clear.
|
|
109
112
|
const MODEL_RETRY_MS = 800;
|
|
110
113
|
// Stands in for the page in a summary when the very first observe fails.
|
|
@@ -199,15 +202,27 @@ export class BrowserTask {
|
|
|
199
202
|
#tabs;
|
|
200
203
|
#ask;
|
|
201
204
|
#recording;
|
|
205
|
+
/** The run folder each call writes to, every call's summary so far, and what the last call left there. */
|
|
206
|
+
#output;
|
|
207
|
+
#summaries = [];
|
|
208
|
+
#lastCall;
|
|
209
|
+
/** This call's step listener, and how many of the call's steps it was told. */
|
|
210
|
+
#onStep;
|
|
211
|
+
#stepsTold = 0;
|
|
202
212
|
/** This call's stop signal (caller cancel or time budget); set while a call runs. */
|
|
203
213
|
#limit;
|
|
204
|
-
constructor(tab, ask, { recordDir, ...options }) {
|
|
214
|
+
constructor(tab, ask, { recordDir, output, ...options }) {
|
|
205
215
|
this.#tab = tab;
|
|
206
216
|
this.#tabs = [tab];
|
|
207
217
|
this.#ask = ask;
|
|
208
218
|
this.#options = { ...DEFAULTS, values: {}, secretKeys: [], ...definedOnly(options), files: checkUploadFiles(options.files) };
|
|
209
|
-
|
|
210
|
-
|
|
219
|
+
this.#output = output ?? (recordDir ? { dir: recordDir, record: true } : undefined);
|
|
220
|
+
if (this.#output?.record)
|
|
221
|
+
this.#recording = new Recording(this.#output.dir, (text) => this.#scrub(text));
|
|
222
|
+
}
|
|
223
|
+
/** What the last call left in the run folder, and its end page as JPEG bytes (absent when the capture failed); none without `output`. */
|
|
224
|
+
get lastCall() {
|
|
225
|
+
return this.#lastCall;
|
|
211
226
|
}
|
|
212
227
|
/** The model a call's requests went to, and what its input tokens cost at that model's published rate. */
|
|
213
228
|
#costOf(inputTokens) {
|
|
@@ -241,6 +256,10 @@ export class BrowserTask {
|
|
|
241
256
|
})),
|
|
242
257
|
};
|
|
243
258
|
}
|
|
259
|
+
/** Reads the session's tab with no model call and no input, its secret values already masked: what `jevway_look` shows. */
|
|
260
|
+
look() {
|
|
261
|
+
return this.#observe();
|
|
262
|
+
}
|
|
244
263
|
/** The tab the session is on now: a tab its clicks opened, once followed, or the one it started on. */
|
|
245
264
|
get tab() {
|
|
246
265
|
return this.#tab;
|
|
@@ -292,14 +311,17 @@ export class BrowserTask {
|
|
|
292
311
|
}
|
|
293
312
|
/** Merge new values, a new goal, narrower hosts (a widening list throws), or a one-time confirmation for the next irreversible click. */
|
|
294
313
|
update(patch) {
|
|
295
|
-
const { confirmIrreversible, confirmTarget, values, secretKeys, files, recordDir: _recordDir, allowedHosts, blockedHosts, ...options } = patch;
|
|
314
|
+
const { confirmIrreversible, confirmTarget, values, secretKeys, files, recordDir: _recordDir, output: _output, allowedHosts, blockedHosts, ...options } = patch;
|
|
296
315
|
// First, so a list that would widen the hosts throws before anything else changes.
|
|
297
316
|
const hosts = narrowHosts(this.#options.hosts, { allowed: allowedHosts, blocked: blockedHosts });
|
|
317
|
+
const newGoal = options.goal !== undefined && options.goal !== this.#options.goal;
|
|
298
318
|
// A new goal may be to flip back what the old one flipped.
|
|
299
|
-
if (
|
|
319
|
+
if (newGoal)
|
|
300
320
|
this.#toggled.clear();
|
|
301
321
|
this.#options = {
|
|
302
322
|
...this.#options,
|
|
323
|
+
// The old goal's done_when may already hold, and would end the new goal before it starts.
|
|
324
|
+
...(newGoal && options.doneWhen === undefined && { doneWhen: undefined }),
|
|
303
325
|
...definedOnly(options),
|
|
304
326
|
hosts,
|
|
305
327
|
values: { ...this.#options.values, ...values },
|
|
@@ -320,9 +342,12 @@ export class BrowserTask {
|
|
|
320
342
|
}
|
|
321
343
|
/**
|
|
322
344
|
* Runs until done or a hand-back. `signal` is the caller's cancel (the MCP request's): the run stops at its next
|
|
323
|
-
* check, `blocked` "Cancelled by the caller"; an input already under way finishes first.
|
|
345
|
+
* check, `blocked` "Cancelled by the caller"; an input already under way finishes first. `onStep` hears each step of
|
|
346
|
+
* this call (numbered from 1) once the run moves past it, with the page's URL then; a listener that throws is ignored.
|
|
324
347
|
*/
|
|
325
|
-
async run({ signal } = {}) {
|
|
348
|
+
async run({ signal, onStep } = {}) {
|
|
349
|
+
this.#onStep = onStep;
|
|
350
|
+
this.#stepsTold = 0;
|
|
326
351
|
// The recording is for people: a browser failure while recording must not change or hang the run's outcome.
|
|
327
352
|
await this.#recording?.start(this.#tab).catch(() => undefined);
|
|
328
353
|
let settled;
|
|
@@ -340,9 +365,44 @@ export class BrowserTask {
|
|
|
340
365
|
for (; this.#traceScrubbed < this.trace.length; this.#traceScrubbed++)
|
|
341
366
|
this.trace[this.#traceScrubbed] = this.#scrub(this.trace[this.#traceScrubbed]);
|
|
342
367
|
const summary = this.#scrub(settled);
|
|
343
|
-
|
|
368
|
+
this.#onStep = undefined;
|
|
369
|
+
await this.#recording?.finish(this.#tab, summary, { goal: this.#options.goal, doneWhen: this.#options.doneWhen }).catch(() => undefined);
|
|
370
|
+
if (this.#output)
|
|
371
|
+
await this.#writeCall(this.#output.dir, summary);
|
|
344
372
|
return summary;
|
|
345
373
|
}
|
|
374
|
+
// What a call leaves in the run folder; like the recording, it never changes or hangs the run's outcome.
|
|
375
|
+
async #writeCall(dir, summary) {
|
|
376
|
+
this.#summaries.push(summary);
|
|
377
|
+
const screenshot = await this.#endPage().catch(() => undefined);
|
|
378
|
+
try {
|
|
379
|
+
const files = writeCall({ dir, call: this.#summaries.length, status: summary.status, shot: screenshot, summaries: this.#summaries, recorded: Boolean(this.#recording) });
|
|
380
|
+
this.#lastCall = { files, ...(screenshot && { screenshot }) };
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
this.#lastCall = undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** The tab's viewport as a light JPEG for agents: quality 70, at most 1280 pixels wide. Pixels: a secret typed into a visible field shows. */
|
|
387
|
+
async #endPage() {
|
|
388
|
+
const ratio = Number(await this.#tab.evaluate('devicePixelRatio')) || 1;
|
|
389
|
+
const width = this.#ending?.page.w || END_PAGE_WIDTH;
|
|
390
|
+
const scale = Math.min(1, END_PAGE_WIDTH / (width * ratio));
|
|
391
|
+
return Buffer.from(await this.#tab.screenshot({ format: 'jpeg', quality: 70, scale }), 'base64');
|
|
392
|
+
}
|
|
393
|
+
/** Tells the step listener about this call's steps it has not heard yet; the page's URL is the one the run is on now. */
|
|
394
|
+
#tellSteps(firstStep, url) {
|
|
395
|
+
if (!this.#onStep)
|
|
396
|
+
return;
|
|
397
|
+
for (; firstStep + this.#stepsTold < this.#history.length; this.#stepsTold++) {
|
|
398
|
+
try {
|
|
399
|
+
this.#onStep(this.#scrub(this.#history[firstStep + this.#stepsTold]), this.#stepsTold + 1, url);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// A listener is the caller's (progress, a log line): it never decides how the run goes.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
346
406
|
async #loop(signal) {
|
|
347
407
|
// The page's own scripts can read what it is told to look for, so a secret reaches the page only when typed into its field.
|
|
348
408
|
this.#tab.want(Object.entries(this.#options.values).filter(([key]) => !this.#options.secretKeys.includes(key)).map(([, value]) => value), { files: Object.keys(this.#options.files).length > 0 });
|
|
@@ -370,6 +430,7 @@ export class BrowserTask {
|
|
|
370
430
|
const stop = (status, reason, decision, { overridable = false } = {}) => {
|
|
371
431
|
// A dialog that opened late still belongs on the step that caused it.
|
|
372
432
|
this.#reportDialogs(firstStep);
|
|
433
|
+
this.#tellSteps(firstStep, page.url);
|
|
373
434
|
// A hand-back asks a new question, so a confirmation still pending (a refused one) is spent.
|
|
374
435
|
if (status === 'needs_confirmation')
|
|
375
436
|
this.#spendConfirmation();
|
|
@@ -492,6 +553,7 @@ export class BrowserTask {
|
|
|
492
553
|
const switchedLate = await this.#switchTabs();
|
|
493
554
|
if (switchedLate)
|
|
494
555
|
page = await this.#afterSwitch(switchedLate, firstStep);
|
|
556
|
+
this.#tellSteps(firstStep, page.url);
|
|
495
557
|
// Never a decision on a page outside the hosts: a start page, a redirect or a late navigation that left them ends the run.
|
|
496
558
|
const outside = outsideHosts(page.url, hosts);
|
|
497
559
|
if (outside)
|
|
@@ -696,6 +758,7 @@ export class BrowserTask {
|
|
|
696
758
|
operation: decision.operation,
|
|
697
759
|
element: action.label,
|
|
698
760
|
valueKey: decision.valueKey ?? undefined,
|
|
761
|
+
fileKey: decision.fileKey ?? undefined,
|
|
699
762
|
option: action.kind === 'select' ? String(action.value) : undefined,
|
|
700
763
|
confidence: round(Math.min(decision.confidence, decision.targetConfidence ?? 1)),
|
|
701
764
|
}, decision.operation === 'CLICK_AT' ? decision.nextClick.rect : action.rect);
|
|
@@ -1086,7 +1149,7 @@ export class BrowserTask {
|
|
|
1086
1149
|
return await this.#decide(page);
|
|
1087
1150
|
}
|
|
1088
1151
|
catch (error) {
|
|
1089
|
-
if (!(error instanceof ModelCallError) || !
|
|
1152
|
+
if (!(error instanceof ModelCallError) || !isTransientModelError(error))
|
|
1090
1153
|
throw error;
|
|
1091
1154
|
await (this.#limit?.pause(MODEL_RETRY_MS) ?? new Promise((resolve) => setTimeout(resolve, MODEL_RETRY_MS)));
|
|
1092
1155
|
return this.#decide(page);
|
package/dist/task/start-task.js
CHANGED
|
@@ -6,6 +6,7 @@ import { JourneySession } from '../journeys/journey-session.js';
|
|
|
6
6
|
import { missingKeyMessage } from '../setup/env-file.js';
|
|
7
7
|
import { hostsFromEnv, narrowHosts, outsideHosts } from './hosts.js';
|
|
8
8
|
import { BrowserTask } from './run-task.js';
|
|
9
|
+
import { retryingAsk } from '../jev/retry.js';
|
|
9
10
|
/**
|
|
10
11
|
* Connects a browser once and starts tasks on tabs inside it. Tasks and journeys need the chosen model's key:
|
|
11
12
|
* `TYPESAFE_API_KEY` for Jev (default), `OPENROUTER_API_KEY` for Clef-omni. Without it the runner is still made, so an
|
|
@@ -39,9 +40,8 @@ export const createRunner = (source, choice = {}) => {
|
|
|
39
40
|
const decider = ask();
|
|
40
41
|
// The server's lists, narrowed by the call; a start URL outside them is never opened.
|
|
41
42
|
const hosts = narrowHosts(hostsFromEnv(), { allowed: allowedHosts, blocked: blockedHosts });
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
throw new Error(`Not opened: ${outside}`);
|
|
43
|
+
if (url)
|
|
44
|
+
refuseOutside(url, hosts);
|
|
45
45
|
const { cdp } = await browser();
|
|
46
46
|
if (!url && !tab)
|
|
47
47
|
throw new Error('Pass `url` to open a new tab or `tab` to reuse an open one.');
|
|
@@ -57,9 +57,11 @@ export const createRunner = (source, choice = {}) => {
|
|
|
57
57
|
},
|
|
58
58
|
/** Reads a page with no model call and no input: an open tab by URL part, or a URL in a background tab closed again. */
|
|
59
59
|
async look({ url, tab }) {
|
|
60
|
-
const { cdp } = await browser();
|
|
61
60
|
if (!url && !tab)
|
|
62
61
|
throw new Error('Pass `url` to open a page or `tab` to read an open one.');
|
|
62
|
+
if (url)
|
|
63
|
+
refuseOutside(url, hostsFromEnv());
|
|
64
|
+
const { cdp } = await browser();
|
|
63
65
|
const page = url ? await Tab.open(cdp, url) : await Tab.find(cdp, tab);
|
|
64
66
|
try {
|
|
65
67
|
return await page.observe();
|
|
@@ -72,11 +74,17 @@ export const createRunner = (source, choice = {}) => {
|
|
|
72
74
|
/** Mobile journey mapping; in the user's own Chrome it opens a separate window so screenshots keep rendering. */
|
|
73
75
|
async startJourney(options) {
|
|
74
76
|
const decider = ask();
|
|
77
|
+
// The server's host lists hold for journeys too: a start page outside them is never opened, and a step that
|
|
78
|
+
// lands outside them is refused before Jev is asked about the page.
|
|
79
|
+
const hosts = hostsFromEnv();
|
|
80
|
+
refuseOutside(options.startUrl, hosts);
|
|
75
81
|
const { cdp } = await browser();
|
|
76
|
-
|
|
82
|
+
// A journey makes one model call per screen and has no loop-level retry, so a busy API gets two more tries.
|
|
83
|
+
return JourneySession.start(cdp, retryingAsk(decider, { delaysMs: JOURNEY_RETRY_MS }), {
|
|
77
84
|
newWindow: source === 'auto',
|
|
78
85
|
...profileOptions(profile),
|
|
79
86
|
...options,
|
|
87
|
+
hosts,
|
|
80
88
|
screenshots: options.screenshots ?? screenshots,
|
|
81
89
|
});
|
|
82
90
|
},
|
|
@@ -87,5 +95,13 @@ export const createRunner = (source, choice = {}) => {
|
|
|
87
95
|
},
|
|
88
96
|
};
|
|
89
97
|
};
|
|
98
|
+
// Waits before each retry of a journey's model call after a busy or flaky API answer.
|
|
99
|
+
const JOURNEY_RETRY_MS = [1000, 3000];
|
|
100
|
+
/** Throws before a page outside the host rules is opened. */
|
|
101
|
+
const refuseOutside = (url, hosts) => {
|
|
102
|
+
const outside = outsideHosts(url, hosts);
|
|
103
|
+
if (outside)
|
|
104
|
+
throw new Error(`Not opened: ${outside}`);
|
|
105
|
+
};
|
|
90
106
|
// Optional MCP/CLI arguments arrive as explicit undefined; they must not erase profile settings.
|
|
91
107
|
const definedOnly = (options) => Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jevway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Hand a browser goal to TypeSafe Jev; get a short verified summary back. MCP server + CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"url": "git+https://github.com/AbdallahAHO/jevway.git"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
|
-
"jevway": "dist/cli.js",
|
|
28
|
+
"jevway": "dist/cli/main.js",
|
|
29
29
|
"jevway-mcp": "dist/mcp.js"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
@@ -39,13 +39,17 @@
|
|
|
39
39
|
"node": ">=24"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"build": "tsc -p tsconfig.build.json && cp src/browser/snapshot.js dist/browser/snapshot.js && chmod +x dist/cli.js dist/mcp.js",
|
|
42
|
+
"build": "tsc -p tsconfig.build.json && cp src/browser/snapshot.js dist/browser/snapshot.js && chmod +x dist/cli/main.js dist/mcp.js",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"test": "vitest run",
|
|
45
45
|
"prepublishOnly": "pnpm typecheck && pnpm test && rm -rf dist && pnpm build",
|
|
46
|
+
"release": "node release/release.ts",
|
|
47
|
+
"release:status": "node release/release.ts status",
|
|
48
|
+
"release:check": "node release/release.ts check",
|
|
46
49
|
"verify": "node --env-file-if-exists=.env verify/run.ts",
|
|
50
|
+
"verify:mcp": "node verify/mcp-consumer.ts",
|
|
47
51
|
"mcp": "node --env-file-if-exists=.env src/mcp.ts",
|
|
48
|
-
"cli": "node --env-file-if-exists=.env src/cli.ts",
|
|
52
|
+
"cli": "node --env-file-if-exists=.env src/cli/main.ts",
|
|
49
53
|
"verify:reader": "node verify/reader.ts",
|
|
50
54
|
"verify:journeys": "node --env-file-if-exists=.env verify/journeys.ts",
|
|
51
55
|
"verify:real": "node --env-file-if-exists=.env verify/real-world.ts",
|
|
@@ -58,7 +62,7 @@
|
|
|
58
62
|
"verify:forgejo": "node --env-file-if-exists=.env verify/forgejo.ts",
|
|
59
63
|
"verify:signup": "node --env-file-if-exists=.env verify/signup.ts",
|
|
60
64
|
"verify:vikunja": "node --env-file-if-exists=.env verify/vikunja.ts",
|
|
61
|
-
"verify:agent": "node verify/agent.ts",
|
|
65
|
+
"verify:agent": "node verify/agent/agent.ts",
|
|
62
66
|
"verify:replay": "node verify/replay.ts",
|
|
63
67
|
"report:render": "node verify/jobs/rerender.ts",
|
|
64
68
|
"apps:forgejo:up": "docker compose -p jevway-forgejo -f verify/apps/forgejo/compose.yml up -d --wait && node verify/apps/forgejo/seed.ts users",
|
|
@@ -67,12 +71,13 @@
|
|
|
67
71
|
"apps:vikunja:down": "docker compose -p jevway-vikunja -f verify/apps/vikunja/compose.yml down -v"
|
|
68
72
|
},
|
|
69
73
|
"dependencies": {
|
|
70
|
-
"@modelcontextprotocol/
|
|
74
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
71
75
|
"@typesafe-ai/sdk": "^0.7.0",
|
|
72
76
|
"zod": "^4.6.5"
|
|
73
77
|
},
|
|
74
78
|
"devDependencies": {
|
|
75
|
-
"@
|
|
79
|
+
"@modelcontextprotocol/client": "^2.4.0",
|
|
80
|
+
"@types/node": "^24.19.2",
|
|
76
81
|
"typescript": "^7.0.2",
|
|
77
82
|
"vitest": "^5.0.1"
|
|
78
83
|
}
|