@spark-apps/quickpeek 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,6 +110,20 @@ var init_utils = __esm({
110
110
  function apiKey() {
111
111
  return process.env.SPARK_AI_KEY || "";
112
112
  }
113
+ function hostOf2(url) {
114
+ try {
115
+ return new URL(url).host;
116
+ } catch {
117
+ return "AI service";
118
+ }
119
+ }
120
+ function firstLine(text) {
121
+ return truncate((text ?? "").trim().split("\n")[0] ?? "", 200);
122
+ }
123
+ function truncate(text, max) {
124
+ const trimmed = (text ?? "").trim();
125
+ return trimmed.length > max ? `${trimmed.slice(0, max)}\u2026` : trimmed;
126
+ }
113
127
  async function getOrRefreshDirectToken() {
114
128
  const now = Date.now();
115
129
  if (cachedDirectToken && cachedDirectToken.expiresAt > now + 3e5) {
@@ -161,8 +175,10 @@ async function getOrRefreshRelayToken(email) {
161
175
  throw new Error(`Failed to get QuickPeek token: ${getErrorMessage(err)}`);
162
176
  }
163
177
  }
164
- async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier) {
165
- let lastRateLimitError = null;
178
+ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
179
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
180
+ let lastRetriableError = null;
181
+ const host = hostOf2(chatUrl);
166
182
  for (let attempt = 1; attempt <= 3; attempt++) {
167
183
  try {
168
184
  const response = await fetch(chatUrl, {
@@ -178,36 +194,53 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
178
194
  ]
179
195
  })
180
196
  });
197
+ const bodyText = await response.text().catch(() => "");
181
198
  let data = {};
182
- const contentType = response.headers.get("content-type") ?? "";
183
- if (contentType.includes("application/json")) {
184
- data = await response.json();
199
+ try {
200
+ data = bodyText ? JSON.parse(bodyText) : {};
201
+ } catch {
185
202
  }
186
203
  if (!response.ok) {
187
- const error = data.error || `Spark AI error: ${response.status} ${response.statusText}`;
188
- if (response.status === 429 && attempt < 3) {
189
- const retryAfter = response.headers.get("Retry-After");
190
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * 2 ** (attempt - 1);
191
- lastRateLimitError = {
204
+ const upstream = data.error || firstLine(bodyText) || response.statusText || "no error body";
205
+ const error = `${host} ${response.status}: ${upstream}`;
206
+ const detail = truncate(bodyText, 800);
207
+ const isRetriable = response.status === 429 || response.status >= 500;
208
+ if (isRetriable && attempt < 3) {
209
+ const retryAfter2 = response.headers.get("Retry-After");
210
+ const delayMs = retryAfter2 ? parseInt(retryAfter2, 10) * 1e3 : response.status >= 500 ? backoffMs[attempt - 1] ?? backoffMs[backoffMs.length - 1] ?? 2e3 : 1e3 * 2 ** (attempt - 1);
211
+ onRetry?.({ attempt, status: response.status, delayMs, host });
212
+ lastRetriableError = {
192
213
  success: false,
193
214
  error,
194
- isRateLimit: true,
195
- ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
215
+ isRateLimit: response.status === 429,
216
+ status: response.status,
217
+ host,
218
+ ...detail ? { detail } : {},
219
+ ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
196
220
  };
197
221
  await new Promise((resolve10) => setTimeout(resolve10, Math.min(delayMs, 3e4)));
198
222
  continue;
199
223
  }
224
+ const retryAfter = response.headers.get("Retry-After");
225
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : Number.NaN;
200
226
  return {
201
227
  success: false,
202
228
  error,
203
229
  isRateLimit: response.status === 429,
204
- isAuthError: response.status === 401 || response.status === 403
230
+ isAuthError: response.status === 401 || response.status === 403,
231
+ status: response.status,
232
+ host,
233
+ ...detail ? { detail } : {},
234
+ ...Number.isFinite(retryAfterSeconds) ? { retryAfterSeconds } : {}
205
235
  };
206
236
  }
207
237
  if (!data.success || !data.response) {
208
238
  return {
209
239
  success: false,
210
- error: data.error || "No response from Spark AI"
240
+ error: `${host} 200 but no content: ${data.error || firstLine(bodyText) || "empty response"}`,
241
+ status: response.status,
242
+ host,
243
+ ...bodyText ? { detail: truncate(bodyText, 800) } : {}
211
244
  };
212
245
  }
213
246
  return {
@@ -221,14 +254,16 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
221
254
  }
222
255
  };
223
256
  } catch (err) {
224
- if (attempt === 3) throw err;
257
+ if (attempt === 3) {
258
+ return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
259
+ }
225
260
  const delayMs = 1e3 * 2 ** (attempt - 1);
226
261
  await new Promise((resolve10) => setTimeout(resolve10, delayMs));
227
262
  }
228
263
  }
229
- return lastRateLimitError || { success: false, error: "Request failed after retries" };
264
+ return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
230
265
  }
231
- async function callAI(systemPrompt, userPrompt, tier = "free") {
266
+ async function callAI(systemPrompt, userPrompt, tier = "free", opts = {}) {
232
267
  if (!apiKey()) {
233
268
  return {
234
269
  success: false,
@@ -238,12 +273,12 @@ async function callAI(systemPrompt, userPrompt, tier = "free") {
238
273
  }
239
274
  try {
240
275
  const token = await getOrRefreshDirectToken();
241
- return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier);
276
+ return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier, opts);
242
277
  } catch (err) {
243
278
  return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
244
279
  }
245
280
  }
246
- async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
281
+ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", opts = {}) {
247
282
  if (!email) {
248
283
  return {
249
284
  success: false,
@@ -253,11 +288,11 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
253
288
  }
254
289
  try {
255
290
  let token = await getOrRefreshRelayToken(email);
256
- let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
291
+ let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier, opts);
257
292
  if (!result.success && result.isAuthError) {
258
293
  cachedRelayToken = null;
259
294
  token = await getOrRefreshRelayToken(email);
260
- result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
295
+ result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier, opts);
261
296
  }
262
297
  return result;
263
298
  } catch (err) {
@@ -289,11 +324,13 @@ function normalizeUrl(input) {
289
324
  url = url.replace(/^(https?:\/\/)?(www\.)?/i, "");
290
325
  return `https://${url}`;
291
326
  }
292
- var SPARK_AI_BASE, SPARK_AI_CHAT, SPARK_AI_TOKEN, RELAY_BASE, RELAY_TOKEN_URL, RELAY_CHAT_URL, cachedDirectToken, cachedRelayToken;
327
+ var SERVER_BACKOFF_MS, CLI_BACKOFF_MS, SPARK_AI_BASE, SPARK_AI_CHAT, SPARK_AI_TOKEN, RELAY_BASE, RELAY_TOKEN_URL, RELAY_CHAT_URL, cachedDirectToken, cachedRelayToken;
293
328
  var init_api_client = __esm({
294
329
  "src/lib/api-client.ts"() {
295
330
  "use strict";
296
331
  init_utils();
332
+ SERVER_BACKOFF_MS = [2e3, 4e3];
333
+ CLI_BACKOFF_MS = [1e4, 3e4];
297
334
  SPARK_AI_BASE = "https://sparkbrain.app";
298
335
  SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
299
336
  SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
@@ -324,7 +361,7 @@ async function getStatusCached(email) {
324
361
  function getTier(d) {
325
362
  if (!d.subscription || !d.registered) return "free";
326
363
  if (d.subscription.status === "canceled" || d.subscription.status === "past_due") return "free";
327
- return TIERS[d.subscription.price_id] ?? "free";
364
+ return TIERS[d.subscription.price_id] ?? "pro";
328
365
  }
329
366
  async function getTierByEmail(email) {
330
367
  try {
@@ -341,17 +378,18 @@ async function isVerified(email) {
341
378
  }
342
379
  }
343
380
  function pricingUrl(opts) {
344
- const u = new URL(`${BASE}/pricing/${APP_ID}`);
381
+ const u = new URL(`${BASE}/${OWNER_SLUG}/${APP_ID}`);
345
382
  if (opts?.email) u.searchParams.set("email", opts.email);
346
383
  if (opts?.returnUrl) u.searchParams.set("return_url", opts.returnUrl);
347
384
  return u.toString();
348
385
  }
349
- var APP_ID, BASE, TIERS, RANK, FEATURES, getStatus, _cache, isPaid, hasAccess, hasTrialRemaining, showPaywall, isCanceling;
386
+ var APP_ID, OWNER_SLUG, BASE, TIERS, RANK, FEATURES, getStatus, _cache, isPaid, hasAccess, hasTrialRemaining, showPaywall, isCanceling;
350
387
  var init_SparkStripe_feature_gating = __esm({
351
388
  "src/lib/SparkStripe-feature-gating.ts"() {
352
389
  "use strict";
353
390
  APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
354
- BASE = "https://sparkstripe.com";
391
+ OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
392
+ BASE = "https://sparkpay.dev";
355
393
  TIERS = {
356
394
  price_1TADnPEf9opkuWid9NtqOYON: "pro",
357
395
  price_1TADnPEf9opkuWidgbvvX9ma: "pro",
@@ -553,7 +591,10 @@ var init_scraping = __esm({
553
591
  });
554
592
 
555
593
  // src/lib/crawl.ts
556
- import { chromium as chromium2 } from "playwright";
594
+ import { chromium as chromium3 } from "playwright";
595
+ function idSelector(id) {
596
+ return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
597
+ }
557
598
  function getActionForType(type) {
558
599
  switch (type) {
559
600
  case "button":
@@ -579,7 +620,7 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
579
620
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
580
621
  }
581
622
  const browserTask = task2("Launching browser");
582
- const browser = await chromium2.launch({ headless: true });
623
+ const browser = await chromium3.launch({ headless: true });
583
624
  const context = await browser.newContext({
584
625
  ...storageStatePath ? { storageState: storageStatePath } : {},
585
626
  // Crawl at the size we will record at, or the measurements below describe
@@ -683,9 +724,9 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
683
724
  } else if (el.isFile || el.type === "file") {
684
725
  selector = 'input[type="file"]';
685
726
  } else if (el.type === "range") {
686
- selector = el.id ? `#${el.id}` : 'input[type="range"]';
727
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
687
728
  } else if (el.type === "checkbox") {
688
- selector = el.id ? `#${el.id}` : 'input[type="checkbox"]';
729
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
689
730
  } else if (el.type === "button") {
690
731
  if (btnTexts[el.text] === 1) {
691
732
  selector = `text="${el.text}"`;
@@ -695,7 +736,7 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
695
736
  } else if (el.type === "link") {
696
737
  selector = `text="${el.text}"`;
697
738
  } else if (el.id) {
698
- selector = `#${el.id}`;
739
+ selector = idSelector(el.id);
699
740
  } else if (el.name) {
700
741
  selector = `[name="${el.name}"]`;
701
742
  } else if (el.placeholder) {
@@ -782,7 +823,7 @@ var init_watermark = __esm({
782
823
  });
783
824
 
784
825
  // src/lib/ffmpeg/spawn.ts
785
- import { spawn } from "child_process";
826
+ import { spawn as spawn2 } from "child_process";
786
827
  function toWSLPath(path) {
787
828
  if (!isWindowsRuntime) return path;
788
829
  return windowsDrivePathToWsl(path) ?? path.replace(/\\/g, "/");
@@ -796,9 +837,9 @@ function convertArgToWSL(arg2) {
796
837
  function spawnFFmpeg(cmd2, args) {
797
838
  if (isWindowsRuntime) {
798
839
  const wslArgs = args.map(convertArgToWSL);
799
- return spawn("wsl", ["-e", cmd2, ...wslArgs]);
840
+ return spawn2("wsl", ["-e", cmd2, ...wslArgs]);
800
841
  }
801
- return spawn(cmd2, args);
842
+ return spawn2(cmd2, args);
802
843
  }
803
844
  function runFFmpeg(args, errorLabel) {
804
845
  return new Promise((resolve10, reject) => {
@@ -997,8 +1038,39 @@ function getMediaDuration(filePath) {
997
1038
  proc.on("error", reject);
998
1039
  });
999
1040
  }
1041
+ function getAudioCodec(filePath) {
1042
+ return new Promise((resolve10) => {
1043
+ const args = [
1044
+ "-v",
1045
+ "error",
1046
+ "-select_streams",
1047
+ "a:0",
1048
+ "-show_entries",
1049
+ "stream=codec_name",
1050
+ "-of",
1051
+ "default=noprint_wrappers=1:nokey=1",
1052
+ filePath
1053
+ ];
1054
+ const proc = spawnFFmpeg("ffprobe", args);
1055
+ let stdout = "";
1056
+ proc.stdout.on("data", (data) => {
1057
+ stdout += data;
1058
+ });
1059
+ proc.on("close", (code) => resolve10(code === 0 ? stdout.trim() : ""));
1060
+ proc.on("error", () => resolve10(""));
1061
+ });
1062
+ }
1063
+ async function isHonestWav(filePath) {
1064
+ const codec = await getAudioCodec(filePath);
1065
+ return codec.startsWith("pcm_");
1066
+ }
1067
+ function buildConcatArgs(listPath, output) {
1068
+ const isWav = output.toLowerCase().endsWith(".wav");
1069
+ const codec = isWav ? ["-c:a", "pcm_s16le"] : ["-c", "copy"];
1070
+ return ["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...codec, output];
1071
+ }
1000
1072
  function concatMedia(listPath, output) {
1001
- return runFFmpeg(["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", output], "ffmpeg concat failed");
1073
+ return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
1002
1074
  }
1003
1075
  function generateSilence(outputPath, durationMs) {
1004
1076
  return runFFmpeg(
@@ -1247,6 +1319,114 @@ var init_ffmpeg2 = __esm({
1247
1319
  }
1248
1320
  });
1249
1321
 
1322
+ // src/lib/json-repair.ts
1323
+ function repairUnicodeEscapes(content) {
1324
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
1325
+ const code = Number.parseInt(hex, 16);
1326
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
1327
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
1328
+ }
1329
+ function salvageTruncatedJson(content) {
1330
+ const stack = [];
1331
+ const cuts = [];
1332
+ let inString = false;
1333
+ let escaped = false;
1334
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
1335
+ for (let i = 0; i < content.length; i++) {
1336
+ const ch = content[i];
1337
+ if (inString) {
1338
+ if (escaped) escaped = false;
1339
+ else if (ch === "\\") escaped = true;
1340
+ else if (ch === '"') {
1341
+ inString = false;
1342
+ record(i + 1, false);
1343
+ }
1344
+ continue;
1345
+ }
1346
+ if (ch === '"') {
1347
+ inString = true;
1348
+ } else if (ch === "{" || ch === "[") {
1349
+ stack.push(ch);
1350
+ } else if (ch === "}" || ch === "]") {
1351
+ stack.pop();
1352
+ record(i + 1, true);
1353
+ } else if (ch === ",") {
1354
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
1355
+ }
1356
+ }
1357
+ const tryCut = (cut) => {
1358
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
1359
+ if (head.endsWith(":")) return null;
1360
+ let closed = head;
1361
+ for (let d = cut.open.length - 1; d >= 0; d--) {
1362
+ closed += CLOSERS[cut.open[d]];
1363
+ }
1364
+ try {
1365
+ JSON.parse(closed);
1366
+ return closed;
1367
+ } catch {
1368
+ return null;
1369
+ }
1370
+ };
1371
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
1372
+ for (const containerOnly of [true, false]) {
1373
+ for (let n = 0; n < attempts; n++) {
1374
+ const cut = cuts[cuts.length - 1 - n];
1375
+ if (containerOnly && !cut.closesContainer) continue;
1376
+ const salvaged = tryCut(cut);
1377
+ if (salvaged !== null) return salvaged;
1378
+ }
1379
+ }
1380
+ return null;
1381
+ }
1382
+ function extractJsonCandidates(content) {
1383
+ let text = content.trim();
1384
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
1385
+ if (fence?.[1]?.trim()) text = fence[1].trim();
1386
+ const start = text.search(/[{[]/);
1387
+ if (start === -1) return [];
1388
+ const closer = text[start] === "{" ? "}" : "]";
1389
+ const end = text.lastIndexOf(closer);
1390
+ const candidates = [text.slice(start)];
1391
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
1392
+ return candidates;
1393
+ }
1394
+ function parseAIJson(content) {
1395
+ const candidates = extractJsonCandidates(content);
1396
+ if (candidates.length === 0) return null;
1397
+ for (const text of candidates) {
1398
+ try {
1399
+ return { value: JSON.parse(text), repaired: false };
1400
+ } catch {
1401
+ }
1402
+ }
1403
+ for (const text of candidates) {
1404
+ try {
1405
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
1406
+ } catch {
1407
+ }
1408
+ }
1409
+ for (const text of candidates) {
1410
+ for (const variant of [repairUnicodeEscapes(text), text]) {
1411
+ const salvaged = salvageTruncatedJson(variant);
1412
+ if (salvaged === null) continue;
1413
+ try {
1414
+ return { value: JSON.parse(salvaged), repaired: true };
1415
+ } catch {
1416
+ }
1417
+ }
1418
+ }
1419
+ return null;
1420
+ }
1421
+ var MAX_SALVAGE_ATTEMPTS, CLOSERS;
1422
+ var init_json_repair = __esm({
1423
+ "src/lib/json-repair.ts"() {
1424
+ "use strict";
1425
+ MAX_SALVAGE_ATTEMPTS = 500;
1426
+ CLOSERS = { "{": "}", "[": "]" };
1427
+ }
1428
+ });
1429
+
1250
1430
  // src/lib/prompts.ts
1251
1431
  function buildSystemPrompt(maxSteps) {
1252
1432
  let scope;
@@ -1448,7 +1628,7 @@ var init_styles = __esm({
1448
1628
  async function registerFreeUser(email) {
1449
1629
  const APP_ID2 = process.env.SPARK_APP_ID ?? "quickpeek";
1450
1630
  try {
1451
- const response = await fetch(`https://sparkstripe.com/api/register-free`, {
1631
+ const response = await fetch(`https://sparkpay.dev/api/register-free`, {
1452
1632
  method: "POST",
1453
1633
  headers: { "Content-Type": "application/json" },
1454
1634
  body: JSON.stringify({ email, app_id: APP_ID2, app_name: "QuickPeek" })
@@ -1483,7 +1663,7 @@ var init_subscription_client = __esm({
1483
1663
  });
1484
1664
 
1485
1665
  // src/lib/tts/helpers.ts
1486
- import { existsSync as existsSync3 } from "fs";
1666
+ import { existsSync as existsSync4 } from "fs";
1487
1667
  import { readFile, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
1488
1668
  import { isAbsolute, join as join2, resolve as resolve2 } from "path";
1489
1669
  function toWSLPathForTTS(path) {
@@ -1518,7 +1698,7 @@ async function getMediaStepDuration(step, baseDir) {
1518
1698
  return 0;
1519
1699
  }
1520
1700
  const mediaPath = isAbsolute(step.value) ? step.value : resolve2(baseDir, step.value);
1521
- if (!existsSync3(mediaPath)) {
1701
+ if (!existsSync4(mediaPath)) {
1522
1702
  return 0;
1523
1703
  }
1524
1704
  try {
@@ -1540,7 +1720,7 @@ async function getMediaStepDuration(step, baseDir) {
1540
1720
  async function loadVoiceoverMeta(outputDir) {
1541
1721
  const metaPath = join2(outputDir, "voiceover.json");
1542
1722
  try {
1543
- if (!existsSync3(metaPath)) return null;
1723
+ if (!existsSync4(metaPath)) return null;
1544
1724
  const content = await readFile(metaPath, "utf-8");
1545
1725
  return JSON.parse(content);
1546
1726
  } catch {
@@ -1579,7 +1759,7 @@ __export(timing_exports, {
1579
1759
  alignVoiceoverToRecording: () => alignVoiceoverToRecording,
1580
1760
  estimateWordTimings: () => estimateWordTimings
1581
1761
  });
1582
- import { existsSync as existsSync4 } from "fs";
1762
+ import { existsSync as existsSync5 } from "fs";
1583
1763
  import { join as join3 } from "path";
1584
1764
  function estimateWordTimings(caption, duration) {
1585
1765
  const words = caption.split(/\s+/).filter((w) => w.length > 0);
@@ -1616,9 +1796,9 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
1616
1796
  const audioPath = join3(audioDir, `step-${stepNum}.mp3`);
1617
1797
  const originalPaddingPath = join3(audioDir, `step-${stepNum}-padding.mp3`);
1618
1798
  const silencePath = join3(audioDir, `step-${stepNum}-silence.mp3`);
1619
- if (existsSync4(audioPath)) {
1799
+ if (existsSync5(audioPath)) {
1620
1800
  segments.push(audioPath);
1621
- if (existsSync4(originalPaddingPath)) {
1801
+ if (existsSync5(originalPaddingPath)) {
1622
1802
  segments.push(originalPaddingPath);
1623
1803
  }
1624
1804
  if (recordingDuration > voiceDuration) {
@@ -1626,7 +1806,7 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
1626
1806
  await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
1627
1807
  segments.push(alignPaddingPath);
1628
1808
  }
1629
- } else if (existsSync4(silencePath)) {
1809
+ } else if (existsSync5(silencePath)) {
1630
1810
  if (targetDuration !== voiceDuration) {
1631
1811
  await generateSilence(silencePath, targetDuration);
1632
1812
  }
@@ -1714,10 +1894,25 @@ var init_voices = __esm({
1714
1894
 
1715
1895
  // src/lib/tts/synthesize.ts
1716
1896
  import { exec } from "child_process";
1717
- import { writeFile as writeFile3 } from "fs/promises";
1897
+ import { createWriteStream } from "fs";
1898
+ import { stat, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
1718
1899
  import { join as join5, resolve as resolve3 } from "path";
1900
+ import { pipeline } from "stream/promises";
1719
1901
  import { promisify } from "util";
1720
1902
  async function synthesizeSpeech(text, outputPath, voice) {
1903
+ let pythonMsg = "";
1904
+ for (let attempt = 0; attempt < PYTHON_ATTEMPTS; attempt++) {
1905
+ try {
1906
+ await synthesizeWithPython(text, outputPath, voice);
1907
+ return;
1908
+ } catch (err) {
1909
+ pythonMsg = err instanceof Error ? err.message : String(err);
1910
+ if (attempt < PYTHON_ATTEMPTS - 1) await new Promise((r) => setTimeout(r, 500));
1911
+ }
1912
+ }
1913
+ await synthesizeWithNode(text, outputPath, voice, pythonMsg);
1914
+ }
1915
+ async function synthesizeWithPython(text, outputPath, voice) {
1721
1916
  const tempScriptPath = join5(resolve3(outputPath, ".."), `tts-${Date.now()}.py`);
1722
1917
  const wslScriptPath = isWindows ? toWSLPathForTTS(resolve3(tempScriptPath)) : tempScriptPath;
1723
1918
  const wslOutputPath = isWindows ? toWSLPathForTTS(resolve3(outputPath)) : outputPath;
@@ -1738,42 +1933,45 @@ asyncio.run(main())
1738
1933
  await writeFile3(tempScriptPath, pythonScript);
1739
1934
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
1740
1935
  await execAsync(pythonCmd, { timeout: 3e4 });
1741
- await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1742
- }));
1743
- return;
1744
- } catch (pythonErr) {
1745
- await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1746
- }));
1747
- const pythonMsg = pythonErr instanceof Error ? pythonErr.message : String(pythonErr);
1748
- const maxRetries = 2;
1749
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1750
- try {
1751
- const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1752
- const tts = new MsEdgeTTS();
1753
- await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1754
- await tts.toFile(outputPath, text);
1755
- return;
1756
- } catch (nodeErr) {
1757
- if (attempt === maxRetries) {
1758
- const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1759
- throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1760
- }
1761
- await new Promise((r) => setTimeout(r, 500));
1936
+ } finally {
1937
+ await unlink3(tempScriptPath).catch(() => {
1938
+ });
1939
+ }
1940
+ }
1941
+ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries = 2) {
1942
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1943
+ try {
1944
+ const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1945
+ const tts = new MsEdgeTTS();
1946
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1947
+ const { audioStream } = tts.toStream(text);
1948
+ await pipeline(audioStream, createWriteStream(outputPath));
1949
+ const { size } = await stat(outputPath);
1950
+ if (size === 0) throw new Error("No audio data received");
1951
+ return;
1952
+ } catch (nodeErr) {
1953
+ await unlink3(outputPath).catch(() => {
1954
+ });
1955
+ if (attempt === maxRetries) {
1956
+ const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1957
+ throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1762
1958
  }
1959
+ await new Promise((r) => setTimeout(r, 500));
1763
1960
  }
1764
1961
  }
1765
1962
  }
1766
- var execAsync;
1963
+ var execAsync, PYTHON_ATTEMPTS;
1767
1964
  var init_synthesize = __esm({
1768
1965
  "src/lib/tts/synthesize.ts"() {
1769
1966
  "use strict";
1770
1967
  init_helpers();
1771
1968
  execAsync = promisify(exec);
1969
+ PYTHON_ATTEMPTS = 2;
1772
1970
  }
1773
1971
  });
1774
1972
 
1775
1973
  // src/lib/tts/generate.ts
1776
- import { mkdir as mkdir2 } from "fs/promises";
1974
+ import { mkdir as mkdir3 } from "fs/promises";
1777
1975
  import { join as join6 } from "path";
1778
1976
  function estimateDurationFromText(text, wordsPerMinute = 150) {
1779
1977
  const words = text.split(/\s+/).filter((w) => w.length > 0).length;
@@ -1807,7 +2005,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
1807
2005
  return { voiceoverPath: "", stepAudio: stepAudio2 };
1808
2006
  }
1809
2007
  const audioDir = join6(workDir, "audio");
1810
- await mkdir2(audioDir, { recursive: true });
2008
+ await mkdir3(audioDir, { recursive: true });
1811
2009
  const segments = [];
1812
2010
  const stepAudio = /* @__PURE__ */ new Map();
1813
2011
  const ttsTask = task2("Generating voiceover");
@@ -1858,7 +2056,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
1858
2056
  return { voiceoverPath: "", stepAudio };
1859
2057
  }
1860
2058
  const concatTask = task2("Combining audio");
1861
- await mkdir2(outputDir, { recursive: true });
2059
+ await mkdir3(outputDir, { recursive: true });
1862
2060
  const voiceoverPath = join6(outputDir, "voiceover.wav");
1863
2061
  await concatenateAudio(segments, voiceoverPath, workDir);
1864
2062
  concatTask.succeed("Done");
@@ -1920,6 +2118,7 @@ var init_types3 = __esm({
1920
2118
  // src/lib/index.ts
1921
2119
  var lib_exports = {};
1922
2120
  __export(lib_exports, {
2121
+ CLI_BACKOFF_MS: () => CLI_BACKOFF_MS,
1923
2122
  CONFIG_FILE: () => CONFIG_FILE,
1924
2123
  CURSOR_CSS: () => CURSOR_CSS,
1925
2124
  CURSOR_PNG: () => CURSOR_PNG,
@@ -1930,9 +2129,11 @@ __export(lib_exports, {
1930
2129
  FEATURES: () => FEATURES,
1931
2130
  INTERACTIVE_SELECTORS: () => INTERACTIVE_SELECTORS,
1932
2131
  LANGUAGE_VOICES: () => WIDE_VOICES,
2132
+ SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
1933
2133
  VERSION: () => VERSION,
1934
2134
  alignVoiceoverToRecording: () => alignVoiceoverToRecording,
1935
2135
  applyTransitions: () => applyTransitions,
2136
+ buildConcatArgs: () => buildConcatArgs,
1936
2137
  buildKeepSegments: () => buildKeepSegments,
1937
2138
  buildStepTransitions: () => buildStepTransitions,
1938
2139
  buildSystemPrompt: () => buildSystemPrompt,
@@ -1946,9 +2147,11 @@ __export(lib_exports, {
1946
2147
  concatVideoWithOutro: () => concatVideoWithOutro,
1947
2148
  crawlPage: () => crawlPage,
1948
2149
  estimateWordTimings: () => estimateWordTimings,
2150
+ extractJsonCandidates: () => extractJsonCandidates,
1949
2151
  generateASSHeader: () => generateASSHeader,
1950
2152
  generateSilence: () => generateSilence,
1951
2153
  generateVoiceover: () => generateVoiceover,
2154
+ getAudioCodec: () => getAudioCodec,
1952
2155
  getErrorMessage: () => getErrorMessage,
1953
2156
  getLanguageName: () => getLanguageName,
1954
2157
  getMediaDuration: () => getMediaDuration,
@@ -1959,20 +2162,25 @@ __export(lib_exports, {
1959
2162
  hasAccess: () => hasAccess,
1960
2163
  hasTrialRemaining: () => hasTrialRemaining,
1961
2164
  hexToASS: () => hexToAss,
2165
+ idSelector: () => idSelector,
1962
2166
  isCanceling: () => isCanceling,
1963
2167
  isExcludedLink: () => isExcludedLink,
2168
+ isHonestWav: () => isHonestWav,
1964
2169
  isPaid: () => isPaid,
1965
2170
  isVerified: () => isVerified,
1966
2171
  loadExistingVoiceover: () => loadExistingVoiceover,
1967
2172
  loadVoiceoverMeta: () => loadVoiceoverMeta,
1968
2173
  mapTimestampsAfterCompression: () => mapTimestampsAfterCompression,
1969
2174
  normalizeUrl: () => normalizeUrl,
2175
+ parseAIJson: () => parseAIJson,
1970
2176
  parseAIPlanResponse: () => parseAIPlanResponse,
1971
2177
  parsePartial: () => parsePartial,
1972
2178
  pricingUrl: () => pricingUrl,
1973
2179
  registerFreeUser: () => registerFreeUser,
2180
+ repairUnicodeEscapes: () => repairUnicodeEscapes,
1974
2181
  resolveVoice: () => resolveVoice,
1975
2182
  rgbaToHex: () => rgbaToHex,
2183
+ salvageTruncatedJson: () => salvageTruncatedJson,
1976
2184
  shouldSkipLink: () => shouldSkipLink,
1977
2185
  shouldWatermark: () => shouldWatermark,
1978
2186
  showPaywall: () => showPaywall,
@@ -1991,6 +2199,7 @@ var init_lib = __esm({
1991
2199
  init_crawl();
1992
2200
  init_cursor();
1993
2201
  init_ffmpeg2();
2202
+ init_json_repair();
1994
2203
  init_prompts();
1995
2204
  init_SparkStripe_feature_gating();
1996
2205
  init_scraping();
@@ -2014,13 +2223,13 @@ __export(config_loader_exports, {
2014
2223
  savePlan: () => savePlan,
2015
2224
  saveUserData: () => saveUserData
2016
2225
  });
2017
- import { existsSync as existsSync5 } from "fs";
2018
- import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile4 } from "fs/promises";
2226
+ import { existsSync as existsSync6 } from "fs";
2227
+ import { mkdir as mkdir4, readFile as readFile2, rm, writeFile as writeFile4 } from "fs/promises";
2019
2228
  import { homedir as homedir2 } from "os";
2020
2229
  import { dirname, join as join7 } from "path";
2021
2230
  async function loadConfig(_url, langOverride) {
2022
2231
  const configPath = join7(process.cwd(), CONFIG_FILE);
2023
- if (existsSync5(configPath)) {
2232
+ if (existsSync6(configPath)) {
2024
2233
  try {
2025
2234
  const content = await readFile2(configPath, "utf-8");
2026
2235
  const userConfig = JSON.parse(content);
@@ -2070,15 +2279,15 @@ async function loadConfig(_url, langOverride) {
2070
2279
  }
2071
2280
  async function prepareWorkDir() {
2072
2281
  const workDir = join7(process.cwd(), WORK_DIR);
2073
- if (existsSync5(workDir)) {
2282
+ if (existsSync6(workDir)) {
2074
2283
  await rm(workDir, { recursive: true });
2075
2284
  }
2076
- await mkdir3(workDir, { recursive: true });
2285
+ await mkdir4(workDir, { recursive: true });
2077
2286
  return workDir;
2078
2287
  }
2079
2288
  async function loadUserData() {
2080
2289
  try {
2081
- if (existsSync5(USER_DATA_FILE)) {
2290
+ if (existsSync6(USER_DATA_FILE)) {
2082
2291
  return JSON.parse(await readFile2(USER_DATA_FILE, "utf-8"));
2083
2292
  }
2084
2293
  } catch {
@@ -2086,8 +2295,8 @@ async function loadUserData() {
2086
2295
  return {};
2087
2296
  }
2088
2297
  async function saveUserData(userData) {
2089
- if (!existsSync5(USER_DATA_DIR)) {
2090
- await mkdir3(USER_DATA_DIR, { recursive: true });
2298
+ if (!existsSync6(USER_DATA_DIR)) {
2299
+ await mkdir4(USER_DATA_DIR, { recursive: true });
2091
2300
  }
2092
2301
  const existing = await loadUserData();
2093
2302
  const merged = { ...existing, ...userData };
@@ -2140,7 +2349,7 @@ function parseCaptionCSS(css) {
2140
2349
  }
2141
2350
  async function loadCaptions() {
2142
2351
  const captionsPath = join7(process.cwd(), "captions.css");
2143
- if (!existsSync5(captionsPath)) {
2352
+ if (!existsSync6(captionsPath)) {
2144
2353
  await writeFile4(captionsPath, DEFAULT_CAPTIONS_CSS);
2145
2354
  return DEFAULT_CAPTIONS;
2146
2355
  }
@@ -2156,7 +2365,7 @@ async function savePlan(plan, planPath) {
2156
2365
  ...plan,
2157
2366
  steps: plan.steps.map(({ duration, ...step }) => step)
2158
2367
  };
2159
- await mkdir3(dirname(planPath), { recursive: true });
2368
+ await mkdir4(dirname(planPath), { recursive: true });
2160
2369
  await writeFile4(planPath, JSON.stringify(cleanPlan, null, 2));
2161
2370
  }
2162
2371
  var WORK_DIR, USER_DATA_DIR, USER_DATA_FILE, DEFAULT_CAPTIONS_CSS;
@@ -2264,19 +2473,16 @@ function fileResult(output, data, poster) {
2264
2473
  }
2265
2474
 
2266
2475
  // src/mcp/tools.ts
2267
- import { readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
2476
+ import { readdirSync as readdirSync5, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
2268
2477
  import { join as join23 } from "path";
2269
2478
  import { WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
2270
2479
 
2271
- // src/browser/auth-session.ts
2272
- import { chromium } from "playwright";
2273
-
2274
2480
  // src/browser/auth-state.ts
2275
- import { existsSync as existsSync2 } from "fs";
2481
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
2276
2482
  import { chmod, mkdir, readdir, unlink, writeFile } from "fs/promises";
2277
2483
  import { homedir } from "os";
2278
2484
  import { join } from "path";
2279
- var AUTH_DIR = join(homedir(), ".quickpeek", "auth");
2485
+ var AUTH_DIR = process.env.QUICKPEEK_AUTH_DIR || join(homedir(), ".quickpeek", "auth");
2280
2486
  function hostOf(url) {
2281
2487
  try {
2282
2488
  return new URL(url).host;
@@ -2288,136 +2494,445 @@ function authStatePath(url) {
2288
2494
  const host = hostOf(url);
2289
2495
  return host ? join(AUTH_DIR, `${host}.json`) : null;
2290
2496
  }
2497
+ function authMetaPath(url) {
2498
+ const host = hostOf(url);
2499
+ return host ? join(AUTH_DIR, `${host}.meta.json`) : null;
2500
+ }
2291
2501
  function findAuthState(url) {
2292
2502
  const path = authStatePath(url);
2293
2503
  return path && existsSync2(path) ? path : void 0;
2294
2504
  }
2295
- async function saveAuthState(url, state) {
2505
+ async function saveAuthState(url, state, startUrl) {
2296
2506
  const path = authStatePath(url);
2297
2507
  if (!path) throw new Error(`Not a valid URL: ${url}`);
2298
2508
  await mkdir(AUTH_DIR, { recursive: true });
2299
2509
  await writeFile(path, JSON.stringify(state, null, 2));
2300
2510
  await chmod(path, 384);
2511
+ if (startUrl) {
2512
+ await writeFile(authMetaPath(url), JSON.stringify({ startUrl, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
2513
+ }
2301
2514
  return path;
2302
2515
  }
2303
-
2304
- // src/browser/auth-session.ts
2305
- var pending = null;
2306
- function pendingSignIn() {
2307
- return pending?.url ?? null;
2308
- }
2309
- async function openSignIn(url) {
2310
- await cancelSignIn();
2311
- const browser = await chromium.launch({ headless: false });
2312
- const context = await browser.newContext();
2313
- const page = await context.newPage();
2314
- pending = { browser, context, url };
2516
+ function findStartUrl(url) {
2517
+ const path = authMetaPath(url);
2518
+ if (!path || !existsSync2(path)) return void 0;
2315
2519
  try {
2316
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
2520
+ const startUrl = JSON.parse(readFileSync2(path, "utf8")).startUrl;
2521
+ return typeof startUrl === "string" && startUrl.startsWith("http") ? startUrl : void 0;
2317
2522
  } catch {
2523
+ return void 0;
2318
2524
  }
2319
2525
  }
2320
- async function saveSignIn() {
2321
- if (!pending) throw new Error("No sign-in window is open. Call start_auth first.");
2322
- const { context, url } = pending;
2526
+ function resolveStartUrl(url) {
2323
2527
  try {
2324
- const path = await saveAuthState(url, await context.storageState());
2325
- return { host: new URL(url).host, path };
2326
- } finally {
2327
- await cancelSignIn();
2528
+ const parsed = new URL(url);
2529
+ const bareOrigin = (parsed.pathname === "/" || parsed.pathname === "") && !parsed.search && !parsed.hash;
2530
+ if (!bareOrigin || !findAuthState(url)) return url;
2531
+ return findStartUrl(url) ?? url;
2532
+ } catch {
2533
+ return url;
2328
2534
  }
2329
2535
  }
2330
- async function cancelSignIn() {
2331
- const open = pending;
2332
- pending = null;
2333
- await open?.browser.close().catch(() => {
2334
- });
2335
- }
2336
-
2337
- // src/commands/edit-cmd.ts
2338
- import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync3 } from "fs";
2339
- import { join as join10 } from "path";
2340
- import chalk7 from "chalk";
2341
2536
 
2342
- // src/help.ts
2343
- init_lib();
2344
- import chalk from "chalk";
2537
+ // src/browser/launch.ts
2538
+ import { mkdir as mkdir2 } from "fs/promises";
2539
+ import { chromium as chromium2 } from "playwright";
2345
2540
 
2346
- // src/logo.ts
2347
- import figlet from "figlet";
2348
- import gradient from "gradient-string";
2349
- var DEFAULT_LOGO_CONFIG = {
2350
- text: "QuickPeek",
2351
- font: "Slant",
2352
- horizontalLayout: "default",
2353
- gradientColors: ["#00ffff", "#00d4ff", "#a855f7", "#ec4899"],
2354
- suffix: " \u{1F440}"
2355
- };
2356
- function renderLogo(config = {}) {
2357
- const resolved = { ...DEFAULT_LOGO_CONFIG, ...config };
2358
- const ascii = figlet.textSync(resolved.text, {
2359
- font: resolved.font,
2360
- horizontalLayout: resolved.horizontalLayout
2361
- });
2362
- const colored = gradient(resolved.gradientColors)(ascii);
2363
- return resolved.suffix ? `${colored}${resolved.suffix}` : colored;
2541
+ // src/browser/wsl.ts
2542
+ import { execSync, spawn } from "child_process";
2543
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
2544
+ import { request } from "http";
2545
+ import { chromium } from "playwright";
2546
+ function isWSL() {
2547
+ if (process.platform !== "linux") return false;
2548
+ try {
2549
+ const r = execSync("uname -r", { encoding: "utf-8" }).toLowerCase();
2550
+ return r.includes("microsoft") || r.includes("wsl");
2551
+ } catch {
2552
+ return false;
2553
+ }
2364
2554
  }
2365
-
2366
- // src/help.ts
2367
- init_config_loader();
2368
- var cmd = chalk.cyan;
2369
- var arg = chalk.green;
2370
- var opt = chalk.magenta;
2371
- var desc = chalk.white;
2372
- var ex = chalk.dim;
2373
- function showLogo() {
2374
- const logo = renderLogo();
2375
- console.log(logo);
2376
- console.log(chalk.dim(` v${VERSION} - Auto-generate demo videos with AI`));
2377
- console.log(chalk.italic.dim(" Because Tutorial Videos are Overrated!\n"));
2555
+ function tryEnableWSLInterop() {
2556
+ return existsSync3("/proc/sys/fs/binfmt_misc/WSLInterop");
2378
2557
  }
2379
- function formatUsageLines(status, tier) {
2380
- const lines = [];
2381
- lines.push(`Videos generated: ${status.usage_count}`);
2382
- if (tier !== "pro" && status.trial) {
2383
- const { uses_remaining, max_uses, days_remaining, is_expired } = status.trial;
2384
- const used = max_uses - uses_remaining;
2385
- lines.push(`Trial: ${used}/${max_uses} uses${is_expired ? " (expired)" : ` (${uses_remaining} remaining)`}`);
2386
- if (!is_expired && days_remaining > 0) {
2387
- lines.push(`Trial ends in ${days_remaining} day${days_remaining === 1 ? "" : "s"}`);
2388
- }
2389
- }
2390
- if (tier === "pro" && status.subscription) {
2391
- const end = status.subscription.current_period_end;
2392
- if (status.subscription.is_lifetime) {
2393
- lines.push("Lifetime access");
2394
- } else if (isCanceling(status)) {
2395
- const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2396
- lines.push(`Cancels: ${date}`);
2397
- } else if (end) {
2398
- const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2399
- lines.push(`Renews: ${date}`);
2400
- }
2558
+ function wslToWindowsPath(wslPath) {
2559
+ if (!wslPath) return wslPath;
2560
+ const m = wslPath.match(/^\/mnt\/([a-z])\/(.*)/);
2561
+ if (!m) return wslPath;
2562
+ return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
2563
+ }
2564
+ function findExe(whereExeQuery, ...hardcoded) {
2565
+ for (const p of hardcoded) if (existsSync3(p)) return p;
2566
+ try {
2567
+ const line = execSync(`where.exe ${whereExeQuery}`, { encoding: "utf-8", timeout: 3e3 }).split("\n")[0]?.trim();
2568
+ if (line) return line.replace(/^([A-Za-z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
2569
+ } catch {
2401
2570
  }
2402
- return lines;
2571
+ return null;
2403
2572
  }
2404
-
2405
- // src/lib/browser-open.ts
2406
- import { exec as exec2 } from "child_process";
2407
- function editorBaseUrl() {
2408
- return (process.env.QUICKPEEK_API_BASE || "https://quickpeek.co").replace(/\/$/, "");
2573
+ var findWindowsChrome = () => findExe(
2574
+ "chrome.exe",
2575
+ "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
2576
+ "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
2577
+ );
2578
+ var findWindowsNode = () => findExe("node.exe");
2579
+ function getWindowsHostIP() {
2580
+ try {
2581
+ const m = execSync("ip route show default", { encoding: "utf-8", timeout: 2e3 }).match(
2582
+ /default via (\d+\.\d+\.\d+\.\d+)/
2583
+ );
2584
+ if (m?.[1]) return m[1];
2585
+ } catch {
2586
+ }
2587
+ try {
2588
+ const m = readFileSync3("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
2589
+ if (m?.[1]) return m[1];
2590
+ } catch {
2591
+ }
2592
+ return "127.0.0.1";
2409
2593
  }
2410
- function openInBrowser(url) {
2411
- const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
2412
- const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
2413
- return new Promise((resolve10) => {
2414
- exec2(command, () => resolve10());
2594
+ function startRelay(nodePath, windowsHost, targetPort) {
2595
+ const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
2596
+ return new Promise((resolve10, reject) => {
2597
+ const proc = spawn(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
2598
+ const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
2599
+ let buf = "";
2600
+ proc.stdout.on("data", (d) => {
2601
+ buf += d.toString();
2602
+ const m = buf.match(/PORT:(\d+)/);
2603
+ if (m) {
2604
+ clearTimeout(timer);
2605
+ resolve10({ proc, port: +m[1] });
2606
+ }
2607
+ });
2608
+ proc.on("error", (e) => {
2609
+ clearTimeout(timer);
2610
+ reject(e);
2611
+ });
2415
2612
  });
2416
2613
  }
2417
-
2418
- // src/lib/plan-validation.ts
2419
- var INVALID_TARGETS = [
2420
- /\.png$/i,
2614
+ function waitForChrome(host, port) {
2615
+ return new Promise((resolve10, reject) => {
2616
+ const deadline = Date.now() + 2e4;
2617
+ const poll = () => {
2618
+ if (Date.now() > deadline) {
2619
+ reject(new Error("Chrome debug port did not open within 20 s"));
2620
+ return;
2621
+ }
2622
+ const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
2623
+ res.resume();
2624
+ resolve10();
2625
+ });
2626
+ req.on("error", () => setTimeout(poll, 600));
2627
+ req.setTimeout(1e3, () => {
2628
+ req.destroy();
2629
+ setTimeout(poll, 600);
2630
+ });
2631
+ req.end();
2632
+ };
2633
+ poll();
2634
+ });
2635
+ }
2636
+ async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
2637
+ const debugPort = options.debugPort ?? 9222;
2638
+ const chromePath = findWindowsChrome();
2639
+ if (!chromePath) throw new Error("Windows Chrome not found. Install Google Chrome for Windows.");
2640
+ const nodePath = findWindowsNode();
2641
+ if (!nodePath) throw new Error("Windows Node.js not found. Install Node.js for Windows.");
2642
+ const winUserData = wslToWindowsPath(userDataDir);
2643
+ const winExtPath = extensionPath ? wslToWindowsPath(extensionPath) : "";
2644
+ const chromeProc = spawn(
2645
+ chromePath,
2646
+ [
2647
+ `--remote-debugging-port=${debugPort}`,
2648
+ `--user-data-dir=${winUserData}`,
2649
+ ...winExtPath ? [`--disable-extensions-except=${winExtPath}`, `--load-extension=${winExtPath}`] : [],
2650
+ "--no-first-run",
2651
+ "--no-default-browser-check",
2652
+ "--disable-background-networking",
2653
+ "--disable-sync",
2654
+ "--disable-features=TranslateUI",
2655
+ options.startMaximized ? "--start-maximized" : "--start-minimized"
2656
+ ],
2657
+ { stdio: "ignore", detached: true }
2658
+ );
2659
+ chromeProc.unref();
2660
+ const windowsHost = getWindowsHostIP();
2661
+ const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
2662
+ try {
2663
+ await waitForChrome(windowsHost, proxyPort);
2664
+ const browser = await chromium.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
2665
+ return {
2666
+ browser,
2667
+ cleanup: async () => {
2668
+ try {
2669
+ await browser.close();
2670
+ } catch {
2671
+ }
2672
+ relay.kill();
2673
+ }
2674
+ };
2675
+ } catch (err) {
2676
+ relay.kill();
2677
+ throw err;
2678
+ }
2679
+ }
2680
+
2681
+ // src/browser/launch.ts
2682
+ function recordingViewport(config) {
2683
+ const zoom = config.video.zoom && config.video.zoom > 0 ? config.video.zoom : 1;
2684
+ return {
2685
+ width: Math.round(config.video.width / zoom),
2686
+ height: Math.round(config.video.height / zoom)
2687
+ };
2688
+ }
2689
+ async function launchBrowserContext(config, userDataDir, recordVideo, storageStatePath) {
2690
+ const extensionPath = config.browser?.extension;
2691
+ const zoom = config.video.zoom && config.video.zoom > 0 ? config.video.zoom : 1;
2692
+ const viewport = recordingViewport(config);
2693
+ const scaleOpts = zoom === 1 ? {} : { deviceScaleFactor: zoom };
2694
+ const videoOpts = recordVideo ? { recordVideo } : {};
2695
+ const cspOpts = { bypassCSP: true };
2696
+ const authOpts = storageStatePath ? { storageState: storageStatePath } : {};
2697
+ let context;
2698
+ let browser = null;
2699
+ let chromeSession = null;
2700
+ if (extensionPath) {
2701
+ await mkdir2(userDataDir, { recursive: true });
2702
+ const persistentArgs = [
2703
+ `--disable-extensions-except=${extensionPath}`,
2704
+ `--load-extension=${extensionPath}`,
2705
+ "--window-position=-32000,-32000",
2706
+ "--window-size=1,1"
2707
+ ];
2708
+ const useWindowsChrome = isWSL() && tryEnableWSLInterop();
2709
+ if (useWindowsChrome) {
2710
+ try {
2711
+ chromeSession = await launchWindowsChrome(extensionPath, userDataDir, { viewport, ...scaleOpts, ...videoOpts });
2712
+ const contexts = chromeSession.browser.contexts();
2713
+ context = contexts[0] || await chromeSession.browser.newContext({
2714
+ viewport,
2715
+ ...scaleOpts,
2716
+ ...videoOpts,
2717
+ ...cspOpts,
2718
+ acceptDownloads: true
2719
+ });
2720
+ } catch {
2721
+ context = await chromium2.launchPersistentContext(userDataDir, {
2722
+ headless: false,
2723
+ args: persistentArgs,
2724
+ viewport,
2725
+ ...scaleOpts,
2726
+ ...videoOpts,
2727
+ ...cspOpts,
2728
+ acceptDownloads: true
2729
+ });
2730
+ }
2731
+ } else {
2732
+ context = await chromium2.launchPersistentContext(userDataDir, {
2733
+ headless: false,
2734
+ args: persistentArgs,
2735
+ viewport,
2736
+ ...scaleOpts,
2737
+ ...videoOpts,
2738
+ ...cspOpts,
2739
+ acceptDownloads: true
2740
+ });
2741
+ }
2742
+ } else {
2743
+ if (process.env.QP_HEADED === "1") {
2744
+ const headedOpts = {
2745
+ headless: false,
2746
+ args: ["--disable-blink-features=AutomationControlled"],
2747
+ ignoreDefaultArgs: ["--enable-automation"]
2748
+ };
2749
+ try {
2750
+ browser = await chromium2.launch({ ...headedOpts, channel: "chrome" });
2751
+ } catch {
2752
+ browser = await chromium2.launch(headedOpts);
2753
+ }
2754
+ } else {
2755
+ browser = await chromium2.launch({ headless: true });
2756
+ }
2757
+ context = await browser.newContext({
2758
+ viewport,
2759
+ ...scaleOpts,
2760
+ ...videoOpts,
2761
+ ...authOpts,
2762
+ ...cspOpts,
2763
+ acceptDownloads: true
2764
+ });
2765
+ }
2766
+ await hideDevOverlays(context);
2767
+ return { context, browser, chromeSession };
2768
+ }
2769
+ async function hideDevOverlays(context) {
2770
+ await context.addInitScript(() => {
2771
+ const css = "nextjs-portal,vite-error-overlay,vite-plugin-checker-error-overlay{display:none!important;pointer-events:none!important}";
2772
+ const add = () => {
2773
+ const style = document.createElement("style");
2774
+ style.textContent = css;
2775
+ document.head?.appendChild(style);
2776
+ };
2777
+ if (document.head) add();
2778
+ else document.addEventListener("DOMContentLoaded", add, { once: true });
2779
+ }).catch(() => {
2780
+ });
2781
+ }
2782
+ async function launchSignInBrowser() {
2783
+ const opts = {
2784
+ headless: false,
2785
+ args: ["--disable-blink-features=AutomationControlled"],
2786
+ ignoreDefaultArgs: ["--enable-automation"]
2787
+ };
2788
+ try {
2789
+ return await chromium2.launch({ ...opts, channel: "chrome" });
2790
+ } catch {
2791
+ return await chromium2.launch(opts);
2792
+ }
2793
+ }
2794
+ async function cleanupBrowserSession(session) {
2795
+ if (session.chromeSession) {
2796
+ await session.chromeSession.cleanup();
2797
+ } else {
2798
+ await session.context.close();
2799
+ if (session.browser) await session.browser.close();
2800
+ }
2801
+ }
2802
+
2803
+ // src/browser/auth-session.ts
2804
+ var pending = null;
2805
+ function pendingSignIn() {
2806
+ return pending?.url ?? null;
2807
+ }
2808
+ async function openSignIn(url) {
2809
+ await cancelSignIn();
2810
+ const browser = await launchSignInBrowser();
2811
+ const context = await browser.newContext();
2812
+ const page = await context.newPage();
2813
+ pending = { browser, context, url };
2814
+ try {
2815
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
2816
+ } catch {
2817
+ }
2818
+ }
2819
+ function sameSite(a, b) {
2820
+ if (a === b) return true;
2821
+ const tail = (h) => h.split(".").slice(-2).join(".");
2822
+ return a.includes(".") && b.includes(".") && tail(a) === tail(b);
2823
+ }
2824
+ function captureStartUrl(context, targetUrl) {
2825
+ const targetHost = new URL(targetUrl).host;
2826
+ const pages = context.pages();
2827
+ for (let i = pages.length - 1; i >= 0; i--) {
2828
+ const pageUrl = pages[i].url();
2829
+ if (!pageUrl.startsWith("http")) continue;
2830
+ if (sameSite(new URL(pageUrl).host, targetHost)) return pageUrl;
2831
+ }
2832
+ return void 0;
2833
+ }
2834
+ async function saveSignIn() {
2835
+ if (!pending) throw new Error("No sign-in window is open. Call start_auth first.");
2836
+ const { context, url } = pending;
2837
+ try {
2838
+ const startUrl = captureStartUrl(context, url);
2839
+ const path = await saveAuthState(url, await context.storageState(), startUrl);
2840
+ return { host: new URL(url).host, path, ...startUrl ? { startUrl } : {} };
2841
+ } finally {
2842
+ await cancelSignIn();
2843
+ }
2844
+ }
2845
+ async function cancelSignIn() {
2846
+ const open = pending;
2847
+ pending = null;
2848
+ await open?.browser.close().catch(() => {
2849
+ });
2850
+ }
2851
+
2852
+ // src/commands/edit-cmd.ts
2853
+ import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync4 } from "fs";
2854
+ import { join as join10 } from "path";
2855
+ import chalk7 from "chalk";
2856
+
2857
+ // src/help.ts
2858
+ init_lib();
2859
+ import chalk from "chalk";
2860
+
2861
+ // src/logo.ts
2862
+ import figlet from "figlet";
2863
+ import gradient from "gradient-string";
2864
+ var DEFAULT_LOGO_CONFIG = {
2865
+ text: "QuickPeek",
2866
+ font: "Slant",
2867
+ horizontalLayout: "default",
2868
+ gradientColors: ["#00ffff", "#00d4ff", "#a855f7", "#ec4899"],
2869
+ suffix: " \u{1F440}"
2870
+ };
2871
+ function renderLogo(config = {}) {
2872
+ const resolved = { ...DEFAULT_LOGO_CONFIG, ...config };
2873
+ const ascii = figlet.textSync(resolved.text, {
2874
+ font: resolved.font,
2875
+ horizontalLayout: resolved.horizontalLayout
2876
+ });
2877
+ const colored = gradient(resolved.gradientColors)(ascii);
2878
+ return resolved.suffix ? `${colored}${resolved.suffix}` : colored;
2879
+ }
2880
+
2881
+ // src/help.ts
2882
+ init_config_loader();
2883
+ var cmd = chalk.cyan;
2884
+ var arg = chalk.green;
2885
+ var opt = chalk.magenta;
2886
+ var desc = chalk.white;
2887
+ var ex = chalk.dim;
2888
+ function showLogo() {
2889
+ const logo = renderLogo();
2890
+ console.log(logo);
2891
+ console.log(chalk.dim(` v${VERSION} - Auto-generate demo videos with AI`));
2892
+ console.log(chalk.italic.dim(" Because Tutorial Videos are Overrated!\n"));
2893
+ }
2894
+ function formatUsageLines(status, tier) {
2895
+ const lines = [];
2896
+ lines.push(`Videos generated: ${status.usage_count}`);
2897
+ if (tier !== "pro" && status.trial) {
2898
+ const { uses_remaining, max_uses, days_remaining, is_expired } = status.trial;
2899
+ const used = max_uses - uses_remaining;
2900
+ lines.push(`Trial: ${used}/${max_uses} uses${is_expired ? " (expired)" : ` (${uses_remaining} remaining)`}`);
2901
+ if (!is_expired && days_remaining > 0) {
2902
+ lines.push(`Trial ends in ${days_remaining} day${days_remaining === 1 ? "" : "s"}`);
2903
+ }
2904
+ }
2905
+ if (tier === "pro" && status.subscription) {
2906
+ const end = status.subscription.current_period_end;
2907
+ if (status.subscription.is_lifetime) {
2908
+ lines.push("Lifetime access");
2909
+ } else if (isCanceling(status)) {
2910
+ const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2911
+ lines.push(`Cancels: ${date}`);
2912
+ } else if (end) {
2913
+ const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2914
+ lines.push(`Renews: ${date}`);
2915
+ }
2916
+ }
2917
+ return lines;
2918
+ }
2919
+
2920
+ // src/lib/browser-open.ts
2921
+ import { exec as exec2 } from "child_process";
2922
+ function editorBaseUrl() {
2923
+ return (process.env.QUICKPEEK_API_BASE || "https://quickpeek.co").replace(/\/$/, "");
2924
+ }
2925
+ function openInBrowser(url) {
2926
+ const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
2927
+ const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
2928
+ return new Promise((resolve10) => {
2929
+ exec2(command, () => resolve10());
2930
+ });
2931
+ }
2932
+
2933
+ // src/lib/plan-validation.ts
2934
+ var INVALID_TARGETS = [
2935
+ /\.png$/i,
2421
2936
  /\.jpg$/i,
2422
2937
  /\.gif$/i,
2423
2938
  /\.webp$/i,
@@ -2531,6 +3046,9 @@ var autoYes = false;
2531
3046
  function setAutoYes(value) {
2532
3047
  autoYes = value;
2533
3048
  }
3049
+ function isAutoYes() {
3050
+ return autoYes;
3051
+ }
2534
3052
  function task(text) {
2535
3053
  return ora({ text, prefixText: INDENT, spinner: "dots" }).start();
2536
3054
  }
@@ -2585,10 +3103,10 @@ import chalk6 from "chalk";
2585
3103
  init_lib();
2586
3104
 
2587
3105
  // src/plan.ts
2588
- import { mkdir as mkdir5, readFile as readFile3, writeFile as writeFile5 } from "fs/promises";
2589
- import { join as join8 } from "path";
3106
+ import { mkdir as mkdir6, readFile as readFile3, writeFile as writeFile6 } from "fs/promises";
3107
+ import { join as join9 } from "path";
2590
3108
  import chalk5 from "chalk";
2591
- import { chromium as chromium5 } from "playwright";
3109
+ import { chromium as chromium4 } from "playwright";
2592
3110
 
2593
3111
  // src/ai/client.ts
2594
3112
  init_lib();
@@ -2664,8 +3182,133 @@ async function browserLogin() {
2664
3182
  });
2665
3183
  }
2666
3184
 
3185
+ // src/utils/logging.ts
3186
+ import { appendFile, mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
3187
+ import { dirname as dirname2, join as join8 } from "path";
3188
+ var LOG_FILE_NAME = "quickpeek.log";
3189
+ var logFileInitialized = false;
3190
+ var logFilePath = LOG_FILE_NAME;
3191
+ async function writeLogEntry(entry) {
3192
+ try {
3193
+ if (!logFileInitialized) {
3194
+ await writeFile5(logFilePath, entry);
3195
+ logFileInitialized = true;
3196
+ } else {
3197
+ await appendFile(logFilePath, entry);
3198
+ }
3199
+ } catch {
3200
+ }
3201
+ }
3202
+ async function setLogFilePath(outputDir) {
3203
+ logFilePath = join8(outputDir, LOG_FILE_NAME);
3204
+ const header2 = `QuickPeek Log - ${(/* @__PURE__ */ new Date()).toISOString()}
3205
+ ${"=".repeat(60)}
3206
+
3207
+ `;
3208
+ try {
3209
+ await mkdir5(dirname2(logFilePath), { recursive: true });
3210
+ await writeFile5(logFilePath, header2);
3211
+ logFileInitialized = true;
3212
+ } catch {
3213
+ }
3214
+ }
3215
+ async function logInfo(context, message) {
3216
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [INFO] [${context}] ${message}
3217
+ `);
3218
+ }
3219
+ async function logDebug(context, message) {
3220
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] [${context}] ${message}
3221
+ `);
3222
+ }
3223
+ async function logError(context, error) {
3224
+ let errorMsg;
3225
+ if (error instanceof Error) {
3226
+ errorMsg = `${error.message}
3227
+ ${error.stack}`;
3228
+ } else if (typeof error === "object" && error !== null) {
3229
+ errorMsg = JSON.stringify(error, null, 2);
3230
+ } else {
3231
+ errorMsg = String(error);
3232
+ }
3233
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [ERROR] [${context}]
3234
+ ${errorMsg}
3235
+
3236
+ `);
3237
+ }
3238
+ async function logStep(stepNum, action, details) {
3239
+ const detailsStr = Object.entries(details).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ");
3240
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [STEP ${stepNum}] ${action} | ${detailsStr}
3241
+ `);
3242
+ }
3243
+
3244
+ // src/ai/mcp-context.ts
3245
+ import { AsyncLocalStorage } from "async_hooks";
3246
+ var DelegatedGenerationError = class extends Error {
3247
+ tool;
3248
+ step;
3249
+ /** The system instructions the call was about to send. */
3250
+ system;
3251
+ /** The user-side prompt. */
3252
+ prompt;
3253
+ constructor(tool, step, system, prompt2) {
3254
+ super(
3255
+ `Generation for "${tool}" (${step}) belongs to the calling model, not Groq. The tool should hand back this prompt and accept the result as a parameter.`
3256
+ );
3257
+ this.name = "DelegatedGenerationError";
3258
+ this.tool = tool;
3259
+ this.step = step;
3260
+ this.system = system;
3261
+ this.prompt = prompt2;
3262
+ }
3263
+ };
3264
+ var mcpCall = new AsyncLocalStorage();
3265
+ function runInMcpTool(tool, fn, answers = {}) {
3266
+ return mcpCall.run({ tool, answers, pending: [] }, fn);
3267
+ }
3268
+ function currentMcpTool() {
3269
+ return mcpCall.getStore()?.tool ?? null;
3270
+ }
3271
+ function suppliedAnswer(step) {
3272
+ return mcpCall.getStore()?.answers?.[step];
3273
+ }
3274
+ function recordDelegation(e) {
3275
+ const store = mcpCall.getStore();
3276
+ if (store && !store.pending.some((p) => p.step === e.step)) store.pending.push(e);
3277
+ }
3278
+ function pendingDelegations() {
3279
+ return mcpCall.getStore()?.pending ?? [];
3280
+ }
3281
+
2667
3282
  // src/ai/client.ts
2668
3283
  var lastRateLimitInfo = null;
3284
+ function wrap(text, width) {
3285
+ const lines = [];
3286
+ for (const paragraph of text.split("\n")) {
3287
+ let current = "";
3288
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
3289
+ if (current && current.length + word.length + 1 > width) {
3290
+ lines.push(current);
3291
+ current = word;
3292
+ } else {
3293
+ current = current ? `${current} ${word}` : word;
3294
+ }
3295
+ }
3296
+ lines.push(current);
3297
+ }
3298
+ return lines.filter((l) => l.length > 0);
3299
+ }
3300
+ function diagnose(status, host) {
3301
+ const where = host ?? "the AI service";
3302
+ if (status === void 0) return `Could not reach ${where}. Check your network, then retry.`;
3303
+ if (status === 401 || status === 403) return `${where} rejected the credentials. Run qp login and retry.`;
3304
+ if (status === 413) return `The prompt was too large for ${where}. Shorten the script or the page.`;
3305
+ if (status === 429) return `${where} is rate limiting. Wait for the quota to reset, then retry.`;
3306
+ if (status === 502 || status === 503 || status === 504) {
3307
+ return `${where} could not reach the AI backend (upstream outage). Retry in a few minutes.`;
3308
+ }
3309
+ if (status >= 500) return `${where} failed internally. Retry in a few minutes; if it persists, report this.`;
3310
+ return `${where} rejected the request. See the body above.`;
3311
+ }
2669
3312
  function getLastRateLimitInfo() {
2670
3313
  return lastRateLimitInfo;
2671
3314
  }
@@ -2713,257 +3356,123 @@ async function setupUserEmail() {
2713
3356
  console.log();
2714
3357
  return email;
2715
3358
  }
2716
- async function callGroq(systemPrompt, userPrompt, logError2, userEmail, tier = "free") {
2717
- const result = process.env.SPARK_AI_KEY ? await callAI(systemPrompt, userPrompt, tier) : await callAIViaRelay(systemPrompt, userPrompt, userEmail ?? "", tier);
3359
+ async function callGroq(systemPrompt, userPrompt, logError2, userEmail, tier = "free", step = "plan") {
3360
+ const mcpTool = currentMcpTool();
3361
+ if (mcpTool) {
3362
+ const answered = suppliedAnswer(step);
3363
+ if (typeof answered === "string") return answered;
3364
+ const refusal = new DelegatedGenerationError(mcpTool, step, systemPrompt, userPrompt);
3365
+ recordDelegation(refusal);
3366
+ throw refusal;
3367
+ }
3368
+ const onRetry = ({ attempt, status, delayMs, host }) => {
3369
+ console.log(
3370
+ chalk4.dim(
3371
+ `
3372
+ ${host} returned ${status}; retrying in ${Math.round(delayMs / 1e3)}s (attempt ${attempt + 1} of 3)`
3373
+ )
3374
+ );
3375
+ };
3376
+ const chatOpts = { onRetry, backoffMs: CLI_BACKOFF_MS };
3377
+ const result = process.env.SPARK_AI_KEY ? await callAI(systemPrompt, userPrompt, tier, chatOpts) : await callAIViaRelay(systemPrompt, userPrompt, userEmail ?? "", tier, chatOpts);
2718
3378
  if (!result.success) {
2719
- await logError2("AI API", new Error(result.error));
3379
+ await logError2("AI API", new Error(result.detail ? `${result.error}
3380
+ Body: ${result.detail}` : result.error));
2720
3381
  if (result.isRateLimit) {
2721
3382
  console.log();
2722
3383
  console.log(chalk4.red(" \u26A0 Rate Limit Reached (after 3 retries)"));
2723
3384
  console.log();
2724
- console.log(chalk4.dim(" Spark AI daily limit exceeded. You may have hit your quota."));
2725
- console.log(chalk4.dim(" Options:"));
2726
- if (result.retryAfterSeconds) {
2727
- const minutes = Math.ceil(result.retryAfterSeconds / 60);
2728
- console.log(chalk4.dim(` \u2022 Wait ${minutes} minutes until quota resets`));
2729
- } else {
2730
- console.log(chalk4.dim(" \u2022 Wait until tomorrow (limits reset at midnight UTC)"));
2731
- }
2732
- console.log(chalk4.dim(" \u2022 Check your usage at: https://sparkbrain.app/dashboard"));
2733
- console.log(chalk4.dim(" \u2022 Upgrade to a higher tier for more requests"));
2734
- console.log();
2735
- process.exit(1);
2736
- }
2737
- if (result.isAuthError) {
2738
- console.log();
2739
- console.log(chalk4.red(" \u26A0 Authentication Error"));
2740
- console.log();
2741
3385
  console.log(chalk4.dim(` ${result.error}`));
2742
- console.log();
2743
- process.exit(1);
2744
- }
2745
- console.log();
2746
- console.log(chalk4.red(" \u26A0 AI Error"));
2747
- console.log();
2748
- const firstLine = result.error.split("\n")[0] ?? result.error;
2749
- console.log(chalk4.dim(` ${firstLine.slice(0, 80)}`));
2750
- console.log();
2751
- process.exit(1);
2752
- }
2753
- lastRateLimitInfo = result.limits;
2754
- return result.content;
2755
- }
2756
-
2757
- // src/browser/launch.ts
2758
- import { mkdir as mkdir4 } from "fs/promises";
2759
- import { chromium as chromium4 } from "playwright";
2760
-
2761
- // src/browser/wsl.ts
2762
- import { execSync, spawn as spawn2 } from "child_process";
2763
- import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
2764
- import { request } from "http";
2765
- import { chromium as chromium3 } from "playwright";
2766
- function isWSL() {
2767
- if (process.platform !== "linux") return false;
2768
- try {
2769
- const r = execSync("uname -r", { encoding: "utf-8" }).toLowerCase();
2770
- return r.includes("microsoft") || r.includes("wsl");
2771
- } catch {
2772
- return false;
2773
- }
2774
- }
2775
- function tryEnableWSLInterop() {
2776
- return existsSync6("/proc/sys/fs/binfmt_misc/WSLInterop");
2777
- }
2778
- function wslToWindowsPath(wslPath) {
2779
- if (!wslPath) return wslPath;
2780
- const m = wslPath.match(/^\/mnt\/([a-z])\/(.*)/);
2781
- if (!m) return wslPath;
2782
- return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
2783
- }
2784
- function findExe(whereExeQuery, ...hardcoded) {
2785
- for (const p of hardcoded) if (existsSync6(p)) return p;
2786
- try {
2787
- const line = execSync(`where.exe ${whereExeQuery}`, { encoding: "utf-8", timeout: 3e3 }).split("\n")[0]?.trim();
2788
- if (line) return line.replace(/^([A-Za-z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
2789
- } catch {
2790
- }
2791
- return null;
2792
- }
2793
- var findWindowsChrome = () => findExe(
2794
- "chrome.exe",
2795
- "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
2796
- "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
2797
- );
2798
- var findWindowsNode = () => findExe("node.exe");
2799
- function getWindowsHostIP() {
2800
- try {
2801
- const m = execSync("ip route show default", { encoding: "utf-8", timeout: 2e3 }).match(
2802
- /default via (\d+\.\d+\.\d+\.\d+)/
2803
- );
2804
- if (m?.[1]) return m[1];
2805
- } catch {
2806
- }
2807
- try {
2808
- const m = readFileSync2("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
2809
- if (m?.[1]) return m[1];
2810
- } catch {
2811
- }
2812
- return "127.0.0.1";
2813
- }
2814
- function startRelay(nodePath, windowsHost, targetPort) {
2815
- const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
2816
- return new Promise((resolve10, reject) => {
2817
- const proc = spawn2(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
2818
- const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
2819
- let buf = "";
2820
- proc.stdout.on("data", (d) => {
2821
- buf += d.toString();
2822
- const m = buf.match(/PORT:(\d+)/);
2823
- if (m) {
2824
- clearTimeout(timer);
2825
- resolve10({ proc, port: +m[1] });
2826
- }
2827
- });
2828
- proc.on("error", (e) => {
2829
- clearTimeout(timer);
2830
- reject(e);
2831
- });
2832
- });
2833
- }
2834
- function waitForChrome(host, port) {
2835
- return new Promise((resolve10, reject) => {
2836
- const deadline = Date.now() + 2e4;
2837
- const poll = () => {
2838
- if (Date.now() > deadline) {
2839
- reject(new Error("Chrome debug port did not open within 20 s"));
2840
- return;
2841
- }
2842
- const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
2843
- res.resume();
2844
- resolve10();
2845
- });
2846
- req.on("error", () => setTimeout(poll, 600));
2847
- req.setTimeout(1e3, () => {
2848
- req.destroy();
2849
- setTimeout(poll, 600);
2850
- });
2851
- req.end();
2852
- };
2853
- poll();
2854
- });
2855
- }
2856
- async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
2857
- const debugPort = options.debugPort ?? 9222;
2858
- const chromePath = findWindowsChrome();
2859
- if (!chromePath) throw new Error("Windows Chrome not found. Install Google Chrome for Windows.");
2860
- const nodePath = findWindowsNode();
2861
- if (!nodePath) throw new Error("Windows Node.js not found. Install Node.js for Windows.");
2862
- const winUserData = wslToWindowsPath(userDataDir);
2863
- const winExtPath = extensionPath ? wslToWindowsPath(extensionPath) : "";
2864
- const chromeProc = spawn2(
2865
- chromePath,
2866
- [
2867
- `--remote-debugging-port=${debugPort}`,
2868
- `--user-data-dir=${winUserData}`,
2869
- ...winExtPath ? [`--disable-extensions-except=${winExtPath}`, `--load-extension=${winExtPath}`] : [],
2870
- "--no-first-run",
2871
- "--no-default-browser-check",
2872
- "--disable-background-networking",
2873
- "--disable-sync",
2874
- "--disable-features=TranslateUI",
2875
- options.startMaximized ? "--start-maximized" : "--start-minimized"
2876
- ],
2877
- { stdio: "ignore", detached: true }
2878
- );
2879
- chromeProc.unref();
2880
- const windowsHost = getWindowsHostIP();
2881
- const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
2882
- try {
2883
- await waitForChrome(windowsHost, proxyPort);
2884
- const browser = await chromium3.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
2885
- return {
2886
- browser,
2887
- cleanup: async () => {
2888
- try {
2889
- await browser.close();
2890
- } catch {
2891
- }
2892
- relay.kill();
3386
+ console.log(chalk4.dim(" Options:"));
3387
+ if (result.retryAfterSeconds) {
3388
+ const minutes = Math.ceil(result.retryAfterSeconds / 60);
3389
+ console.log(chalk4.dim(` \u2022 Wait ${minutes} minutes until quota resets`));
3390
+ } else {
3391
+ console.log(chalk4.dim(" \u2022 Wait until tomorrow (limits reset at midnight UTC)"));
2893
3392
  }
2894
- };
2895
- } catch (err) {
2896
- relay.kill();
2897
- throw err;
2898
- }
2899
- }
2900
-
2901
- // src/browser/launch.ts
2902
- async function launchBrowserContext(config, userDataDir, recordVideo, storageStatePath) {
2903
- const extensionPath = config.browser?.extension;
2904
- const viewport = { width: config.video.width, height: config.video.height };
2905
- const videoOpts = recordVideo ? { recordVideo } : {};
2906
- const authOpts = storageStatePath ? { storageState: storageStatePath } : {};
2907
- let context;
2908
- let browser = null;
2909
- let chromeSession = null;
2910
- if (extensionPath) {
2911
- await mkdir4(userDataDir, { recursive: true });
2912
- const persistentArgs = [
2913
- `--disable-extensions-except=${extensionPath}`,
2914
- `--load-extension=${extensionPath}`,
2915
- "--window-position=-32000,-32000",
2916
- "--window-size=1,1"
2917
- ];
2918
- const useWindowsChrome = isWSL() && tryEnableWSLInterop();
2919
- if (useWindowsChrome) {
2920
- try {
2921
- chromeSession = await launchWindowsChrome(extensionPath, userDataDir, { viewport, ...videoOpts });
2922
- const contexts = chromeSession.browser.contexts();
2923
- context = contexts[0] || await chromeSession.browser.newContext({ viewport, ...videoOpts, acceptDownloads: true });
2924
- } catch {
2925
- context = await chromium4.launchPersistentContext(userDataDir, {
2926
- headless: false,
2927
- args: persistentArgs,
2928
- viewport,
2929
- ...videoOpts,
2930
- acceptDownloads: true
2931
- });
3393
+ console.log(chalk4.dim(" \u2022 Check your usage at: https://sparkbrain.app/dashboard"));
3394
+ console.log(chalk4.dim(" \u2022 Upgrade to a higher tier for more requests"));
3395
+ console.log();
3396
+ process.exit(1);
3397
+ }
3398
+ if (result.isAuthError) {
3399
+ console.log();
3400
+ console.log(chalk4.red(` \u26A0 Authentication Error${result.status ? ` (HTTP ${result.status})` : ""}`));
3401
+ console.log();
3402
+ for (const line of wrap(result.error, 76)) {
3403
+ console.log(chalk4.dim(` ${line}`));
2932
3404
  }
2933
- } else {
2934
- context = await chromium4.launchPersistentContext(userDataDir, {
2935
- headless: false,
2936
- args: persistentArgs,
2937
- viewport,
2938
- ...videoOpts,
2939
- acceptDownloads: true
2940
- });
3405
+ console.log();
3406
+ console.log(chalk4.dim(` ${diagnose(result.status, result.host)}`));
3407
+ console.log();
3408
+ process.exit(1);
2941
3409
  }
2942
- } else {
2943
- browser = await chromium4.launch({ headless: true });
2944
- context = await browser.newContext({ viewport, ...videoOpts, ...authOpts, acceptDownloads: true });
3410
+ console.log();
3411
+ console.log(chalk4.red(` \u26A0 AI Error${result.status ? ` (HTTP ${result.status})` : ""}`));
3412
+ console.log();
3413
+ for (const line of wrap(result.error, 76)) {
3414
+ console.log(chalk4.dim(` ${line}`));
3415
+ }
3416
+ if (result.detail && !result.error.includes(result.detail)) {
3417
+ console.log();
3418
+ for (const line of wrap(result.detail, 76).slice(0, 6)) {
3419
+ console.log(chalk4.dim(` ${line}`));
3420
+ }
3421
+ }
3422
+ console.log();
3423
+ console.log(chalk4.dim(` ${diagnose(result.status, result.host)}`));
3424
+ console.log(chalk4.dim(` Full response body: ${logFilePath}`));
3425
+ console.log();
3426
+ process.exit(1);
2945
3427
  }
2946
- return { context, browser, chromeSession };
3428
+ lastRateLimitInfo = result.limits;
3429
+ return result.content;
2947
3430
  }
2948
- async function cleanupBrowserSession(session) {
2949
- if (session.chromeSession) {
2950
- await session.chromeSession.cleanup();
2951
- } else {
2952
- await session.context.close();
2953
- if (session.browser) await session.browser.close();
3431
+
3432
+ // src/ai/json-plan.ts
3433
+ init_lib();
3434
+ init_utils();
3435
+ var JSON_FIX_SYSTEM_PROMPT = "You fix malformed JSON. Return ONLY valid JSON, no explanation.";
3436
+ var PLAN_SHAPE = `The JSON should have: url, title, description, steps array.
3437
+ Each step has: id (number), action, caption, and optionally target, value.`;
3438
+ var jsonFixUserPrompt = (content, shape) => `Fix this malformed JSON. Return ONLY valid JSON, no explanation.
3439
+ ${shape}
3440
+
3441
+ Malformed content:
3442
+ ${content}`;
3443
+ var noopTask2 = () => ({ succeed: () => {
3444
+ }, fail: () => {
3445
+ } });
3446
+ async function parseAIJsonWithRepair(content, opts) {
3447
+ const { logError: logError2, userEmail, tier = "free", shape = PLAN_SHAPE, context = "JSON Fix" } = opts;
3448
+ const direct = parseAIJson(content);
3449
+ if (direct) return direct.value;
3450
+ const makeTask = opts.task ?? noopTask2;
3451
+ const fixTask = makeTask("Fixing malformed JSON");
3452
+ try {
3453
+ const fixed = await callGroq(JSON_FIX_SYSTEM_PROMPT, jsonFixUserPrompt(content, shape), logError2, userEmail, tier);
3454
+ const repaired = parseAIJson(fixed);
3455
+ if (!repaired) throw new Error("No usable JSON in fix");
3456
+ fixTask.succeed("JSON fixed by AI");
3457
+ return repaired.value;
3458
+ } catch (err) {
3459
+ await logError2(context, err);
3460
+ fixTask.fail("Could not fix JSON");
3461
+ throw new Error(`Malformed JSON: ${getErrorMessage(err)}`);
2954
3462
  }
2955
3463
  }
2956
3464
 
2957
3465
  // src/plan.ts
2958
3466
  init_lib();
3467
+ init_json_repair();
2959
3468
  async function crawlExtension(url, extensionPath, workDir, task2) {
2960
3469
  const browserTask = task2("Launching browser with extension");
2961
- const userDataDir = join8(workDir, ".browser-data");
2962
- await mkdir5(userDataDir, { recursive: true });
3470
+ const userDataDir = join9(workDir, ".browser-data");
3471
+ await mkdir6(userDataDir, { recursive: true });
2963
3472
  let context;
2964
3473
  let chromeSession = null;
2965
3474
  const useWindowsChrome = isWSL() && tryEnableWSLInterop();
2966
- const launchPersistent = () => chromium5.launchPersistentContext(userDataDir, {
3475
+ const launchPersistent = () => chromium4.launchPersistentContext(userDataDir, {
2967
3476
  headless: false,
2968
3477
  args: [
2969
3478
  `--disable-extensions-except=${extensionPath}`,
@@ -3044,7 +3553,7 @@ async function crawlExtension(url, extensionPath, workDir, task2) {
3044
3553
  else if (el.type === "select" || el.type === "select-one") action = "select";
3045
3554
  else if (el.type === "checkbox") action = "check";
3046
3555
  else if (["text", "email", "password", "number", "search", "url", "tel"].includes(el.type)) action = "type";
3047
- const selector = el.id ? `#${el.id}` : el.name ? `[name="${el.name}"]` : `text="${el.text}"`;
3556
+ const selector = el.id ? idSelector(el.id) : el.name ? `[name="${el.name}"]` : `text="${el.text}"`;
3048
3557
  return {
3049
3558
  type: el.type === "button" || el.type === "link" ? el.type : "input",
3050
3559
  text: el.text,
@@ -3056,31 +3565,8 @@ async function crawlExtension(url, extensionPath, workDir, task2) {
3056
3565
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
3057
3566
  return { title, elements };
3058
3567
  }
3059
- var JSON_FIX_SYSTEM_PROMPT = "You fix malformed JSON. Return ONLY valid JSON, no explanation.";
3060
- var jsonFixUserPrompt = (content) => `Fix this malformed JSON plan. Return ONLY valid JSON, no explanation.
3061
- The JSON should have: url, title, description, steps array.
3062
- Each step has: id (number), action, caption, and optionally target, value.
3063
-
3064
- Malformed content:
3065
- ${content}`;
3066
3568
  async function parsePlanJson(content, task2, logError2, userEmail, tier = "free") {
3067
- try {
3068
- return JSON.parse(content);
3069
- } catch {
3070
- const fixTask = task2("Fixing malformed JSON");
3071
- try {
3072
- const fixed = await callGroq(JSON_FIX_SYSTEM_PROMPT, jsonFixUserPrompt(content), logError2, userEmail, tier);
3073
- const jsonMatch = fixed.match(/\{[\s\S]*\}/);
3074
- if (!jsonMatch) throw new Error("No JSON in fix");
3075
- const plan = JSON.parse(jsonMatch[0]);
3076
- fixTask.succeed("JSON fixed by AI");
3077
- return plan;
3078
- } catch (err) {
3079
- await logError2("JSON Fix", err);
3080
- fixTask.fail("Could not fix JSON");
3081
- throw new Error(`Malformed JSON: ${getErrorMessage(err)}`);
3082
- }
3083
- }
3569
+ return parseAIJsonWithRepair(content, { logError: logError2, task: task2, userEmail, tier });
3084
3570
  }
3085
3571
  async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError2, userEmail, extensionPath, workDir, tier = "free", viewport) {
3086
3572
  const { title, elements, scrollable } = extensionPath && workDir ? { ...await crawlExtension(url, extensionPath, workDir, task2), scrollable: void 0 } : await crawlPage(url, task2, findAuthState(url), viewport);
@@ -3089,10 +3575,9 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
3089
3575
  const systemPrompt = buildSystemPrompt(maxSteps);
3090
3576
  const userPrompt = buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable);
3091
3577
  try {
3092
- const content = await callGroq(systemPrompt, userPrompt, logError2, userEmail, tier);
3093
- const jsonMatch = content.match(/\{[\s\S]*\}/);
3094
- if (!jsonMatch) throw new Error("No JSON found in response");
3095
- const plan = await parsePlanJson(jsonMatch[0], task2, logError2, userEmail, tier);
3578
+ const content = await callGroq(systemPrompt, userPrompt, logError2, userEmail, tier, "plan");
3579
+ const plan = await parsePlanJson(content, task2, logError2, userEmail, tier);
3580
+ if (!Array.isArray(plan.steps)) throw new Error("AI response has no steps array");
3096
3581
  if (lang !== void 0) plan.lang = lang;
3097
3582
  plan.steps = plan.steps.map(({ duration, ...s }, i) => ({
3098
3583
  ...s,
@@ -3142,7 +3627,7 @@ async function validateAndFixPlan(planPath, task2, logError2, userEmail, tier =
3142
3627
  renumberTask.fail("No valid steps");
3143
3628
  throw new Error("Plan has no valid steps");
3144
3629
  }
3145
- await writeFile5(planPath, JSON.stringify(plan, null, 2));
3630
+ await writeFile6(planPath, JSON.stringify(plan, null, 2));
3146
3631
  const removed = originalCount - plan.steps.length;
3147
3632
  renumberTask.succeed(
3148
3633
  removed > 0 ? `${plan.steps.length} steps (${removed} removed)` : `${plan.steps.length} steps validated`
@@ -3152,66 +3637,6 @@ async function validateAndFixPlan(planPath, task2, logError2, userEmail, tier =
3152
3637
 
3153
3638
  // src/commands/utils/record-helpers.ts
3154
3639
  init_config_loader();
3155
-
3156
- // src/utils/logging.ts
3157
- import { appendFile, writeFile as writeFile6 } from "fs/promises";
3158
- import { join as join9 } from "path";
3159
- var LOG_FILE_NAME = "quickpeek.log";
3160
- var logFileInitialized = false;
3161
- var logFilePath = LOG_FILE_NAME;
3162
- async function writeLogEntry(entry) {
3163
- try {
3164
- if (!logFileInitialized) {
3165
- await writeFile6(logFilePath, entry);
3166
- logFileInitialized = true;
3167
- } else {
3168
- await appendFile(logFilePath, entry);
3169
- }
3170
- } catch {
3171
- }
3172
- }
3173
- async function setLogFilePath(outputDir) {
3174
- logFilePath = join9(outputDir, LOG_FILE_NAME);
3175
- const header2 = `QuickPeek Log - ${(/* @__PURE__ */ new Date()).toISOString()}
3176
- ${"=".repeat(60)}
3177
-
3178
- `;
3179
- try {
3180
- await writeFile6(logFilePath, header2);
3181
- logFileInitialized = true;
3182
- } catch {
3183
- }
3184
- }
3185
- async function logInfo(context, message) {
3186
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [INFO] [${context}] ${message}
3187
- `);
3188
- }
3189
- async function logDebug(context, message) {
3190
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] [${context}] ${message}
3191
- `);
3192
- }
3193
- async function logError(context, error) {
3194
- let errorMsg;
3195
- if (error instanceof Error) {
3196
- errorMsg = `${error.message}
3197
- ${error.stack}`;
3198
- } else if (typeof error === "object" && error !== null) {
3199
- errorMsg = JSON.stringify(error, null, 2);
3200
- } else {
3201
- errorMsg = String(error);
3202
- }
3203
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [ERROR] [${context}]
3204
- ${errorMsg}
3205
-
3206
- `);
3207
- }
3208
- async function logStep(stepNum, action, details) {
3209
- const detailsStr = Object.entries(details).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ");
3210
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [STEP ${stepNum}] ${action} | ${detailsStr}
3211
- `);
3212
- }
3213
-
3214
- // src/commands/utils/record-helpers.ts
3215
3640
  init_utils();
3216
3641
  var FREE_VIDEO_LIMIT = 5;
3217
3642
  var INDENT2 = " ";
@@ -3331,7 +3756,7 @@ function findPlanFile(dir, lang) {
3331
3756
 
3332
3757
  // src/commands/record-cmd.ts
3333
3758
  import { existsSync as existsSync18 } from "fs";
3334
- import { mkdir as mkdir8 } from "fs/promises";
3759
+ import { mkdir as mkdir9 } from "fs/promises";
3335
3760
  import { isAbsolute as isAbsolute5, join as join21, resolve as resolve9 } from "path";
3336
3761
  import chalk15 from "chalk";
3337
3762
  init_lib();
@@ -3516,10 +3941,10 @@ import chalk10 from "chalk";
3516
3941
  // src/ai/fix.ts
3517
3942
  init_lib();
3518
3943
  async function callGroqAndFinalize(systemPrompt, userMessage, plan, logError2, userEmail, tier = "free") {
3519
- const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier);
3520
- const jsonMatch = content.match(/\{[\s\S]*\}/);
3521
- if (!jsonMatch) throw new Error("No JSON in AI response");
3522
- return finalizePlan(JSON.parse(jsonMatch[0]), plan);
3944
+ const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier, "fix");
3945
+ const result = await parseAIJsonWithRepair(content, { logError: logError2, userEmail, tier, context: "Plan Fix" });
3946
+ if (!Array.isArray(result.steps)) throw new Error("AI response has no steps array");
3947
+ return finalizePlan(result, plan);
3523
3948
  }
3524
3949
  function finalizePlan(result, original) {
3525
3950
  result.url = original.url;
@@ -3643,10 +4068,15 @@ async function translatePlan(plan, targetLang, logError2, userEmail, tier = "fre
3643
4068
  ${JSON.stringify(toTranslate)}
3644
4069
 
3645
4070
  Return ONLY JSON: {"description": "...", "captions": ["...", ...]}`;
3646
- const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier);
3647
- const jsonMatch = content.match(/\{[\s\S]*\}/);
3648
- if (!jsonMatch) throw new Error("No JSON in translation response");
3649
- const translated = JSON.parse(jsonMatch[0]);
4071
+ const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier, "fix");
4072
+ const translated = await parseAIJsonWithRepair(content, {
4073
+ logError: logError2,
4074
+ userEmail,
4075
+ tier,
4076
+ shape: "The JSON should have: description (string), captions (array of strings).",
4077
+ context: "Translate Plan"
4078
+ });
4079
+ if (!Array.isArray(translated.captions)) throw new Error("Translation response has no captions array");
3650
4080
  const translatedPlan = JSON.parse(JSON.stringify(plan));
3651
4081
  translatedPlan.title = plan.title;
3652
4082
  translatedPlan.description = translated.description || plan.description;
@@ -3720,6 +4150,11 @@ async function runPlanPhase(params) {
3720
4150
  }
3721
4151
  const normalizeUrl2 = (u) => u.replace(/\/+$/, "").toLowerCase();
3722
4152
  const urlMatches = existingPlan && normalizeUrl2(existingPlan.url) === normalizeUrl2(url);
4153
+ if (existingPlan && !urlMatches && isAutoYes()) {
4154
+ console.log();
4155
+ console.log(chalk10.dim(` Existing plan is for ${existingPlan.url}, not ${url} \u2014 regenerating.`));
4156
+ existingPlan = null;
4157
+ }
3723
4158
  let plan;
3724
4159
  const doTranslate = async (failMsg) => {
3725
4160
  const translateTask = task(`Translating to ${targetLang}`);
@@ -3884,7 +4319,7 @@ import chalk12 from "chalk";
3884
4319
 
3885
4320
  // src/record.ts
3886
4321
  import { existsSync as existsSync13 } from "fs";
3887
- import { mkdir as mkdir6 } from "fs/promises";
4322
+ import { mkdir as mkdir7 } from "fs/promises";
3888
4323
  import { join as join16 } from "path";
3889
4324
  import chalk11 from "chalk";
3890
4325
 
@@ -3895,7 +4330,7 @@ import { isAbsolute as isAbsolute2, resolve as resolve5 } from "path";
3895
4330
 
3896
4331
  // src/browser/effects.ts
3897
4332
  init_lib();
3898
- import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
4333
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
3899
4334
  import { join as join12 } from "path";
3900
4335
  function loadHighlightCSS() {
3901
4336
  const candidates = [
@@ -3905,7 +4340,7 @@ function loadHighlightCSS() {
3905
4340
  // src/browser/ → src/
3906
4341
  ];
3907
4342
  for (const p of candidates) {
3908
- if (existsSync9(p)) return readFileSync4(p, "utf-8");
4343
+ if (existsSync9(p)) return readFileSync5(p, "utf-8");
3909
4344
  }
3910
4345
  return "";
3911
4346
  }
@@ -4105,6 +4540,10 @@ async function resolveElement(page, target) {
4105
4540
  await locator.waitFor({ state: "visible", timeout });
4106
4541
  return await locator.elementHandle({ timeout: 1e3 });
4107
4542
  }
4543
+ if (target.startsWith("#") && !/^#[A-Za-z_-][\w-]*$/.test(target)) {
4544
+ const attr = `[id="${target.slice(1).replace(/(["\\])/g, "\\$1")}"]`;
4545
+ return await page.waitForSelector(attr, { state: "visible", timeout });
4546
+ }
4108
4547
  return await page.waitForSelector(target, { state: "visible", timeout });
4109
4548
  } catch {
4110
4549
  return null;
@@ -4910,14 +5349,14 @@ function formatStepLabel(step) {
4910
5349
  }
4911
5350
  async function recordSession(plan, config, stepDurations, workDir, baseDir, task2, logError2) {
4912
5351
  const videoDir = join16(workDir, "video");
4913
- await mkdir6(videoDir, { recursive: true });
5352
+ await mkdir7(videoDir, { recursive: true });
4914
5353
  const getStepDuration = (stepNum) => {
4915
5354
  return stepDurations[stepNum - 1] ?? config.timing.minStepDuration;
4916
5355
  };
4917
5356
  const browserTask = task2("Starting browser recording");
4918
5357
  const recordingStartTime = Date.now();
4919
5358
  const userDataDir = join16(workDir, "browser-data");
4920
- const recordVideo = { dir: videoDir, size: { width: config.video.width, height: config.video.height } };
5359
+ const recordVideo = { dir: videoDir, size: recordingViewport(config) };
4921
5360
  const authState = findAuthState(plan.url);
4922
5361
  const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
4923
5362
  const page = context.pages()[0] || await context.newPage();
@@ -4938,6 +5377,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
4938
5377
  let onMediaPage = firstStepIsMedia;
4939
5378
  const failures = [];
4940
5379
  const actualStepDurations = [];
5380
+ let pageClosedEarly = false;
4941
5381
  for (let i = 0; i < plan.steps.length; i++) {
4942
5382
  const step = plan.steps[i];
4943
5383
  const stepNum = i + 1;
@@ -4964,6 +5404,12 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
4964
5404
  let targetEl = null;
4965
5405
  if (step.target) {
4966
5406
  targetEl = await resolveElement(page, step.target);
5407
+ if (!targetEl) {
5408
+ console.log(
5409
+ `
5410
+ \u26A0 target "${step.target}" not found on page \u2014 step ${stepNum} recorded without the action`
5411
+ );
5412
+ }
4967
5413
  }
4968
5414
  if (step.zoom && step.zoom !== 1 && targetEl && !isExtensionPage) {
4969
5415
  const preZoomBox = await targetEl.boundingBox() ?? void 0;
@@ -5000,11 +5446,19 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
5000
5446
  }
5001
5447
  const stepElapsed = Date.now() - stepStartTime;
5002
5448
  const waitTime = Math.max(0, stepDuration - stepElapsed);
5003
- if (waitTime > 0) await page.waitForTimeout(waitTime);
5004
5449
  const stepDelay = config.video.stepDelay ?? 500;
5005
5450
  const isLastStep = i === plan.steps.length - 1;
5006
- if (!isLastStep && stepDelay > 0) {
5007
- await page.waitForTimeout(stepDelay);
5451
+ try {
5452
+ if (waitTime > 0) await page.waitForTimeout(waitTime);
5453
+ if (!isLastStep && stepDelay > 0) await page.waitForTimeout(stepDelay);
5454
+ } catch (err) {
5455
+ if (page.isClosed()) {
5456
+ console.log(chalk11.yellow("\n \u26A0 Browser page closed early \u2014 saving the recording captured so far"));
5457
+ pageClosedEarly = true;
5458
+ actualStepDurations.push(Math.max(stepElapsed, stepDuration));
5459
+ break;
5460
+ }
5461
+ throw err;
5008
5462
  }
5009
5463
  if (!isExtensionPage) {
5010
5464
  const shouldHighlight = step.highlight !== void 0 ? step.highlight : config.video.highlight;
@@ -5022,10 +5476,11 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
5022
5476
  console.log(chalk11.yellow(` - ${f}`));
5023
5477
  }
5024
5478
  }
5479
+ while (actualStepDurations.length < plan.steps.length) actualStepDurations.push(0);
5025
5480
  const saveTask = task2("Saving video");
5026
5481
  const video = page.video();
5027
5482
  if (!video) throw new Error("No video recorder available");
5028
- await page.close();
5483
+ if (!pageClosedEarly) await page.close();
5029
5484
  const videoPath = await video.path();
5030
5485
  await cleanupBrowserSession({ context, browser, chromeSession });
5031
5486
  if (!videoPath || !existsSync13(videoPath)) {
@@ -5195,7 +5650,7 @@ import { join as join18, resolve as resolve8 } from "path";
5195
5650
  init_lib();
5196
5651
  import { chunkWordsByCount, assTimeFromMs as formatASSTime } from "@spark-apps/video-kit";
5197
5652
  import { existsSync as existsSync14 } from "fs";
5198
- import { mkdir as mkdir7, writeFile as writeFile8 } from "fs/promises";
5653
+ import { mkdir as mkdir8, writeFile as writeFile8 } from "fs/promises";
5199
5654
  import { isAbsolute as isAbsolute4, join as join17, resolve as resolve7 } from "path";
5200
5655
  import chalk13 from "chalk";
5201
5656
  var INDENT4 = " ";
@@ -5288,7 +5743,7 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
5288
5743
  process.exit(0);
5289
5744
  }
5290
5745
  const composeTask = task("Building final video");
5291
- await mkdir7(config.output.dir, { recursive: true });
5746
+ await mkdir8(config.output.dir, { recursive: true });
5292
5747
  const suffix = config.lang ? `-${config.lang}` : "";
5293
5748
  const finalPath = join17(config.output.dir, `demo${suffix}.${config.video.format}`);
5294
5749
  const videoDuration = await getMediaDuration(videoPath) / 1e3;
@@ -5356,6 +5811,21 @@ async function speedUpToFitLimit(finalPath, config, limitSecs = 60) {
5356
5811
 
5357
5812
  // src/commands/phases/video-phase.ts
5358
5813
  var INDENT5 = " ";
5814
+ function resolveOutro(config, userTier) {
5815
+ if (userTier !== "free") {
5816
+ const custom = config.video.outro?.trim();
5817
+ if (!custom) return void 0;
5818
+ const path = resolve8(custom);
5819
+ if (existsSync15(path)) return path;
5820
+ info(`${INDENT5}\u26A0 outro not found: ${path}`);
5821
+ return void 0;
5822
+ }
5823
+ const scriptDir = getScriptDir();
5824
+ const bundled = join18(scriptDir, "assets", "qp.wm");
5825
+ if (existsSync15(bundled)) return bundled;
5826
+ const fallback = join18(scriptDir, "..", "dist", "assets", "qp.wm");
5827
+ return existsSync15(fallback) ? fallback : void 0;
5828
+ }
5359
5829
  async function runVideoPhase(params) {
5360
5830
  let { stepAudio } = params;
5361
5831
  const { plan, config, voiceoverPath, workDir, captions, userTier } = params;
@@ -5460,7 +5930,7 @@ async function runVideoPhase(params) {
5460
5930
  const transitionTask = task("Applying transitions");
5461
5931
  try {
5462
5932
  const transitionedPath = join18(workDir, "transitioned.mp4");
5463
- const videoDuration = alignedStepDurations.reduce((a, b) => a + b, 0) / 1e3;
5933
+ const videoDuration = await getMediaDuration(videoPath) / 1e3;
5464
5934
  await applyTransitions({
5465
5935
  inputVideo: videoPath,
5466
5936
  outputVideo: transitionedPath,
@@ -5478,30 +5948,24 @@ async function runVideoPhase(params) {
5478
5948
  const assPath = await generateASS(plan, stepAudio, workDir, config, captions);
5479
5949
  let videoToCompose = videoPath;
5480
5950
  let mainContentDuration;
5481
- if (userTier === "free") {
5482
- const scriptDir = getScriptDir();
5483
- let outroPath = join18(scriptDir, "assets", "qp.wm");
5484
- if (!existsSync15(outroPath)) {
5485
- outroPath = join18(scriptDir, "..", "dist", "assets", "qp.wm");
5486
- }
5487
- if (existsSync15(outroPath)) {
5488
- try {
5489
- const mainDuration = await getMediaDuration(videoPath);
5490
- mainContentDuration = mainDuration / 1e3 - trimStart;
5491
- const combinedPath = join18(workDir, "combined-with-outro.mp4");
5492
- await concatVideoWithOutro({
5493
- inputPath: videoPath,
5494
- outroPath,
5495
- outputPath: combinedPath,
5496
- width: config.video.width,
5497
- height: config.video.height,
5498
- preset: config.video.preset
5499
- });
5500
- videoToCompose = combinedPath;
5501
- } catch (err) {
5502
- await logError("Concat Outro", err);
5503
- mainContentDuration = void 0;
5504
- }
5951
+ const outroPath = resolveOutro(config, userTier);
5952
+ if (outroPath) {
5953
+ try {
5954
+ const mainDuration = await getMediaDuration(videoPath);
5955
+ mainContentDuration = mainDuration / 1e3 - trimStart;
5956
+ const combinedPath = join18(workDir, "combined-with-outro.mp4");
5957
+ await concatVideoWithOutro({
5958
+ inputPath: videoPath,
5959
+ outroPath,
5960
+ outputPath: combinedPath,
5961
+ width: config.video.width,
5962
+ height: config.video.height,
5963
+ preset: config.video.preset
5964
+ });
5965
+ videoToCompose = combinedPath;
5966
+ } catch (err) {
5967
+ await logError("Concat Outro", err);
5968
+ mainContentDuration = void 0;
5505
5969
  }
5506
5970
  }
5507
5971
  let finalPath = await composeAndFinish(
@@ -5546,8 +6010,8 @@ async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5546
6010
  for (const file of filesToDelete) {
5547
6011
  const filePath = join19(config.output.dir, file);
5548
6012
  if (existsSync16(filePath)) {
5549
- const { unlink: unlink3 } = await import("fs/promises");
5550
- await unlink3(filePath).catch(() => {
6013
+ const { unlink: unlink4 } = await import("fs/promises");
6014
+ await unlink4(filePath).catch(() => {
5551
6015
  });
5552
6016
  }
5553
6017
  }
@@ -5556,7 +6020,12 @@ async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5556
6020
  const voice = resolveVoice(config);
5557
6021
  const existingMeta = await loadVoiceoverMeta(config.output.dir);
5558
6022
  const voiceoverFile = join19(config.output.dir, "voiceover.wav");
5559
- const hasExistingVoiceover = existingMeta && existsSync16(voiceoverFile);
6023
+ const voiceoverOnDisk = existingMeta && existsSync16(voiceoverFile);
6024
+ const staleContainer = voiceoverOnDisk ? !await isHonestWav(voiceoverFile) : false;
6025
+ if (staleContainer) {
6026
+ info("Existing voiceover has a stale container, regenerating");
6027
+ }
6028
+ const hasExistingVoiceover = voiceoverOnDisk && !staleContainer;
5560
6029
  const voiceMatches = !existingMeta || !voice || existingMeta.voice === voice;
5561
6030
  let voiceoverPath;
5562
6031
  let stepAudio;
@@ -5613,7 +6082,7 @@ async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5613
6082
  }
5614
6083
 
5615
6084
  // src/commands/utils/chrome-extension.ts
5616
- import { existsSync as existsSync17, readdirSync as readdirSync4, readFileSync as readFileSync5 } from "fs";
6085
+ import { existsSync as existsSync17, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "fs";
5617
6086
  import { homedir as homedir3 } from "os";
5618
6087
  import { join as join20 } from "path";
5619
6088
  function resolveChromeExtension(extensionId) {
@@ -5660,7 +6129,7 @@ function resolveChromeExtension(extensionId) {
5660
6129
  const manifestPath = join20(extPath, "manifest.json");
5661
6130
  if (existsSync17(manifestPath)) {
5662
6131
  try {
5663
- const manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
6132
+ const manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
5664
6133
  if (manifest.name && !manifest.name.startsWith("__MSG_")) {
5665
6134
  name = manifest.name;
5666
6135
  }
@@ -5702,6 +6171,13 @@ async function recordCommand(url, options = {}) {
5702
6171
  console.log(chalk15.dim(` Extension: ${chromeExtension.name}`));
5703
6172
  console.log(chalk15.dim(` Path: ${chromeExtension.path}`));
5704
6173
  }
6174
+ if (!chromeExtension) {
6175
+ const startUrl = resolveStartUrl(url);
6176
+ if (startUrl !== url) {
6177
+ console.log(chalk15.dim(` Signed-in start page: ${startUrl}`));
6178
+ url = startUrl;
6179
+ }
6180
+ }
5705
6181
  config.output.dir = chromeExtension ? `./${chromeExtension.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-demo` : getOutputDirFromUrl(url);
5706
6182
  const userData = await loadUserData();
5707
6183
  const authResult = await runAuthPhase({
@@ -5757,7 +6233,7 @@ async function recordCommand(url, options = {}) {
5757
6233
  const planPath = join21(config.output.dir, `plan-${targetLang}.json`);
5758
6234
  const legacyPlanPath = join21(config.output.dir, "plan.json");
5759
6235
  const isExtensionUrl = url.startsWith("chrome-extension://");
5760
- await mkdir8(config.output.dir, { recursive: true });
6236
+ await mkdir9(config.output.dir, { recursive: true });
5761
6237
  await setLogFilePath(config.output.dir);
5762
6238
  try {
5763
6239
  const plan = await runPlanPhase({
@@ -5820,23 +6296,32 @@ import chalk16 from "chalk";
5820
6296
 
5821
6297
  // src/ai/narrate.ts
5822
6298
  init_lib();
5823
- async function generatePlanFromScript(captions, url, title, pageElementsText, lang, logError2, userEmail, tier = "free") {
6299
+ var SCRIPT_ACTIONS = /* @__PURE__ */ new Set(["click", "type", "scroll", "wait", "select", "check", "navigate"]);
6300
+ var NEEDS_TARGET = /* @__PURE__ */ new Set(["click", "type", "select", "check", "navigate"]);
6301
+ function isUsableStep(step) {
6302
+ if (!step?.action || !SCRIPT_ACTIONS.has(step.action)) return false;
6303
+ if (NEEDS_TARGET.has(step.action) && !step.target) return false;
6304
+ if (step.action === "navigate" && !/^https?:\/\//.test(step.target ?? "")) return false;
6305
+ return true;
6306
+ }
6307
+ async function generatePlanFromScript(captions, url, title, pageElementsText, lang, logError2, userEmail, tier = "free", task2) {
5824
6308
  const systemPrompt = `You map narration sentences to web UI actions.
5825
- Given page elements and a script, return a JSON demo plan.
6309
+ Given page elements and a numbered script, return one action per script line.
5826
6310
 
5827
6311
  RULES:
5828
- - Return ONLY valid JSON
5829
- - Preserve captions EXACTLY as given \u2014 never alter the text
6312
+ - Return ONLY valid JSON, no explanation, no captions
6313
+ - Return exactly one entry per script line, in order, with "line" set to that line's number
5830
6314
  - Use page elements to pick realistic actions/targets
5831
- - If a caption doesn't match a specific UI action, use { "action": "wait" }
6315
+ - If a line doesn't match a specific UI action, use { "action": "wait" }
6316
+ - Keep every target and value short
5832
6317
 
5833
6318
  ACTION FORMATS:
5834
- \u2022 click: { "action": "click", "target": "button text" }
5835
- \u2022 type: { "action": "type", "target": "input selector", "value": "text from caption" }
5836
- \u2022 scroll: { "action": "scroll", "value": "up|down" }
5837
- \u2022 wait: { "action": "wait" }
6319
+ \u2022 click: { "line": 1, "action": "click", "target": "button text" }
6320
+ \u2022 type: { "line": 2, "action": "type", "target": "input selector", "value": "text to type" }
6321
+ \u2022 scroll: { "line": 3, "action": "scroll", "value": "up|down" }
6322
+ \u2022 wait: { "line": 4, "action": "wait" }
5838
6323
 
5839
- OUTPUT: full plan JSON with url, title, description, steps[]`;
6324
+ OUTPUT: {"description":"one short sentence","steps":[ ... ]}`;
5840
6325
  const scriptLines = captions.map((c, i) => `${i + 1}. "${c}"`).join("\n");
5841
6326
  const userMessage = `URL: ${url}
5842
6327
  App: ${title}
@@ -5844,29 +6329,67 @@ App: ${title}
5844
6329
  Available page elements:
5845
6330
  ${pageElementsText}
5846
6331
 
5847
- Narration script (map each line to a UI action, preserve caption text exactly):
6332
+ Narration script (map each line to a UI action):
5848
6333
  ${scriptLines}
5849
6334
 
5850
- Return the full plan JSON.`;
5851
- const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier);
5852
- const jsonMatch = content.match(/\{[\s\S]*\}/);
5853
- if (!jsonMatch) throw new Error("No JSON in AI script response");
5854
- const plan = JSON.parse(jsonMatch[0]);
5855
- plan.url = url;
5856
- if (lang !== void 0) plan.lang = lang;
5857
- plan.steps = plan.steps.map(({ duration: _d, ...s }, i) => ({
5858
- ...s,
5859
- id: i + 1,
5860
- caption: captions[i] ?? s.caption ?? ""
5861
- }));
5862
- return plan;
6335
+ Return JSON with a "description" and a "steps" array of exactly ${captions.length} actions.`;
6336
+ const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier, "narration");
6337
+ const parsed = await parseAIJsonWithRepair(content, {
6338
+ logError: logError2,
6339
+ task: task2,
6340
+ userEmail,
6341
+ tier,
6342
+ shape: "The JSON should have: description (string), steps (array of {line, action, target, value}).",
6343
+ context: "Script Plan"
6344
+ });
6345
+ const raw = Array.isArray(parsed?.steps) ? parsed.steps : [];
6346
+ const byLine = /* @__PURE__ */ new Map();
6347
+ raw.forEach((step, i) => {
6348
+ if (!step || typeof step !== "object") return;
6349
+ const line = typeof step.line === "number" && step.line >= 1 ? step.line : i + 1;
6350
+ if (!byLine.has(line)) byLine.set(line, step);
6351
+ });
6352
+ const steps = captions.map((caption, i) => {
6353
+ const mapped = byLine.get(i + 1);
6354
+ if (!isUsableStep(mapped)) {
6355
+ return { id: i + 1, action: "wait", caption };
6356
+ }
6357
+ return {
6358
+ id: i + 1,
6359
+ action: mapped.action,
6360
+ caption,
6361
+ ...mapped.target ? { target: mapped.target } : {},
6362
+ ...mapped.value ? { value: mapped.value } : {}
6363
+ };
6364
+ });
6365
+ return {
6366
+ url,
6367
+ title,
6368
+ description: typeof parsed?.description === "string" ? parsed.description : title,
6369
+ ...lang !== void 0 ? { lang } : {},
6370
+ steps
6371
+ };
5863
6372
  }
5864
6373
 
5865
6374
  // src/commands/script-cmd.ts
5866
6375
  init_lib();
5867
6376
  init_config_loader();
6377
+ async function reusablePlan(planPath, lines) {
6378
+ try {
6379
+ const plan = JSON.parse(await readFile8(planPath, "utf-8"));
6380
+ if (!Array.isArray(plan.steps)) return null;
6381
+ if (plan.steps.length < lines.length) return null;
6382
+ const matches = lines.every((line, i) => plan.steps[i]?.caption === line);
6383
+ return matches ? plan : null;
6384
+ } catch {
6385
+ return null;
6386
+ }
6387
+ }
5868
6388
  async function scriptCommand(url, scriptPath, lang) {
6389
+ url = resolveStartUrl(url);
5869
6390
  const { config, workDir, captions } = await initCommandEnv(url, lang);
6391
+ const userData = await loadUserData();
6392
+ const userTier = userData.email ? await getTierByEmail(userData.email) : "free";
5870
6393
  header("Script");
5871
6394
  const raw = await readFile8(scriptPath, "utf-8");
5872
6395
  const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
@@ -5875,17 +6398,39 @@ async function scriptCommand(url, scriptPath, lang) {
5875
6398
  process.exit(1);
5876
6399
  }
5877
6400
  info(`${lines.length} captions from script`);
6401
+ const maxSteps = config.plan?.maxSteps ?? 8;
6402
+ if (lines.length > maxSteps) {
6403
+ console.log(
6404
+ chalk16.yellow(` \u26A0 ${lines.length} script lines exceeds plan.maxSteps (${maxSteps}) \u2014 recording all of them.`)
6405
+ );
6406
+ console.log(chalk16.dim(` Raise plan.maxSteps in quickpeek.config.json to silence this, or split the script.`));
6407
+ }
5878
6408
  header("Analyze");
5879
6409
  const crawlTask = task("Crawling page for UI elements");
5880
- const { elements } = await crawlPage(url, () => crawlTask);
6410
+ const { elements } = await crawlPage(url, () => crawlTask, findAuthState(url));
5881
6411
  const elementsText = elements.slice(0, 30).map((e) => ` ${e.action}: ${e.text} (${e.selector})`).join("\n");
5882
6412
  header("Plan");
5883
6413
  const planTask = task("Mapping script to UI actions");
5884
6414
  const appTitle = new URL(url).hostname.replace(/^www\./, "");
5885
- const plan = await generatePlanFromScript(lines, url, appTitle, elementsText, lang, logError);
5886
6415
  const planPath = join22(config.output.dir, `plan-${lang ?? "en"}.json`);
5887
- await savePlan(plan, planPath);
5888
- planTask.succeed(`${plan.steps.length} steps planned`);
6416
+ let plan = await reusablePlan(planPath, lines);
6417
+ if (plan) {
6418
+ planTask.succeed(`${plan.steps.length} steps reused from existing plan`);
6419
+ } else {
6420
+ plan = await generatePlanFromScript(
6421
+ lines,
6422
+ url,
6423
+ appTitle,
6424
+ elementsText,
6425
+ lang,
6426
+ logError,
6427
+ userData.email,
6428
+ userTier,
6429
+ () => planTask
6430
+ );
6431
+ await savePlan(plan, planPath);
6432
+ planTask.succeed(`${plan.steps.length} steps planned`);
6433
+ }
5889
6434
  header("Voiceover");
5890
6435
  const { voiceoverPath, stepAudio } = await generateVoiceover(
5891
6436
  plan.steps,
@@ -5901,7 +6446,7 @@ async function scriptCommand(url, scriptPath, lang) {
5901
6446
  const { videoPath } = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError);
5902
6447
  header("Compose");
5903
6448
  const assPath = await generateASS(plan, stepAudio, workDir, config, captions);
5904
- const finalPath = await composeAndFinish(videoPath, voiceoverPath, assPath, config, "free");
6449
+ const finalPath = await composeAndFinish(videoPath, voiceoverPath, assPath, config, userTier);
5905
6450
  console.log();
5906
6451
  console.log(chalk16.bold.green(" Done!"));
5907
6452
  console.log(chalk16.cyan(` ${finalPath}`));
@@ -5910,11 +6455,102 @@ async function scriptCommand(url, scriptPath, lang) {
5910
6455
  // src/mcp/tools.ts
5911
6456
  init_SparkStripe_feature_gating();
5912
6457
 
6458
+ // src/mcp/delegate.ts
6459
+ init_SparkStripe_feature_gating();
6460
+ var STEPS = {
6461
+ plan: {
6462
+ field: "plan",
6463
+ shape: "the demo plan, as the JSON object the instructions describe"
6464
+ },
6465
+ fix: {
6466
+ field: "fix",
6467
+ shape: "the corrected steps, as the JSON object the instructions describe"
6468
+ },
6469
+ narration: {
6470
+ field: "narration",
6471
+ shape: "the narration, as the JSON object the instructions describe"
6472
+ }
6473
+ };
6474
+ function textResult(text) {
6475
+ return { content: [{ type: "text", text }] };
6476
+ }
6477
+ function section(e, index) {
6478
+ const { field, shape } = STEPS[e.step];
6479
+ return [`### ${index}. ${field}`, ``, e.system, ``, e.prompt, ``, `Answer as \`${field}\`: ${shape}`].join("\n");
6480
+ }
6481
+ function delegatedBrief(pending2) {
6482
+ const tool = pending2[0]?.tool ?? "this tool";
6483
+ const fields = pending2.map((e) => STEPS[e.step].field);
6484
+ return textResult(
6485
+ [
6486
+ `\`${tool}\` needs ${pending2.length === 1 ? "a piece of" : `${pending2.length} pieces of`} writing,`,
6487
+ `and you are a stronger model than the one this CLI would relay to, so it is yours to`,
6488
+ `do. QuickPeek never generates on your behalf.`,
6489
+ ``,
6490
+ ...pending2.map((e, i) => `${section(e, i + 1)}
6491
+ `),
6492
+ `## Next`,
6493
+ ``,
6494
+ `Call \`${tool}\` again with the same arguments plus \`ai\`:`,
6495
+ ``,
6496
+ "```json",
6497
+ `{ "ai": { ${fields.map((f) => `"${f}": ...`).join(", ")} } }`,
6498
+ "```",
6499
+ ``,
6500
+ `Each value is used verbatim and no further writing is asked for.`
6501
+ ].join("\n")
6502
+ );
6503
+ }
6504
+ async function callerTier() {
6505
+ const { loadUserData: loadUserData2 } = await Promise.resolve().then(() => (init_config_loader(), config_loader_exports));
6506
+ const userData = await loadUserData2();
6507
+ if (!userData.email) return "free";
6508
+ try {
6509
+ return await getTierByEmail(userData.email);
6510
+ } catch {
6511
+ return "free";
6512
+ }
6513
+ }
6514
+ async function withDelegation(tool, args, fn) {
6515
+ const answers = args.ai ?? {};
6516
+ const tier = await callerTier();
6517
+ try {
6518
+ const result = await runInMcpTool(tool, fn, answers);
6519
+ const pending2 = pendingDelegations();
6520
+ return pending2.length ? delegatedBrief(pending2) : withTierNote(result, tier);
6521
+ } catch (e) {
6522
+ const pending2 = pendingDelegations();
6523
+ if (pending2.length) return delegatedBrief(pending2);
6524
+ if (e instanceof DelegatedGenerationError) return delegatedBrief([e]);
6525
+ throw e;
6526
+ }
6527
+ }
6528
+ function withTierNote(result, tier) {
6529
+ if (tier !== "free") return result;
6530
+ return {
6531
+ ...result,
6532
+ content: [
6533
+ ...result.content,
6534
+ {
6535
+ type: "text",
6536
+ text: `Free tier: this demo carries a watermark and the free video limit applies. Upgrade at ${pricingUrl()}`
6537
+ }
6538
+ ]
6539
+ };
6540
+ }
6541
+
5913
6542
  // src/mcp/elicit.ts
5914
6543
  var elicit = null;
6544
+ var canPrompt = null;
5915
6545
  function setElicit(fn) {
5916
6546
  elicit = fn;
5917
6547
  }
6548
+ function setCanElicit(fn) {
6549
+ canPrompt = fn;
6550
+ }
6551
+ function canElicit() {
6552
+ return canPrompt ? canPrompt() : elicit !== null;
6553
+ }
5918
6554
  async function askUser(message) {
5919
6555
  return elicit ? elicit(message) : null;
5920
6556
  }
@@ -5932,15 +6568,27 @@ var LANG_PROPERTY = {
5932
6568
  description: 'ISO 639-1 language for narration and captions (e.g. "en", "fr"). Default from config.'
5933
6569
  }
5934
6570
  };
6571
+ var SKIP_AUTH_PROPERTY = {
6572
+ skip_auth: {
6573
+ type: "boolean",
6574
+ description: "Record without signing in. By default, a URL with no saved session prompts the user to sign in first so the demo covers the app, not the logged-out landing page. Pass true only for public sites with nothing behind a login, or when a logged-out demo is intended."
6575
+ }
6576
+ };
5935
6577
  var QUICKPEEK_TOOLS = [
5936
6578
  {
5937
6579
  name: "generate_demo",
5938
- description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. Pages behind a login can be demoed once a session is saved for the host, by start_auth or by the user running `qp auth <url>`; it is picked up automatically. The result says whether this recording was signed in.",
6580
+ description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. If the host has no saved session, the user is first prompted to sign in (a visible browser opens; QuickPeek never sees the password) so the demo starts inside the app, usually the dashboard, instead of the logged-out landing page. Pass skip_auth for public sites. The result says whether this recording was signed in.",
5939
6581
  inputSchema: {
5940
6582
  type: "object",
5941
6583
  properties: {
6584
+ ai: {
6585
+ type: "object",
6586
+ description: `Writing you produced from this tool's brief, keyed by the fields it asked for (e.g. {"plan": {...}}). Each value is used verbatim and no AI call is made for it. Omit on the first call: the tool will tell you what it needs. This server never generates text itself - you are the model it delegates to.`,
6587
+ additionalProperties: true
6588
+ },
5942
6589
  ...URL_PROPERTY,
5943
6590
  ...LANG_PROPERTY,
6591
+ ...SKIP_AUTH_PROPERTY,
5944
6592
  description: {
5945
6593
  type: "string",
5946
6594
  description: "What the demo should focus on, in your words. Steers the AI plan. Without it, the planner infers the story from the page itself."
@@ -5959,16 +6607,22 @@ var QUICKPEEK_TOOLS = [
5959
6607
  },
5960
6608
  {
5961
6609
  name: "generate_demo_from_script",
5962
- description: "Record a demo that follows a script you supply, one caption per line, instead of an AI-planned walkthrough. The AI still maps each line to a UI action. Use when you know exactly what the video should say and in what order.",
6610
+ description: "Record a demo that follows a script you supply, one caption per line, instead of an AI-planned walkthrough. The AI still maps each line to a UI action. Use when you know exactly what the video should say and in what order. Prompts for sign-in like generate_demo when the host has no saved session.",
5963
6611
  inputSchema: {
5964
6612
  type: "object",
5965
6613
  properties: {
6614
+ ai: {
6615
+ type: "object",
6616
+ description: `Writing you produced from this tool's brief, keyed by the fields it asked for (e.g. {"plan": {...}}). Each value is used verbatim and no AI call is made for it. Omit on the first call: the tool will tell you what it needs. This server never generates text itself - you are the model it delegates to.`,
6617
+ additionalProperties: true
6618
+ },
5966
6619
  ...URL_PROPERTY,
5967
6620
  script_path: {
5968
6621
  type: "string",
5969
6622
  description: "Path to a plain text file with one sentence or caption per line."
5970
6623
  },
5971
- ...LANG_PROPERTY
6624
+ ...LANG_PROPERTY,
6625
+ ...SKIP_AUTH_PROPERTY
5972
6626
  },
5973
6627
  required: ["url", "script_path"]
5974
6628
  }
@@ -5984,7 +6638,7 @@ var QUICKPEEK_TOOLS = [
5984
6638
  },
5985
6639
  {
5986
6640
  name: "start_auth",
5987
- description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, so a demo can cover pages behind the login. QuickPeek never sees the password: only the resulting cookies are saved. On clients that support prompting, this saves the session as soon as the user confirms; otherwise call finish_auth when they say they are done.",
6641
+ description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, and demos of the bare URL begin on the page the user leaves open when the session is saved \u2014 tell them to finish on their dashboard. QuickPeek never sees the password: only the resulting cookies are saved. On clients that support prompting, this saves the session as soon as the user confirms; otherwise call finish_auth when they say they are done.",
5988
6642
  inputSchema: {
5989
6643
  type: "object",
5990
6644
  properties: { ...URL_PROPERTY },
@@ -6025,9 +6679,38 @@ var QUICKPEEK_TOOLS = [
6025
6679
  inputSchema: { type: "object", properties: {} }
6026
6680
  }
6027
6681
  ];
6682
+ async function ensureSignedIn(url, skipAuth) {
6683
+ if (!url.startsWith("http") || skipAuth || findAuthState(url)) return null;
6684
+ const host = new URL(url).host;
6685
+ const authRequired = () => ({
6686
+ ...jsonContent({
6687
+ status: "auth_required",
6688
+ host,
6689
+ message: `No saved sign-in for ${host}, so the demo would only show logged-out pages. Ask the user how to proceed: to demo the app signed in, call start_auth with this url, then finish_auth once they confirm, and retry. For a public site with no login, retry with skip_auth: true.`
6690
+ }),
6691
+ isError: true
6692
+ });
6693
+ if (!canElicit()) return authRequired();
6694
+ await openSignIn(url);
6695
+ const confirmed = await askUser(
6696
+ `No saved sign-in for ${host}. A browser window is open: sign in there and leave it on the page the demo should start from (usually your dashboard), then confirm. Decline to record logged out.`
6697
+ );
6698
+ if (confirmed === null) {
6699
+ await cancelSignIn();
6700
+ return authRequired();
6701
+ }
6702
+ if (confirmed) {
6703
+ await saveSignIn();
6704
+ return null;
6705
+ }
6706
+ await cancelSignIn();
6707
+ return null;
6708
+ }
6028
6709
  async function handleGenerateDemo(args) {
6029
6710
  const url = resolveUrl(args.url);
6030
6711
  return withSilencedStdout(async () => {
6712
+ const authGate = await ensureSignedIn(url, args.skip_auth);
6713
+ if (authGate) return authGate;
6031
6714
  const output = await recordCommand(url, {
6032
6715
  langOverride: args.lang,
6033
6716
  description: args.description,
@@ -6039,7 +6722,11 @@ async function handleGenerateDemo(args) {
6039
6722
  rethrow: true
6040
6723
  });
6041
6724
  if (!output) return errorContent("Recording finished without producing a video.");
6042
- return fileResult(output, { name: output.split("/").pop(), url_recorded: url, auth: authStatus(url) });
6725
+ return fileResult(output, {
6726
+ name: output.split("/").pop(),
6727
+ url_recorded: resolveStartUrl(url),
6728
+ auth: authStatus(url)
6729
+ });
6043
6730
  });
6044
6731
  }
6045
6732
  function authStatus(url) {
@@ -6049,18 +6736,20 @@ function authStatus(url) {
6049
6736
  return {
6050
6737
  signedIn: false,
6051
6738
  host,
6052
- note: `No saved session for ${host}, so only pages reachable while logged out were recorded. To cover pages behind the login, call start_auth with this url (or have the user run \`qp auth ${url}\`), then re-run with fresh: true.`
6739
+ note: `No saved session for ${host}, so only pages reachable while logged out were recorded. To cover pages behind the login, call start_auth with this url (or have the user run \`qp auth ${url}\`), then re-run with fresh: true and without skip_auth.`
6053
6740
  };
6054
6741
  }
6055
6742
  async function handleGenerateDemoFromScript(args) {
6056
6743
  const url = resolveUrl(args.url);
6057
6744
  const scriptPath = resolveInputPath(args.script_path);
6058
6745
  return withSilencedStdout(async () => {
6746
+ const authGate = await ensureSignedIn(url, args.skip_auth);
6747
+ if (authGate) return authGate;
6059
6748
  await scriptCommand(url, scriptPath, args.lang);
6060
6749
  const dir = getOutputDirFromUrl(url);
6061
6750
  const newest = newestVideo(dir);
6062
6751
  if (!newest) return errorContent(`No video found in ${dir} after running the script.`);
6063
- return fileResult(newest, { name: newest.split("/").pop(), url_recorded: url, scriptPath });
6752
+ return fileResult(newest, { name: newest.split("/").pop(), url_recorded: resolveStartUrl(url), scriptPath });
6064
6753
  });
6065
6754
  }
6066
6755
  async function handleOpenInEditor(args) {
@@ -6071,7 +6760,7 @@ async function handleOpenInEditor(args) {
6071
6760
  if (!planPath) {
6072
6761
  return errorContent(`No plan found in ${dir}. Record the demo first with generate_demo.`);
6073
6762
  }
6074
- const parsed = parsePlanText(readFileSync6(planPath, "utf8"));
6763
+ const parsed = parsePlanText(readFileSync7(planPath, "utf8"));
6075
6764
  if (!parsed.ok) return errorContent(`${planPath} cannot be opened: ${parsed.error}`);
6076
6765
  const base = editorBaseUrl();
6077
6766
  const editorUrl = planEditorUrl(base, parsed.plan);
@@ -6089,7 +6778,9 @@ async function handleStartAuth(args) {
6089
6778
  const url = resolveUrl(args.url);
6090
6779
  return withSilencedStdout(async () => {
6091
6780
  await openSignIn(url);
6092
- const confirmed = await askUser(`A browser window is open on ${url}. Sign in there, then confirm.`);
6781
+ const confirmed = await askUser(
6782
+ `A browser window is open on ${url}. Sign in there, leave it on the page demos should start from (usually your dashboard), then confirm.`
6783
+ );
6093
6784
  if (confirmed === null) {
6094
6785
  return jsonContent({
6095
6786
  status: "awaiting_sign_in",
@@ -6169,8 +6860,8 @@ async function handleCheckAccount() {
6169
6860
  });
6170
6861
  }
6171
6862
  var QUICKPEEK_HANDLERS = {
6172
- generate_demo: handleGenerateDemo,
6173
- generate_demo_from_script: handleGenerateDemoFromScript,
6863
+ generate_demo: (args) => withDelegation("generate_demo", args, () => handleGenerateDemo(args)),
6864
+ generate_demo_from_script: (args) => withDelegation("generate_demo_from_script", args, () => handleGenerateDemoFromScript(args)),
6174
6865
  open_in_editor: handleOpenInEditor,
6175
6866
  start_auth: handleStartAuth,
6176
6867
  finish_auth: handleFinishAuth,
@@ -6202,5 +6893,6 @@ export {
6202
6893
  TOOL_HANDLERS,
6203
6894
  errorContent,
6204
6895
  jsonContent,
6896
+ setCanElicit,
6205
6897
  setElicit
6206
6898
  };