levante 0.1.4 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "levante",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "levante": "./dist/cli.js",
@@ -18,25 +18,25 @@
18
18
  },
19
19
  "files": ["dist/", "agents/", "scripts/", "templates/"],
20
20
  "scripts": {
21
- "build": "bun build src/cli.ts src/index.ts src/config/schema.ts src/mcp.ts --outdir dist --target node --format esm --splitting",
21
+ "build": "bun build src/cli.ts src/index.ts src/config/schema.ts src/mcp.ts --outdir dist --target node --format esm --packages=bundle",
22
22
  "dev": "bun run src/cli.ts"
23
23
  },
24
- "dependencies": {
24
+ "peerDependencies": {
25
+ "@playwright/test": ">=1.40.0"
26
+ },
27
+ "devDependencies": {
25
28
  "@inquirer/prompts": "^8.3.0",
26
29
  "@modelcontextprotocol/sdk": "^1.27.1",
30
+ "@qai/cli-auth": "workspace:*",
31
+ "@types/node": "^24.0.14",
27
32
  "commander": "^13.1.0",
28
33
  "dotenv": "^17.2.0",
29
34
  "glob": "^13.0.6",
30
35
  "ora": "^9.3.0",
31
36
  "picocolors": "^1.1.1",
37
+ "typescript": "^5.8.3",
38
+ "ws": "^8.19.0",
32
39
  "yaml": "^2.7.1",
33
40
  "zod": "^4.0.5"
34
- },
35
- "peerDependencies": {
36
- "@playwright/test": ">=1.40.0"
37
- },
38
- "devDependencies": {
39
- "@types/node": "^24.0.14",
40
- "typescript": "^5.8.3"
41
41
  }
42
42
  }
@@ -8,10 +8,9 @@
8
8
  *
9
9
  * Usage:
10
10
  * node scripts/auth/setup-auth.mjs # single resource (default)
11
- * node scripts/auth/setup-auth.mjs --multi # multi resource
12
11
  * node scripts/auth/setup-auth.mjs --output .auth/custom.json
13
12
  *
14
- * Credentials come from .env (USERNAME/PASSWORD or MULTI_RESOURCE_USERNAME/PASSWORD).
13
+ * Credentials come from .env (USERNAME/PASSWORD).
15
14
  * Storage state is saved to .auth/codegen.json by default.
16
15
  */
17
16
 
@@ -43,21 +42,15 @@ function loadEnv() {
43
42
  loadEnv();
44
43
 
45
44
  const args = process.argv.slice(2);
46
- const isMulti = args.includes('--multi');
47
45
  const outputIdx = args.indexOf('--output');
48
46
  const customOutput = outputIdx !== -1 ? args[outputIdx + 1] : null;
49
47
 
50
- const username = isMulti
51
- ? process.env.MULTI_RESOURCE_USERNAME
52
- : process.env.SINGLE_RESOURCE_USERNAME;
53
- const password = isMulti
54
- ? process.env.MULTI_RESOURCE_PASSWORD
55
- : process.env.SINGLE_RESOURCE_PASSWORD;
48
+ const username = process.env.USERNAME;
49
+ const password = process.env.PASSWORD;
56
50
  const baseUrl = process.env.BASE_URL;
57
51
 
58
52
  if (!username || !password) {
59
- const prefix = isMulti ? 'MULTI_RESOURCE' : 'SINGLE_RESOURCE';
60
- console.error(`Missing ${prefix}_USERNAME or ${prefix}_PASSWORD in .env`);
53
+ console.error(`Missing USERNAME or PASSWORD in .env`);
61
54
  process.exit(1);
62
55
  }
63
56
  if (!baseUrl) {
@@ -15,13 +15,33 @@
15
15
  * E2E_AI_KEY=PROJ-101 node codegen-env.mjs
16
16
  */
17
17
 
18
- import { spawn, execSync } from 'node:child_process';
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
20
- import { dirname, resolve, relative, isAbsolute } from 'node:path';
18
+ import { execSync, spawn } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
20
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import pc from 'picocolors';
23
+ import { printError, printHeader, printSuccess, saveErrorLog } from './ui.mjs';
22
24
 
23
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
26
 
27
+ const warnings = [];
28
+
29
+ function isoNow() { return new Date().toISOString(); }
30
+
31
+ // getFixHint is declared here (hoisted function declaration) but only called
32
+ // inside the exit handler, after issueKey is assigned. Safe.
33
+ function getFixHint(msg, key) {
34
+ if (/USERNAME|PASSWORD/i.test(msg))
35
+ return 'Set USERNAME and PASSWORD in .env';
36
+ if (/companion server failed/i.test(msg))
37
+ return 'Re-run with --no-companion to disable companion UI';
38
+ if (/trace replay failed/i.test(msg))
39
+ return `Codegen is saved — run: levante transcribe --key ${key}`;
40
+ if (/voice processing error/i.test(msg))
41
+ return 'Codegen is saved — audio may be incomplete';
42
+ return null;
43
+ }
44
+
25
45
  // Project root: prefer env var, then fall back to parent of scripts dir
26
46
  const root = process.env.E2E_AI_PROJECT_ROOT || resolve(__dirname, '..');
27
47
 
@@ -87,6 +107,7 @@ const url = baseUrl ? baseUrl.replace(/\/$/, '') : 'about:blank';
87
107
  const rawArgs = process.argv.slice(2);
88
108
  const voiceEnabled = !rawArgs.includes('--no-voice');
89
109
  const traceEnabled = !rawArgs.includes('--no-trace');
110
+ const companionEnabled = voiceEnabled && !rawArgs.includes('--no-companion');
90
111
  const positionalArgs = rawArgs.filter((a) => !a.startsWith('--no-'));
91
112
 
92
113
  const keyInput = positionalArgs[0];
@@ -106,7 +127,6 @@ const workingDirAbs = isAbsolute(workingDirRel) ? workingDirRel : resolve(root,
106
127
  const issueDir = resolve(workingDirAbs, issueKey);
107
128
  if (!existsSync(issueDir)) {
108
129
  mkdirSync(issueDir, { recursive: true });
109
- console.error(`Created: ${relative(root, issueDir)}/`);
110
130
  }
111
131
 
112
132
  const now = new Date();
@@ -123,11 +143,6 @@ const defaultOut = resolve(issueDir, `codegen-${timestamp}.ts`);
123
143
  const outputPath = customOut ? resolve(root, customOut) : defaultOut;
124
144
  const outputRelative = relative(root, outputPath);
125
145
 
126
- console.error(`Issue key: ${issueKey}`);
127
- console.error(`Recording will be saved to: ${outputRelative}`);
128
- console.error(`Voice recording: ${voiceEnabled ? 'ENABLED' : 'disabled (--no-voice)'}`);
129
- console.error(`Trace replay: ${traceEnabled ? 'ENABLED' : 'disabled (--no-trace)'}`);
130
- console.error('(When you close the Playwright Inspector, the file is written there.)\n');
131
146
 
132
147
  // --- Session timing (always track, used for action timestamps) ---
133
148
  const sessionStartTime = Date.now();
@@ -200,7 +215,146 @@ if (voiceEnabled) {
200
215
  recording = true;
201
216
  segmentIndex++;
202
217
 
203
- console.error(`Audio segments saved to: ${relative(root, recordingsDir)}/`);
218
+ // printRecordingStatus(true) is called after the header (see below)
219
+ }
220
+
221
+ // --- Companion server setup ---
222
+ let companionProcess = null;
223
+ let companionPort = null;
224
+ let companionWs = null;
225
+ let companionStdoutHandler = null;
226
+ let companionAlreadySaved = null;
227
+
228
+ if (companionEnabled) {
229
+ try {
230
+ const companionScript = resolve(__dirname, 'companion', 'server.mjs');
231
+ const companionRecDir = recordingsDir || resolve(issueDir, 'recordings');
232
+ if (!existsSync(companionRecDir)) mkdirSync(companionRecDir, { recursive: true });
233
+
234
+ companionProcess = spawn('node', [companionScript], {
235
+ stdio: ['pipe', 'pipe', 'inherit'],
236
+ cwd: root,
237
+ env: {
238
+ ...process.env,
239
+ COMPANION_OUTPUT_DIR: companionRecDir,
240
+ COMPANION_TIMESTAMP: timestamp,
241
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
242
+ },
243
+ });
244
+
245
+ // Single stdout handler — dispatches all JSON-line messages
246
+ const onCompanionStdout = [];
247
+ let stdoutBuffer = '';
248
+ companionProcess.stdout.on('data', (chunk) => {
249
+ stdoutBuffer += chunk.toString();
250
+ let newlineIdx;
251
+ while ((newlineIdx = stdoutBuffer.indexOf('\n')) !== -1) {
252
+ const line = stdoutBuffer.slice(0, newlineIdx).trim();
253
+ stdoutBuffer = stdoutBuffer.slice(newlineIdx + 1);
254
+ if (line) {
255
+ try {
256
+ const msg = JSON.parse(line);
257
+ for (const handler of onCompanionStdout) handler(msg);
258
+ } catch {
259
+ process.stderr.write(`[companion] ${line}\n`);
260
+ }
261
+ }
262
+ }
263
+ });
264
+
265
+ // Wait for server-started message
266
+ companionPort = await new Promise((resolvePort, rejectPort) => {
267
+ const timeout = setTimeout(() => rejectPort(new Error('Companion server startup timeout')), 5000);
268
+ const handler = (msg) => {
269
+ if (msg.type === 'server-started') {
270
+ clearTimeout(timeout);
271
+ const idx = onCompanionStdout.indexOf(handler);
272
+ if (idx !== -1) onCompanionStdout.splice(idx, 1);
273
+ resolvePort(msg.port);
274
+ } else if (msg.type === 'server-error') {
275
+ clearTimeout(timeout);
276
+ rejectPort(new Error(msg.error));
277
+ }
278
+ };
279
+ onCompanionStdout.push(handler);
280
+ companionProcess.on('error', (err) => { clearTimeout(timeout); rejectPort(err); });
281
+ companionProcess.on('exit', (code) => { if (code) { clearTimeout(timeout); rejectPort(new Error(`Companion exited with code ${code}`)); } });
282
+ });
283
+
284
+ companionStdoutHandler = onCompanionStdout;
285
+
286
+ // Track whether companion already saved (via Save & Close before codegen closed)
287
+ const saveExitTranscriptHandler = (msg) => {
288
+ if (msg.type === 'transcript:saved') {
289
+ companionAlreadySaved = msg.path;
290
+ }
291
+ };
292
+ onCompanionStdout.push(saveExitTranscriptHandler);
293
+
294
+ const saveExitHandler = (msg) => {
295
+ if (msg.type === 'save-and-exit') {
296
+ child.kill('SIGTERM');
297
+ }
298
+ };
299
+ onCompanionStdout.push(saveExitHandler);
300
+
301
+ // Connect parent to companion WebSocket for hotkey relay
302
+ const { WebSocket: WsClient } = await import('ws');
303
+ companionWs = new WsClient(`ws://localhost:${companionPort}`);
304
+ await new Promise((r) => {
305
+ companionWs.on('open', r);
306
+ companionWs.on('error', r);
307
+ });
308
+
309
+ companionWs.on('close', () => {
310
+ companionWs = null;
311
+ });
312
+
313
+ // Listen for companion messages (e.g., user clicked Save & Close)
314
+ companionWs.on('message', (raw) => {
315
+ try {
316
+ const msg = JSON.parse(raw.toString());
317
+ if (msg.type === 'save-and-exit') {
318
+ // User clicked Save & Close in companion — kill codegen to trigger shutdown
319
+ child.kill('SIGTERM');
320
+ }
321
+ } catch {}
322
+ });
323
+ } catch (err) {
324
+ if (err.message.includes('already in use')) {
325
+ printError('Companion port already in use', [
326
+ { label: 'Fix', value: 'Stop the other session first, then try again' },
327
+ ]);
328
+ process.exit(1);
329
+ }
330
+ warnings.push(`[WARN] ${isoNow()} Companion server failed to start: ${err.message}`);
331
+ warnings.push(`[WARN] ${isoNow()} Continuing without companion UI (voice recording still active)`);
332
+ companionProcess = null;
333
+ companionPort = null;
334
+ }
335
+ }
336
+
337
+ // --- Print session header (all pre-flight info is now here) ---
338
+ {
339
+ const flags = [
340
+ voiceEnabled ? pc.green('Voice ✓') : pc.dim('Voice ✗'),
341
+ traceEnabled ? pc.green('Trace ✓') : pc.dim('Trace ✗'),
342
+ (companionEnabled && companionPort) ? pc.green('Companion ✓') : pc.dim('Companion ✗'),
343
+ ].join(' ');
344
+
345
+ const headerItems = [
346
+ { label: 'Output', value: outputRelative },
347
+ { label: 'Status', value: flags },
348
+ ];
349
+ if (companionPort) {
350
+ headerItems.push({ label: 'Companion', value: `http://localhost:${companionPort}` });
351
+ }
352
+
353
+ printHeader('levante record', issueKey, headerItems);
354
+ }
355
+
356
+ // Print recording status line (was previously printed during voice setup)
357
+ if (voiceEnabled && recording) {
204
358
  printRecordingStatus(true);
205
359
  }
206
360
 
@@ -212,15 +366,15 @@ if (!existsSync(authDir)) {
212
366
  }
213
367
 
214
368
  if (!existsSync(storageStatePath)) {
215
- console.error('Authenticating to cache storage state...');
216
369
  try {
217
370
  execSync(`node "${resolve(__dirname, 'auth', 'setup-auth.mjs')}"`, {
218
371
  cwd: root,
219
- stdio: 'inherit',
372
+ stdio: 'pipe',
220
373
  env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
221
374
  });
222
- } catch {
223
- console.error('Auth setup failed \u2014 codegen will start without cached auth.');
375
+ } catch (authErr) {
376
+ const authMsg = (authErr.stderr?.toString().trim() || authErr.message).split('\n')[0];
377
+ warnings.push(`[WARN] ${isoNow()} Auth setup failed: ${authMsg}`);
224
378
  }
225
379
  }
226
380
 
@@ -233,7 +387,6 @@ if (traceEnabled && harPath) {
233
387
  if (existsSync(storageStatePath)) {
234
388
  codegenArgs.push('--load-storage', storageStatePath);
235
389
  codegenArgs.push('--save-storage', storageStatePath);
236
- console.error('Using cached auth \u2014 codegen will start already logged in.');
237
390
  }
238
391
  codegenArgs.push(url);
239
392
 
@@ -246,6 +399,12 @@ const child = spawn('npx', codegenArgs, {
246
399
  // Start polling the output file for new actions to capture timestamps
247
400
  startActionPolling();
248
401
 
402
+ // --- Open companion window ---
403
+ if (companionPort) {
404
+ const { openUrl } = await import(resolve(__dirname, 'companion', 'open-url.mjs'));
405
+ openUrl(`http://localhost:${companionPort}/companion`);
406
+ }
407
+
249
408
  // --- Keyboard listener for pause/resume ---
250
409
  if (voiceEnabled && process.stdin.isTTY) {
251
410
  process.stdin.setRawMode(true);
@@ -266,6 +425,9 @@ if (voiceEnabled && process.stdin.isTTY) {
266
425
  currentRecProcess = null;
267
426
  recording = false;
268
427
  printRecordingStatus(false);
428
+ if (companionWs && companionWs.readyState === 1) {
429
+ companionWs.send(JSON.stringify({ type: 'control:pause' }));
430
+ }
269
431
  } else {
270
432
  const segPath = resolve(recordingsDir, `seg-${String(segmentIndex).padStart(3, '0')}.wav`);
271
433
  segmentPaths.push(segPath);
@@ -274,6 +436,9 @@ if (voiceEnabled && process.stdin.isTTY) {
274
436
  recording = true;
275
437
  segmentIndex++;
276
438
  printRecordingStatus(true);
439
+ if (companionWs && companionWs.readyState === 1) {
440
+ companionWs.send(JSON.stringify({ type: 'control:resume' }));
441
+ }
277
442
  }
278
443
  }
279
444
  });
@@ -287,90 +452,207 @@ function cleanupTerminal() {
287
452
  }
288
453
  }
289
454
 
455
+ function killCompanion() {
456
+ if (companionProcess) {
457
+ try { if (companionWs) companionWs.close(); } catch {}
458
+ try { companionProcess.kill('SIGTERM'); } catch {}
459
+ try { companionProcess.kill('SIGKILL'); } catch {}
460
+ companionProcess = null;
461
+ }
462
+ }
463
+
464
+ // Ensure companion is killed on any exit path
465
+ process.on('exit', killCompanion);
466
+ process.on('SIGINT', () => { killCompanion(); process.exit(130); });
467
+ process.on('SIGTERM', () => { killCompanion(); process.exit(143); });
468
+ process.on('uncaughtException', (err) => {
469
+ printError(err.message || String(err), []);
470
+ killCompanion();
471
+ process.exit(1);
472
+ });
473
+
290
474
  child.on('exit', async (code) => {
291
475
  cleanupTerminal();
292
476
  if (pollInterval) clearInterval(pollInterval);
293
477
 
294
- if (code === 0) {
295
- console.error(`\nSaved: ${outputRelative}`);
296
- }
478
+ try {
479
+ // --- Companion shutdown ---
480
+ let liveTranscriptPath = null;
481
+ if (companionProcess) {
482
+ try {
483
+ if (companionAlreadySaved) {
484
+ // User already clicked Save & Close — transcript was saved before codegen closed
485
+ liveTranscriptPath = companionAlreadySaved;
486
+ if (companionWs) try { companionWs.close(); } catch {}
487
+ companionProcess.kill('SIGTERM');
488
+ } else {
489
+ // Normal shutdown — tell companion to stop and save
490
+ if (companionWs && companionWs.readyState === 1) {
491
+ companionWs.send(JSON.stringify({ type: 'control:stop' }));
492
+ }
493
+ if (companionProcess.stdin?.writable) companionProcess.stdin.write('stop\nsave\n');
494
+
495
+ if (companionStdoutHandler) {
496
+ liveTranscriptPath = await Promise.race([
497
+ new Promise((resolvePromise) => {
498
+ const handler = (msg) => {
499
+ if (msg.type === 'transcript:saved') {
500
+ const idx = companionStdoutHandler.indexOf(handler);
501
+ if (idx !== -1) companionStdoutHandler.splice(idx, 1);
502
+ resolvePromise(msg.path);
503
+ }
504
+ };
505
+ companionStdoutHandler.push(handler);
506
+ }),
507
+ new Promise((resolvePromise) => {
508
+ setTimeout(() => {
509
+ warnings.push(`[WARN] ${isoNow()} Auto-saving transcript (companion did not respond within 30s)`);
510
+ if (companionProcess.stdin?.writable) companionProcess.stdin.write('save\n');
511
+ setTimeout(() => resolvePromise(null), 2000);
512
+ }, 30000);
513
+ }),
514
+ ]);
515
+ }
297
516
 
298
- // --- Inject action timestamps into codegen output ---
299
- if (existsSync(outputPath) && actionElapsedSec.length > 0) {
300
- injectActionTimestamps(outputPath);
301
- console.error(`Injected ${actionElapsedSec.length} action timestamp(s) into: ${outputRelative}`);
302
- }
517
+ // Filesystem fallback
518
+ if (!liveTranscriptPath) {
519
+ const expectedPath = resolve(
520
+ recordingsDir || resolve(issueDir, 'recordings'),
521
+ `transcript-${timestamp}.json`
522
+ );
523
+ if (existsSync(expectedPath)) liveTranscriptPath = expectedPath;
524
+ }
303
525
 
304
- // --- Voice post-processing: merge WAV segments only (transcription deferred to transcribe command) ---
305
- if (voiceEnabled && segmentPaths.length > 0) {
306
- try {
307
- if (recording && currentRecProcess) {
308
- const { stopRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
309
- await stopRecording(currentRecProcess);
310
- currentRecProcess = null;
311
- recording = false;
526
+ if (companionWs) try { companionWs.close(); } catch {}
527
+ companionProcess.kill('SIGTERM');
528
+ }
529
+ } catch (err) {
530
+ warnings.push(`[ERROR] ${isoNow()} Companion shutdown error: ${err.message}`);
531
+ try { companionProcess.kill('SIGKILL'); } catch {}
312
532
  }
533
+ }
313
534
 
314
- const existingSegments = segmentPaths.filter((p) => existsSync(p));
535
+ // --- Inject action timestamps (silent) ---
536
+ if (existsSync(outputPath) && actionElapsedSec.length > 0) {
537
+ injectActionTimestamps(outputPath);
538
+ }
315
539
 
316
- if (existingSegments.length === 0) {
317
- console.error('No audio segments recorded.');
318
- } else {
319
- const mergedWavPath = resolve(recordingsDir, `voice-${timestamp}.wav`);
540
+ // --- Voice post-processing ---
541
+ if (voiceEnabled && segmentPaths.length > 0) {
542
+ try {
543
+ if (recording && currentRecProcess) {
544
+ const { stopRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
545
+ await stopRecording(currentRecProcess);
546
+ currentRecProcess = null;
547
+ recording = false;
548
+ }
320
549
 
321
- if (existingSegments.length === 1) {
322
- renameSync(existingSegments[0], mergedWavPath);
323
- } else {
324
- console.error(`Merging ${existingSegments.length} audio segments...`);
325
- const args = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
326
- execSync(`sox ${args.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
550
+ const existingSegments = segmentPaths.filter((p) => existsSync(p));
327
551
 
328
- for (const seg of existingSegments) {
329
- try { unlinkSync(seg); } catch {}
552
+ if (existingSegments.length === 0) {
553
+ warnings.push(`[WARN] ${isoNow()} No audio segments recorded`);
554
+ } else {
555
+ const mergedWavPath = resolve(recordingsDir, `voice-${timestamp}.wav`);
556
+ if (existingSegments.length === 1) {
557
+ renameSync(existingSegments[0], mergedWavPath);
558
+ } else {
559
+ const soxArgs = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
560
+ execSync(`sox ${soxArgs.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
561
+ for (const seg of existingSegments) {
562
+ try { unlinkSync(seg); } catch {}
563
+ }
330
564
  }
331
565
  }
566
+ } catch (err) {
567
+ warnings.push(`[ERROR] ${isoNow()} Voice processing error: ${err.message}`);
568
+ }
569
+ }
332
570
 
333
- console.error(`\nVoice recording summary:`);
334
- console.error(` Audio: ${relative(root, mergedWavPath)}`);
335
- console.error(` Codegen: ${outputRelative}`);
336
- console.error(` (Run 'transcribe' to process voice → merge into codegen)`);
571
+ // --- Write session metadata ---
572
+ if (voiceEnabled && existsSync(outputPath)) {
573
+ const meta = {
574
+ codegen: `codegen-${timestamp}.ts`,
575
+ audio: recordingsDir ? `recordings/voice-${timestamp}.wav` : null,
576
+ transcript: liveTranscriptPath ? relative(issueDir, liveTranscriptPath) : null,
577
+ liveTranscription: !!liveTranscriptPath,
578
+ engine: 'webspeech',
579
+ duration: null,
580
+ segmentCount: null,
581
+ tagCount: null,
582
+ };
583
+ if (liveTranscriptPath && existsSync(liveTranscriptPath)) {
584
+ try {
585
+ const transcript = JSON.parse(readFileSync(liveTranscriptPath, 'utf-8'));
586
+ meta.duration = transcript.duration;
587
+ meta.segmentCount = transcript.segments?.length ?? 0;
588
+ meta.tagCount = transcript.segments?.reduce((n, s) => n + (s.tags?.length ?? 0), 0) ?? 0;
589
+ meta.engine = transcript.engine || 'webspeech';
590
+ } catch {}
337
591
  }
338
- } catch (err) {
339
- console.error(`\nVoice processing error: ${err.message}`);
592
+ writeFileSync(resolve(issueDir, 'session-meta.json'), JSON.stringify(meta, null, 2));
340
593
  }
341
- }
342
594
 
343
- // --- Trace: inject test.use({ trace: 'on' }) and run replay to generate trace ---
344
- if (existsSync(outputPath)) {
345
- const codegenSrc = readFileSync(outputPath, 'utf-8');
346
- if (!codegenSrc.includes("test.use({ trace: 'on' })")) {
347
- const injected = codegenSrc.replace(
348
- /^(import\s.*from\s+['"]@playwright\/test['"];?\s*\n)/m,
349
- "$1\ntest.use({ trace: 'on' });\n",
350
- );
351
- if (injected !== codegenSrc) {
352
- writeFileSync(outputPath, injected);
353
- console.error("Injected test.use({ trace: 'on' }) into codegen output.");
595
+ // --- Trace injection + replay ---
596
+ if (existsSync(outputPath)) {
597
+ const codegenSrc = readFileSync(outputPath, 'utf-8');
598
+ if (!codegenSrc.includes("test.use({ trace: 'on' })")) {
599
+ const injected = codegenSrc.replace(
600
+ /^(import\s.*from\s+['"]@playwright\/test['"];?\s*\n)/m,
601
+ "$1\ntest.use({ trace: 'on' });\n",
602
+ );
603
+ if (injected !== codegenSrc) writeFileSync(outputPath, injected);
604
+ }
605
+
606
+ if (traceEnabled) {
607
+ try {
608
+ const replayScript = resolve(__dirname, 'trace', 'replay-with-trace.mjs');
609
+ execSync(`node "${replayScript}" "${outputRelative}"`, {
610
+ cwd: root,
611
+ stdio: 'inherit',
612
+ env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
613
+ });
614
+ } catch {
615
+ warnings.push(`[WARN] ${isoNow()} Trace replay failed (codegen file is still saved)`);
616
+ }
354
617
  }
355
618
  }
356
619
 
357
- if (traceEnabled) {
358
- console.error('\nStarting trace replay...');
359
- try {
360
- const replayScript = resolve(__dirname, 'trace', 'replay-with-trace.mjs');
361
- execSync(`node "${replayScript}" "${outputRelative}"`, {
362
- cwd: root,
363
- stdio: 'inherit',
364
- env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
365
- });
366
- } catch {
367
- console.error('Trace replay failed (codegen file is still saved).');
620
+ // --- Print outcome card ---
621
+ const hasErrors = warnings.some((w) => w.startsWith('[ERROR]'));
622
+ const hasWarnings = warnings.length > 0;
623
+ const metaPath = relative(root, resolve(issueDir, 'session-meta.json'));
624
+
625
+ if (code === 0 && !hasErrors) {
626
+ const successItems = [{ label: 'Details', value: metaPath }];
627
+ if (hasWarnings) {
628
+ const logPath = saveErrorLog(issueDir, 'record-error.log', warnings);
629
+ if (logPath) successItems.push({ label: 'Warnings', value: relative(root, logPath) });
368
630
  }
631
+ successItems.push({ label: 'Next', value: `levante transcribe --key ${issueKey}` });
632
+ if (harPath && existsSync(harPath)) {
633
+ successItems.push({ label: 'HAR', value: relative(root, harPath) });
634
+ }
635
+ printSuccess('Session saved', successItems);
636
+ } else {
637
+ const firstError = warnings.find((w) => w.startsWith('[ERROR]'));
638
+ const primaryMsg = firstError
639
+ ? firstError.replace(/^\[ERROR\] \S+ /, '')
640
+ : `Recording exited with code ${code}`;
641
+ const logEntries = warnings.length
642
+ ? warnings
643
+ : [`[ERROR] ${isoNow()} Recording exited with code ${code}`];
644
+ const logPath = saveErrorLog(issueDir, 'record-error.log', logEntries);
645
+ const errorItems = [];
646
+ if (logPath) errorItems.push({ label: 'Log', value: relative(root, logPath) });
647
+ const hint = getFixHint(primaryMsg, issueKey);
648
+ if (hint) errorItems.push({ label: 'Fix', value: hint });
649
+ printError(primaryMsg, errorItems);
369
650
  }
370
- }
371
651
 
372
- if (harPath && existsSync(harPath)) {
373
- console.error(`HAR saved: ${relative(root, harPath)}`);
652
+ } catch (fatalErr) {
653
+ warnings.push(`[ERROR] ${isoNow()} Fatal exit handler error: ${fatalErr.message}`);
654
+ const logPath = saveErrorLog(issueDir, 'record-error.log', warnings);
655
+ printError(fatalErr.message, logPath ? [{ label: 'Log', value: relative(root, logPath) }] : []);
374
656
  }
375
657
 
376
658
  process.exit(code ?? 0);
@@ -0,0 +1,20 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function openUrl(url) {
4
+ const platform = process.platform;
5
+ let cmd, args;
6
+
7
+ if (platform === 'darwin') {
8
+ cmd = 'open';
9
+ args = [url];
10
+ } else if (platform === 'win32') {
11
+ cmd = 'cmd';
12
+ args = ['/c', 'start', '', url];
13
+ } else {
14
+ cmd = 'xdg-open';
15
+ args = [url];
16
+ }
17
+
18
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
19
+ child.unref();
20
+ }