jevway 0.0.0-stage → 0.1.2

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 (43) hide show
  1. package/INSTALL-FOR-AGENTS.md +138 -0
  2. package/LICENSE +11 -0
  3. package/LICENSE.jev-ultrafast +21 -0
  4. package/README.md +218 -2
  5. package/dist/browser/bot-wall.js +105 -0
  6. package/dist/browser/cdp.js +112 -0
  7. package/dist/browser/chrome.js +147 -0
  8. package/dist/browser/devices.js +20 -0
  9. package/dist/browser/frames.js +516 -0
  10. package/dist/browser/snapshot.js +611 -0
  11. package/dist/browser/tab.js +938 -0
  12. package/dist/browser/words.js +51 -0
  13. package/dist/cli.js +112 -0
  14. package/dist/jev/action-space.js +48 -0
  15. package/dist/jev/box.js +58 -0
  16. package/dist/jev/decide.js +405 -0
  17. package/dist/jev/instructions.js +67 -0
  18. package/dist/jev/model.js +189 -0
  19. package/dist/jev/replay.js +213 -0
  20. package/dist/jev/secrets.js +60 -0
  21. package/dist/journeys/diagnostics.js +85 -0
  22. package/dist/journeys/journey-doc.js +50 -0
  23. package/dist/journeys/journey-session.js +503 -0
  24. package/dist/journeys/pick-next-move.js +59 -0
  25. package/dist/journeys/render-viewer.js +153 -0
  26. package/dist/journeys/screen-boxes.js +49 -0
  27. package/dist/journeys/screen-judgments.js +149 -0
  28. package/dist/mcp.js +361 -0
  29. package/dist/setup/doctor-command.js +81 -0
  30. package/dist/setup/doctor.js +163 -0
  31. package/dist/setup/env-file.js +50 -0
  32. package/dist/task/checks.js +156 -0
  33. package/dist/task/evidence.js +96 -0
  34. package/dist/task/hosts.js +91 -0
  35. package/dist/task/look.js +17 -0
  36. package/dist/task/recording-page.js +310 -0
  37. package/dist/task/recording.js +232 -0
  38. package/dist/task/run-limit.js +66 -0
  39. package/dist/task/run-task.js +1607 -0
  40. package/dist/task/start-task.js +91 -0
  41. package/dist/task/upload-files.js +43 -0
  42. package/package.json +80 -4
  43. package/skill/jevway/SKILL.md +73 -0
@@ -0,0 +1,51 @@
1
+ // How the words a caller quotes are compared with what a page shows (see `BrowserTask` and `Tab.armConfirm`).
2
+ // The words a confirmation names are compared the way a person would: case, accents and spacing set aside.
3
+ const normalized = (text) => text.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
4
+ const AT_THE_ENDS = /^[\s"'“”‘’`()[\]{}.,;:!?]+|[\s"'“”‘’`()[\]{}.,;:!?]+$/gu;
5
+ // Words split on spaces and quotes; `/ . _ -` stay inside, so "ada/scratch" is one word.
6
+ const WORD_BREAKS = /[\s,;:!?()"'“”‘’`]+/u;
7
+ const MIN_WORD_CHARS = 3;
8
+ // Words that join a phrase and name nothing: "the repository named ada/scratch".
9
+ const FILLER_WORDS = new Set(['the', 'and', 'for', 'from', 'with', 'its', 'our', 'your', 'their', 'named', 'called', 'titled']);
10
+ // Where a word may end or begin inside the page's text: next to a letter, a digit, "_" or "-" it is part of a longer word,
11
+ // and so is a "." that goes on with another character ("v1.2" is not in "v1.2.3").
12
+ const LONGER_BEFORE = '(?<![\\p{L}\\p{N}_-])';
13
+ const LONGER_AFTER = '(?![\\p{L}\\p{N}_-]|\\.[\\p{L}\\p{N}])';
14
+ const appearsWhole = (word, text) => new RegExp(`${LONGER_BEFORE}${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${LONGER_AFTER}`, 'u').test(text);
15
+ const wordsOf = (text) => normalized(text)
16
+ .split(WORD_BREAKS)
17
+ .map((word) => word.replace(AT_THE_ENDS, ''))
18
+ .filter((word) => word.length >= MIN_WORD_CHARS);
19
+ /**
20
+ * Whether the page shows the user's words for the target. The words of the clicked control and filler words describe
21
+ * the action, not its target, so they never count; what is left must be there, every word, and at least one must be.
22
+ * Without that, "delete" would match any page that shows a Delete button. A word counts where the page has it whole:
23
+ * "app" is not in "apply", nor "ada/scratch" in "ada/scratch-2".
24
+ *
25
+ * @example
26
+ * showsTarget('the repository ada/scratch', 'Delete this repository', ['ada/scratch - Settings']) // true
27
+ * showsTarget('delete', 'Delete this repository', ['ada/scratch - Settings', 'Delete this repository']) // false
28
+ * showsTarget('anything unused', 'Delete this repository', ['ada/scratch - Settings']) // false
29
+ */
30
+ export const showsTarget = (target, control, shown) => {
31
+ const controlWords = new Set(wordsOf(control));
32
+ const specific = wordsOf(target).filter((word) => !controlWords.has(word) && !FILLER_WORDS.has(word));
33
+ const corpus = shown.map(normalized).join('\n');
34
+ return specific.length > 0 && specific.every((word) => appearsWhole(word, corpus));
35
+ };
36
+ /**
37
+ * Whether a dialog's message names what the caller confirmed or the control they clicked: every word of either phrase
38
+ * (3+ characters, not filler) is in it, whole. A dialog about something else, like the draft comments a reload would
39
+ * discard, names neither.
40
+ *
41
+ * @example
42
+ * messageNames('Delete the draft?', ['the draft', 'Delete draft']) // true
43
+ * messageNames('Page will be reloaded, but there are draft comments. Continue?', ['Labels', 'Labels']) // false
44
+ */
45
+ export const messageNames = (message, phrases) => {
46
+ const text = normalized(message);
47
+ return phrases.some((phrase) => {
48
+ const words = wordsOf(phrase).filter((word) => !FILLER_WORDS.has(word));
49
+ return words.length > 0 && words.every((word) => appearsWhole(word, text));
50
+ });
51
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { runDoctor } from './setup/doctor-command.js';
4
+ import { loadEnvFile } from './setup/env-file.js';
5
+ import { CHECKS } from './task/checks.js';
6
+ import { createRunner } from './task/start-task.js';
7
+ const USAGE = `jevway --goal "<outcome>" (--url <url> | --tab <url part>) [options]
8
+ jevway doctor [--plan] [--live] check the setup (key, browser, Node, ffmpeg); --help for more
9
+
10
+ --values '{"city":"Lisbon"}' text values Jev may type
11
+ --secret-keys token,otp keys of --values that are secrets: Jev sees the key only, output shows [secret:<key>]
12
+ --files '{"avatar":"/abs/me.png"}' files Jev may upload (absolute paths; Jev sees names, never paths)
13
+ --done-when "<evidence>" visible evidence of success
14
+ --checks '[{"type":"url_contains","text":"/issues/"}]' facts checked by code on the final page
15
+ --browser auto|launch|launch-headless|http://127.0.0.1:9222 (default: auto, or $JEVWAY_BROWSER)
16
+ --model jev|pplx-decider|clef-omni|clef|clef-flash|gpt-6-luna decision model (default: jev, or $JEVWAY_MODEL)
17
+ --screenshots off|fallback|always marked screenshots, vision models only (default: per model, or $JEVWAY_SCREENSHOTS)
18
+ --max-steps 40
19
+ --allowed-hosts a.com,*.b.com hosts the run may be on (narrows $JEVWAY_ALLOWED_HOSTS); leaving them ends it blocked
20
+ --blocked-hosts ads.com hosts it must never be on (adds to $JEVWAY_BLOCKED_HOSTS)
21
+ --max-seconds 300 time budget; past it the run ends step_budget (Ctrl-C ends it blocked)
22
+ --keep-open leave the tabs open (auto/remote browsers), including any the run's clicks opened
23
+ --allow-coordinate-clicks click a boxed control the DOM never reported (canvas, iframe); Jev 1.14 direct only
24
+ --record <dir> sharp screenshot before every action + final page, summary.json, index.html
25
+
26
+ Prints one JSON summary. Exit code 0 only when status is "done".
27
+ Needs TYPESAFE_API_KEY (Jev only) or OPENROUTER_API_KEY (every model), from the environment or ~/.config/jevway/env.`;
28
+ // Keys and JEVWAY_* settings the environment lacks come from ~/.config/jevway/env.
29
+ const fromEnvFile = loadEnvFile();
30
+ if (process.argv[2] === 'doctor')
31
+ process.exit(await runDoctor(process.argv.slice(3), fromEnvFile));
32
+ const { values: args } = parseArgs({
33
+ options: {
34
+ goal: { type: 'string' },
35
+ url: { type: 'string' },
36
+ tab: { type: 'string' },
37
+ values: { type: 'string' },
38
+ 'secret-keys': { type: 'string' },
39
+ files: { type: 'string' },
40
+ 'done-when': { type: 'string' },
41
+ checks: { type: 'string' },
42
+ browser: { type: 'string' },
43
+ model: { type: 'string' },
44
+ screenshots: { type: 'string' },
45
+ 'max-steps': { type: 'string' },
46
+ 'max-seconds': { type: 'string' },
47
+ 'allowed-hosts': { type: 'string' },
48
+ 'blocked-hosts': { type: 'string' },
49
+ 'keep-open': { type: 'boolean' },
50
+ 'allow-coordinate-clicks': { type: 'boolean' },
51
+ record: { type: 'string' },
52
+ help: { type: 'boolean', short: 'h' },
53
+ },
54
+ });
55
+ if (args.help || !args.goal || !(args.url || args.tab)) {
56
+ console.error(USAGE);
57
+ process.exit(args.help ? 0 : 2);
58
+ }
59
+ // Malformed checks, like model or key problems, are usage errors: report them before any browser starts.
60
+ let checks;
61
+ try {
62
+ checks = args.checks ? CHECKS.parse(JSON.parse(args.checks)) : undefined;
63
+ }
64
+ catch (error) {
65
+ console.error(`jevway: --checks: ${error.message}`);
66
+ process.exit(2);
67
+ }
68
+ let runner;
69
+ try {
70
+ runner = createRunner(args.browser ?? process.env.JEVWAY_BROWSER ?? 'auto', {
71
+ model: args.model,
72
+ screenshots: args.screenshots,
73
+ });
74
+ runner.requireKey();
75
+ }
76
+ catch (error) {
77
+ console.error(`jevway: ${error.message}`);
78
+ process.exit(2);
79
+ }
80
+ try {
81
+ const task = await runner.start({
82
+ goal: args.goal,
83
+ url: args.url,
84
+ tab: args.tab,
85
+ values: args.values ? JSON.parse(args.values) : undefined,
86
+ secretKeys: args['secret-keys']?.split(',').map((key) => key.trim()).filter(Boolean),
87
+ files: args.files ? JSON.parse(args.files) : undefined,
88
+ doneWhen: args['done-when'],
89
+ checks,
90
+ maxSteps: args['max-steps'] ? Number(args['max-steps']) : undefined,
91
+ maxSeconds: args['max-seconds'] ? Number(args['max-seconds']) : undefined,
92
+ allowedHosts: args['allowed-hosts']?.split(','),
93
+ blockedHosts: args['blocked-hosts']?.split(','),
94
+ allowCoordinateClicks: args['allow-coordinate-clicks'],
95
+ recordDir: args.record,
96
+ });
97
+ // Ctrl-C stops the run at its next check (an action under way finishes), so the summary still prints and the tab closes.
98
+ const cancel = new AbortController();
99
+ process.once('SIGINT', () => cancel.abort());
100
+ const summary = await task.run({ signal: cancel.signal });
101
+ if (!args['keep-open'])
102
+ await task.closeTabs({ keepFirst: !args.url });
103
+ console.log(JSON.stringify(summary, null, 2));
104
+ process.exitCode = summary.status === 'done' ? 0 : 1;
105
+ }
106
+ catch (error) {
107
+ console.error(`jevway: ${error.message}`);
108
+ process.exitCode = 1;
109
+ }
110
+ finally {
111
+ await runner.close();
112
+ }
@@ -0,0 +1,48 @@
1
+ const OPERATION_BY_KIND = { click: 'CLICK', fill: 'TYPE_TEXT', select: 'SELECT', upload: 'UPLOAD' };
2
+ /**
3
+ * Turns a snapshot's flat action list into Jev's dynamic action space:
4
+ * one index per element, and a separate valid-target set per operation.
5
+ *
6
+ * @example
7
+ * const { elements, targets } = buildActionSpace(page.actions);
8
+ * targets.TYPE_TEXT?.['3'] // the fill action for element [3]
9
+ */
10
+ export const buildActionSpace = (actions) => {
11
+ const elements = [];
12
+ const indexByNode = new Map();
13
+ const targets = {};
14
+ const controls = {};
15
+ const marks = [];
16
+ for (const action of actions) {
17
+ if (!(action.kind in OPERATION_BY_KIND)) {
18
+ controls[action.id.toUpperCase()] = action;
19
+ continue;
20
+ }
21
+ const operation = OPERATION_BY_KIND[action.kind];
22
+ let index = indexByNode.get(action.node);
23
+ if (!index) {
24
+ index = String(elements.length + 1);
25
+ indexByNode.set(action.node, index);
26
+ elements.push({
27
+ index,
28
+ label: action.label.split(' → ')[0],
29
+ operations: [],
30
+ ...pick(action, ['role', 'value', 'checked', 'selected', 'expanded']),
31
+ ...(action.kind === 'select' && { value: action.current_value ?? '', options: [] }),
32
+ });
33
+ if (action.rect)
34
+ marks.push({ label: index, node: action.node, rect: action.rect });
35
+ }
36
+ const element = elements[Number(index) - 1];
37
+ if (!element.operations.includes(operation))
38
+ element.operations.push(operation);
39
+ let target = index;
40
+ if (action.kind === 'select') {
41
+ target = `${index}:${element.options.length + 1}`;
42
+ element.options.push({ index: target, label: action.label, value: action.value });
43
+ }
44
+ (targets[operation] ??= {})[target] = action;
45
+ }
46
+ return { elements, targets, controls, marks };
47
+ };
48
+ export const pick = (source, keys) => Object.fromEntries(keys.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]));
@@ -0,0 +1,58 @@
1
+ export const centerOf = (rect) => ({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
2
+ /**
3
+ * Where a Box answer sits in the viewport. The screenshot is exactly the viewport, so fractions map
4
+ * straight to CSS pixels.
5
+ *
6
+ * @example
7
+ * toViewportRect({ top: 0.7, bottom: 0.75, left: 0.1, right: 0.9 }, { w: 400, h: 800 }) // { x: 40, y: 560, w: 320, h: 40 }
8
+ */
9
+ export const toViewportRect = (box, viewport) => ({
10
+ x: box.left * viewport.w,
11
+ y: box.top * viewport.h,
12
+ w: (box.right - box.left) * viewport.w,
13
+ h: (box.bottom - box.top) * viewport.h,
14
+ });
15
+ /** Intersection over union, 0 (apart) to 1 (identical). */
16
+ export const overlap = (a, b) => {
17
+ const w = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
18
+ const h = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
19
+ const shared = w * h;
20
+ const union = a.w * a.h + b.w * b.h - shared;
21
+ return union > 0 ? shared / union : 0;
22
+ };
23
+ // Below this a box and an element are different things; at or above it, the box points at the element.
24
+ export const MATCH_OVERLAP = 0.3;
25
+ export const contains = (rect, point) => point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h;
26
+ // Boxes often hug a whole row around a narrower button: each centre inside the other also counts.
27
+ const pointsAt = (box, element) => overlap(box, element) >= MATCH_OVERLAP || (contains(box, centerOf(element)) && contains(element, centerOf(box)));
28
+ /**
29
+ * The observed target a box points at: the best-overlapping one that counts as a match.
30
+ * This is how a Box answer turns back into an observed id instead of a coordinate.
31
+ *
32
+ * @example
33
+ * matchTarget(boxRect, { '2': acceptButton, '5': searchBar }) // { index: '2', overlap: 0.81 }
34
+ */
35
+ export const matchTarget = (rect, targets) => {
36
+ let best = { index: undefined, overlap: -1 };
37
+ let closest = 0;
38
+ for (const [index, action] of Object.entries(targets)) {
39
+ if (!action.rect)
40
+ continue;
41
+ const score = overlap(rect, action.rect);
42
+ closest = Math.max(closest, score);
43
+ if (pointsAt(rect, action.rect) && score > best.overlap)
44
+ best = { index, overlap: score };
45
+ }
46
+ return best.index ? best : { index: undefined, overlap: closest };
47
+ };
48
+ /** A malformed or inverted box reads as no box at all. */
49
+ export const readBox = (answer) => {
50
+ const edges = answer?.box;
51
+ const valid = edges &&
52
+ ['top', 'bottom', 'left', 'right'].every((k) => typeof edges[k] === 'number' && edges[k] >= 0 && edges[k] <= 1) &&
53
+ edges.left < edges.right &&
54
+ edges.top < edges.bottom;
55
+ if (!valid)
56
+ return undefined;
57
+ return { edges, confidence: typeof answer.confidence === 'number' ? answer.confidence : 0 };
58
+ };