jevway 0.1.2 → 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 +10 -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 +9 -7
- package/skill/jevway/SKILL.md +50 -14
- package/dist/cli.js +0 -112
|
@@ -2,7 +2,11 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import {
|
|
5
|
+
import { safeSlug, stepFileName } from '../output/layout.js';
|
|
6
|
+
import { describeStep } from './describe-step.js';
|
|
7
|
+
import { escapeHtml } from './recording-page.js';
|
|
8
|
+
import { renderRunPage } from './run-page.js';
|
|
9
|
+
export { describeStep, escapeHtml };
|
|
6
10
|
const VIDEO = 'run.mp4';
|
|
7
11
|
// The video shows what changed, not how long anyone waited: no frame holds longer than this.
|
|
8
12
|
const LONGEST_FRAME_S = 1.5;
|
|
@@ -17,41 +21,26 @@ const SHORTEST_FRAME_S = 1 / 30;
|
|
|
17
21
|
export const frameDurations = (times) => times.map((at, i) => i === times.length - 1 ? LONGEST_FRAME_S : Math.min(LONGEST_FRAME_S, Math.max(SHORTEST_FRAME_S, times[i + 1] - at)));
|
|
18
22
|
/** Seconds into the encoded video at which the wall-clock moment `at` appears. */
|
|
19
23
|
export const videoOffset = (times, durations, at) => times.reduce((offset, time, i) => (time < at ? offset + durations[i] : offset), 0);
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
const text = label.replace(/\s+/g, ' ').trim();
|
|
23
|
-
return text.length > max ? `${text.slice(0, max).replace(/\s+\S*$/, '')}…` : text;
|
|
24
|
-
};
|
|
25
|
-
/**
|
|
26
|
-
* A step as one plain sentence for people reading a recording.
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* describeStep({ operation: 'TYPE_TEXT', element: 'Search traces', valueKey: 'search_query' })
|
|
30
|
-
* // 'Type the "search_query" value into "Search traces"'
|
|
31
|
-
*/
|
|
32
|
-
export const describeStep = ({ operation, element = '', valueKey, option }) => {
|
|
33
|
-
const target = `"${shorten(element)}"`;
|
|
24
|
+
// A step shot's name: the verb and what it acts on, short enough to read in a file list.
|
|
25
|
+
const stepWords = ({ operation, element = '', valueKey, fileKey, option }) => {
|
|
34
26
|
switch (operation) {
|
|
35
27
|
case 'CLICK':
|
|
36
|
-
return `Click ${target}`;
|
|
37
28
|
case 'CLICK_AT':
|
|
38
|
-
return `
|
|
29
|
+
return `click ${element}`;
|
|
39
30
|
case 'TYPE_TEXT':
|
|
40
|
-
return `
|
|
31
|
+
return `type ${valueKey ?? element}`;
|
|
32
|
+
case 'UPLOAD':
|
|
33
|
+
return `upload ${fileKey ?? element}`;
|
|
41
34
|
case 'SELECT':
|
|
42
|
-
return
|
|
35
|
+
return `select ${option ?? element}`;
|
|
43
36
|
case 'PRESS_ENTER':
|
|
44
|
-
return `Press Enter in ${target} to submit it`;
|
|
45
37
|
case 'PRESS_ESCAPE':
|
|
46
|
-
return 'Press Escape to close the open menu or popup';
|
|
47
38
|
case 'SCROLL_DOWN':
|
|
48
|
-
return 'Scroll down to reveal more of the page';
|
|
49
39
|
case 'SCROLL_UP':
|
|
50
|
-
return 'Scroll back up';
|
|
51
40
|
case 'WAIT':
|
|
52
|
-
return '
|
|
41
|
+
return operation.replace('_', ' ');
|
|
53
42
|
default:
|
|
54
|
-
return
|
|
43
|
+
return `finished ${operation}`;
|
|
55
44
|
}
|
|
56
45
|
};
|
|
57
46
|
/**
|
|
@@ -60,12 +49,14 @@ export const describeStep = ({ operation, element = '', valueKey, option }) => {
|
|
|
60
49
|
* and an `index.html` viewer. Separate from what the model sees.
|
|
61
50
|
*
|
|
62
51
|
* @example
|
|
63
|
-
* const task = new BrowserTask(tab, ask, { goal,
|
|
64
|
-
* await task.run(); // runs/langfuse/run.mp4, 01.jpg … index.html
|
|
52
|
+
* const task = new BrowserTask(tab, ask, { goal, output: { dir: 'runs/langfuse', record: true } });
|
|
53
|
+
* await task.run(); // runs/langfuse/run.mp4, steps/01-click-traces.jpg … index.html
|
|
65
54
|
*/
|
|
66
55
|
export class Recording {
|
|
67
56
|
steps = [];
|
|
68
57
|
summaries = [];
|
|
58
|
+
/** What each call was asked, scrubbed, one per summary. */
|
|
59
|
+
asked = [];
|
|
69
60
|
#dir;
|
|
70
61
|
#frames = [];
|
|
71
62
|
/** The page size the screencast reports (CSS pixels): the run page and the reports shape the video's frame by it. */
|
|
@@ -96,7 +87,8 @@ export class Recording {
|
|
|
96
87
|
}
|
|
97
88
|
async step(tab, page, intent, target) {
|
|
98
89
|
const marks = target ? [{ label: intent.operation, rect: target }] : [];
|
|
99
|
-
const file =
|
|
90
|
+
const file = stepFileName(this.steps.length + 1, safeSlug(stepWords(intent), this.#scrub));
|
|
91
|
+
mkdirSync(join(this.#dir, 'steps'), { recursive: true });
|
|
100
92
|
writeFileSync(join(this.#dir, file), Buffer.from(await tab.markedScreenshot(marks, page, { sharp: true }), 'base64'));
|
|
101
93
|
this.steps.push({
|
|
102
94
|
file,
|
|
@@ -109,7 +101,7 @@ export class Recording {
|
|
|
109
101
|
}
|
|
110
102
|
/** The action shot just taken never ran (the page moved first); the retry takes its place. */
|
|
111
103
|
discardLast() {
|
|
112
|
-
this.steps.pop();
|
|
104
|
+
this.#remove(this.steps.pop());
|
|
113
105
|
}
|
|
114
106
|
/** The action shot just taken never ran: its target couldn't be reached. Repeats of the same miss fold into one. */
|
|
115
107
|
markUnreachable() {
|
|
@@ -118,26 +110,34 @@ export class Recording {
|
|
|
118
110
|
return;
|
|
119
111
|
if (previous?.unreachable && previous.description === last.description) {
|
|
120
112
|
previous.attempts = (previous.attempts ?? 1) + 1;
|
|
121
|
-
this.steps.pop();
|
|
113
|
+
this.#remove(this.steps.pop());
|
|
122
114
|
return;
|
|
123
115
|
}
|
|
124
116
|
last.unreachable = true;
|
|
125
117
|
last.attempts = 1;
|
|
126
118
|
}
|
|
119
|
+
// A shot named for a step that never ran would sit in the folder with no step to explain it.
|
|
120
|
+
#remove(step) {
|
|
121
|
+
if (step)
|
|
122
|
+
rmSync(join(this.#dir, step.file), { force: true });
|
|
123
|
+
}
|
|
127
124
|
// A session can run several times (missing value, confirmation); each run ends with its own final shot,
|
|
128
125
|
// and the video is re-encoded to cover every run so far.
|
|
129
|
-
async finish(tab, summary) {
|
|
126
|
+
async finish(tab, summary, asked) {
|
|
130
127
|
const page = await tab.observe();
|
|
131
128
|
await this.step(tab, page, { operation: summary.status });
|
|
132
129
|
this.steps.at(-1).description = `Finished: ${summary.status}. ${summary.reason}`;
|
|
133
130
|
this.summaries.push(summary);
|
|
131
|
+
if (asked)
|
|
132
|
+
this.asked.push({ goal: this.#scrub(asked.goal), ...(asked.doneWhen && { done_when: this.#scrub(asked.doneWhen) }) });
|
|
134
133
|
await tab.send('Page.stopScreencast').catch(() => undefined);
|
|
135
134
|
this.#stopListening?.();
|
|
136
135
|
this.#stopListening = undefined;
|
|
137
136
|
const video = (await this.#encode()) ?? null;
|
|
138
137
|
const viewport = this.#viewport;
|
|
139
|
-
|
|
140
|
-
writeFileSync(join(this.#dir, '
|
|
138
|
+
const record = { summaries: this.summaries, video, steps: this.steps, ...(viewport && { viewport }), ...(this.asked.length && { asked: this.asked }) };
|
|
139
|
+
writeFileSync(join(this.#dir, 'summary.json'), JSON.stringify(record, null, 1));
|
|
140
|
+
writeFileSync(join(this.#dir, 'index.html'), renderRunPage(record));
|
|
141
141
|
}
|
|
142
142
|
// Frames → H.264 through ffmpeg's concat list, each frame held for its duration. Without ffmpeg: no video.
|
|
143
143
|
async #encode() {
|
|
@@ -172,61 +172,3 @@ export class Recording {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
-
export { escapeHtml };
|
|
176
|
-
// What a run's calls cost, summed: requests, input tokens, time, and the estimate unless a call has none.
|
|
177
|
-
const costLine = (summaries) => {
|
|
178
|
-
const sum = (pick) => summaries.reduce((total, s) => total + pick(s), 0);
|
|
179
|
-
const cost = summaries.reduce((total, s) => (total === null || s.jev.est_cost_usd == null ? null : total + s.jev.est_cost_usd), 0);
|
|
180
|
-
return `${plural(sum((s) => s.jev.requests), 'Jev request')} · ${thousands(sum((s) => s.jev.input_tokens))} input tokens · ${seconds(sum((s) => s.elapsed_ms))} · ${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}`;
|
|
181
|
-
};
|
|
182
|
-
// epoch seconds → "2026-11-01 18:39 UTC"
|
|
183
|
-
const recordedAt = (at) => `${new Date(at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
184
|
-
/**
|
|
185
|
-
* A run's own page (`index.html` in its record folder), in the reports' layout: how it ended, then the screencast with
|
|
186
|
-
* its steps on a timeline and a strip kept in sync, then the facts. One self-contained file next to its shots and
|
|
187
|
-
* video; it reads without JS (the script only syncs the player).
|
|
188
|
-
*
|
|
189
|
-
* @example
|
|
190
|
-
* writeFileSync('runs/a/index.html', renderSteps(steps, summaries, 'run.mp4', { width: 1120, height: 780 }));
|
|
191
|
-
*/
|
|
192
|
-
export const renderSteps = (steps, summaries, video = null, viewport) => {
|
|
193
|
-
const last = summaries.at(-1);
|
|
194
|
-
const ending = last ? `Finished: ${statusWord(last.status)}` : 'jevway run';
|
|
195
|
-
const actions = summaries.reduce((total, s) => total + s.steps.length, 0);
|
|
196
|
-
const bar = `<div class="player-bar grid4"><p class="meta c1s3">${plural(steps.length, 'step')}${video ? ' · pick one to play from there' : ''}</p><span class="meta clock c4 end">${video ? clock(0) : ''}</span></div>`;
|
|
197
|
-
const player = steps.length
|
|
198
|
-
? playerMarkup({ dir: '', video, steps, label: 'this run', featured: true, viewport, bar, preload: 'metadata' })
|
|
199
|
-
: '<div class="screen quiet"><p class="small">No step was recorded.</p></div>';
|
|
200
|
-
const facts = [
|
|
201
|
-
...(summaries.length ? [fact('Calls', `${escapeHtml(summaries.map((s) => statusWord(s.status)).join(' → '))} · ${plural(actions, 'action')}`)] : []),
|
|
202
|
-
...(last ? [fact('Final reason', escapeHtml(last.reason)), fact('Final address', `<span class="address">${escapeHtml(last.url)}</span>`), fact('Page title', escapeHtml(last.title))] : []),
|
|
203
|
-
...(summaries.length ? [fact('Cost', `<span class="num">${costLine(summaries)}</span>`)] : []),
|
|
204
|
-
fact('Files', `<a href="summary.json">summary.json</a>${video ? ` · <a href="${escapeHtml(video)}">${escapeHtml(video)}</a>` : ''}`),
|
|
205
|
-
];
|
|
206
|
-
return `<!doctype html>
|
|
207
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
208
|
-
<title>${escapeHtml(`jevway run: ${last ? statusWord(last.status) : 'recorded'}`)}</title>
|
|
209
|
-
<link rel="preconnect" href="https://api.fontshare.com" crossorigin>
|
|
210
|
-
<link rel="stylesheet" href="${FONT_LINK}">
|
|
211
|
-
<style>${BASE_STYLE}
|
|
212
|
-
.run-page { padding-bottom: 4rem; }
|
|
213
|
-
.run-page .facts { margin-top: 4rem; }
|
|
214
|
-
</style>
|
|
215
|
-
</head><body>
|
|
216
|
-
<main class="wrapper run-page">
|
|
217
|
-
<header class="job-head grid4">
|
|
218
|
-
<p class="meta">jevway run<br>${steps.length ? recordedAt(steps[0].at) : ''}</p>
|
|
219
|
-
<h1 class="job-title c2s2">${escapeHtml(ending)}</h1>
|
|
220
|
-
<p class="meta c4 end">${plural(summaries.length, 'call')} · ${plural(actions, 'action')}</p>
|
|
221
|
-
${last ? `<p class="small story c2s2">${escapeHtml(last.reason)}</p>` : ''}
|
|
222
|
-
</header>
|
|
223
|
-
<div class="run player current">
|
|
224
|
-
${player}
|
|
225
|
-
</div>
|
|
226
|
-
<dl class="facts">${facts.join('\n')}</dl>
|
|
227
|
-
</main>
|
|
228
|
-
<footer><div class="wrapper"><span>Recorded by jevway for people to read; the model never sees these shots.</span><span>${plural(steps.length, 'step')}${video ? ` · ${escapeHtml(video)}` : ' · no video (needs ffmpeg)'}</span></div></footer>
|
|
229
|
-
${PLAYER_SCRIPT}
|
|
230
|
-
</body></html>
|
|
231
|
-
`;
|
|
232
|
-
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { BASE_STYLE, clock, dollars, embeddedJson, escapeHtml as escape, fact, FONT_STYLESHEET, GENERATOR_META, indexLabel, JEVWAY_VERSION, pathOf, playerMarkup, plural, PLAYER_SCRIPT, seconds, sentencesOf, statusLabel, statusTone, statusWord, storyOfRecording, thousands, } from './recording-page.js';
|
|
2
|
+
const total = (summaries, pick) => summaries.reduce((sum, s) => sum + pick(s), 0);
|
|
3
|
+
// Unknown when any call has no estimate (a model without a published rate).
|
|
4
|
+
const estimatedCost = (summaries) => summaries.reduce((sum, s) => (sum === null || s.jev.est_cost_usd == null ? null : sum + s.jev.est_cost_usd), 0);
|
|
5
|
+
// epoch seconds → "2026-11-01 18:39 UTC"
|
|
6
|
+
const utc = (at) => `${new Date(at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
7
|
+
const RUN_STYLE = `
|
|
8
|
+
.run-top { padding-block: 2.5rem 0; display: grid; gap: 1.75rem; }
|
|
9
|
+
.run-top .label { padding-top: 0.5rem; }
|
|
10
|
+
.done-when { margin-top: 0.75rem; }
|
|
11
|
+
.outcome-line { font-size: 1.125rem; line-height: 1.625rem; overflow-wrap: anywhere; }
|
|
12
|
+
.status { display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 500; margin-right: 0.5rem; }
|
|
13
|
+
.status::before { content: ""; flex: none; width: 0.625rem; height: 0.625rem; border-radius: 99px; background: var(--muted-foreground); }
|
|
14
|
+
.status.held::before { background: var(--held); } .status.reached::before { background: var(--reached); } .status.missed::before { background: var(--missed); }
|
|
15
|
+
.glance { border-top: 1px solid var(--muted); padding-top: 1.25rem; grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 1.25rem; }
|
|
16
|
+
@media (min-width: 64rem) { .glance { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
|
17
|
+
.glance dt { margin-bottom: 0.25rem; }
|
|
18
|
+
.glance dd { font-size: 1.125rem; line-height: 1.5rem; font-weight: 500; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
19
|
+
.glance dd .small { display: block; font-weight: 400; }
|
|
20
|
+
.run-top .facts .fact { padding-block: 0.875rem; }
|
|
21
|
+
.block { margin-top: 4rem; }
|
|
22
|
+
.block-head { margin-bottom: 1.5rem; }
|
|
23
|
+
#recording .screen { margin-top: 0; }
|
|
24
|
+
.call + .call { margin-top: 1.5rem; }
|
|
25
|
+
.call-head { padding-top: 0.875rem; }
|
|
26
|
+
.call-asked { margin-top: 0.25rem; }
|
|
27
|
+
.story { border-bottom: 1px solid var(--muted); }
|
|
28
|
+
.entry { display: grid; grid-template-columns: 2.25rem minmax(0, 1fr); gap: 0.125rem 0.75rem; padding: 0.875rem 0.75rem 0.875rem 0.5rem; border-top: 1px solid var(--muted); scroll-margin-top: 4rem; }
|
|
29
|
+
.entry .n { padding-top: 0.2rem; }
|
|
30
|
+
.entry .sentence { font-size: 1rem; line-height: 1.5rem; overflow-wrap: anywhere; }
|
|
31
|
+
.entry .links { grid-column: 2; display: flex; flex-wrap: wrap; gap: 0.125rem 1rem; font-size: 0.75rem; line-height: 1.25rem; color: var(--muted-foreground); }
|
|
32
|
+
.entry .links > * { min-width: 0; }
|
|
33
|
+
.entry .where { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
34
|
+
.entry .links a { text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; }
|
|
35
|
+
.entry .links a:hover { color: var(--foreground); text-decoration-color: currentColor; }
|
|
36
|
+
.entry.unshot .sentence { color: var(--muted-foreground); }
|
|
37
|
+
.entry.ended .sentence { font-weight: 500; }
|
|
38
|
+
.entry:target, .entry[aria-current="step"] { box-shadow: inset 2px 0 0 var(--foreground); background: color-mix(in oklch, var(--muted) 35%, transparent); }
|
|
39
|
+
.entry[aria-current="step"] .n { color: var(--foreground); }
|
|
40
|
+
.files dt { text-transform: none; letter-spacing: 0; font-family: var(--font-mono); }
|
|
41
|
+
.files dd a { text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; }
|
|
42
|
+
.run-page { padding-bottom: 4rem; }
|
|
43
|
+
`;
|
|
44
|
+
/** One line of a call's story: its number and sentence, then its time in the video, its shot file and its address. */
|
|
45
|
+
const storyEntry = ({ sentence, shot }, steps, video) => {
|
|
46
|
+
if (shot === undefined)
|
|
47
|
+
return `<li class="entry unshot"><span class="n meta num" aria-hidden="true">·</span><p class="sentence">${escape(sentence)}</p></li>`;
|
|
48
|
+
const step = steps[shot];
|
|
49
|
+
const id = `s${shot + 1}`;
|
|
50
|
+
const name = step.file.split('/').pop();
|
|
51
|
+
const links = [
|
|
52
|
+
...(video && step.video_at !== undefined ? [`<a class="at num" data-seek="${shot}" href="#${id}" aria-label="Play from step ${shot + 1}, at ${clock(step.video_at)}">${clock(step.video_at)}</a>`] : []),
|
|
53
|
+
`<a href="${escape(step.file)}">${escape(name)}</a>`,
|
|
54
|
+
`<span class="where">${escape(pathOf(step.url))}</span>`,
|
|
55
|
+
];
|
|
56
|
+
const ended = step.operation === step.operation.toLowerCase();
|
|
57
|
+
return `<li class="entry${ended ? ' ended' : ''}" id="${id}" data-shot="${shot}"><span class="n meta num">${indexLabel(shot)}</span><p class="sentence">${escape(sentence)}</p><p class="links">${links.join('')}</p></li>`;
|
|
58
|
+
};
|
|
59
|
+
const callStories = (stories, record) => {
|
|
60
|
+
const several = stories.length > 1;
|
|
61
|
+
return stories
|
|
62
|
+
.map((story, n) => {
|
|
63
|
+
const asked = record.asked?.[n];
|
|
64
|
+
const before = record.asked?.[n - 1];
|
|
65
|
+
const differs = n > 0 && asked && before && (asked.goal !== before.goal || asked.done_when !== before.done_when);
|
|
66
|
+
const status = story.status ?? record.summaries[n]?.status;
|
|
67
|
+
const head = several ? `<h3 class="meta">Call ${n + 1}${status ? ` · ${escape(statusWord(status))}` : ''}</h3>` : '';
|
|
68
|
+
return `<div class="call grid4"><div class="call-head">${head}${differs ? `<p class="small call-asked">Asked: ${escape(asked.goal)}${asked.done_when ? `<br>Done when: ${escape(asked.done_when)}` : ''}</p>` : ''}</div><ol class="story c2s3">${story.entries.map((entry) => storyEntry(entry, record.steps, record.video)).join('')}</ol></div>`;
|
|
69
|
+
})
|
|
70
|
+
.join('\n');
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The page for a recorded run folder, from its `summary.json` record. The top says what was asked, how the run ended
|
|
74
|
+
* and why, when, how long, what it cost, and where it started and ended; then the screencast (or the first shot); then
|
|
75
|
+
* each call's story, every step linking its shot file and its video time (`#s<n>`, also a deep link); then the
|
|
76
|
+
* details and the folder's files. The record itself is embedded as `#jevway-summary`.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* writeFileSync('runs/a/index.html', renderRunPage(JSON.parse(readFileSync('runs/a/summary.json', 'utf8'))));
|
|
80
|
+
*/
|
|
81
|
+
export const renderRunPage = (record) => {
|
|
82
|
+
const { summaries, steps, video, viewport } = record;
|
|
83
|
+
const last = summaries.at(-1);
|
|
84
|
+
const goal = record.asked?.[0]?.goal;
|
|
85
|
+
const doneWhen = record.asked?.[0]?.done_when;
|
|
86
|
+
// Actions that ran: a step told back to Jev as not done (`not_executed`) is in the story, not in the count.
|
|
87
|
+
const actions = total(summaries, (s) => s.steps.filter((step) => !step.not_executed).length);
|
|
88
|
+
const cost = estimatedCost(summaries);
|
|
89
|
+
const stories = storyOfRecording(steps, summaries);
|
|
90
|
+
const outcome = last ? statusLabel(last.status) : 'Recorded';
|
|
91
|
+
const title = `${outcome}${goal ? `: ${goal}` : ''} · jevway run`;
|
|
92
|
+
const bar = `<div class="player-bar grid4"><p class="meta c1s3">${plural(steps.length, 'step')}${video ? ' · pick one to play from there' : ''}</p><span class="meta clock c4 end">${video ? clock(0) : ''}</span></div>`;
|
|
93
|
+
const player = steps.length
|
|
94
|
+
? playerMarkup({ dir: '', video, steps, label: 'this run', featured: true, viewport, bar, preload: 'metadata', sentences: sentencesOf(stories) })
|
|
95
|
+
: '<div class="screen quiet"><p class="small">No step was recorded.</p></div>';
|
|
96
|
+
const glance = [
|
|
97
|
+
['Started', steps.length ? utc(steps[0].at) : 'unknown'],
|
|
98
|
+
['Took', summaries.length ? seconds(total(summaries, (s) => s.elapsed_ms)) : 'unknown'],
|
|
99
|
+
['Cost', `${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}<span class="small">${plural(total(summaries, (s) => s.jev.requests), 'Jev request')}</span>`],
|
|
100
|
+
['Steps', `${plural(summaries.length, 'call')} · ${plural(actions, 'action')}`],
|
|
101
|
+
];
|
|
102
|
+
const header = `<header class="wrapper run-top">
|
|
103
|
+
<div class="grid4"><p class="meta label">Asked</p><div class="c2s3"><h1 class="job-title">${goal ? escape(goal) : 'Goal not recorded'}</h1>${doneWhen ? `<p class="small done-when"><span class="kv">Done when</span> ${escape(doneWhen)}</p>` : ''}${goal ? '' : '<p class="small done-when">This run was recorded before jevway kept the goal in its folder.</p>'}</div></div>
|
|
104
|
+
<div class="grid4"><p class="meta label">Outcome</p><p class="c2s3 outcome-line">${last ? `<span class="status ${statusTone(last.status)}">${escape(outcome)}</span> ${escape(last.reason)}` : 'No call finished.'}</p></div>
|
|
105
|
+
<dl class="glance grid4">${glance.map(([label, value]) => `<div><dt class="meta">${label}</dt><dd>${value}</dd></div>`).join('')}</dl>
|
|
106
|
+
<dl class="facts">${[
|
|
107
|
+
...(steps.length ? [fact('Start page', `<span class="address">${escape(steps[0].url)}</span>`)] : []),
|
|
108
|
+
...(last ? [fact('End page', `<span class="address">${escape(last.url)}</span>${last.title ? ` · ${escape(last.title)}` : ''}`)] : []),
|
|
109
|
+
].join('\n')}</dl>
|
|
110
|
+
</header>`;
|
|
111
|
+
const details = [
|
|
112
|
+
...(summaries.length ? [fact('Calls', `${escape(summaries.map((s) => statusWord(s.status)).join(' → '))} · ${plural(actions, 'action')}`)] : []),
|
|
113
|
+
...(last ? [fact('Final page', `${escape(last.title)}<br><span class="address">${escape(last.url)}</span>`)] : []),
|
|
114
|
+
...(summaries.length
|
|
115
|
+
? [fact('Cost', `<span class="num">${plural(total(summaries, (s) => s.jev.requests), 'Jev request')} · ${thousands(total(summaries, (s) => s.jev.input_tokens))} input tokens · ${seconds(total(summaries, (s) => s.elapsed_ms))} · ${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}</span>`)]
|
|
116
|
+
: []),
|
|
117
|
+
...(last?.jev.model ? [fact('Model', escape(last.jev.model))] : []),
|
|
118
|
+
...(viewport ? [fact('Viewport', `<span class="num">${viewport.width} × ${viewport.height}</span> CSS pixels`)] : []),
|
|
119
|
+
];
|
|
120
|
+
const files = [
|
|
121
|
+
fact('<a href="summary.json">summary.json</a>', "Each call's summary and the recorded steps: the same data this page embeds as <code>#jevway-summary</code>"),
|
|
122
|
+
fact('<a href="manifest.json">manifest.json</a>', 'Every file in this folder, with what it is'),
|
|
123
|
+
...(video ? [fact(`<a href="${escape(video)}">${escape(video)}</a>`, 'Screencast of the whole session, idle stretches shortened')] : []),
|
|
124
|
+
fact('steps/', `${plural(steps.length, 'shot')}: one before each action, its target outlined, and one as each call ended; each step above links its own`),
|
|
125
|
+
];
|
|
126
|
+
return `<!doctype html>
|
|
127
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
128
|
+
<title>${escape(title)}</title>
|
|
129
|
+
<meta name="description" content="${escape(`${goal ?? 'A jevway run'}: ${outcome.toLowerCase()}${last ? `. ${last.reason}` : ''}`)}">
|
|
130
|
+
${GENERATOR_META}
|
|
131
|
+
<meta name="color-scheme" content="light dark">
|
|
132
|
+
<link rel="alternate" type="application/json" href="summary.json" title="This run as data">
|
|
133
|
+
${FONT_STYLESHEET}
|
|
134
|
+
<style>${BASE_STYLE}${RUN_STYLE}</style>
|
|
135
|
+
</head><body>
|
|
136
|
+
<nav class="top" aria-label="This page"><div class="wrapper"><span>jevway · run</span><div class="links"><a href="#steps">Steps</a><a href="#details">Details</a><a href="#files">Files</a></div></div></nav>
|
|
137
|
+
${header}
|
|
138
|
+
<main class="wrapper run-page">
|
|
139
|
+
<div class="run player current" data-step-links>
|
|
140
|
+
<section id="recording" class="block" aria-labelledby="recording-title"><h2 class="block-head meta" id="recording-title">Recording</h2>
|
|
141
|
+
${player}
|
|
142
|
+
</section>
|
|
143
|
+
<section id="steps" class="block" aria-labelledby="steps-title"><h2 class="section-title block-head" id="steps-title">Steps</h2>
|
|
144
|
+
${callStories(stories, record)}
|
|
145
|
+
</section>
|
|
146
|
+
</div>
|
|
147
|
+
<section id="details" class="block" aria-labelledby="details-title"><h2 class="section-title block-head" id="details-title">Details</h2><dl class="facts">${details.join('\n')}</dl></section>
|
|
148
|
+
<section id="files" class="block" aria-labelledby="files-title"><h2 class="section-title block-head" id="files-title">Files</h2><dl class="facts files">${files.join('\n')}</dl></section>
|
|
149
|
+
</main>
|
|
150
|
+
<footer><div class="wrapper"><span>Recorded by jevway ${escape(JEVWAY_VERSION)} for people to read; the model never sees these shots.</span><span>${plural(steps.length, 'step')}${video ? ` · ${escape(video)}` : ' · no video (recording one needs ffmpeg)'}</span></div></footer>
|
|
151
|
+
${embeddedJson('jevway-summary', record)}
|
|
152
|
+
${PLAYER_SCRIPT}
|
|
153
|
+
</body></html>
|
|
154
|
+
`;
|
|
155
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { scrubberFor } from '../jev/secrets.js';
|
|
4
|
+
import { ensureOutRoot, journeyFolderName, outRoot, runFolderName } from '../output/layout.js';
|
|
5
|
+
import { lookAt } from '../task/look.js';
|
|
6
|
+
import { storyLine } from '../task/present.js';
|
|
7
|
+
/** A session id the service does not hold: it ended, expired, or never existed. `code` is for structured CLI errors. */
|
|
8
|
+
export class UnknownSession extends Error {
|
|
9
|
+
code = 'unknown_session';
|
|
10
|
+
}
|
|
11
|
+
// 3600000 → '1 h', 1800000 → '30 min'.
|
|
12
|
+
const idleWords = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000} h` : `${Math.round(ms / 60_000)} min`);
|
|
13
|
+
/**
|
|
14
|
+
* The sessions behind every adapter (the MCP server, the CLI daemon): task sessions and journeys by opaque UUID, each
|
|
15
|
+
* with its folder under the output root, calls that wait for a session's still-running call, and errors that say what
|
|
16
|
+
* to do next. Transport-free: adapters translate parameters in and present results out.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const sessions = createSessionService(createRunner('auto'));
|
|
20
|
+
* const first = await sessions.task({ goal: 'Book a stay', url: 'https://stays.example' }, { onEvent: console.error });
|
|
21
|
+
* presentSummary(first); // { session, status: 'needs_input', next: 'Add the missing values …', story, files, … }
|
|
22
|
+
* await sessions.task({ goal: 'Book a stay', session: first.session, values: { email: 'ada@example.com' } });
|
|
23
|
+
* await sessions.end(first.session);
|
|
24
|
+
*/
|
|
25
|
+
export const createSessionService = (runner, options = {}) => {
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
const cwd = options.cwd ?? process.cwd();
|
|
28
|
+
const root = resolve(cwd, options.outDir ?? outRoot(env, cwd));
|
|
29
|
+
const now = options.now ?? (() => new Date());
|
|
30
|
+
const newId = options.newId ?? randomUUID;
|
|
31
|
+
const sessions = new Map();
|
|
32
|
+
const journeys = new Map();
|
|
33
|
+
const lifetime = `when the server restarts${options.idleMs ? ` or after ${idleWords(options.idleMs)} idle` : ''}`;
|
|
34
|
+
const findTask = (id) => {
|
|
35
|
+
const entry = sessions.get(id);
|
|
36
|
+
if (!entry)
|
|
37
|
+
throw new UnknownSession(`Unknown session "${id}". Sessions end ${lifetime}; start a new one with \`url\` (or \`tab\`).`);
|
|
38
|
+
entry.lastUsedAt = now();
|
|
39
|
+
return entry;
|
|
40
|
+
};
|
|
41
|
+
const findJourney = (id) => {
|
|
42
|
+
const entry = journeys.get(id);
|
|
43
|
+
if (!entry)
|
|
44
|
+
throw new UnknownSession(`Unknown journey "${id}". Journeys end when finished or ${lifetime}; start a new one with its \`url\`.`);
|
|
45
|
+
entry.lastUsedAt = now();
|
|
46
|
+
return entry;
|
|
47
|
+
};
|
|
48
|
+
const startTask = async ({ session: _session, record, recordDir, ...request }) => {
|
|
49
|
+
const id = newId();
|
|
50
|
+
const at = now();
|
|
51
|
+
const values = request.values ?? {};
|
|
52
|
+
const secretKeys = request.secretKeys ?? [];
|
|
53
|
+
const scrub = scrubberFor(values, secretKeys);
|
|
54
|
+
const runDir = recordDir ? resolve(cwd, recordDir) : join(ensureOutRoot(root), 'runs', runFolderName({ at, goal: request.goal, id, scrub }));
|
|
55
|
+
const task = await runner.start({ ...request, output: { dir: runDir, record: Boolean(record || recordDir) } });
|
|
56
|
+
const entry = { task, ownsTab: Boolean(request.url), busy: false, goal: scrub(request.goal), values, secretKeys, runDir, startedAt: at, lastUsedAt: at };
|
|
57
|
+
sessions.set(id, entry);
|
|
58
|
+
return { id, entry };
|
|
59
|
+
};
|
|
60
|
+
const continueTask = async (id, request) => {
|
|
61
|
+
const entry = findTask(id);
|
|
62
|
+
await entry.running;
|
|
63
|
+
// Only what a later call may change: start-only fields (url, tab, coordinate clicks, recording) stay as started.
|
|
64
|
+
entry.task.update({
|
|
65
|
+
goal: request.goal,
|
|
66
|
+
values: request.values,
|
|
67
|
+
secretKeys: request.secretKeys,
|
|
68
|
+
files: request.files,
|
|
69
|
+
doneWhen: request.doneWhen,
|
|
70
|
+
checks: request.checks,
|
|
71
|
+
maxSteps: request.maxSteps,
|
|
72
|
+
maxSeconds: request.maxSeconds,
|
|
73
|
+
allowedHosts: request.allowedHosts,
|
|
74
|
+
blockedHosts: request.blockedHosts,
|
|
75
|
+
confirmIrreversible: request.confirmIrreversible,
|
|
76
|
+
confirmTarget: request.confirmTarget,
|
|
77
|
+
});
|
|
78
|
+
entry.values = { ...entry.values, ...request.values };
|
|
79
|
+
entry.secretKeys = [...new Set([...entry.secretKeys, ...(request.secretKeys ?? [])])];
|
|
80
|
+
entry.goal = scrubberFor(entry.values, entry.secretKeys)(request.goal);
|
|
81
|
+
return { id, entry };
|
|
82
|
+
};
|
|
83
|
+
const journeysRoot = () => env.JEVWAY_JOURNEYS_DIR ? resolve(cwd, env.JEVWAY_JOURNEYS_DIR) : join(ensureOutRoot(root), 'journeys');
|
|
84
|
+
const journeyReply = async (id, journey, report) => ({
|
|
85
|
+
journey: id,
|
|
86
|
+
report: report,
|
|
87
|
+
preview: await journey.preview(),
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
/** The output root this service writes under. */
|
|
91
|
+
outDir: root,
|
|
92
|
+
/**
|
|
93
|
+
* Runs one call: a new session (with `url` or `tab`) or the next call of `session`, which first waits for that
|
|
94
|
+
* session's still-running call. `signal` cancels this call (the run ends `blocked` at its next check).
|
|
95
|
+
*/
|
|
96
|
+
async task(request, { signal, onEvent } = {}) {
|
|
97
|
+
const { id, entry } = request.session ? await continueTask(request.session, request) : await startTask(request);
|
|
98
|
+
// A listener is the adapter's (progress, NDJSON): it never decides how the run goes.
|
|
99
|
+
const tell = (event) => {
|
|
100
|
+
try {
|
|
101
|
+
onEvent?.(event);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// ignored
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
tell({ type: 'started', session: id });
|
|
108
|
+
entry.busy = true;
|
|
109
|
+
const running = entry.task.run({ signal, onStep: (step, n, url) => tell({ type: 'step', session: id, n, story: storyLine(step), url }) });
|
|
110
|
+
entry.running = running.catch(() => undefined);
|
|
111
|
+
try {
|
|
112
|
+
const summary = await running;
|
|
113
|
+
entry.status = summary.status;
|
|
114
|
+
entry.url = summary.url;
|
|
115
|
+
tell({ type: 'finished', session: id, status: summary.status });
|
|
116
|
+
const call = entry.task.lastCall;
|
|
117
|
+
return { session: id, summary, ...(call && { files: call.files }), ...(call?.screenshot && { screenshot: call.screenshot }) };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
entry.busy = false;
|
|
121
|
+
entry.lastUsedAt = now();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
/** Reads a page with no model call and no input: a session's tab (its secret values masked), an open tab by URL part, or a URL opened and closed again. */
|
|
125
|
+
async look({ session, tab, url }) {
|
|
126
|
+
if ([session, tab, url].filter(Boolean).length !== 1)
|
|
127
|
+
throw new Error('Pass exactly one of session, tab or url.');
|
|
128
|
+
if (session)
|
|
129
|
+
return lookAt(await findTask(session).task.look());
|
|
130
|
+
return lookAt(await runner.look({ url, tab }));
|
|
131
|
+
},
|
|
132
|
+
/** Forgets a session and closes the tabs it opened (its first tab only when it started from `url`), unless `keepTab`. */
|
|
133
|
+
async end(session, { keepTab = false } = {}) {
|
|
134
|
+
const entry = findTask(session);
|
|
135
|
+
sessions.delete(session);
|
|
136
|
+
if (!keepTab)
|
|
137
|
+
await entry.task.closeTabs({ keepFirst: !entry.ownsTab });
|
|
138
|
+
return { session, run_dir: entry.runDir };
|
|
139
|
+
},
|
|
140
|
+
/** Every open task session and journey, oldest first, with what it is doing and where its files are. */
|
|
141
|
+
listSessions() {
|
|
142
|
+
const tasks = [...sessions].map(([session, entry]) => ({
|
|
143
|
+
session,
|
|
144
|
+
kind: 'task',
|
|
145
|
+
goal: entry.goal,
|
|
146
|
+
...(entry.status && { status: entry.status }),
|
|
147
|
+
...(entry.url && { url: entry.url }),
|
|
148
|
+
running: entry.busy,
|
|
149
|
+
started_at: entry.startedAt.toISOString(),
|
|
150
|
+
last_used_at: entry.lastUsedAt.toISOString(),
|
|
151
|
+
run_dir: entry.runDir,
|
|
152
|
+
}));
|
|
153
|
+
const maps = [...journeys].map(([session, entry]) => ({
|
|
154
|
+
session,
|
|
155
|
+
kind: 'journey',
|
|
156
|
+
url: entry.url,
|
|
157
|
+
started_at: entry.startedAt.toISOString(),
|
|
158
|
+
last_used_at: entry.lastUsedAt.toISOString(),
|
|
159
|
+
run_dir: entry.outDir,
|
|
160
|
+
}));
|
|
161
|
+
return [...tasks, ...maps].sort((a, b) => a.started_at.localeCompare(b.started_at));
|
|
162
|
+
},
|
|
163
|
+
/** Opens `url` on an emulated phone and maps its first screen, into `<root>/journeys/<stamp>_<host>_<id4>/` (or `outDir`). */
|
|
164
|
+
async journeyStart({ url, device, intent, values, stayWithin, outDir }) {
|
|
165
|
+
const id = newId();
|
|
166
|
+
const at = now();
|
|
167
|
+
const dir = outDir ? resolve(cwd, outDir) : join(journeysRoot(), journeyFolderName({ at, host: new URL(url).host, id }));
|
|
168
|
+
const journey = await runner.startJourney({ startUrl: url, device, intent, values, stayWithin, outDir: dir });
|
|
169
|
+
journeys.set(id, { journey, url, outDir: dir, startedAt: at, lastUsedAt: at });
|
|
170
|
+
return journeyReply(id, journey, journey.report());
|
|
171
|
+
},
|
|
172
|
+
async journeyStep(id, input) {
|
|
173
|
+
const { journey } = findJourney(id);
|
|
174
|
+
return journeyReply(id, journey, await journey.step(input));
|
|
175
|
+
},
|
|
176
|
+
async journeyAutopilot(id, input) {
|
|
177
|
+
const { journey } = findJourney(id);
|
|
178
|
+
return journeyReply(id, journey, await journey.autopilot(input));
|
|
179
|
+
},
|
|
180
|
+
journeyAnnotate(id, screen, notes, relabel) {
|
|
181
|
+
findJourney(id).journey.annotate(screen, notes, relabel);
|
|
182
|
+
},
|
|
183
|
+
/** Writes the final map, closes the journey's tab unless `keepTab`, and forgets the journey. */
|
|
184
|
+
async journeyFinish(id, { keepTab = false } = {}) {
|
|
185
|
+
const { journey, outDir } = findJourney(id);
|
|
186
|
+
const summary = await journey.finish({ closeTab: !keepTab });
|
|
187
|
+
journeys.delete(id);
|
|
188
|
+
return { ...summary, manifest: join(outDir, 'manifest.json') };
|
|
189
|
+
},
|
|
190
|
+
/** Closes the browser connection (a launched browser closes with it). */
|
|
191
|
+
close: () => runner.close(),
|
|
192
|
+
};
|
|
193
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Writes `text` to a temporary file beside `path` and renames it into place, so an interrupted setup leaves the file
|
|
5
|
+
* either as it was or complete. `mode` applies to the result even where the file existed before.
|
|
6
|
+
*/
|
|
7
|
+
export const writeFileAtomic = (path, text, mode = 0o644) => {
|
|
8
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
9
|
+
const temporary = `${path}.jevway-${process.pid}.tmp`;
|
|
10
|
+
writeFileSync(temporary, text, { mode });
|
|
11
|
+
chmodSync(temporary, mode);
|
|
12
|
+
renameSync(temporary, path);
|
|
13
|
+
};
|