@openruntime/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/assets/auth-connector/icon-128.png +0 -0
  2. package/assets/auth-connector/icon-16.png +0 -0
  3. package/assets/auth-connector/icon-32.png +0 -0
  4. package/assets/auth-connector/icon-48.png +0 -0
  5. package/dist/args.d.ts +9 -0
  6. package/dist/args.d.ts.map +1 -0
  7. package/dist/args.js +52 -0
  8. package/dist/args.js.map +1 -0
  9. package/dist/auth-connector.d.ts +66 -0
  10. package/dist/auth-connector.d.ts.map +1 -0
  11. package/dist/auth-connector.js +1031 -0
  12. package/dist/auth-connector.js.map +1 -0
  13. package/dist/bridge-process.d.ts +64 -0
  14. package/dist/bridge-process.d.ts.map +1 -0
  15. package/dist/bridge-process.js +228 -0
  16. package/dist/bridge-process.js.map +1 -0
  17. package/dist/browser.d.ts +25 -0
  18. package/dist/browser.d.ts.map +1 -0
  19. package/dist/browser.js +180 -0
  20. package/dist/browser.js.map +1 -0
  21. package/dist/client.d.ts +21 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +132 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/command-definition.d.ts +19 -0
  26. package/dist/command-definition.d.ts.map +1 -0
  27. package/dist/command-definition.js +42 -0
  28. package/dist/command-definition.js.map +1 -0
  29. package/dist/command-skill.d.ts +5 -0
  30. package/dist/command-skill.d.ts.map +1 -0
  31. package/dist/command-skill.js +25 -0
  32. package/dist/command-skill.js.map +1 -0
  33. package/dist/entry.d.ts +2 -0
  34. package/dist/entry.d.ts.map +1 -0
  35. package/dist/entry.js +16 -0
  36. package/dist/entry.js.map +1 -0
  37. package/dist/extension-api.d.ts +85 -0
  38. package/dist/extension-api.d.ts.map +1 -0
  39. package/dist/extension-api.js +313 -0
  40. package/dist/extension-api.js.map +1 -0
  41. package/dist/extensions/types.d.ts +3 -0
  42. package/dist/extensions/types.d.ts.map +1 -0
  43. package/dist/extensions/types.js +2 -0
  44. package/dist/extensions/types.js.map +1 -0
  45. package/dist/external-extensions.d.ts +19 -0
  46. package/dist/external-extensions.d.ts.map +1 -0
  47. package/dist/external-extensions.js +168 -0
  48. package/dist/external-extensions.js.map +1 -0
  49. package/dist/help.d.ts +17 -0
  50. package/dist/help.d.ts.map +1 -0
  51. package/dist/help.js +214 -0
  52. package/dist/help.js.map +1 -0
  53. package/dist/index.d.ts +100 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +1579 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/next-browser-profile-preload.d.ts +2 -0
  58. package/dist/next-browser-profile-preload.d.ts.map +1 -0
  59. package/dist/next-browser-profile-preload.js +391 -0
  60. package/dist/next-browser-profile-preload.js.map +1 -0
  61. package/dist/operation-log.d.ts +23 -0
  62. package/dist/operation-log.d.ts.map +1 -0
  63. package/dist/operation-log.js +76 -0
  64. package/dist/operation-log.js.map +1 -0
  65. package/dist/output.d.ts +42 -0
  66. package/dist/output.d.ts.map +1 -0
  67. package/dist/output.js +111 -0
  68. package/dist/output.js.map +1 -0
  69. package/dist/profile.d.ts +56 -0
  70. package/dist/profile.d.ts.map +1 -0
  71. package/dist/profile.js +414 -0
  72. package/dist/profile.js.map +1 -0
  73. package/dist/record.d.ts +17 -0
  74. package/dist/record.d.ts.map +1 -0
  75. package/dist/record.js +1494 -0
  76. package/dist/record.js.map +1 -0
  77. package/package.json +46 -0
package/dist/record.js ADDED
@@ -0,0 +1,1494 @@
1
+ import { appendFile, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { OPEN_RUNTIME_SESSION_QUERY_PARAM } from "@openruntime/core";
4
+ import { getNumberOption, getOptionValue } from "./args.js";
5
+ import { parseBrowserJsonOutput, resolveBrowserProfileDirectory } from "./browser.js";
6
+ import { ensureBridge } from "./bridge-process.js";
7
+ import { fetchRuntimeResource, fetchRuntimes, selectRuntime } from "./client.js";
8
+ const DEFAULT_RECORD_DURATION_MS = 10_000;
9
+ const DEFAULT_RECORD_INTERVAL_MS = 1_000;
10
+ const DEFAULT_RECORD_START_URL = "about:blank";
11
+ const RECORDING_FORMAT = "openruntime-recording";
12
+ const RECORDING_VERSION = 1;
13
+ const RECORD_EVENT_CONSOLE_MARKER = "__OPENRUNTIME_RECORD_EVENT__";
14
+ const RECORD_AUDIO_CONSOLE_MARKER = "__OPENRUNTIME_RECORD_AUDIO__";
15
+ const OPENRUNTIME_RECORDING_CONTROL_FILE = "recording-session.json";
16
+ const DEFAULT_TRANSCRIPTION_MODEL = "whisper-1";
17
+ export async function runRecordCommand(options) {
18
+ const subcommand = options.args.command[1];
19
+ if (subcommand === "start") {
20
+ return await runRecordStartCommand(options);
21
+ }
22
+ if (subcommand === "stop") {
23
+ return await runRecordStopCommand(options);
24
+ }
25
+ if (subcommand === "generate-script") {
26
+ return await runRecordGenerateScriptCommand(options);
27
+ }
28
+ if (subcommand === "transcribe") {
29
+ return await runRecordTranscribeCommand(options);
30
+ }
31
+ return await runRecordFixedDurationCommand(options);
32
+ }
33
+ async function runRecordStartCommand(options) {
34
+ const requestedUrl = getRecordUrl(options.args, "start");
35
+ const url = requestedUrl ?? DEFAULT_RECORD_START_URL;
36
+ const startedAt = new Date();
37
+ const outputDirectory = resolveRecordStartOutputDirectory(options.args, startedAt);
38
+ const intervalMs = getPositiveNumberOption(options.args, "interval") ?? DEFAULT_RECORD_INTERVAL_MS;
39
+ const sessionId = getOptionValue(options.args, "session");
40
+ const runtimeSelector = createRecordRuntimeSelector(requestedUrl, sessionId, getOptionValue(options.args, "runtime"));
41
+ const openedUrl = withOpenRuntimeSession(url, sessionId);
42
+ const files = createRecordingFiles();
43
+ const operations = [
44
+ {
45
+ type: "record.start",
46
+ startedAt: startedAt.toISOString()
47
+ }
48
+ ];
49
+ const runtimeSamples = [];
50
+ const pageSnapshots = [];
51
+ const domSnapshots = [];
52
+ const interactions = [];
53
+ await mkdir(outputDirectory, { recursive: true });
54
+ operations.push(await writeRecordingControlFile(outputDirectory, files, startedAt, hasOption(options.args, "mic")));
55
+ operations.push(await resetRecordingBrowser(options.browserRunner));
56
+ await ensureRecordBridge(options, options.bridgeUrl);
57
+ if (!hasOption(options.args, "no-open")) {
58
+ operations.push(await openRecordingBrowser(options, openedUrl));
59
+ }
60
+ else {
61
+ operations.push(createSkippedBrowserOpenOperation(openedUrl));
62
+ }
63
+ operations.push(await setRecordingInstrumentation(options.browserRunner, outputDirectory, startedAt));
64
+ const sampledAt = new Date();
65
+ const [runtimeSample, pageSnapshot, domSnapshot] = await Promise.all([
66
+ sampleRuntime(options.fetcher, options.bridgeUrl, runtimeSelector, sampledAt),
67
+ samplePageSnapshot(options.browserRunner, sampledAt),
68
+ sampleDomSnapshot(options.browserRunner, sampledAt)
69
+ ]);
70
+ runtimeSamples.push(runtimeSample);
71
+ pageSnapshots.push(pageSnapshot);
72
+ domSnapshots.push(domSnapshot);
73
+ const manifest = createRecordingManifest({
74
+ args: options.args,
75
+ url,
76
+ openedUrl,
77
+ bridgeUrl: options.bridgeUrl,
78
+ sessionId,
79
+ startedAt,
80
+ intervalMs,
81
+ status: "recording",
82
+ files,
83
+ counts: {
84
+ runtimeSamples: runtimeSamples.length,
85
+ pageSnapshots: pageSnapshots.length,
86
+ domSnapshots: domSnapshots.length,
87
+ interactions: interactions.length,
88
+ audioChunks: 0,
89
+ transcriptSegments: 0,
90
+ operations: operations.length
91
+ }
92
+ });
93
+ await writeRecordingFiles(outputDirectory, manifest, runtimeSamples, pageSnapshots, domSnapshots, interactions, operations);
94
+ writeJson(options.stdout, {
95
+ ok: true,
96
+ status: "recording",
97
+ output: outputDirectory,
98
+ manifest: join(outputDirectory, files.manifest),
99
+ next: `open-runtime record stop --out ${outputDirectory}`
100
+ });
101
+ return 0;
102
+ }
103
+ async function runRecordStopCommand(options) {
104
+ const outputDirectory = resolve(requireOption(options.args, "out"));
105
+ const recording = await readRecordingData(outputDirectory);
106
+ const bridgeUrl = resolveRecordingBridgeUrl(options.args, options.bridgeUrl, recording.manifest);
107
+ const sessionId = getOptionValue(options.args, "session") ?? recording.manifest.sessionId;
108
+ const runtimeSelector = createRecordRuntimeSelector(recording.manifest.url, sessionId, getOptionValue(options.args, "runtime"));
109
+ const stopStartedAt = new Date();
110
+ const bridge = await tryEnsureRecordBridge(options, bridgeUrl);
111
+ const sampledAt = new Date();
112
+ const [runtimeSample, pageSnapshot, domSnapshot] = await Promise.all([
113
+ sampleRuntime(options.fetcher, bridgeUrl, runtimeSelector, sampledAt),
114
+ samplePageSnapshot(options.browserRunner, sampledAt),
115
+ sampleDomSnapshot(options.browserRunner, sampledAt)
116
+ ]);
117
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.runtime), runtimeSample);
118
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.pageSnapshots), pageSnapshot);
119
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.domSnapshots), domSnapshot);
120
+ const interactionCollection = await collectInteractionEvents(outputDirectory, options.browserRunner);
121
+ await writeJsonLines(join(outputDirectory, recording.manifest.files.interactions), interactionCollection.interactions);
122
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), interactionCollection.operation);
123
+ const audioCollection = await collectAudioCapture(outputDirectory, recording.manifest.files, recording.manifest.capture.audio.requested);
124
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), audioCollection.operation);
125
+ const stopOperation = {
126
+ type: "record.stop",
127
+ startedAt: stopStartedAt.toISOString(),
128
+ endedAt: new Date().toISOString()
129
+ };
130
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), stopOperation);
131
+ let closeOperation;
132
+ if (!hasOption(options.args, "no-close")) {
133
+ closeOperation = await closeRecordingBrowser(options.browserRunner);
134
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), closeOperation);
135
+ }
136
+ else {
137
+ closeOperation = {
138
+ type: "browser.close",
139
+ startedAt: new Date().toISOString(),
140
+ skipped: true,
141
+ reason: "--no-close was set"
142
+ };
143
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), closeOperation);
144
+ }
145
+ await clearRecordingControlFile();
146
+ const refreshedRecording = await readRecordingData(outputDirectory);
147
+ let generatedScript;
148
+ if (!hasOption(options.args, "no-script")) {
149
+ generatedScript = await writeGeneratedScript(outputDirectory, refreshedRecording, getOptionValue(options.args, "script-out"));
150
+ await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), {
151
+ type: "script.generated",
152
+ startedAt: new Date().toISOString(),
153
+ path: generatedScript.path
154
+ });
155
+ }
156
+ const completedAt = new Date();
157
+ const counts = await readRecordingCounts(outputDirectory, recording.manifest.files);
158
+ const manifest = {
159
+ ...recording.manifest,
160
+ bridgeUrl,
161
+ status: "completed",
162
+ endedAt: completedAt.toISOString(),
163
+ durationMs: completedAt.getTime() - Date.parse(recording.manifest.startedAt),
164
+ capture: {
165
+ ...recording.manifest.capture,
166
+ audio: createCompletedAudioCapture(recording.manifest.files, audioCollection.summary)
167
+ },
168
+ counts,
169
+ ...createOptionalGeneratedProperty(generatedScript)
170
+ };
171
+ await writeJsonFile(join(outputDirectory, recording.manifest.files.manifest), manifest);
172
+ writeJson(options.stdout, {
173
+ ok: true,
174
+ status: "completed",
175
+ output: outputDirectory,
176
+ manifest: join(outputDirectory, recording.manifest.files.manifest),
177
+ script: generatedScript?.path,
178
+ bridge,
179
+ close: closeOperation,
180
+ counts: manifest.counts
181
+ });
182
+ return 0;
183
+ }
184
+ async function runRecordGenerateScriptCommand(options) {
185
+ const inputDirectory = resolve(requireOption(options.args, "input"));
186
+ const recording = await readRecordingData(inputDirectory);
187
+ const generatedScript = await writeGeneratedScript(inputDirectory, recording, getOptionValue(options.args, "out"));
188
+ const generatedAt = new Date().toISOString();
189
+ const manifest = {
190
+ ...recording.manifest,
191
+ generated: {
192
+ ...(recording.manifest.generated ?? {}),
193
+ script: generatedScript.relativePath,
194
+ generatedAt
195
+ }
196
+ };
197
+ await appendJsonLine(join(inputDirectory, recording.manifest.files.operations), {
198
+ type: "script.generated",
199
+ startedAt: generatedAt,
200
+ path: generatedScript.path
201
+ });
202
+ const counts = await readRecordingCounts(inputDirectory, recording.manifest.files);
203
+ await writeJsonFile(join(inputDirectory, recording.manifest.files.manifest), {
204
+ ...manifest,
205
+ counts
206
+ });
207
+ writeJson(options.stdout, {
208
+ ok: true,
209
+ input: inputDirectory,
210
+ script: generatedScript.path
211
+ });
212
+ return 0;
213
+ }
214
+ async function runRecordTranscribeCommand(options) {
215
+ const inputDirectory = resolve(requireOption(options.args, "input"));
216
+ const recording = await readRecordingData(inputDirectory);
217
+ const model = getOptionValue(options.args, "model") ?? DEFAULT_TRANSCRIPTION_MODEL;
218
+ const apiKey = getOptionValue(options.args, "api-key") ?? process.env.OPENAI_API_KEY;
219
+ if (apiKey === undefined || apiKey.length === 0) {
220
+ throw new Error("Missing OpenAI API key. Set OPENAI_API_KEY or pass --api-key.");
221
+ }
222
+ const audioPath = resolve(inputDirectory, getOptionValue(options.args, "audio") ?? recording.manifest.files.audio);
223
+ const startedAt = new Date();
224
+ const transcript = await transcribeAudioFile(options.fetcher, audioPath, apiKey, model);
225
+ const transcribedAt = new Date().toISOString();
226
+ const transcriptData = {
227
+ status: "completed",
228
+ audio: createManifestPath(inputDirectory, audioPath),
229
+ model,
230
+ transcribedAt,
231
+ text: transcript.text,
232
+ segments: transcript.segments,
233
+ ...(transcript.words.length > 0 ? { words: transcript.words } : {})
234
+ };
235
+ await writeJsonFile(join(inputDirectory, recording.manifest.files.transcript), transcriptData);
236
+ await appendJsonLine(join(inputDirectory, recording.manifest.files.operations), {
237
+ type: "audio.transcribe",
238
+ startedAt: startedAt.toISOString(),
239
+ endedAt: transcribedAt,
240
+ model,
241
+ audio: audioPath,
242
+ segmentCount: transcript.segments.length,
243
+ wordCount: transcript.words.length
244
+ });
245
+ const counts = await readRecordingCounts(inputDirectory, recording.manifest.files);
246
+ const manifest = {
247
+ ...recording.manifest,
248
+ capture: {
249
+ ...recording.manifest.capture,
250
+ audio: {
251
+ ...recording.manifest.capture.audio,
252
+ requested: true,
253
+ status: "transcribed",
254
+ file: recording.manifest.files.audio,
255
+ chunks: recording.manifest.files.audioChunks,
256
+ transcript: recording.manifest.files.transcript,
257
+ segmentCount: transcript.segments.length,
258
+ chunkCount: counts.audioChunks,
259
+ reason: "Microphone audio was transcribed with timestamps."
260
+ }
261
+ },
262
+ counts
263
+ };
264
+ await writeJsonFile(join(inputDirectory, recording.manifest.files.manifest), manifest);
265
+ writeJson(options.stdout, {
266
+ ok: true,
267
+ input: inputDirectory,
268
+ transcript: join(inputDirectory, recording.manifest.files.transcript),
269
+ model,
270
+ segmentCount: transcript.segments.length,
271
+ wordCount: transcript.words.length
272
+ });
273
+ return 0;
274
+ }
275
+ async function transcribeAudioFile(fetcher, audioPath, apiKey, model) {
276
+ const audio = await readFile(audioPath);
277
+ const form = new FormData();
278
+ form.set("file", new Blob([audio], { type: "audio/webm" }), "audio.webm");
279
+ form.set("model", model);
280
+ if (model === DEFAULT_TRANSCRIPTION_MODEL) {
281
+ form.set("response_format", "verbose_json");
282
+ form.append("timestamp_granularities[]", "segment");
283
+ form.append("timestamp_granularities[]", "word");
284
+ }
285
+ else {
286
+ form.set("response_format", "json");
287
+ }
288
+ const response = await fetcher("https://api.openai.com/v1/audio/transcriptions", {
289
+ method: "POST",
290
+ headers: {
291
+ authorization: `Bearer ${apiKey}`
292
+ },
293
+ body: form
294
+ });
295
+ const text = await response.text();
296
+ if (!response.ok) {
297
+ throw new Error(text.length === 0 ? `Audio transcription failed with status ${response.status}.` : text);
298
+ }
299
+ const parsed = JSON.parse(text);
300
+ return {
301
+ text: typeof parsed.text === "string" ? parsed.text : "",
302
+ segments: normalizeTranscriptSegments(parsed.segments, parsed.text),
303
+ words: normalizeTranscriptWords(parsed.words)
304
+ };
305
+ }
306
+ async function runRecordFixedDurationCommand(options) {
307
+ const url = requireRecordUrl(options.args);
308
+ const outputDirectory = resolve(requireOption(options.args, "out"));
309
+ const durationMs = getPositiveNumberOption(options.args, "duration") ?? DEFAULT_RECORD_DURATION_MS;
310
+ const intervalMs = getPositiveNumberOption(options.args, "interval") ?? DEFAULT_RECORD_INTERVAL_MS;
311
+ const sessionId = getOptionValue(options.args, "session");
312
+ const runtimeSelector = createRecordRuntimeSelector(url, sessionId, getOptionValue(options.args, "runtime"));
313
+ const openedUrl = withOpenRuntimeSession(url, sessionId);
314
+ const startedAt = new Date();
315
+ const files = createRecordingFiles();
316
+ const operations = [];
317
+ const runtimeSamples = [];
318
+ const pageSnapshots = [];
319
+ const domSnapshots = [];
320
+ const interactions = [];
321
+ await mkdir(outputDirectory, { recursive: true });
322
+ await ensureRecordBridge(options, options.bridgeUrl);
323
+ if (!hasOption(options.args, "no-open")) {
324
+ operations.push(await openRecordingBrowser(options, openedUrl));
325
+ }
326
+ else {
327
+ operations.push(createSkippedBrowserOpenOperation(openedUrl));
328
+ }
329
+ const deadline = Date.now() + durationMs;
330
+ do {
331
+ const sampledAt = new Date();
332
+ const [runtimeSample, pageSnapshot, domSnapshot] = await Promise.all([
333
+ sampleRuntime(options.fetcher, options.bridgeUrl, runtimeSelector, sampledAt),
334
+ samplePageSnapshot(options.browserRunner, sampledAt),
335
+ sampleDomSnapshot(options.browserRunner, sampledAt)
336
+ ]);
337
+ runtimeSamples.push(runtimeSample);
338
+ pageSnapshots.push(pageSnapshot);
339
+ domSnapshots.push(domSnapshot);
340
+ const remainingMs = deadline - Date.now();
341
+ if (remainingMs <= 0)
342
+ break;
343
+ await sleep(Math.min(intervalMs, remainingMs));
344
+ } while (Date.now() <= deadline);
345
+ const endedAt = new Date();
346
+ const manifest = createRecordingManifest({
347
+ args: options.args,
348
+ url,
349
+ openedUrl,
350
+ bridgeUrl: options.bridgeUrl,
351
+ sessionId,
352
+ startedAt,
353
+ intervalMs,
354
+ status: "completed",
355
+ files,
356
+ requestedDurationMs: durationMs,
357
+ endedAt,
358
+ counts: {
359
+ runtimeSamples: runtimeSamples.length,
360
+ pageSnapshots: pageSnapshots.length,
361
+ domSnapshots: domSnapshots.length,
362
+ interactions: interactions.length,
363
+ audioChunks: 0,
364
+ transcriptSegments: 0,
365
+ operations: operations.length
366
+ }
367
+ });
368
+ await writeRecordingFiles(outputDirectory, manifest, runtimeSamples, pageSnapshots, domSnapshots, interactions, operations);
369
+ writeJson(options.stdout, {
370
+ ok: true,
371
+ output: outputDirectory,
372
+ manifest: join(outputDirectory, files.manifest),
373
+ counts: manifest.counts,
374
+ media: manifest.capture
375
+ });
376
+ return 0;
377
+ }
378
+ async function ensureRecordBridge(options, bridgeUrl) {
379
+ await ensureBridge({
380
+ fetcher: options.fetcher,
381
+ bridgeUrl,
382
+ starter: options.bridgeStarter,
383
+ stateStore: options.bridgeStateStore,
384
+ ...createOptionalNumberProperty("port", getNumberOption(options.args, "port"))
385
+ });
386
+ }
387
+ async function tryEnsureRecordBridge(options, bridgeUrl) {
388
+ try {
389
+ await ensureRecordBridge(options, bridgeUrl);
390
+ return { ok: true };
391
+ }
392
+ catch (error) {
393
+ return {
394
+ ok: false,
395
+ error: error instanceof Error ? error.message : String(error)
396
+ };
397
+ }
398
+ }
399
+ async function openRecordingBrowser(options, openedUrl) {
400
+ const openStartedAt = new Date();
401
+ const openResult = await options.browserRunner.run(["open", openedUrl], {
402
+ ui: !hasOption(options.args, "headless")
403
+ });
404
+ const operation = {
405
+ type: "browser.open",
406
+ url: openedUrl,
407
+ startedAt: openStartedAt.toISOString(),
408
+ endedAt: new Date().toISOString(),
409
+ exitCode: openResult.exitCode,
410
+ stdout: openResult.stdout.trim(),
411
+ stderr: openResult.stderr.trim()
412
+ };
413
+ if (openResult.exitCode !== 0) {
414
+ throw new Error(openResult.stderr.trim() || openResult.stdout.trim() || "Could not open browser for recording.");
415
+ }
416
+ return operation;
417
+ }
418
+ function createSkippedBrowserOpenOperation(openedUrl) {
419
+ return {
420
+ type: "browser.open",
421
+ url: openedUrl,
422
+ startedAt: new Date().toISOString(),
423
+ skipped: true,
424
+ reason: "--no-open was set"
425
+ };
426
+ }
427
+ async function closeRecordingBrowser(browserRunner) {
428
+ const closeStartedAt = new Date();
429
+ const closeResult = await browserRunner.run(["close"]);
430
+ return {
431
+ type: "browser.close",
432
+ startedAt: closeStartedAt.toISOString(),
433
+ endedAt: new Date().toISOString(),
434
+ exitCode: closeResult.exitCode,
435
+ stdout: closeResult.stdout.trim(),
436
+ stderr: closeResult.stderr.trim()
437
+ };
438
+ }
439
+ async function writeRecordingControlFile(outputDirectory, files, startedAt, audioRequested) {
440
+ const operationStartedAt = new Date();
441
+ const profileDirectory = resolveBrowserProfileDirectory();
442
+ const controlFile = join(profileDirectory, OPENRUNTIME_RECORDING_CONTROL_FILE);
443
+ const eventsFile = join(outputDirectory, "interaction-events.raw.jsonl");
444
+ const audioFile = join(outputDirectory, files.audio);
445
+ const audioChunksFile = join(outputDirectory, files.audioChunks);
446
+ const audioEventsFile = join(outputDirectory, files.audioEvents);
447
+ const audioChunksDirectory = join(outputDirectory, "audio-chunks");
448
+ await mkdir(profileDirectory, { recursive: true });
449
+ await mkdir(audioChunksDirectory, { recursive: true });
450
+ await writeJsonFile(controlFile, {
451
+ marker: RECORD_EVENT_CONSOLE_MARKER,
452
+ eventsFile,
453
+ startedAt: startedAt.toISOString(),
454
+ audio: audioRequested
455
+ ? {
456
+ marker: RECORD_AUDIO_CONSOLE_MARKER,
457
+ audioFile,
458
+ chunksFile: audioChunksFile,
459
+ eventsFile: audioEventsFile,
460
+ chunksDirectory: audioChunksDirectory,
461
+ recorderUrl: "https://openruntime-recorder.localhost/recorder",
462
+ startedAt: startedAt.toISOString(),
463
+ chunkMs: 1000
464
+ }
465
+ : undefined
466
+ });
467
+ await writeFile(eventsFile, "", "utf8");
468
+ await ensureJsonLinesFile(audioChunksFile);
469
+ await ensureJsonLinesFile(audioEventsFile);
470
+ if (audioRequested) {
471
+ await writeFile(audioFile, "");
472
+ }
473
+ return {
474
+ type: "recording.control.write",
475
+ startedAt: operationStartedAt.toISOString(),
476
+ endedAt: new Date().toISOString(),
477
+ profileDirectory,
478
+ controlFile,
479
+ eventsFile,
480
+ audioRequested,
481
+ ...(audioRequested
482
+ ? {
483
+ audioFile,
484
+ audioChunksFile,
485
+ audioEventsFile
486
+ }
487
+ : {})
488
+ };
489
+ }
490
+ async function clearRecordingControlFile() {
491
+ await rm(join(resolveBrowserProfileDirectory(), OPENRUNTIME_RECORDING_CONTROL_FILE), {
492
+ force: true
493
+ });
494
+ }
495
+ async function resetRecordingBrowser(browserRunner) {
496
+ const started = new Date();
497
+ const result = await browserRunner.run(["close"]);
498
+ return {
499
+ type: "browser.reset",
500
+ startedAt: started.toISOString(),
501
+ endedAt: new Date().toISOString(),
502
+ exitCode: result.exitCode,
503
+ stdout: result.stdout.trim(),
504
+ stderr: result.stderr.trim()
505
+ };
506
+ }
507
+ async function setRecordingInstrumentation(browserRunner, outputDirectory, startedAt) {
508
+ const started = new Date();
509
+ const scriptPath = join(outputDirectory, "recording-instrumentation.js");
510
+ await writeFile(scriptPath, createInteractionRecorderScript(startedAt.getTime()), "utf8");
511
+ const result = await browserRunner.run(["instrumentation", "set", scriptPath]);
512
+ return {
513
+ type: "browser.instrumentation.set",
514
+ path: scriptPath,
515
+ startedAt: started.toISOString(),
516
+ endedAt: new Date().toISOString(),
517
+ exitCode: result.exitCode,
518
+ stdout: result.stdout.trim(),
519
+ stderr: result.stderr.trim()
520
+ };
521
+ }
522
+ async function collectInteractionEvents(outputDirectory, browserRunner) {
523
+ const started = new Date();
524
+ const result = await browserRunner.run(["browser-logs"]);
525
+ const persistedInteractions = await readJsonLinesIfExists(join(outputDirectory, "interaction-events.raw.jsonl"));
526
+ const browserLogInteractions = result.exitCode === 0 ? parseInteractionEventsFromBrowserLogs(result.stdout) : [];
527
+ const interactions = mergeInteractionEvents(persistedInteractions, browserLogInteractions);
528
+ return {
529
+ operation: {
530
+ type: "interactions.collect",
531
+ startedAt: started.toISOString(),
532
+ endedAt: new Date().toISOString(),
533
+ exitCode: result.exitCode,
534
+ count: interactions.length,
535
+ persistedCount: persistedInteractions.length,
536
+ browserLogCount: browserLogInteractions.length,
537
+ stdout: result.stdout.trim(),
538
+ stderr: result.stderr.trim()
539
+ },
540
+ interactions
541
+ };
542
+ }
543
+ async function collectAudioCapture(outputDirectory, files, requested) {
544
+ const started = new Date();
545
+ const audioFile = join(outputDirectory, files.audio);
546
+ const chunksFile = join(outputDirectory, files.audioChunks);
547
+ const eventsFile = join(outputDirectory, files.audioEvents);
548
+ const chunks = await readJsonLinesIfExists(chunksFile);
549
+ const events = await readJsonLinesIfExists(eventsFile);
550
+ const liveTranscript = createLiveTranscriptFromAudioEvents(files, events);
551
+ if (liveTranscript.segments.length > 0) {
552
+ await writeJsonFile(join(outputDirectory, files.transcript), liveTranscript);
553
+ }
554
+ const summary = {
555
+ requested,
556
+ chunkCount: chunks.length,
557
+ audioFile,
558
+ chunksFile,
559
+ eventsFile,
560
+ status: requested && chunks.length > 0 ? "captured" : requested ? "not-captured" : "not-requested",
561
+ ...createOptionalStringProperty("reason", createAudioCaptureReason(requested, chunks, events))
562
+ };
563
+ return {
564
+ operation: {
565
+ type: "audio.collect",
566
+ startedAt: started.toISOString(),
567
+ endedAt: new Date().toISOString(),
568
+ requested,
569
+ chunkCount: chunks.length,
570
+ audioFile,
571
+ chunksFile,
572
+ eventsFile,
573
+ status: summary.status,
574
+ transcriptSegmentCount: liveTranscript.segments.length,
575
+ ...createOptionalStringProperty("reason", summary.reason)
576
+ },
577
+ summary
578
+ };
579
+ }
580
+ function createLiveTranscriptFromAudioEvents(files, events) {
581
+ const speechEvents = events
582
+ .map((event) => normalizeLiveTranscriptEvent(event))
583
+ .filter((event) => event !== undefined);
584
+ if (speechEvents.length === 0) {
585
+ return {
586
+ status: "pending",
587
+ audio: files.audio,
588
+ segments: []
589
+ };
590
+ }
591
+ const segments = speechEvents.map((event) => {
592
+ const endMs = event.endMs ?? event.timeMs ?? 0;
593
+ const startMs = event.startMs ?? Math.max(0, endMs - estimateSpeechDurationMs(event.text ?? ""));
594
+ return {
595
+ startMs,
596
+ endMs,
597
+ text: event.text ?? ""
598
+ };
599
+ });
600
+ return {
601
+ status: "completed",
602
+ audio: files.audio,
603
+ model: "browser-speech-recognition",
604
+ transcribedAt: new Date().toISOString(),
605
+ text: segments.map((segment) => segment.text).join(" ").trim(),
606
+ segments
607
+ };
608
+ }
609
+ function normalizeLiveTranscriptEvent(event) {
610
+ if (event.type !== "speech-result")
611
+ return undefined;
612
+ const text = typeof event.text === "string" ? event.text.trim() : "";
613
+ if (text.length === 0)
614
+ return undefined;
615
+ return {
616
+ type: "speech-result",
617
+ ...createOptionalNumberProperty("timeMs", getNumberProperty(event, "timeMs")),
618
+ ...createOptionalNumberProperty("startMs", getNumberProperty(event, "startMs")),
619
+ ...createOptionalNumberProperty("endMs", getNumberProperty(event, "endMs")),
620
+ text,
621
+ ...createOptionalNumberProperty("confidence", getNumberProperty(event, "confidence"))
622
+ };
623
+ }
624
+ function estimateSpeechDurationMs(text) {
625
+ const normalized = text.trim();
626
+ if (normalized.length === 0)
627
+ return 0;
628
+ return Math.min(12_000, Math.max(1200, normalized.length * 180));
629
+ }
630
+ function createAudioCaptureReason(requested, chunks, events) {
631
+ if (!requested)
632
+ return "Microphone capture was not requested.";
633
+ if (chunks.length > 0)
634
+ return undefined;
635
+ const errorEvent = [...events].reverse().find((event) => event.type === "audio-error");
636
+ const message = typeof errorEvent?.message === "string" ? errorEvent.message : undefined;
637
+ return message === undefined ? "No microphone audio chunks were captured." : message;
638
+ }
639
+ function createRecordingManifest(input) {
640
+ return {
641
+ format: RECORDING_FORMAT,
642
+ version: RECORDING_VERSION,
643
+ status: input.status,
644
+ url: input.url,
645
+ openedUrl: input.openedUrl,
646
+ bridgeUrl: input.bridgeUrl,
647
+ ...createOptionalStringProperty("sessionId", input.sessionId),
648
+ startedAt: input.startedAt.toISOString(),
649
+ ...createOptionalEndedAtProperties(input.startedAt, input.endedAt),
650
+ ...createOptionalNumberProperty("requestedDurationMs", input.requestedDurationMs),
651
+ intervalMs: input.intervalMs,
652
+ capture: {
653
+ runtime: true,
654
+ browserSnapshots: true,
655
+ operations: true,
656
+ video: {
657
+ requested: !hasOption(input.args, "headless"),
658
+ status: "not-captured",
659
+ reason: "This prototype records browser snapshots and OpenRuntime state; continuous video capture is reserved for the next stage."
660
+ },
661
+ audio: createInitialAudioCapture(input.args, input.files, input.status)
662
+ },
663
+ counts: input.counts,
664
+ files: input.files
665
+ };
666
+ }
667
+ function createOptionalEndedAtProperties(startedAt, endedAt) {
668
+ if (endedAt === undefined)
669
+ return {};
670
+ return {
671
+ endedAt: endedAt.toISOString(),
672
+ durationMs: endedAt.getTime() - startedAt.getTime()
673
+ };
674
+ }
675
+ function createOptionalGeneratedProperty(generatedScript) {
676
+ if (generatedScript === undefined)
677
+ return {};
678
+ return {
679
+ generated: {
680
+ script: generatedScript.relativePath,
681
+ generatedAt: new Date().toISOString()
682
+ }
683
+ };
684
+ }
685
+ function createInitialAudioCapture(args, files, status) {
686
+ if (!hasOption(args, "mic")) {
687
+ return {
688
+ requested: false,
689
+ status: "not-requested",
690
+ reason: "Microphone capture was not requested."
691
+ };
692
+ }
693
+ if (status === "completed") {
694
+ return {
695
+ requested: true,
696
+ status: "not-captured",
697
+ file: files.audio,
698
+ chunks: files.audioChunks,
699
+ transcript: files.transcript,
700
+ reason: "Microphone capture is available for manual record start/stop sessions."
701
+ };
702
+ }
703
+ return {
704
+ requested: true,
705
+ status: "recording",
706
+ file: files.audio,
707
+ chunks: files.audioChunks,
708
+ transcript: files.transcript,
709
+ reason: "Microphone capture was requested; audio chunks are written while the browser recording is active."
710
+ };
711
+ }
712
+ function createCompletedAudioCapture(files, summary) {
713
+ if (!summary.requested) {
714
+ return {
715
+ requested: false,
716
+ status: "not-requested",
717
+ reason: "Microphone capture was not requested."
718
+ };
719
+ }
720
+ if (summary.chunkCount === 0) {
721
+ return {
722
+ requested: true,
723
+ status: "not-captured",
724
+ file: files.audio,
725
+ chunks: files.audioChunks,
726
+ transcript: files.transcript,
727
+ reason: summary.reason ?? "No microphone audio chunks were captured."
728
+ };
729
+ }
730
+ return {
731
+ requested: true,
732
+ status: "captured",
733
+ file: files.audio,
734
+ chunks: files.audioChunks,
735
+ transcript: files.transcript,
736
+ chunkCount: summary.chunkCount,
737
+ reason: "Microphone audio was captured. Run record transcribe to extract text with timestamps."
738
+ };
739
+ }
740
+ function createRecordingFiles() {
741
+ return {
742
+ manifest: "manifest.json",
743
+ runtime: "runtime.jsonl",
744
+ pageSnapshots: "page-snapshots.jsonl",
745
+ domSnapshots: "dom-snapshots.jsonl",
746
+ interactions: "interactions.jsonl",
747
+ audio: "audio.webm",
748
+ audioChunks: "audio-chunks.jsonl",
749
+ audioEvents: "audio-events.jsonl",
750
+ operations: "operations.jsonl",
751
+ transcript: "transcript.json"
752
+ };
753
+ }
754
+ async function writeRecordingFiles(outputDirectory, manifest, runtimeSamples, pageSnapshots, domSnapshots, interactions, operations) {
755
+ await writeJsonFile(join(outputDirectory, manifest.files.manifest), manifest);
756
+ await writeJsonLines(join(outputDirectory, manifest.files.runtime), runtimeSamples);
757
+ await writeJsonLines(join(outputDirectory, manifest.files.pageSnapshots), pageSnapshots);
758
+ await writeJsonLines(join(outputDirectory, manifest.files.domSnapshots), domSnapshots);
759
+ await writeJsonLines(join(outputDirectory, manifest.files.interactions), interactions);
760
+ await ensureJsonLinesFile(join(outputDirectory, manifest.files.audioChunks));
761
+ await ensureJsonLinesFile(join(outputDirectory, manifest.files.audioEvents));
762
+ await writeJsonLines(join(outputDirectory, manifest.files.operations), operations);
763
+ await ensureJsonFile(join(outputDirectory, manifest.files.transcript), {
764
+ status: manifest.capture.audio.requested ? "pending" : "not-requested",
765
+ ...(manifest.capture.audio.requested ? { audio: manifest.files.audio } : {}),
766
+ segments: []
767
+ });
768
+ }
769
+ async function readRecordingData(outputDirectory) {
770
+ const manifest = await readRecordingManifest(outputDirectory);
771
+ return {
772
+ manifest,
773
+ runtimeSamples: await readJsonLines(join(outputDirectory, manifest.files.runtime)),
774
+ pageSnapshots: await readJsonLines(join(outputDirectory, manifest.files.pageSnapshots)),
775
+ domSnapshots: await readJsonLinesIfExists(join(outputDirectory, manifest.files.domSnapshots)),
776
+ interactions: await readJsonLinesIfExists(join(outputDirectory, manifest.files.interactions)),
777
+ transcript: await readTranscriptData(outputDirectory, manifest.files),
778
+ operations: await readJsonLines(join(outputDirectory, manifest.files.operations))
779
+ };
780
+ }
781
+ async function readRecordingManifest(outputDirectory) {
782
+ const manifest = await readJsonFile(join(outputDirectory, createRecordingFiles().manifest));
783
+ if (manifest.format !== RECORDING_FORMAT) {
784
+ throw new Error(`Unsupported recording format in ${outputDirectory}.`);
785
+ }
786
+ if (manifest.version !== RECORDING_VERSION) {
787
+ throw new Error(`Unsupported recording version "${manifest.version}".`);
788
+ }
789
+ return {
790
+ ...manifest,
791
+ files: {
792
+ ...createRecordingFiles(),
793
+ ...(manifest.files ?? {})
794
+ }
795
+ };
796
+ }
797
+ async function readRecordingCounts(outputDirectory, files) {
798
+ const [runtimeSamples, pageSnapshots, domSnapshots, interactions, audioChunks, transcriptSegments, operations] = await Promise.all([
799
+ countJsonLines(join(outputDirectory, files.runtime)),
800
+ countJsonLines(join(outputDirectory, files.pageSnapshots)),
801
+ countJsonLinesIfExists(join(outputDirectory, files.domSnapshots)),
802
+ countJsonLinesIfExists(join(outputDirectory, files.interactions)),
803
+ countJsonLinesIfExists(join(outputDirectory, files.audioChunks)),
804
+ countTranscriptSegments(outputDirectory, files),
805
+ countJsonLines(join(outputDirectory, files.operations))
806
+ ]);
807
+ return {
808
+ runtimeSamples,
809
+ pageSnapshots,
810
+ domSnapshots,
811
+ interactions,
812
+ audioChunks,
813
+ transcriptSegments,
814
+ operations
815
+ };
816
+ }
817
+ async function writeGeneratedScript(recordingDirectory, recording, outputPath) {
818
+ const scriptPath = resolve(outputPath ?? join(recordingDirectory, "generated-script.mjs"));
819
+ const content = createGeneratedScriptContent(recording);
820
+ await mkdir(dirname(scriptPath), { recursive: true });
821
+ await writeFile(scriptPath, content, "utf8");
822
+ await chmod(scriptPath, 0o755);
823
+ return {
824
+ path: scriptPath,
825
+ relativePath: createManifestPath(recordingDirectory, scriptPath)
826
+ };
827
+ }
828
+ function createGeneratedScriptContent(recording) {
829
+ const manifest = recording.manifest;
830
+ const waitTarget = findWaitTarget(recording.runtimeSamples);
831
+ const actionNames = findActionNames(recording.runtimeSamples);
832
+ const pageTitle = findLatestPageTitle(recording.pageSnapshots);
833
+ const recordedUrl = findLatestRuntimeUrl(recording.runtimeSamples);
834
+ const scriptUrl = manifest.url === DEFAULT_RECORD_START_URL
835
+ ? recordedUrl ?? (manifest.openedUrl || manifest.url)
836
+ : manifest.openedUrl || manifest.url;
837
+ const waitTargetLiteral = waitTarget === undefined ? "undefined" : JSON.stringify(waitTarget, null, 2);
838
+ const actionsComment = actionNames.length === 0
839
+ ? "No runtime actions were discovered in the recording."
840
+ : `Discovered runtime actions: ${actionNames.join(", ")}`;
841
+ const pageComment = pageTitle === undefined ? "No page title was captured." : `Captured page title: ${pageTitle}`;
842
+ const interactionSteps = createInteractionScriptSteps(recording.interactions);
843
+ const interactionComment = interactionSteps.length === 0
844
+ ? "No browser interaction events were captured."
845
+ : `Captured browser interaction events: ${recording.interactions.length}`;
846
+ const transcriptComment = createTranscriptScriptComment(recording.transcript);
847
+ return `#!/usr/bin/env node
848
+ import { execFile } from "node:child_process";
849
+ import { promisify } from "node:util";
850
+
851
+ const execFileAsync = promisify(execFile);
852
+ const cli = process.env.OPENRUNTIME_CLI ?? "openruntime";
853
+ const bridgeUrl = ${JSON.stringify(manifest.bridgeUrl)};
854
+ const url = ${JSON.stringify(scriptUrl)};
855
+ const waitTarget = ${waitTargetLiteral};
856
+
857
+ // Generated from an OpenRuntime recording.
858
+ // ${pageComment}
859
+ // ${actionsComment}
860
+ // ${interactionComment}
861
+ // ${transcriptComment}
862
+
863
+ async function run(args) {
864
+ const { stdout, stderr } = await execFileAsync(cli, args, {
865
+ env: process.env
866
+ });
867
+ if (stdout.trim().length > 0) process.stdout.write(stdout);
868
+ if (stderr.trim().length > 0) process.stderr.write(stderr);
869
+ }
870
+
871
+ async function main() {
872
+ await run(["open", url, "--bridge", bridgeUrl, "--ui"]);
873
+
874
+ ${interactionSteps.length === 0 ? " // TODO: no click/input events were captured for this recording." : interactionSteps.map((step) => ` ${step}`).join("\n")}
875
+
876
+ if (waitTarget !== undefined) {
877
+ await run([
878
+ "wait-for",
879
+ "--bridge",
880
+ bridgeUrl,
881
+ "--url",
882
+ url,
883
+ waitTarget.targetId,
884
+ waitTarget.status,
885
+ "--timeout",
886
+ "10000"
887
+ ]);
888
+ }
889
+
890
+ await run(["snapshot", "--bridge", bridgeUrl, "--url", url]);
891
+ await run(["events", "--bridge", bridgeUrl, "--url", url, "--limit", "50"]);
892
+ }
893
+
894
+ main().catch((error) => {
895
+ console.error(error);
896
+ process.exitCode = 1;
897
+ });
898
+ `;
899
+ }
900
+ function createTranscriptScriptComment(transcript) {
901
+ if (transcript.segments.length === 0) {
902
+ return transcript.audio === undefined
903
+ ? "No voice transcript was captured."
904
+ : `Voice audio was saved to ${transcript.audio}, but no transcript text is available.`;
905
+ }
906
+ const summary = transcript.segments
907
+ .slice(0, 4)
908
+ .map((segment) => `[${segment.startMs}-${segment.endMs}ms] ${segment.text}`)
909
+ .join(" | ");
910
+ return `Voice transcript: ${summary}`;
911
+ }
912
+ function createInteractionScriptSteps(interactions) {
913
+ const steps = [];
914
+ for (const interaction of compactInteractionEvents(interactions)) {
915
+ const selector = interaction.target?.selector;
916
+ if (selector === undefined || selector.length === 0)
917
+ continue;
918
+ if ((interaction.type === "input" || interaction.type === "change") && typeof interaction.target?.value === "string") {
919
+ if (interaction.target.value === "[redacted]") {
920
+ steps.push(`// ${formatInteractionTime(interaction)} skipped redacted input for ${JSON.stringify(selector)}.`);
921
+ continue;
922
+ }
923
+ steps.push(`await run(["fill", ${JSON.stringify(selector)}, ${JSON.stringify(interaction.target.value)}]); // ${formatInteractionTime(interaction)}`);
924
+ continue;
925
+ }
926
+ if (interaction.type === "click") {
927
+ steps.push(`await run(["click", ${JSON.stringify(selector)}]); // ${formatInteractionTime(interaction)}`);
928
+ continue;
929
+ }
930
+ if (interaction.type === "keydown" && interaction.key === "Enter") {
931
+ steps.push(`await run(["eval", ${JSON.stringify(createKeyboardEventScript(selector, "Enter"))}]); // ${formatInteractionTime(interaction)}`);
932
+ }
933
+ }
934
+ return steps;
935
+ }
936
+ function normalizeTranscriptSegments(value, fallbackText) {
937
+ if (!Array.isArray(value)) {
938
+ return typeof fallbackText === "string" && fallbackText.length > 0
939
+ ? [{ startMs: 0, endMs: 0, text: fallbackText }]
940
+ : [];
941
+ }
942
+ return value
943
+ .map((item) => {
944
+ const record = asRecord(item);
945
+ if (record === undefined)
946
+ return undefined;
947
+ const text = getStringProperty(record, "text");
948
+ if (text === undefined)
949
+ return undefined;
950
+ const start = getNumberProperty(record, "start");
951
+ const end = getNumberProperty(record, "end");
952
+ return {
953
+ startMs: secondsToMs(start),
954
+ endMs: secondsToMs(end),
955
+ text
956
+ };
957
+ })
958
+ .filter((item) => item !== undefined);
959
+ }
960
+ function normalizeTranscriptWords(value) {
961
+ if (!Array.isArray(value))
962
+ return [];
963
+ return value
964
+ .map((item) => {
965
+ const record = asRecord(item);
966
+ if (record === undefined)
967
+ return undefined;
968
+ const text = getStringProperty(record, "word") ?? getStringProperty(record, "text");
969
+ if (text === undefined)
970
+ return undefined;
971
+ return {
972
+ startMs: secondsToMs(getNumberProperty(record, "start")),
973
+ endMs: secondsToMs(getNumberProperty(record, "end")),
974
+ text
975
+ };
976
+ })
977
+ .filter((item) => item !== undefined);
978
+ }
979
+ function secondsToMs(value) {
980
+ return value === undefined ? 0 : Math.max(0, Math.round(value * 1000));
981
+ }
982
+ function compactInteractionEvents(interactions) {
983
+ const output = [];
984
+ for (const interaction of interactions) {
985
+ if (interaction.type === "recorder-ready" || interaction.type === "recorder-error")
986
+ continue;
987
+ const previous = output.at(-1);
988
+ if (previous !== undefined &&
989
+ (interaction.type === "input" || interaction.type === "change") &&
990
+ (previous.type === "input" || previous.type === "change") &&
991
+ previous.target?.selector === interaction.target?.selector &&
992
+ interaction.timeMs - previous.timeMs < 1200) {
993
+ output[output.length - 1] = interaction;
994
+ continue;
995
+ }
996
+ output.push(interaction);
997
+ }
998
+ return output;
999
+ }
1000
+ function createKeyboardEventScript(selector, key) {
1001
+ return [
1002
+ "(() => {",
1003
+ ` const element = document.querySelector(${JSON.stringify(selector)});`,
1004
+ " if (!element) throw new Error('Recorded target was not found.');",
1005
+ " element.focus?.();",
1006
+ ` element.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, code: ${JSON.stringify(key)}, bubbles: true, cancelable: true }));`,
1007
+ "})()"
1008
+ ].join("\n");
1009
+ }
1010
+ function formatInteractionTime(interaction) {
1011
+ return `t=${Math.round(interaction.timeMs)}ms ${interaction.type}`;
1012
+ }
1013
+ function findWaitTarget(runtimeSamples) {
1014
+ for (const sample of [...runtimeSamples].reverse()) {
1015
+ const snapshot = asRecord(sample.resources?.snapshot);
1016
+ const targets = asRecord(snapshot?.targets);
1017
+ if (targets === undefined)
1018
+ continue;
1019
+ const candidates = [];
1020
+ for (const [targetId, targetValue] of Object.entries(targets)) {
1021
+ const target = asRecord(targetValue);
1022
+ const status = getStringProperty(target, "status");
1023
+ if (status === undefined)
1024
+ continue;
1025
+ candidates.push({ targetId, status });
1026
+ }
1027
+ const readyTarget = candidates.find((candidate) => candidate.status === "ready");
1028
+ if (readyTarget !== undefined)
1029
+ return readyTarget;
1030
+ if (candidates[0] !== undefined)
1031
+ return candidates[0];
1032
+ }
1033
+ return undefined;
1034
+ }
1035
+ function findActionNames(runtimeSamples) {
1036
+ const names = new Set();
1037
+ for (const sample of runtimeSamples) {
1038
+ collectActionNames(sample.resources?.actions, names);
1039
+ }
1040
+ return [...names].slice(0, 8);
1041
+ }
1042
+ function collectActionNames(value, names) {
1043
+ if (Array.isArray(value)) {
1044
+ for (const item of value)
1045
+ collectActionNames(item, names);
1046
+ return;
1047
+ }
1048
+ const record = asRecord(value);
1049
+ if (record === undefined)
1050
+ return;
1051
+ const name = getStringProperty(record, "name");
1052
+ if (name !== undefined)
1053
+ names.add(name);
1054
+ collectActionNames(record.actions, names);
1055
+ collectActionNames(record.items, names);
1056
+ }
1057
+ function findLatestPageTitle(pageSnapshots) {
1058
+ for (const sample of [...pageSnapshots].reverse()) {
1059
+ const result = asRecord(sample.result);
1060
+ const title = getStringProperty(result, "title");
1061
+ if (title !== undefined)
1062
+ return title;
1063
+ }
1064
+ return undefined;
1065
+ }
1066
+ function findLatestRuntimeUrl(runtimeSamples) {
1067
+ for (const sample of [...runtimeSamples].reverse()) {
1068
+ const url = sample.runtime?.url;
1069
+ if (url !== undefined && url.length > 0)
1070
+ return url;
1071
+ }
1072
+ return undefined;
1073
+ }
1074
+ function createManifestPath(recordingDirectory, scriptPath) {
1075
+ const relativePath = relative(recordingDirectory, scriptPath);
1076
+ if (relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)) {
1077
+ return relativePath;
1078
+ }
1079
+ return scriptPath;
1080
+ }
1081
+ function resolveRecordingBridgeUrl(args, defaultBridgeUrl, manifest) {
1082
+ return getOptionValue(args, "bridge") === undefined ? manifest.bridgeUrl : defaultBridgeUrl;
1083
+ }
1084
+ function getRecordUrl(args, subcommand) {
1085
+ const optionUrl = getOptionValue(args, "url");
1086
+ const commandUrl = args.command[subcommand === "start" ? 2 : 1];
1087
+ return optionUrl ?? commandUrl;
1088
+ }
1089
+ function requireRecordUrl(args) {
1090
+ const url = getRecordUrl(args);
1091
+ if (url === undefined || url.length === 0) {
1092
+ throw new Error("Missing required URL. Use open-runtime record --url <url> --out <path>.");
1093
+ }
1094
+ return url;
1095
+ }
1096
+ function resolveRecordStartOutputDirectory(args, startedAt) {
1097
+ const output = getOptionValue(args, "out");
1098
+ if (output !== undefined && output.length > 0)
1099
+ return resolve(output);
1100
+ return resolve("recordings", `openruntime-${formatTimestampForPath(startedAt)}.orrec`);
1101
+ }
1102
+ function formatTimestampForPath(date) {
1103
+ return date.toISOString().replace(/[:.]/gu, "-");
1104
+ }
1105
+ function requireOption(args, name) {
1106
+ const value = getOptionValue(args, name);
1107
+ if (value === undefined || value.length === 0) {
1108
+ throw new Error(`Missing required option "--${name}".`);
1109
+ }
1110
+ return value;
1111
+ }
1112
+ function getPositiveNumberOption(args, name) {
1113
+ const rawValue = getOptionValue(args, name);
1114
+ if (rawValue === undefined)
1115
+ return undefined;
1116
+ const value = Number(rawValue);
1117
+ if (!Number.isFinite(value) || value <= 0) {
1118
+ throw new Error(`--${name} must be a positive number.`);
1119
+ }
1120
+ return value;
1121
+ }
1122
+ function createRecordRuntimeSelector(url, sessionId, runtimeId) {
1123
+ return {
1124
+ ...createOptionalStringProperty("runtimeId", runtimeId),
1125
+ ...createOptionalStringProperty("sessionId", sessionId),
1126
+ ...createOptionalStringProperty("url", url === undefined || url === DEFAULT_RECORD_START_URL
1127
+ ? undefined
1128
+ : withOpenRuntimeSession(url, sessionId))
1129
+ };
1130
+ }
1131
+ async function sampleRuntime(fetcher, bridgeUrl, selector, sampledAt) {
1132
+ try {
1133
+ const runtimes = await fetchRuntimes(fetcher, bridgeUrl);
1134
+ const runtime = selectRuntime(runtimes, selector);
1135
+ const [targets, snapshot, actions, events] = await Promise.all([
1136
+ fetchRuntimeResource(fetcher, bridgeUrl, runtime, "targets", new URLSearchParams()),
1137
+ fetchRuntimeResource(fetcher, bridgeUrl, runtime, "snapshot", new URLSearchParams()),
1138
+ fetchRuntimeResource(fetcher, bridgeUrl, runtime, "actions", new URLSearchParams()),
1139
+ fetchRuntimeResource(fetcher, bridgeUrl, runtime, "events", createEventsQuery())
1140
+ ]);
1141
+ return {
1142
+ sampledAt: sampledAt.toISOString(),
1143
+ ok: true,
1144
+ runtimes,
1145
+ runtime,
1146
+ resources: {
1147
+ targets: targets.result,
1148
+ snapshot: snapshot.result,
1149
+ actions: actions.result,
1150
+ events: events.result
1151
+ }
1152
+ };
1153
+ }
1154
+ catch (error) {
1155
+ return {
1156
+ sampledAt: sampledAt.toISOString(),
1157
+ ok: false,
1158
+ error: error instanceof Error ? error.message : String(error)
1159
+ };
1160
+ }
1161
+ }
1162
+ async function samplePageSnapshot(browserRunner, sampledAt) {
1163
+ const result = await browserRunner.run(["snapshot"]);
1164
+ if (result.exitCode !== 0) {
1165
+ return {
1166
+ sampledAt: sampledAt.toISOString(),
1167
+ ok: false,
1168
+ exitCode: result.exitCode,
1169
+ stdout: result.stdout.trim(),
1170
+ stderr: result.stderr.trim()
1171
+ };
1172
+ }
1173
+ try {
1174
+ return {
1175
+ sampledAt: sampledAt.toISOString(),
1176
+ ok: true,
1177
+ exitCode: result.exitCode,
1178
+ result: parseBrowserJsonOutput(result.stdout)
1179
+ };
1180
+ }
1181
+ catch {
1182
+ return {
1183
+ sampledAt: sampledAt.toISOString(),
1184
+ ok: true,
1185
+ exitCode: result.exitCode,
1186
+ stdout: result.stdout.trim(),
1187
+ stderr: result.stderr.trim()
1188
+ };
1189
+ }
1190
+ }
1191
+ async function sampleDomSnapshot(browserRunner, sampledAt) {
1192
+ const result = await browserRunner.run(["eval", createDomSnapshotScript()]);
1193
+ if (result.exitCode !== 0) {
1194
+ return {
1195
+ sampledAt: sampledAt.toISOString(),
1196
+ ok: false,
1197
+ exitCode: result.exitCode,
1198
+ stdout: result.stdout.trim(),
1199
+ stderr: result.stderr.trim()
1200
+ };
1201
+ }
1202
+ try {
1203
+ return {
1204
+ sampledAt: sampledAt.toISOString(),
1205
+ ok: true,
1206
+ exitCode: result.exitCode,
1207
+ result: parseBrowserJsonOutput(result.stdout)
1208
+ };
1209
+ }
1210
+ catch {
1211
+ return {
1212
+ sampledAt: sampledAt.toISOString(),
1213
+ ok: true,
1214
+ exitCode: result.exitCode,
1215
+ stdout: result.stdout.trim(),
1216
+ stderr: result.stderr.trim()
1217
+ };
1218
+ }
1219
+ }
1220
+ function createDomSnapshotScript() {
1221
+ return [
1222
+ "(() => {",
1223
+ " const html = document.documentElement?.outerHTML ?? '';",
1224
+ " return {",
1225
+ " url: location.href,",
1226
+ " title: document.title,",
1227
+ " capturedAt: Date.now(),",
1228
+ " htmlLength: html.length,",
1229
+ " html: html.slice(0, 200000)",
1230
+ " };",
1231
+ "})()"
1232
+ ].join("\n");
1233
+ }
1234
+ function createInteractionRecorderScript(recordingStartedAtMs) {
1235
+ return [
1236
+ "(() => {",
1237
+ " const marker = " + JSON.stringify(RECORD_EVENT_CONSOLE_MARKER) + ";",
1238
+ " const startedAt = " + JSON.stringify(recordingStartedAtMs) + ";",
1239
+ " if (window.__OPENRUNTIME_INTERACTION_RECORDER_INSTALLED__) return;",
1240
+ " window.__OPENRUNTIME_INTERACTION_RECORDER_INSTALLED__ = true;",
1241
+ " const textOf = (value, max = 160) => String(value ?? '').replace(/\\s+/g, ' ').trim().slice(0, max);",
1242
+ " const cssEscape = (value) => {",
1243
+ " if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);",
1244
+ " return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');",
1245
+ " };",
1246
+ " const selectorFor = (element) => {",
1247
+ " if (!(element instanceof Element)) return undefined;",
1248
+ " const test = (selector) => {",
1249
+ " try { return document.querySelectorAll(selector).length === 1; } catch { return false; }",
1250
+ " };",
1251
+ " const id = element.getAttribute('id');",
1252
+ " if (id) {",
1253
+ " const selector = `#${cssEscape(id)}`;",
1254
+ " if (test(selector)) return selector;",
1255
+ " }",
1256
+ " const attrs = ['data-testid', 'data-test-id', 'aria-label', 'name', 'placeholder', 'title'];",
1257
+ " for (const attr of attrs) {",
1258
+ " const value = element.getAttribute(attr);",
1259
+ " if (!value) continue;",
1260
+ " const selector = `${element.tagName.toLowerCase()}[${attr}=${JSON.stringify(value)}]`;",
1261
+ " if (test(selector)) return selector;",
1262
+ " }",
1263
+ " const parts = [];",
1264
+ " let current = element;",
1265
+ " while (current && current.nodeType === 1 && current !== document.documentElement) {",
1266
+ " let part = current.tagName.toLowerCase();",
1267
+ " const parent = current.parentElement;",
1268
+ " if (!parent) break;",
1269
+ " const siblings = Array.from(parent.children).filter((item) => item.tagName === current.tagName);",
1270
+ " if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(current) + 1})`;",
1271
+ " parts.unshift(part);",
1272
+ " const selector = parts.join(' > ');",
1273
+ " if (test(selector)) return selector;",
1274
+ " current = parent;",
1275
+ " }",
1276
+ " return parts.join(' > ') || undefined;",
1277
+ " };",
1278
+ " const targetOf = (event) => {",
1279
+ " const element = event.target instanceof Element ? event.target : undefined;",
1280
+ " if (!element) return undefined;",
1281
+ " const input = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element : undefined;",
1282
+ " const value = input ? (input.type === 'password' ? '[redacted]' : input.value) : undefined;",
1283
+ " return {",
1284
+ " selector: selectorFor(element),",
1285
+ " tagName: element.tagName.toLowerCase(),",
1286
+ " role: element.getAttribute('role') ?? undefined,",
1287
+ " name: element.getAttribute('name') ?? undefined,",
1288
+ " inputType: input instanceof HTMLInputElement ? input.type : undefined,",
1289
+ " text: textOf(element.textContent),",
1290
+ " value",
1291
+ " };",
1292
+ " };",
1293
+ " const emit = (type, event, extra = {}) => {",
1294
+ " try {",
1295
+ " const entry = {",
1296
+ " type,",
1297
+ " timeMs: Math.max(0, Date.now() - startedAt),",
1298
+ " url: location.href,",
1299
+ " title: document.title,",
1300
+ " target: targetOf(event),",
1301
+ " ...extra",
1302
+ " };",
1303
+ " console.info(marker + JSON.stringify(entry));",
1304
+ " } catch (error) {",
1305
+ " console.info(marker + JSON.stringify({ type: 'recorder-error', timeMs: Math.max(0, Date.now() - startedAt), message: String(error) }));",
1306
+ " }",
1307
+ " };",
1308
+ " document.addEventListener('click', (event) => emit('click', event, { pointer: { x: event.clientX, y: event.clientY, button: event.button } }), true);",
1309
+ " document.addEventListener('input', (event) => emit('input', event), true);",
1310
+ " document.addEventListener('change', (event) => emit('change', event), true);",
1311
+ " document.addEventListener('keydown', (event) => emit('keydown', event, { key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }), true);",
1312
+ " document.addEventListener('submit', (event) => emit('submit', event), true);",
1313
+ " console.info(marker + JSON.stringify({ type: 'recorder-ready', timeMs: Math.max(0, Date.now() - startedAt), url: location.href, title: document.title }));",
1314
+ "})()"
1315
+ ].join("\n");
1316
+ }
1317
+ function parseInteractionEventsFromBrowserLogs(stdout) {
1318
+ const events = [];
1319
+ const seen = new Set();
1320
+ for (const line of stdout.split(/\r?\n/u)) {
1321
+ const markerIndex = line.indexOf(RECORD_EVENT_CONSOLE_MARKER);
1322
+ if (markerIndex < 0)
1323
+ continue;
1324
+ const payload = line.slice(markerIndex + RECORD_EVENT_CONSOLE_MARKER.length).trim();
1325
+ if (payload.length === 0 || seen.has(payload))
1326
+ continue;
1327
+ try {
1328
+ const parsed = JSON.parse(payload);
1329
+ if (typeof parsed.type === "string" && typeof parsed.timeMs === "number") {
1330
+ seen.add(payload);
1331
+ events.push(parsed);
1332
+ }
1333
+ }
1334
+ catch {
1335
+ // Ignore unrelated console lines that happen to contain the marker.
1336
+ }
1337
+ }
1338
+ return events.sort((left, right) => left.timeMs - right.timeMs);
1339
+ }
1340
+ function mergeInteractionEvents(...sources) {
1341
+ const merged = [];
1342
+ const seen = new Set();
1343
+ for (const source of sources) {
1344
+ for (const event of source) {
1345
+ const key = JSON.stringify(event);
1346
+ if (seen.has(key))
1347
+ continue;
1348
+ seen.add(key);
1349
+ merged.push(event);
1350
+ }
1351
+ }
1352
+ return merged.sort((left, right) => left.timeMs - right.timeMs);
1353
+ }
1354
+ function createEventsQuery() {
1355
+ const params = new URLSearchParams();
1356
+ params.set("limit", "50");
1357
+ return params;
1358
+ }
1359
+ function hasOption(args, name) {
1360
+ return args.options.has(name);
1361
+ }
1362
+ function withOpenRuntimeSession(input, sessionId) {
1363
+ if (sessionId === undefined || sessionId.length === 0)
1364
+ return input;
1365
+ try {
1366
+ const url = new URL(input);
1367
+ url.searchParams.set(OPEN_RUNTIME_SESSION_QUERY_PARAM, sessionId);
1368
+ return url.toString();
1369
+ }
1370
+ catch {
1371
+ return input;
1372
+ }
1373
+ }
1374
+ async function writeJsonFile(path, value) {
1375
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1376
+ }
1377
+ async function readJsonFile(path) {
1378
+ return JSON.parse(await readFile(path, "utf8"));
1379
+ }
1380
+ async function writeJsonLines(path, values) {
1381
+ await writeFile(path, values.map((value) => JSON.stringify(value)).join("\n") + (values.length === 0 ? "" : "\n"), "utf8");
1382
+ }
1383
+ async function ensureJsonLinesFile(path) {
1384
+ try {
1385
+ await writeFile(path, "", {
1386
+ encoding: "utf8",
1387
+ flag: "wx"
1388
+ });
1389
+ }
1390
+ catch (error) {
1391
+ if (isNodeError(error) && error.code === "EEXIST")
1392
+ return;
1393
+ throw error;
1394
+ }
1395
+ }
1396
+ async function ensureJsonFile(path, value) {
1397
+ try {
1398
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, {
1399
+ encoding: "utf8",
1400
+ flag: "wx"
1401
+ });
1402
+ }
1403
+ catch (error) {
1404
+ if (isNodeError(error) && error.code === "EEXIST")
1405
+ return;
1406
+ throw error;
1407
+ }
1408
+ }
1409
+ async function appendJsonLine(path, value) {
1410
+ await appendFile(path, `${JSON.stringify(value)}\n`, "utf8");
1411
+ }
1412
+ async function readJsonLines(path) {
1413
+ const text = await readFile(path, "utf8");
1414
+ if (text.trim().length === 0)
1415
+ return [];
1416
+ return text
1417
+ .split(/\r?\n/u)
1418
+ .filter((line) => line.trim().length > 0)
1419
+ .map((line) => JSON.parse(line));
1420
+ }
1421
+ async function readJsonLinesIfExists(path) {
1422
+ try {
1423
+ return await readJsonLines(path);
1424
+ }
1425
+ catch (error) {
1426
+ if (isNodeError(error) && error.code === "ENOENT")
1427
+ return [];
1428
+ throw error;
1429
+ }
1430
+ }
1431
+ async function countJsonLines(path) {
1432
+ return (await readJsonLines(path)).length;
1433
+ }
1434
+ async function countJsonLinesIfExists(path) {
1435
+ return (await readJsonLinesIfExists(path)).length;
1436
+ }
1437
+ async function readTranscriptData(outputDirectory, files) {
1438
+ try {
1439
+ const data = await readJsonFile(join(outputDirectory, files.transcript));
1440
+ return {
1441
+ status: data.status,
1442
+ ...createOptionalStringProperty("audio", data.audio),
1443
+ ...createOptionalStringProperty("model", data.model),
1444
+ ...createOptionalStringProperty("transcribedAt", data.transcribedAt),
1445
+ ...createOptionalStringProperty("text", data.text),
1446
+ segments: Array.isArray(data.segments) ? data.segments : [],
1447
+ ...(Array.isArray(data.words) ? { words: data.words } : {}),
1448
+ ...createOptionalStringProperty("error", data.error)
1449
+ };
1450
+ }
1451
+ catch (error) {
1452
+ if (isNodeError(error) && error.code === "ENOENT") {
1453
+ return {
1454
+ status: "not-requested",
1455
+ segments: []
1456
+ };
1457
+ }
1458
+ throw error;
1459
+ }
1460
+ }
1461
+ async function countTranscriptSegments(outputDirectory, files) {
1462
+ return (await readTranscriptData(outputDirectory, files)).segments.length;
1463
+ }
1464
+ function writeJson(stdout, value) {
1465
+ stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1466
+ }
1467
+ function createOptionalNumberProperty(name, value) {
1468
+ return value === undefined ? {} : { [name]: value };
1469
+ }
1470
+ function createOptionalStringProperty(name, value) {
1471
+ return value === undefined ? {} : { [name]: value };
1472
+ }
1473
+ function asRecord(value) {
1474
+ return value !== null && typeof value === "object" && !Array.isArray(value)
1475
+ ? value
1476
+ : undefined;
1477
+ }
1478
+ function getStringProperty(record, name) {
1479
+ const value = record?.[name];
1480
+ return typeof value === "string" ? value : undefined;
1481
+ }
1482
+ function getNumberProperty(record, name) {
1483
+ const value = record?.[name];
1484
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1485
+ }
1486
+ function isNodeError(error) {
1487
+ return error instanceof Error && "code" in error;
1488
+ }
1489
+ function sleep(milliseconds) {
1490
+ return new Promise((resolve) => {
1491
+ setTimeout(resolve, milliseconds);
1492
+ });
1493
+ }
1494
+ //# sourceMappingURL=record.js.map