@spark-apps/quickpeek 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web.mjs CHANGED
@@ -74,8 +74,27 @@ function parsePartial(partial) {
74
74
  function getErrorMessage(err) {
75
75
  return err instanceof Error ? err.message : String(err);
76
76
  }
77
+ var STOCK_PHRASES = [
78
+ [/\bfree to start\b/gi, "free to try"],
79
+ [/\bget started today\b/gi, "start now"],
80
+ [/\bsay goodbye to\b/gi, "no more"],
81
+ [/\bsupercharge\b/gi, "speed up"],
82
+ [/\beffortlessly\b/gi, "easily"],
83
+ [/\bseamlessly\b/gi, ""]
84
+ ];
85
+ function deStock(text) {
86
+ let out = text;
87
+ for (const [re, replacement] of STOCK_PHRASES) out = out.replace(re, replacement);
88
+ return out.replace(/\s{2,}/g, " ").replace(/\s+([,.!?])/g, "$1").trim();
89
+ }
90
+ function stripLongDashes(text) {
91
+ return text.replace(/(\d)\s*[\u2013\u2014]\s*(\d)/g, "$1-$2").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/,\s*,/g, ",").replace(/\s+([,.!?])/g, "$1").replace(/\s{2,}/g, " ").trim();
92
+ }
77
93
  function toTitleCase(text) {
78
- return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
94
+ return deStock(stripLongDashes(text)).replace(/\w+(?:'\w+)*/g, (word, index) => {
95
+ if (/[A-Z]/.test(word.slice(1))) {
96
+ return word;
97
+ }
79
98
  if (index === 0) {
80
99
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
81
100
  }
@@ -85,8 +104,19 @@ function toTitleCase(text) {
85
104
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
86
105
  });
87
106
  }
107
+ async function runPooled(count, size, task) {
108
+ let cursor = 0;
109
+ const worker = async () => {
110
+ while (cursor < count) {
111
+ const index = cursor++;
112
+ await task(index);
113
+ }
114
+ };
115
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(size, count)) }, worker));
116
+ }
88
117
 
89
118
  // src/lib/api-client.ts
119
+ var PLAN_MAX_TOKENS = 1500;
90
120
  var SERVER_BACKOFF_MS = [2e3, 4e3];
91
121
  var SPARK_AI_BASE = "https://sparkbrain.app";
92
122
  var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
@@ -139,7 +169,7 @@ async function getOrRefreshDirectToken() {
139
169
  }
140
170
  }
141
171
  async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
142
- const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
172
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
143
173
  let lastRetriableError = null;
144
174
  const host = hostOf(chatUrl);
145
175
  for (let attempt = 1; attempt <= 3; attempt++) {
@@ -154,7 +184,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
154
184
  messages: [
155
185
  { role: "system", content: systemPrompt },
156
186
  { role: "user", content: userPrompt }
157
- ]
187
+ ],
188
+ // Explicit numeric test, not truthiness: a 0 or negative budget is a
189
+ // caller bug, and silently falling back to the tiny backend default
190
+ // reproduces exactly the failure this option exists to prevent.
191
+ ...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
158
192
  })
159
193
  });
160
194
  const bodyText = await response.text().catch(() => "");
@@ -367,6 +401,75 @@ function shouldSkipLink(text, href, currentPath) {
367
401
  }
368
402
 
369
403
  // src/lib/crawl.ts
404
+ var SETTLE_POLL_MS = 400;
405
+ var SETTLE_STABLE_POLLS = 2;
406
+ var SETTLE_CAP_MS = 2e4;
407
+ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
408
+ const deadline = Date.now() + cap;
409
+ let previous = -1;
410
+ let unchanged = 0;
411
+ while (Date.now() < deadline) {
412
+ const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
413
+ if (count < 0) return;
414
+ if (count === previous) {
415
+ if (++unchanged >= SETTLE_STABLE_POLLS) return;
416
+ } else {
417
+ unchanged = 0;
418
+ previous = count;
419
+ }
420
+ await page.waitForTimeout(SETTLE_POLL_MS);
421
+ }
422
+ }
423
+ var PLACEHOLDER_POLL_MS = 250;
424
+ var PLACEHOLDER_CAP_MS = 8e3;
425
+ var PLACEHOLDER_STATIC_POLLS = 4;
426
+ var PLACEHOLDER_CLEAR_POLLS = 3;
427
+ var PLACEHOLDER_SELECTOR = [
428
+ '[aria-busy="true"]',
429
+ '[role="progressbar"]',
430
+ '[data-loading="true"]',
431
+ '[class*="skeleton" i]',
432
+ '[class*="animate-pulse" i]',
433
+ '[class*="spinner" i]'
434
+ ].join(",");
435
+ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
436
+ const deadline = Date.now() + cap;
437
+ let previous = -1;
438
+ let unchanged = 0;
439
+ let clear = 0;
440
+ while (Date.now() < deadline) {
441
+ const count = await page.evaluate((selector) => {
442
+ let visible = 0;
443
+ for (const el of Array.from(document.querySelectorAll(selector))) {
444
+ const rect = el.getBoundingClientRect();
445
+ if (rect.width > 0 && rect.height > 0) visible++;
446
+ }
447
+ return visible;
448
+ }, PLACEHOLDER_SELECTOR).catch(() => 0);
449
+ if (count === 0) {
450
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
451
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
452
+ continue;
453
+ }
454
+ clear = 0;
455
+ if (count === previous) {
456
+ if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
457
+ } else {
458
+ unchanged = 0;
459
+ previous = count;
460
+ }
461
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
462
+ }
463
+ }
464
+ async function gotoSettled(page, url, timeout = 3e4) {
465
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
466
+ await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
467
+ });
468
+ await waitForDomStable(page).catch(() => {
469
+ });
470
+ await waitForPlaceholdersGone(page).catch(() => {
471
+ });
472
+ }
370
473
  function idSelector(id) {
371
474
  return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
372
475
  }
@@ -387,6 +490,40 @@ function getActionForType(type) {
387
490
  return "type";
388
491
  }
389
492
  }
493
+ async function sitemapRoutes(pageUrl) {
494
+ let origin;
495
+ try {
496
+ origin = new URL(pageUrl).origin;
497
+ } catch {
498
+ return [];
499
+ }
500
+ try {
501
+ const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
502
+ if (!res.ok) return [];
503
+ const xml = await res.text();
504
+ const seen = /* @__PURE__ */ new Set();
505
+ const routes = [];
506
+ for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
507
+ const raw = (m[1] ?? "").trim();
508
+ let u;
509
+ try {
510
+ u = new URL(raw);
511
+ } catch {
512
+ continue;
513
+ }
514
+ if (u.origin !== origin) continue;
515
+ if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
516
+ const path = u.pathname.replace(/\/+$/, "");
517
+ if (!path || seen.has(u.href)) continue;
518
+ seen.add(u.href);
519
+ routes.push({ text: path.slice(1), href: u.href });
520
+ if (routes.length >= 20) break;
521
+ }
522
+ return routes;
523
+ } catch {
524
+ return [];
525
+ }
526
+ }
390
527
  var noopTask = () => ({ succeed: () => {
391
528
  } });
392
529
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -401,155 +538,180 @@ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
401
538
  }
402
539
  const browserTask = task("Launching browser");
403
540
  const browser = await chromium.launch({ headless: true });
404
- const context = await browser.newContext({
405
- ...storageStatePath ? { storageState: storageStatePath } : {},
406
- // Crawl at the size we will record at, or the measurements below describe
407
- // a page nobody will ever see.
408
- ...viewport ? { viewport } : {}
409
- });
410
- const page = await context.newPage();
411
- browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
412
- const crawlTask = task("Crawling page");
413
- await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
414
- const title = await page.title();
415
- const scrollable = await page.evaluate(
416
- "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
417
- );
418
- const rawElements = await page.evaluate(`
419
- (() => {
420
- const items = [];
541
+ try {
542
+ const context = await browser.newContext({
543
+ ...storageStatePath ? { storageState: storageStatePath } : {},
544
+ // Crawl at the size we will record at, or the measurements below describe
545
+ // a page nobody will ever see.
546
+ ...viewport ? { viewport } : {}
547
+ });
548
+ const page = await context.newPage();
549
+ browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
550
+ const crawlTask = task("Crawling page");
551
+ await gotoSettled(page, url, 3e4);
552
+ const title = await page.title();
553
+ const scrollable = await page.evaluate(
554
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
555
+ );
556
+ const rawElements = await page.evaluate(`
557
+ (() => {
558
+ const items = [];
421
559
 
422
- // Helper to get parent context
423
- const getCtx = (el) => {
424
- let p = el.parentElement;
425
- while (p && p !== document.body) {
426
- if (p.id) return '#' + p.id;
427
- const cls = Array.from(p.classList).find(c =>
428
- /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
429
- );
430
- if (cls) return '.' + cls;
431
- if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
432
- return p.tagName.toLowerCase();
560
+ // Helper to get parent context
561
+ const getCtx = (el) => {
562
+ let p = el.parentElement;
563
+ while (p && p !== document.body) {
564
+ if (p.id) return '#' + p.id;
565
+ const cls = Array.from(p.classList).find(c =>
566
+ /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
567
+ );
568
+ if (cls) return '.' + cls;
569
+ if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
570
+ return p.tagName.toLowerCase();
571
+ }
572
+ p = p.parentElement;
433
573
  }
434
- p = p.parentElement;
435
- }
436
- return '';
437
- };
574
+ return '';
575
+ };
438
576
 
439
- // Buttons (use textContent to catch hidden elements too)
440
- document.querySelectorAll('button, [role="button"]').forEach(el => {
441
- const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
442
- const testId = el.getAttribute('data-testid');
443
- const ariaLabel = el.getAttribute('aria-label');
444
- if (!text && !testId && !ariaLabel) return;
445
- items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
446
- });
577
+ // Buttons (use textContent to catch hidden elements too)
578
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
579
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
580
+ const testId = el.getAttribute('data-testid');
581
+ const ariaLabel = el.getAttribute('aria-label');
582
+ if (!text && !testId && !ariaLabel) return;
583
+ items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
584
+ });
447
585
 
448
- // Inputs (including file inputs, range sliders, checkboxes, selects)
449
- document.querySelectorAll('input, textarea, select').forEach(el => {
450
- if (el.type === 'hidden') return;
451
- const testId = el.getAttribute('data-testid');
452
- const inputType = el.type || 'text';
586
+ // Inputs (including file inputs, range sliders, checkboxes, selects)
587
+ document.querySelectorAll('input, textarea, select').forEach(el => {
588
+ if (el.type === 'hidden') return;
589
+ const testId = el.getAttribute('data-testid');
590
+ const inputType = el.type || 'text';
453
591
 
454
- // Get label text for any input type
455
- let labelText = '';
456
- if (el.labels?.[0]) {
457
- labelText = el.labels[0].textContent?.trim() || '';
458
- } else if (el.id) {
459
- const label = document.querySelector('label[for="' + el.id + '"]');
460
- if (label) labelText = label.textContent?.trim() || '';
461
- }
592
+ // Get label text for any input type
593
+ let labelText = '';
594
+ if (el.labels?.[0]) {
595
+ labelText = el.labels[0].textContent?.trim() || '';
596
+ } else if (el.id) {
597
+ const label = document.querySelector('label[for="' + el.id + '"]');
598
+ if (label) labelText = label.textContent?.trim() || '';
599
+ }
462
600
 
463
- const text = labelText || el.placeholder || el.name || el.id || '';
601
+ const text = labelText || el.placeholder || el.name || el.id || '';
464
602
 
465
- // Include file, range, and checkbox inputs even without text
466
- const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
467
- if (!text && !testId && !alwaysInclude) return;
603
+ // Include file, range, and checkbox inputs even without text
604
+ const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
605
+ if (!text && !testId && !alwaysInclude) return;
468
606
 
469
- items.push({
470
- type: inputType,
471
- text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
472
- testId,
473
- id: el.id,
474
- name: el.name,
475
- placeholder: el.placeholder,
476
- isFile: inputType === 'file'
607
+ items.push({
608
+ type: inputType,
609
+ text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
610
+ testId,
611
+ id: el.id,
612
+ name: el.name,
613
+ placeholder: el.placeholder,
614
+ isFile: inputType === 'file'
615
+ });
477
616
  });
478
- });
479
617
 
480
- // Links (use textContent to catch hidden elements too)
481
- document.querySelectorAll('a[href]').forEach(el => {
482
- const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
483
- if (text && text.length >= 2) items.push({ type: 'link', text });
484
- });
618
+ // Links (use textContent to catch hidden elements too)
619
+ document.querySelectorAll('a[href]').forEach(el => {
620
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
621
+ if (!text || text.length < 2) return;
622
+ // Keep the destination, not just the label. Without it the planner
623
+ // can name a page in a caption but has no way to go there, so every
624
+ // line gets forced onto whatever is already on screen.
625
+ let href = '';
626
+ try {
627
+ const u = new URL(el.getAttribute('href') || '', location.href);
628
+ if (u.origin === location.origin) href = u.href;
629
+ } catch { /* a javascript: or mailto: href is not somewhere to go */ }
630
+ items.push({ type: 'link', text, href });
631
+ });
485
632
 
486
- return items.slice(0, 40);
487
- })()
488
- `);
489
- const filteredElements = rawElements.filter((el) => {
490
- if (!el.text) return true;
491
- const text = el.text.toLowerCase().trim();
492
- return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
493
- });
494
- const btnTexts = {};
495
- for (const el of filteredElements) {
496
- if (el.type === "button" && el.text) {
497
- btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
633
+ return items.slice(0, 40);
634
+ })()
635
+ `);
636
+ const filteredElements = rawElements.filter((el) => {
637
+ if (!el.text) return true;
638
+ const text = el.text.toLowerCase().trim();
639
+ return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
640
+ });
641
+ const btnTexts = {};
642
+ for (const el of filteredElements) {
643
+ if (el.type === "button" && el.text) {
644
+ btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
645
+ }
498
646
  }
499
- }
500
- const elements = filteredElements.map((el) => {
501
- let selector;
502
- if (el.testId) {
503
- selector = `[data-testid="${el.testId}"]`;
504
- } else if (el.isFile || el.type === "file") {
505
- selector = 'input[type="file"]';
506
- } else if (el.type === "range") {
507
- selector = el.id ? idSelector(el.id) : 'input[type="range"]';
508
- } else if (el.type === "checkbox") {
509
- selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
510
- } else if (el.type === "button") {
511
- if (btnTexts[el.text] === 1) {
647
+ const elements = filteredElements.map((el) => {
648
+ let selector;
649
+ if (el.testId) {
650
+ selector = `[data-testid="${el.testId}"]`;
651
+ } else if (el.isFile || el.type === "file") {
652
+ selector = 'input[type="file"]';
653
+ } else if (el.type === "range") {
654
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
655
+ } else if (el.type === "checkbox") {
656
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
657
+ } else if (el.type === "button") {
658
+ if (btnTexts[el.text] === 1) {
659
+ selector = `text="${el.text}"`;
660
+ } else {
661
+ selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
662
+ }
663
+ } else if (el.type === "link") {
512
664
  selector = `text="${el.text}"`;
665
+ } else if (el.id) {
666
+ selector = idSelector(el.id);
667
+ } else if (el.name) {
668
+ selector = `[name="${el.name}"]`;
669
+ } else if (el.placeholder) {
670
+ selector = `[placeholder="${el.placeholder}"]`;
513
671
  } else {
514
- selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
672
+ selector = `text="${el.text}"`;
515
673
  }
516
- } else if (el.type === "link") {
517
- selector = `text="${el.text}"`;
518
- } else if (el.id) {
519
- selector = idSelector(el.id);
520
- } else if (el.name) {
521
- selector = `[name="${el.name}"]`;
522
- } else if (el.placeholder) {
523
- selector = `[placeholder="${el.placeholder}"]`;
524
- } else {
525
- selector = `text="${el.text}"`;
674
+ let normalizedType;
675
+ if (el.isFile || el.type === "file") {
676
+ normalizedType = "file";
677
+ } else if (el.type === "range") {
678
+ normalizedType = "range";
679
+ } else if (el.type === "checkbox") {
680
+ normalizedType = "checkbox";
681
+ } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
682
+ normalizedType = "select";
683
+ } else if (el.type === "button" || el.type === "link") {
684
+ normalizedType = el.type;
685
+ } else {
686
+ normalizedType = "input";
687
+ }
688
+ const action = getActionForType(normalizedType);
689
+ const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
690
+ return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
691
+ });
692
+ await browser.close();
693
+ for (const route of await sitemapRoutes(url)) {
694
+ if (elements.some((e) => e.href === route.href)) continue;
695
+ elements.push({
696
+ type: "link",
697
+ text: route.text,
698
+ selector: "",
699
+ action: "click",
700
+ target: route.text,
701
+ href: route.href
702
+ });
526
703
  }
527
- let normalizedType;
528
- if (el.isFile || el.type === "file") {
529
- normalizedType = "file";
530
- } else if (el.type === "range") {
531
- normalizedType = "range";
532
- } else if (el.type === "checkbox") {
533
- normalizedType = "checkbox";
534
- } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
535
- normalizedType = "select";
536
- } else if (el.type === "button" || el.type === "link") {
537
- normalizedType = el.type;
538
- } else {
539
- normalizedType = "input";
704
+ crawlTask.succeed(`Found ${elements.length} interactive elements`);
705
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
706
+ if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
707
+ const oldest = crawlCache.keys().next().value;
708
+ if (oldest !== void 0) crawlCache.delete(oldest);
540
709
  }
541
- const action = getActionForType(normalizedType);
542
- const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
543
- return { type: normalizedType, text: el.text, selector, action, target };
544
- });
545
- await browser.close();
546
- crawlTask.succeed(`Found ${elements.length} interactive elements`);
547
- crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
548
- if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
549
- const oldest = crawlCache.keys().next().value;
550
- if (oldest !== void 0) crawlCache.delete(oldest);
710
+ return { title, elements, scrollable };
711
+ } finally {
712
+ await browser.close().catch(() => {
713
+ });
551
714
  }
552
- return { title, elements, scrollable };
553
715
  }
554
716
 
555
717
  // src/lib/plan-validation.ts
@@ -747,8 +909,9 @@ function collectPreservedValues(steps) {
747
909
  mediaFile: step.mediaFile,
748
910
  mediaType: step.mediaType
749
911
  });
750
- } else if (step.action === "media") {
912
+ } else if (step.action === "media" || step.action === "image") {
751
913
  mediaSteps.push({
914
+ value: step.value,
752
915
  mediaFile: step.mediaFile,
753
916
  mediaType: step.mediaType,
754
917
  target: step.target
@@ -769,14 +932,15 @@ function restorePreservedValues(step, preserved, indices) {
769
932
  if (original.mediaType !== void 0) step.mediaType = original.mediaType;
770
933
  uploadIdx++;
771
934
  }
772
- if (step.action === "media" && mediaIdx < preserved.mediaSteps.length) {
935
+ if ((step.action === "media" || step.action === "image") && mediaIdx < preserved.mediaSteps.length) {
773
936
  const original = preserved.mediaSteps[mediaIdx];
937
+ if (original.value !== void 0) step.value = original.value;
774
938
  if (original.mediaFile !== void 0) step.mediaFile = original.mediaFile;
775
939
  if (original.mediaType !== void 0) step.mediaType = original.mediaType;
776
940
  if (!step.target && original.target) step.target = original.target;
777
941
  mediaIdx++;
778
942
  }
779
- if (step.action !== "upload" && step.action !== "media" && step.target) {
943
+ if (step.action !== "upload" && step.action !== "media" && step.action !== "image" && step.target) {
780
944
  const original = preserved.otherValues.get(`${step.action}:${step.target}`);
781
945
  if (original) step.value = original;
782
946
  }
@@ -858,9 +1022,13 @@ function planEditorUrl(baseUrl, plan) {
858
1022
  }
859
1023
 
860
1024
  // src/lib/prompts.ts
861
- function buildSystemPrompt(maxSteps) {
1025
+ function buildSystemPrompt(maxSteps, profile) {
862
1026
  let scope;
863
- if (maxSteps <= 5) {
1027
+ if (profile === "wide") {
1028
+ scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
1029
+ } else if (profile === "short") {
1030
+ scope = `SCOPE: Quick-paced short (${maxSteps} steps max) - cover every interesting detail with fast cuts and terse captions; running long is fine, the video is compressed afterwards`;
1031
+ } else if (maxSteps <= 5) {
864
1032
  scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
865
1033
  } else if (maxSteps <= 8) {
866
1034
  scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
@@ -872,9 +1040,47 @@ function buildSystemPrompt(maxSteps) {
872
1040
  ${scope}
873
1041
 
874
1042
  CORE FLOW (always follow this structure):
875
- 1. wait - Intro (what does this app do?)
1043
+ 1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
876
1044
  2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
877
- 3. wait - Outro (what was accomplished?)
1045
+ 3. Stop. The closing card is appended for you - do not write one
1046
+
1047
+ NARRATION STYLE:
1048
+ \u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
1049
+ \u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
1050
+ \u2022 NEVER build the demo out of marketing surfaces. The landing page and the PRICING page are not the product: a viewer wants to see the thing working, not read plans and prices. If the app is behind a sign-in, demo the signed-in app; if that is impossible, demo its functional public pages (tools, docs, editors), not its sales pages
1051
+ \u2022 A marketing/landing page earns ONE brief opening caption of about a SECOND - a title card, nothing more. Never scroll it, never hover or highlight anything on it, never describe its sections. Optionally return to it for the closing call to action. The demo lives in the app itself, behind the login when a session exists. Never return to the landing page mid-demo to describe a feature the app can show you doing
1052
+ \u2022 NARRATE ONLY WHAT IS ON SCREEN: every caption must describe what the viewer can see at that moment. To talk about a specific component, make it the step's target (hover it, click it, or scroll with that target) so the recorder brings it into frame first
1053
+ \u2022 A blind scroll (no target) lands wherever it lands - use it only for "there is more below", never to reveal something the caption names
1054
+ \u2022 Never combine scrolling with pointing: a step either moves the page or holds still on a component, so the highlight is never dragged across the screen mid-scroll
1055
+ \u2022 A step that starts something asynchronous (processing, generating, analyzing) must WAIT for the result on screen before moving on: give the wait step a "duration" in seconds long enough to show the outcome
1056
+ \u2022 Caption length drives step length - keep captions tight so the video moves
1057
+
1058
+ OPENING AND CLOSING (a fixed template - both cards are built for you):
1059
+ \u2022 Step 1 is ALWAYS a bare { "action": "wait" } with no target. Its caption is the HOOK, and the recorder puts the app's own OG card behind it
1060
+ \u2022 The hook names the VIEWER'S problem in the viewer's words - the cost of not having this app, in one line someone would recognise as their own situation. It is the reason to keep watching
1061
+ \u2022 NEVER open the hook with the product: no "This is X", no "Meet X", no "X helps you..." and no feature. The problem comes first
1062
+ \u2022 Then, in that SAME caption, say the app's address once - "<problem>. <domain> fixes that." A viewer who leaves halfway has to have heard where to go, and the closing card is too late for them. The opening card is both spoken AND captioned, so write the hook to read as well as it sounds: short words, no spelled-out punctuation, and the domain written the way it is typed
1063
+ \u2022 The closing card is appended for you: the app's name, tagline, domain and any live offer, narrated. Never write a sign-off step and never end back on the landing page. Apart from the hook, keep the domain and any discount code out of captions - the card carries both, and repeating them mid-demo is the most common way these scripts read as padding
1064
+
1065
+ HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
1066
+ \u2022 Write the way a person talks about something they actually like. Warm, quick, a bit of a grin. Never the voice of a landing page
1067
+ \u2022 BANNED, because they are what marketing copy sounds like when nobody meant it: "free to start", "get started today", "unlock", "empower", "seamlessly", "effortlessly", "revolutionise", "supercharge", "take your X to the next level", "say goodbye to". If a line could sit on any product's homepage unchanged, it is not about this product
1068
+ \u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
1069
+ \u2022 Short sentences beat long ones out loud. A comma is a breath; a full stop is a beat. Read it back before shipping it
1070
+ \u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
1071
+
1072
+ WHAT TO COVER (a tour of tabs is a menu, not a demo):
1073
+ \u2022 Lead with the outcome the app produces, not the screens it has
1074
+ \u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
1075
+ \u2022 Name any integration that removes work rather than adding a screen - an MCP server, a browser extension that fills the forms, an API, a CLI. An app that can be driven from an assistant is a headline feature, not a footnote
1076
+ \u2022 Do NOT write a pricing step. When the app has a free tier the closing card says so for you, narrated, at the end where it belongs. Never navigate to the pricing page for it
1077
+ \u2022 Cover what surrounds the product as well as the product: a community, a network its users list each other on, a support channel, anything published on the user's behalf. These are reasons to sign up and they are never on screen by themselves
1078
+ \u2022 Say what a feature DOES, never what it is implemented as. Filenames, formats and protocols are not benefits: "so AI assistants can read your product" lands, "it writes llms.txt and robots.txt" is a changelog line read aloud. Same for sitemap.xml, JSON-LD, schema markup, API endpoints and anything ending in .txt, .xml or .json. If a viewer would have to already know what it is, it is not a selling point
1079
+ \u2022 Prefer one feature shown working over three named in passing
1080
+ \u2022 When a step types into a URL or domain field, type a REAL domain, never example.com or example.org. A demo that audits "example.com" on camera shows the tool working on nothing. Use a real site the viewer might recognise, or the demo subject's own domain if nothing better is on the page
1081
+ \u2022 A plan that is mostly bare wait steps is a failed plan. One real run came back 22 waits out of 26: a slideshow of one static screen, narrating pages it never visited. If a caption names a page, NAVIGATE to it; if it names a control, CLICK it. Use wait only for the opening and closing cards
1082
+ \u2022 NEVER narrate a screen you are not on. If a line describes a page the demo has not reached, navigate to it first. A caption saying "start with a free roast" over the dashboard is worse than cutting the line: it makes the whole video look faked
1083
+ \u2022 Cover everything worth covering and let it run long. Length is fixed afterwards by playing the demo faster, never by dropping a feature - a plan trimmed to hit a target is the one mistake the compressor cannot undo
878
1084
 
879
1085
  TARGET SYNTAX (simplified):
880
1086
  \u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
@@ -897,9 +1103,12 @@ FORBIDDEN:
897
1103
 
898
1104
  ACTION FORMATS (STRICT - follow exactly):
899
1105
  \u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
1106
+ \u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
900
1107
  \u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
901
1108
  \u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
1109
+ \u2022 navigate: { "action": "navigate", "target": "https://example.com/roast" } - GO TO ANOTHER PAGE. Use it whenever the caption describes something that is not on the current screen. Any path on the SAME ORIGIN as the demo URL is allowed, not only the NAVIGATE targets listed below: an app's own routes are frequently reached by client-side buttons rather than links, so the list is a hint, not the limit. Never navigate off-origin.
902
1110
  \u2022 wait: { "action": "wait" } - for intro/outro captions
1111
+ \u2022 scroll to a component: { "action": "scroll", "target": "seo files", "value": "down" } - brings that element into frame; PREFER this over a blind scroll whenever the caption names something
903
1112
  \u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
904
1113
  \u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
905
1114
  \u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
@@ -924,6 +1133,17 @@ Purpose: ${description}` : "";
924
1133
  const selectables = elements.filter((e) => e.action === "select");
925
1134
  const checkables = elements.filter((e) => e.action === "check");
926
1135
  const elementDict = [];
1136
+ const navigables = elements.filter((e) => e.href);
1137
+ const seenHref = /* @__PURE__ */ new Set();
1138
+ const linkTargets = navigables.filter((e) => {
1139
+ if (!e.href || seenHref.has(e.href)) return false;
1140
+ seenHref.add(e.href);
1141
+ return true;
1142
+ });
1143
+ if (linkTargets.length > 0) {
1144
+ elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
1145
+ ${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
1146
+ }
927
1147
  if (clickables.length > 0) {
928
1148
  elementDict.push(`CLICK targets (use simplified text):
929
1149
  ${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
@@ -994,26 +1214,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
994
1214
  var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
995
1215
  var BASE = "https://sparkpay.dev";
996
1216
  var TIERS = {
997
- price_1TADnPEf9opkuWid9NtqOYON: "pro",
998
- price_1TADnPEf9opkuWidgbvvX9ma: "pro",
1217
+ // Essentials
1218
+ price_1TCREmEf9opkuWidvhuqzqDM: "pro",
1219
+ // live monthly
1220
+ price_1TCREmEf9opkuWidacf1tiC7: "pro",
1221
+ // live yearly
999
1222
  price_1TADm7Ef9opkuWid3I9tquIB: "pro",
1223
+ // test monthly
1000
1224
  price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
1001
- price_1TCREmEf9opkuWidvhuqzqDM: "pro",
1002
- price_1TCREmEf9opkuWidacf1tiC7: "pro"
1003
- };
1004
- var RANK = {
1005
- free: 0,
1006
- pro: 1
1007
- };
1008
- var FEATURES = {
1009
- free: [],
1010
- pro: [
1011
- "Everything in Free",
1012
- "No watermark",
1013
- "Full video composition (voiceover + captions + music + transitions)",
1014
- "Unlimited videos",
1015
- "Priority support"
1016
- ]
1225
+ // test yearly
1226
+ // Elite. These were missing entirely, so an Elite subscriber fell through
1227
+ // the `?? 'pro'` fallback below and got Essentials limits for $29.
1228
+ price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
1229
+ // live monthly
1230
+ price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
1231
+ // live yearly
1232
+ price_1TGPCREf9opkuWidR0iJeJNe: "elite"
1233
+ // test monthly
1017
1234
  };
1018
1235
  async function _fetch(email, init) {
1019
1236
  const r = await fetch(
@@ -1038,7 +1255,6 @@ function getTier(d) {
1038
1255
  return TIERS[d.subscription.price_id] ?? "pro";
1039
1256
  }
1040
1257
  var isPaid = (d) => getTier(d) !== "free";
1041
- var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
1042
1258
  var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
1043
1259
  var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
1044
1260
  var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
@@ -1064,22 +1280,43 @@ function pricingUrl(opts) {
1064
1280
  }
1065
1281
 
1066
1282
  // src/lib/voices.ts
1067
- import { WIDE_VOICES } from "@spark-apps/video-kit";
1283
+ import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
1284
+ import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
1285
+ var INTERNATIONAL_VOICE = "international";
1286
+ function internationalVoiceFor(lang, gender = "female") {
1287
+ if (!hasVoice(lang)) return null;
1288
+ const raw = lang.trim().toLowerCase();
1289
+ const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
1290
+ if (gender === "male") return voiceFor(lang, gender);
1291
+ return multilingual ?? voiceFor(lang, gender);
1292
+ }
1293
+ function hasVoice(lang) {
1294
+ const raw = lang.trim().toLowerCase();
1295
+ return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
1296
+ }
1297
+ function voiceFor(lang, gender = "female") {
1298
+ if (!hasVoice(lang)) return null;
1299
+ return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
1300
+ }
1068
1301
  export {
1069
1302
  ACTIONS_REQUIRING_TARGET,
1070
1303
  EXCLUDED_LINK_PATTERNS,
1071
- FEATURES,
1072
1304
  FIX_PLAN_SYSTEM_PROMPT,
1073
1305
  INTERACTIVE_SELECTORS,
1306
+ INTERNATIONAL_VOICE,
1074
1307
  INVALID_TARGETS,
1075
- WIDE_VOICES as LANGUAGE_VOICES,
1308
+ MALE_VOICES2 as MALE_VOICES,
1076
1309
  MAX_HANDOFF_URL_CHARS,
1310
+ MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
1311
+ PLAN_MAX_TOKENS,
1077
1312
  REFINE_PLAN_SYSTEM_PROMPT,
1313
+ WIDE_VOICES2 as WIDE_VOICES,
1078
1314
  buildSystemPrompt,
1079
1315
  buildUserPrompt,
1080
1316
  callAI,
1081
1317
  collectPreservedValues,
1082
1318
  crawlPage,
1319
+ deStock,
1083
1320
  decodePlan,
1084
1321
  encodePlan,
1085
1322
  filterValidSteps,
@@ -1089,9 +1326,10 @@ export {
1089
1326
  getStatusCached,
1090
1327
  getTier,
1091
1328
  getTierByEmail,
1092
- hasAccess,
1093
1329
  hasTrialRemaining,
1330
+ hasVoice,
1094
1331
  hexToAss as hexToASS,
1332
+ internationalVoiceFor,
1095
1333
  isCanceling,
1096
1334
  isExcludedLink,
1097
1335
  isInvalidTarget,
@@ -1106,10 +1344,13 @@ export {
1106
1344
  pricingUrl,
1107
1345
  restorePreservedValues,
1108
1346
  rgbaToHex,
1347
+ runPooled,
1109
1348
  serializePlan,
1110
1349
  shouldSkipLink,
1111
1350
  showPaywall,
1351
+ stripLongDashes,
1112
1352
  toTitleCase,
1113
1353
  validateStep,
1354
+ voiceFor,
1114
1355
  windowsDrivePathToWsl as windowsPathToWSL
1115
1356
  };